diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b5c90a9..77d8c382 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Enabled measuring when magnified for precision measuring - Pressing an arrow button once will move the pattern by 1/8" or 2mm - Slow down speed of pattern movement with arrow keys +- Arrow key movement uses modifier keys for granularity (Shift = coarse, Ctrl = fine, Alt = finer, combinations for extremes); supports diagonal movement and seamless granularity switching without releasing the arrow key ## [1.3.0] - 2025-06-25 diff --git a/app/_hooks/use-prog-arrow-key-handler.ts b/app/_hooks/use-prog-arrow-key-handler.ts index 0cefaf49..a28846f8 100644 --- a/app/_hooks/use-prog-arrow-key-handler.ts +++ b/app/_hooks/use-prog-arrow-key-handler.ts @@ -1,61 +1,145 @@ import { KeyCode } from "@/_lib/key-code"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef } from "react"; + +export interface ArrowKeyGranularity { + normal: number; + shift: number; + ctrl: number; + alt: number; + shiftCtrl: number; + shiftAlt: number; +} + +const DEFAULT_GRANULARITY: ArrowKeyGranularity = { + normal: 1, + shift: 10, + ctrl: 0.1, + alt: 0.5, + shiftCtrl: 100, + shiftAlt: 0.05, +}; + +const REPEAT_DELAY_MS = 400; +const REPEAT_INTERVAL_MS = 30; + +interface Modifiers { + shift: boolean; + ctrl: boolean; + alt: boolean; +} + +function granularityFromModifiers(m: Modifiers, g: ArrowKeyGranularity): number { + if (m.shift && m.alt) return g.shiftAlt; + if (m.shift && m.ctrl) return g.shiftCtrl; + if (m.alt) return g.alt; + if (m.ctrl) return g.ctrl; + if (m.shift) return g.shift; + return g.normal; +} + +function isArrowCode(code: string): code is KeyCode { + return ( + code === KeyCode.ArrowLeft || + code === KeyCode.ArrowUp || + code === KeyCode.ArrowRight || + code === KeyCode.ArrowDown + ); +} + +function isModifierCode(code: string): boolean { + return ( + code === "ShiftLeft" || + code === "ShiftRight" || + code === "ControlLeft" || + code === "ControlRight" || + code === "AltLeft" || + code === "AltRight" + ); +} -const PIXEL_LIST = [1, 10, 20, 40]; export default function useProgArrowKeyHandler( handler: (key: KeyCode, px: number, fullScreen: boolean) => void, active: boolean, - pixelList: number[] = PIXEL_LIST, fullScreen: boolean, + granularity: ArrowKeyGranularity = DEFAULT_GRANULARITY, ) { - const [pixelIdx, setPixelIdx] = useState(0); - const [timeoutFunc, setTimeoutFunc] = useState(); - - const keydownHandler = useCallback( - function (e: KeyboardEvent) { - if (e.target instanceof HTMLInputElement) return; - switch (e.code) { - case KeyCode.ArrowLeft: - case KeyCode.ArrowUp: - case KeyCode.ArrowRight: - case KeyCode.ArrowDown: - e.preventDefault(); - if (!timeoutFunc && pixelIdx < pixelList.length - 1) { - setTimeoutFunc( - setTimeout(() => { - setPixelIdx(pixelIdx + 1); - setTimeoutFunc(null); - }, 600), - ); - } - handler(e.code, pixelList[pixelIdx], fullScreen); - break; - default: - break; - } - }, - [timeoutFunc, pixelIdx, handler, pixelList, fullScreen], - ); + const heldArrowsRef = useRef>(new Set()); + const modifiersRef = useRef({ shift: false, ctrl: false, alt: false }); + const intervalRef = useRef | null>(null); + const delayRef = useRef | null>(null); - const keyupHandler = useCallback( - function () { - if (timeoutFunc) { - clearTimeout(timeoutFunc); - setTimeoutFunc(null); - } - setPixelIdx(0); - }, - [timeoutFunc], - ); + // Stable refs so interval callbacks don't go stale + const handlerRef = useRef(handler); + handlerRef.current = handler; + const fullScreenRef = useRef(fullScreen); + fullScreenRef.current = fullScreen; + const granularityRef = useRef(granularity); + granularityRef.current = granularity; + + const fire = useCallback(() => { + const px = granularityFromModifiers(modifiersRef.current, granularityRef.current); + heldArrowsRef.current.forEach((key) => { + handlerRef.current(key, px, fullScreenRef.current); + }); + }, []); + + const stopRepeat = useCallback(() => { + if (delayRef.current !== null) { + clearTimeout(delayRef.current); + delayRef.current = null; + } + if (intervalRef.current !== null) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }, []); + + const ensureRepeatRunning = useCallback(() => { + if (delayRef.current !== null || intervalRef.current !== null) return; + delayRef.current = setTimeout(() => { + delayRef.current = null; + intervalRef.current = setInterval(fire, REPEAT_INTERVAL_MS); + }, REPEAT_DELAY_MS); + }, [fire]); + + const keyDownHandler = useCallback((e: KeyboardEvent) => { + if (e.target instanceof HTMLInputElement) return; + + if (isArrowCode(e.code)) { + e.preventDefault(); + if (e.repeat) return; // our interval handles repeating + modifiersRef.current = { shift: e.shiftKey, ctrl: e.ctrlKey, alt: e.altKey }; + heldArrowsRef.current.add(e.code); + fire(); + ensureRepeatRunning(); + } else if (isModifierCode(e.code) && heldArrowsRef.current.size > 0) { + // Modifier added while arrow held — update state and fire once immediately. + // The running interval will continue at the same rate with the new granularity. + modifiersRef.current = { shift: e.shiftKey, ctrl: e.ctrlKey, alt: e.altKey }; + fire(); + } + }, [fire, ensureRepeatRunning]); + + const keyUpHandler = useCallback((e: KeyboardEvent) => { + if (isArrowCode(e.code)) { + heldArrowsRef.current.delete(e.code); + if (heldArrowsRef.current.size === 0) stopRepeat(); + } else if (isModifierCode(e.code) && heldArrowsRef.current.size > 0) { + modifiersRef.current = { shift: e.shiftKey, ctrl: e.ctrlKey, alt: e.altKey }; + } + }, [stopRepeat]); useEffect(() => { if (active) { - document.addEventListener("keydown", keydownHandler); - document.addEventListener("keyup", keyupHandler); + document.addEventListener("keydown", keyDownHandler); + document.addEventListener("keyup", keyUpHandler); + const heldArrows = heldArrowsRef.current; return () => { - document.removeEventListener("keydown", keydownHandler); - document.removeEventListener("keyup", keyupHandler); + document.removeEventListener("keydown", keyDownHandler); + document.removeEventListener("keyup", keyUpHandler); + stopRepeat(); + heldArrows.clear(); }; } - }, [keydownHandler, keyupHandler, active]); + }, [keyDownHandler, keyUpHandler, active, stopRepeat]); } diff --git a/app/_hooks/use-prog-arrow-key-points.ts b/app/_hooks/use-prog-arrow-key-points.ts index adce452a..39975ade 100644 --- a/app/_hooks/use-prog-arrow-key-points.ts +++ b/app/_hooks/use-prog-arrow-key-points.ts @@ -39,7 +39,6 @@ export default function useProgArrowKeyPoints( useProgArrowKeyHandler( applyOffset, corners.size > 0 && active, - [1, 3, 5, 10], fullScreen, ); diff --git a/app/_hooks/use-prog-arrow-key-to-matrix.ts b/app/_hooks/use-prog-arrow-key-to-matrix.ts index 5653006a..a5bc9309 100644 --- a/app/_hooks/use-prog-arrow-key-to-matrix.ts +++ b/app/_hooks/use-prog-arrow-key-to-matrix.ts @@ -8,7 +8,6 @@ export default function useProgArrowKeyToMatrix( scale: number, applyChange: (matrix: Matrix) => void, ) { - const PIXEL_LIST = [1, 2, 4]; function moveWithArrowKey(key: string, px: number) { let newOffset: Point = { x: 0, y: 0 }; const dist = px * scale; @@ -32,5 +31,9 @@ export default function useProgArrowKeyToMatrix( applyChange(m); } - useProgArrowKeyHandler(moveWithArrowKey, active, PIXEL_LIST, false); + useProgArrowKeyHandler( + moveWithArrowKey, + active, + false, + ); }