|
| 1 | +// Toolbox Category Drag |
| 2 | +// By: SharkPool |
| 3 | +export default async function ({ addon }) { |
| 4 | + // wait for scratchblocks to be defined |
| 5 | + await addon.tab.traps.getBlockly(); |
| 6 | + |
| 7 | + const COMMENT_TRAPPER_ID = "--Category_Order_ADDON-config"; |
| 8 | + const soup = "!#%()*+,-./:;=?@[]^_`{|}~ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; |
| 9 | + |
| 10 | + let categoryOrdering = undefined; |
| 11 | + |
| 12 | + const genUID = () => { |
| 13 | + const id = []; |
| 14 | + for (let i = 0; i < 20; i++) { |
| 15 | + id[i] = soup.charAt(Math.random() * soup.length); |
| 16 | + } |
| 17 | + return id.join(""); |
| 18 | + } |
| 19 | + |
| 20 | + const createSep = () => { |
| 21 | + const sep = document.createElement("sep"); |
| 22 | + sep.setAttribute("gap", "36"); |
| 23 | + return sep; |
| 24 | + }; |
| 25 | + |
| 26 | + const extractCategoryID = (classList) => { |
| 27 | + for (const text of classList) { |
| 28 | + if (text.startsWith("scratchCategoryId-")) return text.replace("scratchCategoryId-", ""); |
| 29 | + } |
| 30 | + return undefined; |
| 31 | + } |
| 32 | + |
| 33 | + const ogPopulate = ScratchBlocks.Toolbox.CategoryMenu.prototype.populate; |
| 34 | + ScratchBlocks.Toolbox.CategoryMenu.prototype.populate = function (...args) { |
| 35 | + if (!categoryOrdering) { |
| 36 | + ogPopulate.call(this, ...args); |
| 37 | + return; |
| 38 | + } |
| 39 | + |
| 40 | + const toolboxXml = args[0]; |
| 41 | + const children = Array.from(toolboxXml.children); |
| 42 | + const categories = children.filter(e => e.tagName === "category"); |
| 43 | + |
| 44 | + /* sort categories based on categoryOrdering */ |
| 45 | + categories.sort((a, b) => { |
| 46 | + const aIndex = categoryOrdering.indexOf(a.getAttribute("id")); |
| 47 | + const bIndex = categoryOrdering.indexOf(b.getAttribute("id")); |
| 48 | + return (aIndex === -1 ? 999 : aIndex) - (bIndex === -1 ? 999 : bIndex); |
| 49 | + }); |
| 50 | + |
| 51 | + while (toolboxXml.firstChild) toolboxXml.removeChild(toolboxXml.firstChild); |
| 52 | + |
| 53 | + /* <sep> + <category> + <sep> + ... + <category> + <sep> */ |
| 54 | + toolboxXml.appendChild(createSep()); |
| 55 | + categories.forEach((cat) => { |
| 56 | + toolboxXml.appendChild(cat); |
| 57 | + toolboxXml.appendChild(createSep()); |
| 58 | + }); |
| 59 | + |
| 60 | + ogPopulate.call(this, ...args); |
| 61 | + }; |
| 62 | + |
| 63 | + const ogSaveJSON = vm.toJSON; |
| 64 | + vm.toJSON = function (...args) { |
| 65 | + saveOrdering(); |
| 66 | + return ogSaveJSON.call(this, ...args); |
| 67 | + } |
| 68 | + |
| 69 | + vm.runtime.on("PROJECT_LOADED", () => { |
| 70 | + const storedOrder = findOrderingComment(true); |
| 71 | + if (storedOrder) { |
| 72 | + try { |
| 73 | + categoryOrdering = JSON.parse(storedOrder); |
| 74 | + setTimeout(forceRefreshToolbox, 100); |
| 75 | + } catch { } |
| 76 | + } |
| 77 | + }); |
| 78 | + |
| 79 | + function findOrderingComment(optParse) { |
| 80 | + const stageTarget = vm.runtime.getTargetForStage(); |
| 81 | + if (!stageTarget) return undefined; |
| 82 | + |
| 83 | + let configComment; |
| 84 | + const comments = Object.values(stageTarget.comments); |
| 85 | + for (const comment of comments) { |
| 86 | + if (comment.text.endsWith(COMMENT_TRAPPER_ID)) { |
| 87 | + configComment = comment.text; |
| 88 | + break; |
| 89 | + } |
| 90 | + } |
| 91 | + |
| 92 | + if (optParse && configComment) { |
| 93 | + const dataLine = configComment.split("\n").find(i => i.endsWith(COMMENT_TRAPPER_ID)); |
| 94 | + if (!dataLine) return undefined; |
| 95 | + return dataLine.substr(0, dataLine.length - COMMENT_TRAPPER_ID.length); |
| 96 | + } |
| 97 | + } |
| 98 | + |
| 99 | + function saveOrdering() { |
| 100 | + if (findOrderingComment()) return; |
| 101 | + |
| 102 | + const stageTarget = vm.runtime.getTargetForStage(); |
| 103 | + if (!stageTarget) return; |
| 104 | + |
| 105 | + const text = `Configuration for 'Category Ordering' Addon\nYou can move, resize, and minimize this comment, but don't edit it by hand. This comment can be deleted to remove the stored settings.\n${JSON.stringify(categoryOrdering)}${COMMENT_TRAPPER_ID}`; |
| 106 | + stageTarget.createComment(genUID(), null, text, 50, 50, 350, 170, false); |
| 107 | + vm.runtime.emitProjectChanged(); |
| 108 | + } |
| 109 | + |
| 110 | + function compileNewOrder(htmlCategoryList) { |
| 111 | + const orderedIDs = []; |
| 112 | + for (const cat of htmlCategoryList) { |
| 113 | + const id = extractCategoryID(cat.firstChild.classList); |
| 114 | + if (id) orderedIDs.push(id); |
| 115 | + } |
| 116 | + categoryOrdering = orderedIDs; |
| 117 | + } |
| 118 | + |
| 119 | + function forceRefreshToolbox() { |
| 120 | + const workspace = ScratchBlocks.getMainWorkspace(); |
| 121 | + const toolbox = workspace.getToolbox(); |
| 122 | + if (!toolbox) return; |
| 123 | + const categoryMenu = toolbox.categoryMenu_; |
| 124 | + if (!categoryMenu) return; |
| 125 | + if (categoryMenu.secondTable) return; |
| 126 | + |
| 127 | + categoryMenu.dispose(); |
| 128 | + categoryMenu.createDom(); |
| 129 | + toolbox.populate_(workspace.options.languageTree); |
| 130 | + toolbox.position(); |
| 131 | + } |
| 132 | + |
| 133 | + function initDragDroper(clickEvent) { |
| 134 | + const draggedCat = clickEvent.target.closest(`div[class="scratchCategoryMenuRow"]`); |
| 135 | + if (!draggedCat) return; |
| 136 | + |
| 137 | + const categoryList = blocklyToolboxDiv.querySelectorAll(`div[class*="scratchCategoryMenuRow"]`); |
| 138 | + |
| 139 | + const rect = draggedCat.getBoundingClientRect(); |
| 140 | + const generalHeight = rect.height; |
| 141 | + const offsetX = clickEvent.clientX - rect.left; |
| 142 | + const offsetY = clickEvent.clientY - rect.top; |
| 143 | + |
| 144 | + const dragger = draggedCat.cloneNode(true); |
| 145 | + draggedCat.style.opacity = 0.5; |
| 146 | + |
| 147 | + dragger.setAttribute("style", `position: absolute; z-index: 99999; left: ${rect.left}px; top: ${rect.top}px; width: ${rect.width}px; pointer-events: none;`); |
| 148 | + dragger.firstChild.setAttribute("style", `box-shadow: #000 5px 5px 10px; border-radius: 8px;`); |
| 149 | + dragger.dataset.dragger = true; |
| 150 | + document.body.appendChild(dragger); |
| 151 | + |
| 152 | + let lastHovered = null; |
| 153 | + |
| 154 | + const onMouseMove = (moveEvent) => { |
| 155 | + /* drag visual */ |
| 156 | + const newLeft = moveEvent.clientX - offsetX; |
| 157 | + const newTop = moveEvent.clientY - offsetY; |
| 158 | + dragger.style.left = `${newLeft}px`; |
| 159 | + dragger.style.top = `${newTop}px`; |
| 160 | + |
| 161 | + // auto scroll if dragger is near the top/bottom |
| 162 | + const scrollZoneSize = 40; |
| 163 | + const bounds = blocklyToolboxDiv.getBoundingClientRect(); |
| 164 | + |
| 165 | + if (moveEvent.clientY < bounds.top + scrollZoneSize) { |
| 166 | + blocklyToolboxDiv.scrollTop -= 4; |
| 167 | + } else if (moveEvent.clientY > bounds.bottom - scrollZoneSize) { |
| 168 | + blocklyToolboxDiv.scrollTop += 4; |
| 169 | + } |
| 170 | + |
| 171 | + // check if we are near any category |
| 172 | + // if so, bump down everything below the dragger |
| 173 | + let target; |
| 174 | + for (const cat of categoryList) { |
| 175 | + if (cat === draggedCat) continue; |
| 176 | + const catRect = cat.getBoundingClientRect(); |
| 177 | + const midpointY = catRect.top + catRect.height / 2; |
| 178 | + const midpointX = catRect.left + catRect.width / 2; |
| 179 | + |
| 180 | + const xDist = Math.abs(moveEvent.clientX - midpointX); |
| 181 | + const yCheck = moveEvent.clientY < midpointY; |
| 182 | + if (yCheck && xDist < 100) { |
| 183 | + target = cat; |
| 184 | + break; |
| 185 | + } |
| 186 | + } |
| 187 | + |
| 188 | + for (const cat of categoryList) cat.style.transform = ""; |
| 189 | + if (target) { |
| 190 | + lastHovered = target; |
| 191 | + let shifter = target; |
| 192 | + while (shifter) { |
| 193 | + if (shifter === draggedCat) return; |
| 194 | + shifter.style.transform = `translateY(${generalHeight}px)`; |
| 195 | + shifter = shifter.nextSibling; |
| 196 | + } |
| 197 | + } else { |
| 198 | + lastHovered = null; |
| 199 | + } |
| 200 | + }; |
| 201 | + const onMouseUp = () => { |
| 202 | + /* cleanup */ |
| 203 | + document.removeEventListener("mousemove", onMouseMove); |
| 204 | + document.removeEventListener("mouseup", onMouseUp); |
| 205 | + for (const cat of categoryList) cat.style.transform = ""; |
| 206 | + draggedCat.style.opacity = ""; |
| 207 | + dragger.remove(); |
| 208 | + |
| 209 | + // if the category drag was valid, move the category |
| 210 | + if (lastHovered) { |
| 211 | + const id = extractCategoryID(draggedCat.firstChild.classList); |
| 212 | + draggedCat.parentNode.insertBefore(draggedCat, lastHovered); |
| 213 | + |
| 214 | + const newCatList = blocklyToolboxDiv.querySelectorAll(`div[class*="scratchCategoryMenuRow"]`); |
| 215 | + compileNewOrder(newCatList); |
| 216 | + setTimeout(() => { |
| 217 | + forceRefreshToolbox(); |
| 218 | + if (id) ScratchBlocks.mainWorkspace.toolbox_.setSelectedCategoryById(id); |
| 219 | + }, 100); |
| 220 | + } |
| 221 | + }; |
| 222 | + |
| 223 | + document.addEventListener("mousemove", onMouseMove); |
| 224 | + document.addEventListener("mouseup", onMouseUp); |
| 225 | + } |
| 226 | + |
| 227 | + /* Check for Long (500ms) Presses to not confuse with Selecting Categories */ |
| 228 | + const blocklyToolboxDiv = document.querySelector(`div[class*="blocklyToolboxDiv"`); |
| 229 | + blocklyToolboxDiv.addEventListener("mousedown", (e) => { |
| 230 | + const longPressTimer = setTimeout(() => initDragDroper(e), 500); |
| 231 | + const cancel = () => clearTimeout(longPressTimer); |
| 232 | + |
| 233 | + document.addEventListener("mouseup", cancel, { once: true }); |
| 234 | + document.addEventListener("mouseleave", cancel, { once: true }); |
| 235 | + }); |
| 236 | +} |
0 commit comments