From 8788ed6fbfc9701c0998280ec7f1bf2e461acc7b Mon Sep 17 00:00:00 2001 From: lawsie <5183697+lawsie@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:10:43 +0100 Subject: [PATCH 1/8] Move canvasCircle to its own module --- style.css | 15 ++++++++ ui/canvas-utils.js | 75 +++++++++++++++++++++++++++++++++++++++ ui/gizmos.js | 88 +++++++++------------------------------------- 3 files changed, 107 insertions(+), 71 deletions(-) create mode 100644 ui/canvas-utils.js diff --git a/style.css b/style.css index e3f52892..e71a9c39 100644 --- a/style.css +++ b/style.css @@ -1325,3 +1325,18 @@ body.color-picker-open #renderCanvas { overflow: visible !important; /* override old scrollbar-hiding rule */ list-style: none; } + +/* Style for the yellow circle to place/select using kb controls */ +.canvas-selector-circle { + position: fixed; + width: 20px; + height: 20px; + border: 3px solid #ffff00; + border-radius: 50%; + pointer-events: none; + z-index: 10000; + box-shadow: + 0 0 0 1px rgba(0, 0, 0, 0.3), + 0 0 8px rgba(255, 255, 0, 0.5); + transform: translate(-50%, -50%); +} diff --git a/ui/canvas-utils.js b/ui/canvas-utils.js new file mode 100644 index 00000000..fb2796fc --- /dev/null +++ b/ui/canvas-utils.js @@ -0,0 +1,75 @@ +import { flock } from "../flock.js"; + +// Create yellow circle for canvas position indicator +// One circle selector can be active on the canvas at once +let canvasCircle = null; +let canvasCirclePosition = { x: 0, y: 0 }; + +// Returns a reference to the canvasCircle +export function getCanvasCircle() { + return canvasCircle; +} + +// Destroys the canvasCircle if it exists +export function destroyCanvasCircle() { + if (canvasCircle) { + canvasCircle.remove(); + canvasCircle = null; + } +} + +// Creates a canvasCircle if it doesn't exist - one can be active at a time +export function createCanvasCircle() { + if (canvasCircle) return; + + // Create the visual indicator circle + canvasCircle = document.createElement("div"); + canvasCircle.className = "canvas-selector-circle"; // Set style + document.body.appendChild(canvasCircle); + + // Initialize position to canvas center + const canvas = flock.scene.getEngine().getRenderingCanvas(); + const canvasBounds = canvas.getBoundingClientRect(); + canvasCirclePosition.x = canvasBounds.width / 2; + canvasCirclePosition.y = canvasBounds.height / 2; + + updateCanvasCirclePosition(); +} + +// Update the circle position and constrain it to the canvas +export function updateCanvasCirclePosition() { + if (!canvasCircle) return; + + const canvas = flock.scene.getEngine().getRenderingCanvas(); + const canvasBounds = canvas.getBoundingClientRect(); + + // Constrain position to canvas bounds + canvasCirclePosition.x = Math.max( + 10, + Math.min(canvasBounds.width - 10, canvasCirclePosition.x), + ); + canvasCirclePosition.y = Math.max( + 10, + Math.min(canvasBounds.height - 10, canvasCirclePosition.y), + ); + + // Position relative to canvas + canvasCircle.style.left = canvasBounds.left + canvasCirclePosition.x + "px"; + canvasCircle.style.top = canvasBounds.top + canvasCirclePosition.y + "px"; +} + +// Changes the coordinates of the canvasCircle +// Specify minus numbers to move left/up +// Auto updates the rendered position to keep in sync +export function moveCanvasCircle(dx, dy) { + canvasCirclePosition.x += dx; + canvasCirclePosition.y += dy; + updateCanvasCirclePosition(); +} + +// Calls the callback with the current canvasCircle coordinates if it exists +export function clickCanvasCircle(callback) { + if (canvasCircle) { + callback(canvasCirclePosition.x, canvasCirclePosition.y); + } +} diff --git a/ui/gizmos.js b/ui/gizmos.js index ea55e96f..51d8ef92 100644 --- a/ui/gizmos.js +++ b/ui/gizmos.js @@ -22,6 +22,13 @@ import { roundVectorToFixed, pickLeafFromRay, } from "./meshhelpers.js"; +import { + createCanvasCircle, + getCanvasCircle, + destroyCanvasCircle, + moveCanvasCircle, + clickCanvasCircle, +} from "./canvas-utils.js"; export let gizmoManager; const blueColor = flock.BABYLON.Color3.FromHexString("#0072B2"); // Colour for X-axis @@ -38,8 +45,6 @@ let textOrigScaleZ = 1; // Color picking keyboard mode variables let colorPickingKeyboardMode = false; let colorPickingCallback = null; -let colorPickingCircle = null; -let colorPickingCirclePosition = { x: 0, y: 0 }; let _onPickMeshRef = null; let cameraMode = "play"; @@ -204,8 +209,8 @@ function startColorPickingKeyboardMode(callback) { function handleColorPickingKeydown(event) { function preventDefaultEventAndDefineColourPickingCircle() { event.preventDefault(); - if (!colorPickingCircle) { - createColorPickingCircle(); + if (!getCanvasCircle()) { + createCanvasCircle(); document.body.style.cursor = "none"; } } @@ -216,32 +221,24 @@ function handleColorPickingKeydown(event) { switch (event.key) { case "ArrowRight": preventDefaultEventAndDefineColourPickingCircle(); - colorPickingCirclePosition.x += moveDistance; - updateColorPickingCirclePosition(); + moveCanvasCircle(moveDistance, 0); break; case "ArrowLeft": preventDefaultEventAndDefineColourPickingCircle(); - colorPickingCirclePosition.x -= moveDistance; - updateColorPickingCirclePosition(); + moveCanvasCircle(-moveDistance, 0); break; case "ArrowUp": preventDefaultEventAndDefineColourPickingCircle(); - colorPickingCirclePosition.y -= moveDistance; - updateColorPickingCirclePosition(); + moveCanvasCircle(0, -moveDistance); break; case "ArrowDown": preventDefaultEventAndDefineColourPickingCircle(); - colorPickingCirclePosition.y += moveDistance; - updateColorPickingCirclePosition(); + moveCanvasCircle(0, moveDistance); break; case "Enter": event.preventDefault(); - if (colorPickingCircle) { - applyColorAtPosition( - colorPickingCirclePosition.x, - colorPickingCirclePosition.y, - ); - } + // Apply the colour at the circle's position + clickCanvasCircle((x, y) => applyColorAtPosition(x, y)); break; case "Escape": event.preventDefault(); @@ -249,56 +246,6 @@ function handleColorPickingKeydown(event) { } } -function createColorPickingCircle() { - if (colorPickingCircle) return; - - // Create the visual indicator circle - colorPickingCircle = document.createElement("div"); - colorPickingCircle.style.cssText = ` - position: fixed; - width: 20px; - height: 20px; - border: 3px solid #ffff00; - border-radius: 50%; - pointer-events: none; - z-index: 10000; - box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.3), 0 0 8px rgba(255, 255, 0, 0.5); - transform: translate(-50%, -50%); - `; - document.body.appendChild(colorPickingCircle); - - // Initialize position to canvas center - const canvas = flock.scene.getEngine().getRenderingCanvas(); - const canvasRect = canvas.getBoundingClientRect(); - colorPickingCirclePosition.x = canvasRect.width / 2; - colorPickingCirclePosition.y = canvasRect.height / 2; - - updateColorPickingCirclePosition(); -} - -function updateColorPickingCirclePosition() { - if (!colorPickingCircle) return; - - const canvas = flock.scene.getEngine().getRenderingCanvas(); - const canvasRect = canvas.getBoundingClientRect(); - - // Constrain position to canvas bounds - colorPickingCirclePosition.x = Math.max( - 10, - Math.min(canvasRect.width - 10, colorPickingCirclePosition.x), - ); - colorPickingCirclePosition.y = Math.max( - 10, - Math.min(canvasRect.height - 10, colorPickingCirclePosition.y), - ); - - // Position relative to canvas - colorPickingCircle.style.left = - canvasRect.left + colorPickingCirclePosition.x + "px"; - colorPickingCircle.style.top = - canvasRect.top + colorPickingCirclePosition.y + "px"; -} - function endColorPickingMode() { colorPickingKeyboardMode = false; colorPickingCallback = null; @@ -317,9 +264,8 @@ function endColorPickingMode() { document.body.style.cursor = "default"; - if (colorPickingCircle) { - colorPickingCircle.remove(); - colorPickingCircle = null; + if (getCanvasCircle()) { + destroyCanvasCircle(); } } From 66c171201dc9c049d1d869d62c6c013ef232b923 Mon Sep 17 00:00:00 2001 From: lawsie <5183697+lawsie@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:23:08 +0100 Subject: [PATCH 2/8] Use canvasCircle for objects --- ui/addmenu.js | 105 +++++++++++++++----------------------------------- 1 file changed, 30 insertions(+), 75 deletions(-) diff --git a/ui/addmenu.js b/ui/addmenu.js index 29c75332..8dbd62dd 100644 --- a/ui/addmenu.js +++ b/ui/addmenu.js @@ -14,6 +14,13 @@ import { createBlockForCharacter, } from "./blocklyutil.js"; import { roundPositionValue } from "./blocklyshadowutil.js"; +import { + createCanvasCircle, + getCanvasCircle, + destroyCanvasCircle, + moveCanvasCircle, + clickCanvasCircle, +} from "./canvas-utils.js"; const colorFields = { HAIR_COLOR: "#000000", // Hair: black @@ -666,8 +673,6 @@ function cleanupPlacementMode() { let placementCallback = null; // Keyboard placement callback singleton let keyboardPlacementMode = false; -let placementCircle = null; -let placementCirclePosition = { x: 0, y: 0 }; function showShapes() { cancelPlacement(); // Always remove all placement modes when menu is opened/closed @@ -748,61 +753,13 @@ function endKeyboardPlacementMode() { keyboardPlacementMode = false; placementCallback = null; - if (placementCircle) { - placementCircle.remove(); - placementCircle = null; - } + destroyCanvasCircle(); document.removeEventListener("keydown", handlePlacementKeydown); document.body.style.cursor = "default"; } -function createPlacementCircle() { - if (placementCircle) placementCircle.remove(); - placementCircle = document.createElement("div"); - placementCircle.style.position = "fixed"; - placementCircle.style.width = "20px"; - placementCircle.style.height = "20px"; - placementCircle.style.borderRadius = "50%"; - placementCircle.style.border = "2px solid #FFD700"; - placementCircle.style.backgroundColor = "rgba(255, 215, 0, 0.3)"; - placementCircle.style.pointerEvents = "none"; - placementCircle.style.zIndex = "9999"; - placementCircle.style.transform = "translate(-50%, -50%)"; - - // Initialize position here: - const canvas = flock.scene.getEngine().getRenderingCanvas(); - const canvasRect = canvas.getBoundingClientRect(); - placementCirclePosition.x = canvasRect.width / 2; - placementCirclePosition.y = canvasRect.height * 0.7; - - updatePlacementCirclePosition(); - document.body.appendChild(placementCircle); -} - -function updatePlacementCirclePosition() { - if (!placementCircle) return; - - const canvas = flock.scene.getEngine().getRenderingCanvas(); - const canvasRect = canvas.getBoundingClientRect(); - - // Constrain position to canvas bounds - placementCirclePosition.x = Math.max( - 10, - Math.min(canvasRect.width - 10, placementCirclePosition.x), - ); - placementCirclePosition.y = Math.max( - 10, - Math.min(canvasRect.height - 10, placementCirclePosition.y), - ); - - // Position relative to canvas - placementCircle.style.left = - canvasRect.left + placementCirclePosition.x + "px"; - placementCircle.style.top = canvasRect.top + placementCirclePosition.y + "px"; -} - // --- Menu Keyboard Navigation Handling --- function handleShapeMenuKeydown(event) { @@ -892,42 +849,38 @@ function handlePlacementKeydown(event) { switch (event.key) { case "ArrowRight": event.preventDefault(); - if (!placementCircle) { - createPlacementCircle(); + if (!getCanvasCircle()) { + createCanvasCircle(); document.body.style.cursor = "none"; } - placementCirclePosition.x += moveDistance; - updatePlacementCirclePosition(); + moveCanvasCircle(moveDistance, 0); break; case "ArrowLeft": event.preventDefault(); - if (!placementCircle) { - createPlacementCircle(); + if (!getCanvasCircle()) { + createCanvasCircle(); document.body.style.cursor = "none"; } - placementCirclePosition.x -= moveDistance; - updatePlacementCirclePosition(); + moveCanvasCircle(-moveDistance, 0); break; case "ArrowDown": event.preventDefault(); - if (!placementCircle) { - createPlacementCircle(); + if (!getCanvasCircle()) { + createCanvasCircle(); document.body.style.cursor = "none"; } - placementCirclePosition.y += moveDistance; - updatePlacementCirclePosition(); + moveCanvasCircle(0, moveDistance); break; case "ArrowUp": event.preventDefault(); - if (!placementCircle) { - createPlacementCircle(); + if (!getCanvasCircle()) { + createCanvasCircle(); document.body.style.cursor = "none"; } - placementCirclePosition.y -= moveDistance; - updatePlacementCirclePosition(); + moveCanvasCircle(0, -moveDistance); break; case "Enter": @@ -950,17 +903,19 @@ function handlePlacementKeydown(event) { function triggerPlacement() { if (!placementCallback || !keyboardPlacementMode) return; - // Use placementCirclePosition as the "click" location for keyboard placement const canvas = flock.scene.getEngine().getRenderingCanvas(); const canvasRect = canvas.getBoundingClientRect(); - const syntheticEvent = { - clientX: canvasRect.left + placementCirclePosition.x, - clientY: canvasRect.top + placementCirclePosition.y, - defaultPosition: flock.BABYLON.Vector3.Zero(), - }; - placementCallback(syntheticEvent); - cancelPlacement(); + // Re-implement this using the clickCanvasCircle callback + clickCanvasCircle((x, y) => { + const syntheticEvent = { + clientX: canvasRect.left + x, + clientY: canvasRect.top + y, + defaultPosition: flock.BABYLON.Vector3.Zero(), + }; + placementCallback(syntheticEvent); + cancelPlacement(); + }); } // Export functions to be used globally From 2e4739002e89c94f1ef98a35280268d4f30e0b89 Mon Sep 17 00:00:00 2001 From: lawsie <5183697+lawsie@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:48:48 +0100 Subject: [PATCH 3/8] Colour picker uses generic kb mode --- ui/canvas-utils.js | 84 ++++++++++++++++++++++++++++++++++++++++++++++ ui/gizmos.js | 67 +++--------------------------------- 2 files changed, 88 insertions(+), 63 deletions(-) diff --git a/ui/canvas-utils.js b/ui/canvas-utils.js index fb2796fc..d1b02955 100644 --- a/ui/canvas-utils.js +++ b/ui/canvas-utils.js @@ -4,6 +4,8 @@ import { flock } from "../flock.js"; // One circle selector can be active on the canvas at once let canvasCircle = null; let canvasCirclePosition = { x: 0, y: 0 }; +let keyboardModeActive = false; +let keyboardModeCallback = null; // Returns a reference to the canvasCircle export function getCanvasCircle() { @@ -73,3 +75,85 @@ export function clickCanvasCircle(callback) { callback(canvasCirclePosition.x, canvasCirclePosition.y); } } + +// Start keyboard mode on the canvas +export function startCanvasKeyboardMode( + callback, + showCircleImmediately = false, +) { + stopCanvasKeyboardMode(); // Ensure any existing mode is cleared + keyboardModeActive = true; + keyboardModeCallback = callback; + document.addEventListener("keydown", handleKeydown); + if (showCircleImmediately) { + createCanvasCircle(); + document.body.style.cursor = "none"; // Hide cursor when circle is active + } else { + document.body.style.cursor = "default"; + } +} + +// Stop using keyboard mode on the canvas +export function stopCanvasKeyboardMode() { + keyboardModeActive = false; + keyboardModeCallback = null; + document.removeEventListener("keydown", handleKeydown); + destroyCanvasCircle(); + document.body.style.cursor = "default"; +} + +// Make sure there actually is a circle +function ensureCircle() { + if (!getCanvasCircle()) { + createCanvasCircle(); + document.body.style.cursor = "none"; + } +} + +// Deal with key down events for canvas keyboard mode +function handleKeydown(event) { + if (!keyboardModeActive) return; + + const moveDistance = event.shiftKey ? 10 : 2; + switch (event.key) { + case "ArrowRight": + event.preventDefault(); + ensureCircle(); + moveCanvasCircle(moveDistance, 0); + break; + + case "ArrowLeft": + event.preventDefault(); + ensureCircle(); + moveCanvasCircle(-moveDistance, 0); + break; + + case "ArrowDown": + event.preventDefault(); + ensureCircle(); + moveCanvasCircle(0, moveDistance); + break; + + case "ArrowUp": + event.preventDefault(); + ensureCircle(); + moveCanvasCircle(0, -moveDistance); + break; + + case "Enter": + case " ": + case "Spacebar": + case "Space": + event.preventDefault(); + clickCanvasCircle(keyboardModeCallback); + break; + + case "Escape": + event.preventDefault(); + stopCanvasKeyboardMode(); + break; + + default: + break; + } +} diff --git a/ui/gizmos.js b/ui/gizmos.js index 51d8ef92..39058158 100644 --- a/ui/gizmos.js +++ b/ui/gizmos.js @@ -28,6 +28,8 @@ import { destroyCanvasCircle, moveCanvasCircle, clickCanvasCircle, + startCanvasKeyboardMode, + stopCanvasKeyboardMode, } from "./canvas-utils.js"; export let gizmoManager; @@ -42,10 +44,6 @@ let colorPicker = null; let textScaleAxis = null; let textOrigScaleZ = 1; -// Color picking keyboard mode variables -let colorPickingKeyboardMode = false; -let colorPickingCallback = null; - let _onPickMeshRef = null; let cameraMode = "play"; @@ -165,7 +163,7 @@ function pickMeshFromCanvas() { canvas.style.cursor = "crosshair"; }; - startColorPickingKeyboardMode(onPickMesh); + startCanvasKeyboardMode((x, y) => applyColorAtPosition(x, y)); document.body.style.cursor = "crosshair"; canvas.style.cursor = "crosshair"; @@ -196,65 +194,8 @@ function applyColorAtPosition(canvasX, canvasY) { } } -// Color Picking Keyboard Mode Functions - -function startColorPickingKeyboardMode(callback) { - endColorPickingMode(); - colorPickingKeyboardMode = true; - colorPickingCallback = callback; - document.addEventListener("keydown", handleColorPickingKeydown); - document.body.style.cursor = "crosshair"; -} - -function handleColorPickingKeydown(event) { - function preventDefaultEventAndDefineColourPickingCircle() { - event.preventDefault(); - if (!getCanvasCircle()) { - createCanvasCircle(); - document.body.style.cursor = "none"; - } - } - - if (!colorPickingKeyboardMode) return; - - const moveDistance = event.shiftKey ? 10 : 2; - switch (event.key) { - case "ArrowRight": - preventDefaultEventAndDefineColourPickingCircle(); - moveCanvasCircle(moveDistance, 0); - break; - case "ArrowLeft": - preventDefaultEventAndDefineColourPickingCircle(); - moveCanvasCircle(-moveDistance, 0); - break; - case "ArrowUp": - preventDefaultEventAndDefineColourPickingCircle(); - moveCanvasCircle(0, -moveDistance); - break; - case "ArrowDown": - preventDefaultEventAndDefineColourPickingCircle(); - moveCanvasCircle(0, moveDistance); - break; - case "Enter": - event.preventDefault(); - // Apply the colour at the circle's position - clickCanvasCircle((x, y) => applyColorAtPosition(x, y)); - break; - case "Escape": - event.preventDefault(); - break; - } -} - function endColorPickingMode() { - colorPickingKeyboardMode = false; - colorPickingCallback = null; - - // Remove keyboard listener(s) - document.removeEventListener("keydown", handleColorPickingKeydown, { - capture: true, - }); - document.removeEventListener("keydown", handleColorPickingKeydown); + stopCanvasKeyboardMode(); // Remove pointer listener if active if (_onPickMeshRef) { From c83f7c71282b41134b78ce3f8798f039cfab4185 Mon Sep 17 00:00:00 2001 From: lawsie <5183697+lawsie@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:17:45 +0100 Subject: [PATCH 4/8] Use canvasCircle for placing too --- ui/addmenu.js | 117 ++++++--------------------------------------- ui/canvas-utils.js | 6 +++ ui/gizmos.js | 24 +--------- 3 files changed, 23 insertions(+), 124 deletions(-) diff --git a/ui/addmenu.js b/ui/addmenu.js index 8dbd62dd..a67b0724 100644 --- a/ui/addmenu.js +++ b/ui/addmenu.js @@ -15,11 +15,8 @@ import { } from "./blocklyutil.js"; import { roundPositionValue } from "./blocklyshadowutil.js"; import { - createCanvasCircle, - getCanvasCircle, - destroyCanvasCircle, - moveCanvasCircle, - clickCanvasCircle, + startCanvasKeyboardMode, + stopCanvasKeyboardMode, } from "./canvas-utils.js"; const colorFields = { @@ -267,7 +264,7 @@ function selectCharacter(characterName) { }; try { - startKeyboardPlacementMode?.(flock.activePickHandler); + startPlacementKeyboardMode(); } catch (error) { console.warn("Unable to start keyboard placement mode.", error); } @@ -319,7 +316,7 @@ function selectShape(shapeType) { }; // Start keyboard placement mode with singleton handler - startKeyboardPlacementMode(flock.activePickHandler); + startPlacementKeyboardMode(); // Also set up mouse click as fallback document.body.style.cursor = "crosshair"; @@ -388,7 +385,7 @@ function selectObjectWithCommand(objectName, menu, command) { }; try { - startKeyboardPlacementMode?.(flock.activePickHandler); + startPlacementKeyboardMode(); } catch (error) { console.warn("Unable to start keyboard placement mode.", error); } @@ -667,13 +664,10 @@ function registerActivePickHandler( function cleanupPlacementMode() { detachActivePickHandler(); - endKeyboardPlacementMode(); + stopCanvasKeyboardMode(); document.body.style.cursor = "default"; } -let placementCallback = null; // Keyboard placement callback singleton -let keyboardPlacementMode = false; - function showShapes() { cancelPlacement(); // Always remove all placement modes when menu is opened/closed @@ -749,22 +743,10 @@ function removeKeyboardNavigation() { }); } -function endKeyboardPlacementMode() { - keyboardPlacementMode = false; - placementCallback = null; - - destroyCanvasCircle(); - - document.removeEventListener("keydown", handlePlacementKeydown); - - document.body.style.cursor = "default"; -} - // --- Menu Keyboard Navigation Handling --- function handleShapeMenuKeydown(event) { if (!keyboardNavigationActive) return; - if (keyboardPlacementMode) return; const allItems = getAllNavigableItems(); if (allItems.length === 0) return; @@ -834,88 +816,19 @@ function handleShapeMenuKeydown(event) { } } -function startKeyboardPlacementMode(callback) { - endKeyboardPlacementMode(); - keyboardPlacementMode = true; - placementCallback = callback; - document.addEventListener("keydown", handlePlacementKeydown); - document.body.style.cursor = "crosshair"; -} - -function handlePlacementKeydown(event) { - if (!keyboardPlacementMode) return; - - const moveDistance = event.shiftKey ? 10 : 2; - switch (event.key) { - case "ArrowRight": - event.preventDefault(); - if (!getCanvasCircle()) { - createCanvasCircle(); - document.body.style.cursor = "none"; - } - moveCanvasCircle(moveDistance, 0); - break; - - case "ArrowLeft": - event.preventDefault(); - if (!getCanvasCircle()) { - createCanvasCircle(); - document.body.style.cursor = "none"; - } - moveCanvasCircle(-moveDistance, 0); - break; - - case "ArrowDown": - event.preventDefault(); - if (!getCanvasCircle()) { - createCanvasCircle(); - document.body.style.cursor = "none"; - } - moveCanvasCircle(0, moveDistance); - break; - - case "ArrowUp": - event.preventDefault(); - if (!getCanvasCircle()) { - createCanvasCircle(); - document.body.style.cursor = "none"; - } - moveCanvasCircle(0, -moveDistance); - break; - - case "Enter": - case " ": - case "Spacebar": - case "Space": - event.preventDefault(); - triggerPlacement(); - break; - - case "Escape": - event.preventDefault(); - cancelPlacement(); - break; - - default: - break; - } -} - -function triggerPlacement() { - if (!placementCallback || !keyboardPlacementMode) return; - const canvas = flock.scene.getEngine().getRenderingCanvas(); - const canvasRect = canvas.getBoundingClientRect(); - - // Re-implement this using the clickCanvasCircle callback - clickCanvasCircle((x, y) => { - const syntheticEvent = { +function startPlacementKeyboardMode() { + startCanvasKeyboardMode((x, y) => { + const canvasRect = flock.scene + .getEngine() + .getRenderingCanvas() + .getBoundingClientRect(); + flock.activePickHandler({ clientX: canvasRect.left + x, clientY: canvasRect.top + y, defaultPosition: flock.BABYLON.Vector3.Zero(), - }; - placementCallback(syntheticEvent); + }); cancelPlacement(); - }); + }, keyboardNavigationActive); } // Export functions to be used globally diff --git a/ui/canvas-utils.js b/ui/canvas-utils.js index d1b02955..c3a44a0a 100644 --- a/ui/canvas-utils.js +++ b/ui/canvas-utils.js @@ -99,6 +99,9 @@ export function stopCanvasKeyboardMode() { keyboardModeCallback = null; document.removeEventListener("keydown", handleKeydown); destroyCanvasCircle(); + // Reinstate mouse cursor when exiting keyboard mode + const canvas = flock.scene?.getEngine?.()?.getRenderingCanvas?.(); + if (canvas) canvas.style.cursor = ""; document.body.style.cursor = "default"; } @@ -106,6 +109,9 @@ export function stopCanvasKeyboardMode() { function ensureCircle() { if (!getCanvasCircle()) { createCanvasCircle(); + // Remove cursor otherwise you get both which is confusing + const canvas = flock.scene.getEngine().getRenderingCanvas(); + canvas.style.cursor = "none"; document.body.style.cursor = "none"; } } diff --git a/ui/gizmos.js b/ui/gizmos.js index 39058158..4e6784b1 100644 --- a/ui/gizmos.js +++ b/ui/gizmos.js @@ -23,11 +23,8 @@ import { pickLeafFromRay, } from "./meshhelpers.js"; import { - createCanvasCircle, getCanvasCircle, destroyCanvasCircle, - moveCanvasCircle, - clickCanvasCircle, startCanvasKeyboardMode, stopCanvasKeyboardMode, } from "./canvas-utils.js"; @@ -44,7 +41,6 @@ let colorPicker = null; let textScaleAxis = null; let textOrigScaleZ = 1; -let _onPickMeshRef = null; let cameraMode = "play"; // Track DO sections and their associated blocks for cleanup @@ -150,7 +146,7 @@ function pickMeshFromCanvas() { // Exit if outside canvas if (eventIsOutOfCanvasBounds(event, canvasRect)) { window.removeEventListener("click", onPickMesh); - endColorPickingMode(); + stopCanvasKeyboardMode(); // restore cursors document.body.style.cursor = "default"; canvas.style.cursor = "auto"; @@ -194,22 +190,6 @@ function applyColorAtPosition(canvasX, canvasY) { } } -function endColorPickingMode() { - stopCanvasKeyboardMode(); - - // Remove pointer listener if active - if (_onPickMeshRef) { - document.removeEventListener("pointerdown", _onPickMeshRef, true); - _onPickMeshRef = null; - } - - document.body.style.cursor = "default"; - - if (getCanvasCircle()) { - destroyCanvasCircle(); - } -} - // For composite meshes where visibility needs setting to // 0.001 in order to show parent mesh's bounding box function resetBoundingBoxVisibilityIfManuallyChanged(mesh) { @@ -391,7 +371,7 @@ export function disableGizmos() { gizmoManager.rotationGizmoEnabled = false; gizmoManager.scaleGizmoEnabled = false; gizmoManager.boundingBoxGizmoEnabled = false; - endColorPickingMode(); + stopCanvasKeyboardMode(); } // Toggle which Gizmo is being used From 47574fc586ef723926208beb8eb5f59414f2ec76 Mon Sep 17 00:00:00 2001 From: lawsie <5183697+lawsie@users.noreply.github.com> Date: Thu, 16 Apr 2026 09:47:47 +0100 Subject: [PATCH 5/8] Fix bug with placement --- ui/canvas-utils.js | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/ui/canvas-utils.js b/ui/canvas-utils.js index c3a44a0a..b34756cf 100644 --- a/ui/canvas-utils.js +++ b/ui/canvas-utils.js @@ -4,8 +4,8 @@ import { flock } from "../flock.js"; // One circle selector can be active on the canvas at once let canvasCircle = null; let canvasCirclePosition = { x: 0, y: 0 }; -let keyboardModeActive = false; -let keyboardModeCallback = null; +let keyboardCursorActive = false; +let keyboardCursorCallback = null; // Returns a reference to the canvasCircle export function getCanvasCircle() { @@ -82,8 +82,8 @@ export function startCanvasKeyboardMode( showCircleImmediately = false, ) { stopCanvasKeyboardMode(); // Ensure any existing mode is cleared - keyboardModeActive = true; - keyboardModeCallback = callback; + keyboardCursorActive = true; + keyboardCursorCallback = callback; document.addEventListener("keydown", handleKeydown); if (showCircleImmediately) { createCanvasCircle(); @@ -95,8 +95,8 @@ export function startCanvasKeyboardMode( // Stop using keyboard mode on the canvas export function stopCanvasKeyboardMode() { - keyboardModeActive = false; - keyboardModeCallback = null; + keyboardCursorActive = false; + keyboardCursorCallback = null; document.removeEventListener("keydown", handleKeydown); destroyCanvasCircle(); // Reinstate mouse cursor when exiting keyboard mode @@ -118,7 +118,7 @@ function ensureCircle() { // Deal with key down events for canvas keyboard mode function handleKeydown(event) { - if (!keyboardModeActive) return; + if (!keyboardCursorActive) return; const moveDistance = event.shiftKey ? 10 : 2; switch (event.key) { @@ -151,7 +151,8 @@ function handleKeydown(event) { case "Spacebar": case "Space": event.preventDefault(); - clickCanvasCircle(keyboardModeCallback); + ensureCircle(); // It must exist to click it + clickCanvasCircle(keyboardCursorCallback); break; case "Escape": From 9bb2486a32b11da284699ad9bc5d8706e2fd67c3 Mon Sep 17 00:00:00 2001 From: lawsie <5183697+lawsie@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:14:39 +0100 Subject: [PATCH 6/8] Cursor hint that placement invalid --- style.css | 34 ++++++++++++++++++++++++++++++++++ ui/addmenu.js | 28 ++++++++++++++++------------ ui/canvas-utils.js | 32 +++++++++++++++++++++++++++++++- 3 files changed, 81 insertions(+), 13 deletions(-) diff --git a/style.css b/style.css index e71a9c39..3529e9ab 100644 --- a/style.css +++ b/style.css @@ -1340,3 +1340,37 @@ body.color-picker-open #renderCanvas { 0 0 8px rgba(255, 255, 0, 0.5); transform: translate(-50%, -50%); } + +/* Canvas circle cursor when over invalid drop target */ +.canvas-selector-circle--no-hit { + border-color: #ffff00; + box-shadow: + 0 0 0 1px rgba(0, 0, 0, 0.3), + 0 0 8px rgba(255, 68, 68, 0.5); +} + +/* Animate an invalid cursor when trying to place on an invalid target */ +.canvas-selector-circle--no-hit::before { + content: ""; + position: absolute; + top: 50%; + left: 50%; + width: 22px; + height: 2px; + background: #ffff00; + transform: translate(-50%, -50%) rotate(45deg); +} + +@keyframes circle-invalid-flash { + 0%, + 100% { + transform: translate(-50%, -50%) scale(1); + } + 50% { + transform: translate(-50%, -50%) scale(1.5); + } +} + +.canvas-selector-circle--invalid-press { + animation: circle-invalid-flash 0.25s ease-out; +} diff --git a/ui/addmenu.js b/ui/addmenu.js index a67b0724..9ef6dffb 100644 --- a/ui/addmenu.js +++ b/ui/addmenu.js @@ -817,18 +817,22 @@ function handleShapeMenuKeydown(event) { } function startPlacementKeyboardMode() { - startCanvasKeyboardMode((x, y) => { - const canvasRect = flock.scene - .getEngine() - .getRenderingCanvas() - .getBoundingClientRect(); - flock.activePickHandler({ - clientX: canvasRect.left + x, - clientY: canvasRect.top + y, - defaultPosition: flock.BABYLON.Vector3.Zero(), - }); - cancelPlacement(); - }, keyboardNavigationActive); + startCanvasKeyboardMode( + (x, y) => { + const canvasRect = flock.scene + .getEngine() + .getRenderingCanvas() + .getBoundingClientRect(); + flock.activePickHandler({ + clientX: canvasRect.left + x, + clientY: canvasRect.top + y, + defaultPosition: flock.BABYLON.Vector3.Zero(), + }); + cancelPlacement(); + }, + keyboardNavigationActive, + (x, y) => !!flock.scene.pick(x, y, (mesh) => mesh.isPickable)?.hit, + ); } // Export functions to be used globally diff --git a/ui/canvas-utils.js b/ui/canvas-utils.js index b34756cf..02f17c80 100644 --- a/ui/canvas-utils.js +++ b/ui/canvas-utils.js @@ -6,6 +6,7 @@ let canvasCircle = null; let canvasCirclePosition = { x: 0, y: 0 }; let keyboardCursorActive = false; let keyboardCursorCallback = null; +let hitChecker = null; // Returns a reference to the canvasCircle export function getCanvasCircle() { @@ -38,6 +39,13 @@ export function createCanvasCircle() { updateCanvasCirclePosition(); } +// Check whether current position is on a pickable mesh +function updateCanvasCircleHitState() { + if (!canvasCircle || !hitChecker) return; + const valid = hitChecker(canvasCirclePosition.x, canvasCirclePosition.y); + canvasCircle.classList.toggle("canvas-selector-circle--no-hit", !valid); +} + // Update the circle position and constrain it to the canvas export function updateCanvasCirclePosition() { if (!canvasCircle) return; @@ -58,6 +66,7 @@ export function updateCanvasCirclePosition() { // Position relative to canvas canvasCircle.style.left = canvasBounds.left + canvasCirclePosition.x + "px"; canvasCircle.style.top = canvasBounds.top + canvasCirclePosition.y + "px"; + updateCanvasCircleHitState(); } // Changes the coordinates of the canvasCircle @@ -80,10 +89,12 @@ export function clickCanvasCircle(callback) { export function startCanvasKeyboardMode( callback, showCircleImmediately = false, + isValidPosition = null, ) { stopCanvasKeyboardMode(); // Ensure any existing mode is cleared keyboardCursorActive = true; keyboardCursorCallback = callback; + hitChecker = isValidPosition; document.addEventListener("keydown", handleKeydown); if (showCircleImmediately) { createCanvasCircle(); @@ -97,6 +108,7 @@ export function startCanvasKeyboardMode( export function stopCanvasKeyboardMode() { keyboardCursorActive = false; keyboardCursorCallback = null; + hitChecker = null; document.removeEventListener("keydown", handleKeydown); destroyCanvasCircle(); // Reinstate mouse cursor when exiting keyboard mode @@ -152,7 +164,25 @@ function handleKeydown(event) { case "Space": event.preventDefault(); ensureCircle(); // It must exist to click it - clickCanvasCircle(keyboardCursorCallback); + // If there's a hitChecker and it returns false + // show invalid press animation instead of clicking + if ( + hitChecker && + !hitChecker(canvasCirclePosition.x, canvasCirclePosition.y) + ) { + canvasCircle.classList.add("canvas-selector-circle--invalid-press"); + canvasCircle.addEventListener( + "animationend", + () => { + canvasCircle.classList.remove( + "canvas-selector-circle--invalid-press", + ); + }, + { once: true }, + ); + } else { + clickCanvasCircle(keyboardCursorCallback); + } break; case "Escape": From 7ed9a1ef5b546721c342e24970819e7a0d22c80b Mon Sep 17 00:00:00 2001 From: lawsie <5183697+lawsie@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:20:25 +0100 Subject: [PATCH 7/8] Small bit of tidying --- style.css | 2 +- ui/addmenu.js | 5 ++++- ui/canvas-utils.js | 1 + 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/style.css b/style.css index 3529e9ab..b9394a24 100644 --- a/style.css +++ b/style.css @@ -1349,7 +1349,7 @@ body.color-picker-open #renderCanvas { 0 0 8px rgba(255, 68, 68, 0.5); } -/* Animate an invalid cursor when trying to place on an invalid target */ +/* Animate the invalid cursor when trying to place on an invalid target */ .canvas-selector-circle--no-hit::before { content: ""; position: absolute; diff --git a/ui/addmenu.js b/ui/addmenu.js index 9ef6dffb..a512a473 100644 --- a/ui/addmenu.js +++ b/ui/addmenu.js @@ -817,6 +817,9 @@ function handleShapeMenuKeydown(event) { } function startPlacementKeyboardMode() { + const isValidHit = (x, y) => + !!flock.scene.pick(x, y, (mesh) => mesh.isPickable)?.hit; + startCanvasKeyboardMode( (x, y) => { const canvasRect = flock.scene @@ -831,7 +834,7 @@ function startPlacementKeyboardMode() { cancelPlacement(); }, keyboardNavigationActive, - (x, y) => !!flock.scene.pick(x, y, (mesh) => mesh.isPickable)?.hit, + isValidHit, ); } diff --git a/ui/canvas-utils.js b/ui/canvas-utils.js index 02f17c80..e731a7b7 100644 --- a/ui/canvas-utils.js +++ b/ui/canvas-utils.js @@ -181,6 +181,7 @@ function handleKeydown(event) { { once: true }, ); } else { + // The location was valid, do the click clickCanvasCircle(keyboardCursorCallback); } break; From eab582aa728ac6b64d1ead523e03325525df3cb4 Mon Sep 17 00:00:00 2001 From: lawsie <5183697+lawsie@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:55:40 +0100 Subject: [PATCH 8/8] Remove pointless imports --- ui/gizmos.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/ui/gizmos.js b/ui/gizmos.js index 4e6784b1..1c78654a 100644 --- a/ui/gizmos.js +++ b/ui/gizmos.js @@ -23,8 +23,6 @@ import { pickLeafFromRay, } from "./meshhelpers.js"; import { - getCanvasCircle, - destroyCanvasCircle, startCanvasKeyboardMode, stopCanvasKeyboardMode, } from "./canvas-utils.js";