diff --git a/style.css b/style.css index e3f52892..b9394a24 100644 --- a/style.css +++ b/style.css @@ -1325,3 +1325,52 @@ 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%); +} + +/* 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 the 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 29c75332..a512a473 100644 --- a/ui/addmenu.js +++ b/ui/addmenu.js @@ -14,6 +14,10 @@ import { createBlockForCharacter, } from "./blocklyutil.js"; import { roundPositionValue } from "./blocklyshadowutil.js"; +import { + startCanvasKeyboardMode, + stopCanvasKeyboardMode, +} from "./canvas-utils.js"; const colorFields = { HAIR_COLOR: "#000000", // Hair: black @@ -260,7 +264,7 @@ function selectCharacter(characterName) { }; try { - startKeyboardPlacementMode?.(flock.activePickHandler); + startPlacementKeyboardMode(); } catch (error) { console.warn("Unable to start keyboard placement mode.", error); } @@ -312,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"; @@ -381,7 +385,7 @@ function selectObjectWithCommand(objectName, menu, command) { }; try { - startKeyboardPlacementMode?.(flock.activePickHandler); + startPlacementKeyboardMode(); } catch (error) { console.warn("Unable to start keyboard placement mode.", error); } @@ -660,15 +664,10 @@ function registerActivePickHandler( function cleanupPlacementMode() { detachActivePickHandler(); - endKeyboardPlacementMode(); + stopCanvasKeyboardMode(); document.body.style.cursor = "default"; } -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 @@ -744,70 +743,10 @@ function removeKeyboardNavigation() { }); } -function endKeyboardPlacementMode() { - keyboardPlacementMode = false; - placementCallback = null; - - if (placementCircle) { - placementCircle.remove(); - placementCircle = null; - } - - 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) { if (!keyboardNavigationActive) return; - if (keyboardPlacementMode) return; const allItems = getAllNavigableItems(); if (allItems.length === 0) return; @@ -877,90 +816,26 @@ 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 (!placementCircle) { - createPlacementCircle(); - document.body.style.cursor = "none"; - } - placementCirclePosition.x += moveDistance; - updatePlacementCirclePosition(); - break; - - case "ArrowLeft": - event.preventDefault(); - if (!placementCircle) { - createPlacementCircle(); - document.body.style.cursor = "none"; - } - placementCirclePosition.x -= moveDistance; - updatePlacementCirclePosition(); - break; - - case "ArrowDown": - event.preventDefault(); - if (!placementCircle) { - createPlacementCircle(); - document.body.style.cursor = "none"; - } - placementCirclePosition.y += moveDistance; - updatePlacementCirclePosition(); - break; - - case "ArrowUp": - event.preventDefault(); - if (!placementCircle) { - createPlacementCircle(); - document.body.style.cursor = "none"; - } - placementCirclePosition.y -= moveDistance; - updatePlacementCirclePosition(); - break; - - case "Enter": - case " ": - case "Spacebar": - case "Space": - event.preventDefault(); - triggerPlacement(); - break; - - case "Escape": - event.preventDefault(); +function startPlacementKeyboardMode() { + const isValidHit = (x, y) => + !!flock.scene.pick(x, y, (mesh) => mesh.isPickable)?.hit; + + 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(); - break; - - default: - break; - } -} - -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(); + }, + keyboardNavigationActive, + isValidHit, + ); } // Export functions to be used globally diff --git a/ui/canvas-utils.js b/ui/canvas-utils.js new file mode 100644 index 00000000..e731a7b7 --- /dev/null +++ b/ui/canvas-utils.js @@ -0,0 +1,197 @@ +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 }; +let keyboardCursorActive = false; +let keyboardCursorCallback = null; +let hitChecker = null; + +// 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(); +} + +// 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; + + 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"; + updateCanvasCircleHitState(); +} + +// 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); + } +} + +// Start keyboard mode on the canvas +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(); + 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() { + keyboardCursorActive = false; + keyboardCursorCallback = null; + hitChecker = 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"; +} + +// Make sure there actually is a circle +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"; + } +} + +// Deal with key down events for canvas keyboard mode +function handleKeydown(event) { + if (!keyboardCursorActive) 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(); + ensureCircle(); // It must exist to click it + // 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 { + // The location was valid, do the click + clickCanvasCircle(keyboardCursorCallback); + } + break; + + case "Escape": + event.preventDefault(); + stopCanvasKeyboardMode(); + break; + + default: + break; + } +} diff --git a/ui/gizmos.js b/ui/gizmos.js index ea55e96f..1c78654a 100644 --- a/ui/gizmos.js +++ b/ui/gizmos.js @@ -22,6 +22,10 @@ import { roundVectorToFixed, pickLeafFromRay, } from "./meshhelpers.js"; +import { + startCanvasKeyboardMode, + stopCanvasKeyboardMode, +} from "./canvas-utils.js"; export let gizmoManager; const blueColor = flock.BABYLON.Color3.FromHexString("#0072B2"); // Colour for X-axis @@ -35,13 +39,6 @@ let colorPicker = null; let textScaleAxis = null; 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"; // Track DO sections and their associated blocks for cleanup @@ -147,7 +144,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"; @@ -160,7 +157,7 @@ function pickMeshFromCanvas() { canvas.style.cursor = "crosshair"; }; - startColorPickingKeyboardMode(onPickMesh); + startCanvasKeyboardMode((x, y) => applyColorAtPosition(x, y)); document.body.style.cursor = "crosshair"; canvas.style.cursor = "crosshair"; @@ -191,138 +188,6 @@ 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 (!colorPickingCircle) { - createColorPickingCircle(); - document.body.style.cursor = "none"; - } - } - - if (!colorPickingKeyboardMode) return; - - const moveDistance = event.shiftKey ? 10 : 2; - switch (event.key) { - case "ArrowRight": - preventDefaultEventAndDefineColourPickingCircle(); - colorPickingCirclePosition.x += moveDistance; - updateColorPickingCirclePosition(); - break; - case "ArrowLeft": - preventDefaultEventAndDefineColourPickingCircle(); - colorPickingCirclePosition.x -= moveDistance; - updateColorPickingCirclePosition(); - break; - case "ArrowUp": - preventDefaultEventAndDefineColourPickingCircle(); - colorPickingCirclePosition.y -= moveDistance; - updateColorPickingCirclePosition(); - break; - case "ArrowDown": - preventDefaultEventAndDefineColourPickingCircle(); - colorPickingCirclePosition.y += moveDistance; - updateColorPickingCirclePosition(); - break; - case "Enter": - event.preventDefault(); - if (colorPickingCircle) { - applyColorAtPosition( - colorPickingCirclePosition.x, - colorPickingCirclePosition.y, - ); - } - break; - case "Escape": - event.preventDefault(); - break; - } -} - -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; - - // Remove keyboard listener(s) - document.removeEventListener("keydown", handleColorPickingKeydown, { - capture: true, - }); - document.removeEventListener("keydown", handleColorPickingKeydown); - - // Remove pointer listener if active - if (_onPickMeshRef) { - document.removeEventListener("pointerdown", _onPickMeshRef, true); - _onPickMeshRef = null; - } - - document.body.style.cursor = "default"; - - if (colorPickingCircle) { - colorPickingCircle.remove(); - colorPickingCircle = null; - } -} - // For composite meshes where visibility needs setting to // 0.001 in order to show parent mesh's bounding box function resetBoundingBoxVisibilityIfManuallyChanged(mesh) { @@ -504,7 +369,7 @@ export function disableGizmos() { gizmoManager.rotationGizmoEnabled = false; gizmoManager.scaleGizmoEnabled = false; gizmoManager.boundingBoxGizmoEnabled = false; - endColorPickingMode(); + stopCanvasKeyboardMode(); } // Toggle which Gizmo is being used