Skip to content

Commit 7c66f24

Browse files
addon: Draggable Categories in Block Palette
Co-Authored-By: SharkPool <[email protected]>
1 parent 9dfd223 commit 7c66f24

File tree

6 files changed

+284
-0
lines changed

6 files changed

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

src/addons/generated/addon-entries.js

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/addons/generated/addon-manifests.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import _disable_paste_offset from "../addons/disable-paste-offset/_manifest_entr
6161
import _block_duplicate from "../addons/block-duplicate/_manifest_entry.js";
6262
import _swap_local_global from "../addons/swap-local-global/_manifest_entry.js";
6363
import _toolbox_full_blocks_on_hover from "../addons/toolbox-full-blocks-on-hover/_manifest_entry.js";
64+
import _toolbox_category_drag from "../addons/toolbox-category-drag/_manifest_entry.js";
6465
import _editor_comment_previews from "../addons/editor-comment-previews/_manifest_entry.js";
6566
import _columns from "../addons/columns/_manifest_entry.js";
6667
import _number_pad from "../addons/number-pad/_manifest_entry.js";
@@ -136,6 +137,7 @@ export default {
136137
"block-duplicate": _block_duplicate,
137138
"swap-local-global": _swap_local_global,
138139
"toolbox-full-blocks-on-hover": _toolbox_full_blocks_on_hover,
140+
"toolbox-category-drag": _toolbox_category_drag,
139141
"editor-comment-previews": _editor_comment_previews,
140142
"columns": _columns,
141143
"number-pad": _number_pad,

0 commit comments

Comments
 (0)