diff --git a/main/accessibility.js b/main/accessibility.js index cbda5ef5..2565f64c 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 @@ -45,54 +48,48 @@ 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}`, (e) => { + e.preventDefault(); + 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 @@ -105,6 +102,7 @@ const AreaManager = { ) ?? el; // Focus the area itself if no suitable child focusable?.focus(); + if (area.selector === "#gizmoButtons") GizmoMenuManager.toggle(true); }, renderHighlights() { @@ -179,6 +177,17 @@ const GizmoMenuManager = { if (!this.overlay) return; if (show) { this.renderBadges(); + + // Check if the Gizmo number shortcut 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"); @@ -194,49 +203,28 @@ const GizmoMenuManager = { }, setupListeners() { - window.addEventListener( - "keydown", - (e) => { - // Show the overlay on Ctrl+G - if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "g") { - e.preventDefault(); - e.stopPropagation(); // prevent main.js from also handling this - 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(true); + }); - // Do nothing if the overlay isn't open + // 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); + }); + } - // 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; - - // 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 (e.key === "Escape") { - e.preventDefault(); - this.toggle(false); - } - }, - true, - ); - + // Move the gizmo buttons 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); @@ -430,6 +418,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/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? 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..ff67488d 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() { @@ -584,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) { @@ -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); } @@ -727,7 +680,7 @@ function initializeApp() { if (projectSave) { projectSave.addEventListener("click", function (e) { e.preventDefault(); - exportCode(); + exportCode(workspace); document.getElementById("menuDropdown")?.classList.add("hidden"); }); } 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() { diff --git a/ui/gizmos.js b/ui/gizmos.js index 677f5ab9..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(() => { @@ -583,7 +590,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 @@ -1292,8 +1298,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";