From 90d012875fb948b81888bfcc349a1265e957d42a Mon Sep 17 00:00:00 2001 From: lawsie <5183697+lawsie@users.noreply.github.com> Date: Thu, 7 May 2026 09:56:21 +0100 Subject: [PATCH 1/8] Make gizmo overlay persist --- main/accessibility.js | 32 ++++++++++++++------------------ ui/gizmos.js | 2 +- 2 files changed, 15 insertions(+), 19 deletions(-) diff --git a/main/accessibility.js b/main/accessibility.js index cbda5ef5..e0ddf7ed 100644 --- a/main/accessibility.js +++ b/main/accessibility.js @@ -1,3 +1,6 @@ +import { InputManager } from "./inputmanager.js"; +import { ContextManager } from "./context.js"; + // Area menu accessed with Ctrl + B to quickly skip to // different areas on the interface @@ -179,6 +182,7 @@ const GizmoMenuManager = { if (!this.overlay) return; if (show) { this.renderBadges(); + // Focus 1st button if nothing in gizmos is already focused, // but if another gizmo is active, leave focus there const alreadyFocused = document.activeElement?.closest("#gizmoButtons"); @@ -197,46 +201,38 @@ const GizmoMenuManager = { window.addEventListener( "keydown", (e) => { - // Show the overlay on Ctrl+G + // Ctrl+G: toggle overlay from any context if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "g") { e.preventDefault(); - e.stopPropagation(); // prevent main.js from also handling this + e.stopPropagation(); this.toggle(!this.isOpen()); return; } - // Do nothing if the overlay isn't open if (!this.isOpen()) return; - // Guard against typing in inputs triggering gizmo shortcuts - const t = e.target; - const tag = (t?.tagName || "").toLowerCase(); - if ( - t?.isContentEditable || - tag === "input" || - tag === "textarea" || - tag === "select" - ) - return; + // Respect TYPING and OVERLAY contexts + const ctx = ContextManager.getCurrentContext(); + if (ctx === "TYPING" || ctx === "OVERLAY") return; - // If the overlay is open and a number key is pressed, - // activate the gizmo if (e.key >= "1" && e.key <= "9") { const entry = this.buttons.find((b) => b.label === e.key); - if (entry) this.activateButton(entry); + if (entry) { + this.activateButton(entry); + e.stopPropagation(); + } } if (e.key === "Escape") { - e.preventDefault(); this.toggle(false); } }, true, ); + // Move the badges if the window is resized const gizmoButtons = document.getElementById("gizmoButtons"); const resizer = document.getElementById("resizer"); if (gizmoButtons) { - // Move the badges if the window is resized new ResizeObserver(() => { if (this.isOpen()) this.renderBadges(); }).observe(gizmoButtons); diff --git a/ui/gizmos.js b/ui/gizmos.js index 677f5ab9..314ec909 100644 --- a/ui/gizmos.js +++ b/ui/gizmos.js @@ -1292,8 +1292,8 @@ export function toggleGizmo(gizmoType) { return; } - GizmoMenuManager.toggle(true); exitGizmoState(); // Clean up any existing gizmo state + GizmoMenuManager.toggle(true); resetAttachedMeshIfMeshAttached(); document.body.style.cursor = "default"; From e296207a2e4c1a63b1a7c9d6279907eff31db52c Mon Sep 17 00:00:00 2001 From: lawsie <5183697+lawsie@users.noreply.github.com> Date: Thu, 7 May 2026 11:20:54 +0100 Subject: [PATCH 2/8] Make gizmo buttons stay open --- main/accessibility.js | 14 +++++++++++--- ui/gizmos.js | 1 - 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/main/accessibility.js b/main/accessibility.js index e0ddf7ed..361fb32e 100644 --- a/main/accessibility.js +++ b/main/accessibility.js @@ -108,6 +108,7 @@ const AreaManager = { ) ?? el; // Focus the area itself if no suitable child focusable?.focus(); + if (area.selector === "#gizmoButtons") GizmoMenuManager.toggle(true); }, renderHighlights() { @@ -183,6 +184,16 @@ const GizmoMenuManager = { if (show) { this.renderBadges(); + // Check if the overlay should exit + this._watcher = () => { + const ctx = ContextManager.getCurrentContext(); + if (ctx !== "GIZMO" && ctx !== "NAVIGATION") this.toggle(false); + }; + document.addEventListener("focusin", this._watcher); + document.addEventListener("pointerdown", this._watcher, { + capture: true, + }); + // Focus 1st button if nothing in gizmos is already focused, // but if another gizmo is active, leave focus there const alreadyFocused = document.activeElement?.closest("#gizmoButtons"); @@ -222,9 +233,6 @@ const GizmoMenuManager = { e.stopPropagation(); } } - if (e.key === "Escape") { - this.toggle(false); - } }, true, ); diff --git a/ui/gizmos.js b/ui/gizmos.js index 314ec909..75aff205 100644 --- a/ui/gizmos.js +++ b/ui/gizmos.js @@ -583,7 +583,6 @@ export function exitGizmoState() { .forEach((btn) => btn.classList.remove("active")); disableGizmos(); document.body.style.cursor = "default"; - GizmoMenuManager.toggle(false); } // Start the keyboard handler for moving a mesh From 544cae7c9ed736188784cc168f0ba8c1475e40d2 Mon Sep 17 00:00:00 2001 From: lawsie <5183697+lawsie@users.noreply.github.com> Date: Thu, 7 May 2026 12:10:27 +0100 Subject: [PATCH 3/8] Migrate to input handler --- main/accessibility.js | 135 +++++++++++++++++++----------------------- main/inputmanager.js | 13 ++-- main/main.js | 75 +++++------------------ ui/colourpicker.js | 18 ++---- 4 files changed, 86 insertions(+), 155 deletions(-) diff --git a/main/accessibility.js b/main/accessibility.js index 361fb32e..7fc33a99 100644 --- a/main/accessibility.js +++ b/main/accessibility.js @@ -48,54 +48,47 @@ const AreaManager = { }, setupListeners() { - window.addEventListener( - "keydown", - (e) => { - // Open: Ctrl+B - if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "b") { - e.preventDefault(); - this.toggle(this.overlay.classList.contains("hidden")); - } - // Close: Escape - if (e.key === "Escape") { - this.toggle(false); - } - // Handle number keys - if (e.key >= "1" && e.key <= "9") { - // Only if the overlay is open (otherwise you can't type numbers) - if (!this.overlay.classList.contains("hidden")) { - // Find the area and set the focus - const area = this.areas.find((a) => a.label === e.key); - if (area) this.activateArea(area); - } - } - // Tab through badges when overlay is open - if (e.key === "Tab" && !this.overlay.classList.contains("hidden")) { - e.preventDefault(); - const badges = [ - ...this.overlay.querySelectorAll(".area-number-badge"), - ]; - if (badges.length === 0) return; - const currentIndex = badges.indexOf(document.activeElement); - const nextIndex = e.shiftKey - ? (currentIndex - 1 + badges.length) % badges.length - : (currentIndex + 1) % badges.length; - badges[nextIndex].focus(); - } - // Enter opens the area if a badge is focused - if (e.key === "Enter" && !this.overlay.classList.contains("hidden")) { - const focused = document.activeElement; - // Do nothing if a badge is not focused - if (!focused?.classList.contains("area-number-badge")) return; - e.preventDefault(); - - // Find the area and set the focus - const area = this.areas.find((a) => a.label === focused.innerText); - if (area) this.activateArea(area); - } - }, - true, - ); // 'true' uses the capture phase to beat Blockly's listeners + InputManager.on("*", "Mod+KeyB", (e) => { + e.preventDefault(); + e.stopPropagation(); + this.toggle(this.overlay.classList.contains("hidden")); + }); + + InputManager.on("OVERLAY", "Escape", () => this.toggle(false)); + + for (let i = 1; i <= 9; i++) { + InputManager.on("OVERLAY", `Digit${i}`, () => { + const area = this.areas.find((a) => a.label === String(i)); + if (area) this.activateArea(area); + }); + } + + const cycleBadges = (reverse) => { + const badges = [...this.overlay.querySelectorAll(".area-number-badge")]; + if (badges.length === 0) return; + const currentIndex = badges.indexOf(document.activeElement); + const nextIndex = reverse + ? (currentIndex - 1 + badges.length) % badges.length + : (currentIndex + 1) % badges.length; + badges[nextIndex].focus(); + }; + + InputManager.on("OVERLAY", "Tab", (e) => { + e.preventDefault(); + cycleBadges(false); + }); + InputManager.on("OVERLAY", "Shift+Tab", (e) => { + e.preventDefault(); + cycleBadges(true); + }); + + InputManager.on("OVERLAY", "Enter", (e) => { + const focused = document.activeElement; + if (!focused?.classList.contains("area-number-badge")) return; + e.preventDefault(); + const area = this.areas.find((a) => a.label === focused.innerText); + if (area) this.activateArea(area); + }); }, // Set the focus to this area and close overlay @@ -184,7 +177,7 @@ const GizmoMenuManager = { if (show) { this.renderBadges(); - // Check if the overlay should exit + // Check if the Gizmo number shortcut overlay should exit this._watcher = () => { const ctx = ContextManager.getCurrentContext(); if (ctx !== "GIZMO" && ctx !== "NAVIGATION") this.toggle(false); @@ -209,35 +202,25 @@ const GizmoMenuManager = { }, setupListeners() { - window.addEventListener( - "keydown", - (e) => { - // Ctrl+G: toggle overlay from any context - if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "g") { - e.preventDefault(); - e.stopPropagation(); - this.toggle(!this.isOpen()); - return; - } + // Toggle gizmo menu with Ctrl + G + InputManager.on("*", "Mod+KeyG", (e) => { + const ctx = ContextManager.getCurrentContext(); + if (ctx === "TYPING" || ctx === "OVERLAY") return; + e.preventDefault(); + e.stopPropagation(); + this.toggle(!this.isOpen()); + }); + // Activate gizmo buttons with number keys + for (let i = 1; i <= 9; i++) { + InputManager.on("*", `Digit${i}`, () => { if (!this.isOpen()) return; + const entry = this.buttons.find((b) => b.label === String(i)); + if (entry) this.activateButton(entry); + }); + } - // Respect TYPING and OVERLAY contexts - const ctx = ContextManager.getCurrentContext(); - if (ctx === "TYPING" || ctx === "OVERLAY") return; - - if (e.key >= "1" && e.key <= "9") { - const entry = this.buttons.find((b) => b.label === e.key); - if (entry) { - this.activateButton(entry); - e.stopPropagation(); - } - } - }, - true, - ); - - // Move the badges if the window is resized + // Move the gizmo buttons if the window is resized const gizmoButtons = document.getElementById("gizmoButtons"); const resizer = document.getElementById("resizer"); if (gizmoButtons) { @@ -434,6 +417,8 @@ const ShortcutsPanel = { }, setupListeners() { + // Not handled by InputManager as they are set specifically + // to listen when the panel has focus, not globally document.addEventListener("click", (e) => { if (e.target.id === "closeShortcutsPanel") this.hide(); }); diff --git a/main/inputmanager.js b/main/inputmanager.js index 38520a64..8618e8ad 100644 --- a/main/inputmanager.js +++ b/main/inputmanager.js @@ -8,8 +8,7 @@ import { ContextManager } from "./context.js"; // Work out what key combo was pressed (e.g. Ctrl+Shift+KeyA) function _keyCombo(event) { const mods = [ - event.ctrlKey && "Ctrl", - event.metaKey && "Meta", + (event.ctrlKey || event.metaKey) && "Mod", event.altKey && "Alt", event.shiftKey && "Shift", ].filter(Boolean); @@ -47,13 +46,13 @@ const InputManager = { return; } } + const combo = _keyCombo(event); const handler = + this._registry[`${context}:${combo}`] || + this._registry[`*:${combo}`] || this._registry[`${context}:${event.code}`] || - this._registry[`*:${event.code}`]; - _debugShow( - _keyCombo(event), - handler ? `${context}:${event.code}` : "external", - ); + this._registry[`*:${event.code}`]; // Wildcard context * + _debugShow(combo, handler ? `${context}:${combo}` : "external"); if (handler) handler(event); }, }; diff --git a/main/main.js b/main/main.js index fab6fd4e..b376e466 100644 --- a/main/main.js +++ b/main/main.js @@ -49,6 +49,7 @@ import { translate, } from "./translation.js"; import { ShortcutsPanel } from "./accessibility.js"; +import { InputManager } from "./inputmanager.js"; import "./context.js"; function isEmbedModeEnabled() { @@ -603,67 +604,19 @@ function initializeApp() { // Enable the file input after initialization fileInput.removeAttribute("disabled"); - // keydown event listener (capture phase to ensure shortcuts - // are handled before any other handler can stop propagation) - document.addEventListener( - "keydown", - function (e) { - // Check for modifier key (Ctrl on Windows/Linux, Cmd on Mac) - if (!(e.ctrlKey || e.metaKey)) return; - - let key = e.key.toLowerCase(); - if (e.code === "KeyM" && key !== "m") key = "m"; - if (e.code === "KeyE" && key !== "e") key = "e"; - - switch (key) { - case "o": // Ctrl+O - Open file - e.preventDefault(); - openFile(workspace, executeCode); - break; - - case "s": // Ctrl+S - Save/Export - e.preventDefault(); - exportCode(workspace); // Or saveWorkspace(workspace) for autosave - break; - - case "p": // Ctrl+P - Execute code - e.preventDefault(); - const canvas = document.getElementById("renderCanvas"); - canvas.focus({ preventScroll: true }); - break; - - case "/": { - e.preventDefault(); - ShortcutsPanel.toggle(); - break; - } - - case "m": { - // Ctrl+M - Move focus to main menu button - e.preventDefault(); - if (menuButton) menuButton.focus(); - break; - } - - case "g": { - // Ctrl+G - Focus shapes button - e.preventDefault(); - const btn = document.getElementById("showShapesButton"); - if (btn && !btn.disabled && btn.offsetParent !== null) { - btn.focus(); - } - break; - } - - case "e": // Ctrl+E - Focus Blockly workspace/editor and move cursor - e.preventDefault(); - Blockly.keyboardNavigationController?.setIsActive?.(true); - Blockly.getFocusManager()?.focusTree?.(workspace); - break; - } - }, - true, - ); + InputManager.on("*", "Mod+KeyO", (e) => { e.preventDefault(); openFile(workspace, executeCode); }); + InputManager.on("*", "Mod+KeyS", (e) => { e.preventDefault(); exportCode(workspace); }); + InputManager.on("*", "Mod+KeyP", (e) => { + e.preventDefault(); + document.getElementById("renderCanvas")?.focus({ preventScroll: true }); + }); + InputManager.on("*", "Mod+Slash", (e) => { e.preventDefault(); ShortcutsPanel.toggle(); }); + InputManager.on("*", "Mod+KeyM", (e) => { e.preventDefault(); if (menuButton) menuButton.focus(); }); + InputManager.on("*", "Mod+KeyE", (e) => { + e.preventDefault(); + Blockly.keyboardNavigationController?.setIsActive?.(true); + Blockly.getFocusManager()?.focusTree?.(workspace); + }); if (toggleDesignButton) { toggleDesignButton.addEventListener("click", toggleDesignMode); } diff --git a/ui/colourpicker.js b/ui/colourpicker.js index 50e9e3a6..2e097b16 100644 --- a/ui/colourpicker.js +++ b/ui/colourpicker.js @@ -1,5 +1,7 @@ import { translate } from "../main/translation.js"; import { exitGizmoState } from "./gizmos.js"; +import { InputManager } from "../main/inputmanager.js"; +import { ContextManager } from "../main/context.js"; const COLOR_PALETTES = { Bright: [ @@ -1863,18 +1865,11 @@ class CustomColorPicker { document.getElementById("colorPickerButton")?.classList.add("active"); // Add P shortcut to pick current colour - if (document._colorPickerShortcut) { - document.removeEventListener("keydown", document._colorPickerShortcut); - } - document._colorPickerShortcut = (e) => { - if (e.key !== "p" && e.key !== "P") return; - const tag = (e.target?.tagName || "").toLowerCase(); - if (tag === "input" || tag === "textarea" || e.target?.isContentEditable) - return; + InputManager.on("*", "KeyP", (e) => { + if (ContextManager.getCurrentContext() === "TYPING") return; e.preventDefault(); this.container.querySelector(".color-picker-use")?.click(); - }; - document.addEventListener("keydown", document._colorPickerShortcut); + }); // --- Positioning (unchanged) --- const colorButton = document.getElementById("colorPickerButton"); @@ -2086,8 +2081,7 @@ class CustomColorPicker { document.getElementById("colorPickerButton")?.classList.remove("active"); document.removeEventListener("click", this.outsideClickHandler, true); window.removeEventListener("keydown", this.globalEscapeHandler, true); - document.removeEventListener("keydown", document._colorPickerShortcut); - document._colorPickerShortcut = null; + InputManager.off("*", "KeyP"); } confirmColor() { From fcdca13b80d741a1d736205c80f49f9bbd3046f8 Mon Sep 17 00:00:00 2001 From: lawsie <5183697+lawsie@users.noreply.github.com> Date: Thu, 7 May 2026 12:19:03 +0100 Subject: [PATCH 4/8] Color picker bugfix --- ui/gizmos.js | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/ui/gizmos.js b/ui/gizmos.js index 75aff205..b4e8a9d1 100644 --- a/ui/gizmos.js +++ b/ui/gizmos.js @@ -129,7 +129,8 @@ document.addEventListener("DOMContentLoaded", function () { window.selectedColor = newColor; }, onClose: () => { - // After color picker closes, start mesh selection + // Re-activate button: painting mode is still a gizmo action + document.getElementById("colorPickerButton")?.classList.add("active"); pickMeshFromCanvas(); }, excludeFromClose: (target) => { @@ -164,6 +165,7 @@ document.addEventListener("DOMContentLoaded", function () { colorButton.addEventListener("click", (event) => { event.preventDefault(); if (colorPicker) { + GizmoMenuManager.toggle(false); colorPicker.open(window.selectedColor); } }); @@ -180,10 +182,7 @@ function pickMeshFromCanvas() { // Exit if outside canvas if (eventIsOutOfCanvasBounds(event, canvasRect)) { window.removeEventListener("click", onPickMesh); - stopCanvasKeyboardMode(); - // restore cursors - document.body.style.cursor = "default"; - flock.scene.defaultCursor = ""; + exitGizmoState(); return; } @@ -191,8 +190,16 @@ function pickMeshFromCanvas() { applyColorAtPosition(canvasX, canvasY); }; + // Register cleanup so Escape during painting mode also tears down correctly + onExit(() => { + window.removeEventListener("click", onPickMesh); + stopCanvasKeyboardMode(); + document.body.style.cursor = "default"; + if (flock.scene) flock.scene.defaultCursor = ""; + }); + startCanvasKeyboardMode((x, y) => applyColorAtPosition(x, y)); - document.body.style.cursor = "crosshair"; // works + document.body.style.cursor = "crosshair"; flock.scene.defaultCursor = "crosshair"; setTimeout(() => { From c303fd5f597016e0c0eeaf1d6be34ebf14541a7f Mon Sep 17 00:00:00 2001 From: lawsie <5183697+lawsie@users.noreply.github.com> Date: Thu, 7 May 2026 12:24:16 +0100 Subject: [PATCH 5/8] Ctrl + G always opens overlay --- main/accessibility.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main/accessibility.js b/main/accessibility.js index 7fc33a99..f4b444c8 100644 --- a/main/accessibility.js +++ b/main/accessibility.js @@ -208,7 +208,7 @@ const GizmoMenuManager = { if (ctx === "TYPING" || ctx === "OVERLAY") return; e.preventDefault(); e.stopPropagation(); - this.toggle(!this.isOpen()); + this.toggle(true); }); // Activate gizmo buttons with number keys From 70fc026a4d4220bedfcb889335a01c860413b354 Mon Sep 17 00:00:00 2001 From: lawsie <5183697+lawsie@users.noreply.github.com> Date: Thu, 7 May 2026 12:26:15 +0100 Subject: [PATCH 6/8] Yield to editor --- main/context.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/main/context.js b/main/context.js index ced736f0..000be22a 100644 --- a/main/context.js +++ b/main/context.js @@ -54,8 +54,14 @@ export const ContextManager = { } // GIZMO: Is a gizmo currently active? + // Yield to EDITOR if the user is actively working in Blockly if (document.querySelector(".gizmo-button.active")) { - return "GIZMO"; + const currentGesture = window.Blockly?.Gesture?.getCurrentGesture?.(); + const isBlocklyActive = + currentGesture?.isDragging?.() || + activeEl?.closest(".blocklySvg") || + activeEl?.closest(".blocklyToolbox"); + if (!isBlocklyActive) return "GIZMO"; } // RESIZER: Are they changing the canvas size? From 3015df4afc74314aac4e98237b91b3da46c3c156 Mon Sep 17 00:00:00 2001 From: lawsie <5183697+lawsie@users.noreply.github.com> Date: Thu, 7 May 2026 12:32:23 +0100 Subject: [PATCH 7/8] Stop typing after overlay --- main/accessibility.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/main/accessibility.js b/main/accessibility.js index f4b444c8..2565f64c 100644 --- a/main/accessibility.js +++ b/main/accessibility.js @@ -57,7 +57,8 @@ const AreaManager = { InputManager.on("OVERLAY", "Escape", () => this.toggle(false)); for (let i = 1; i <= 9; i++) { - InputManager.on("OVERLAY", `Digit${i}`, () => { + InputManager.on("OVERLAY", `Digit${i}`, (e) => { + e.preventDefault(); const area = this.areas.find((a) => a.label === String(i)); if (area) this.activateArea(area); }); From ae142118684aaa624e66a1b23a0bea27c213e2f0 Mon Sep 17 00:00:00 2001 From: lawsie <5183697+lawsie@users.noreply.github.com> Date: Thu, 7 May 2026 12:56:25 +0100 Subject: [PATCH 8/8] Fix bug with saving --- main/main.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/main/main.js b/main/main.js index b376e466..ff67488d 100644 --- a/main/main.js +++ b/main/main.js @@ -585,7 +585,7 @@ function initializeApp() { } runCodeButton.addEventListener("click", executeCode); stopCodeButton.addEventListener("click", stopCode); - exportCodeButton.addEventListener("click", exportCode); + exportCodeButton.addEventListener("click", () => exportCode(workspace)); // Make open button work with keyboard if (openButton) { @@ -680,7 +680,7 @@ function initializeApp() { if (projectSave) { projectSave.addEventListener("click", function (e) { e.preventDefault(); - exportCode(); + exportCode(workspace); document.getElementById("menuDropdown")?.classList.add("hidden"); }); }