Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
176 changes: 130 additions & 46 deletions app/_hooks/use-prog-arrow-key-handler.ts
Original file line number Diff line number Diff line change
@@ -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<number>(0);
const [timeoutFunc, setTimeoutFunc] = useState<NodeJS.Timeout | null>();

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<Set<KeyCode>>(new Set());
const modifiersRef = useRef<Modifiers>({ shift: false, ctrl: false, alt: false });
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const delayRef = useRef<ReturnType<typeof setTimeout> | 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]);
}
1 change: 0 additions & 1 deletion app/_hooks/use-prog-arrow-key-points.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ export default function useProgArrowKeyPoints(
useProgArrowKeyHandler(
applyOffset,
corners.size > 0 && active,
[1, 3, 5, 10],
fullScreen,
);

Expand Down
7 changes: 5 additions & 2 deletions app/_hooks/use-prog-arrow-key-to-matrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -32,5 +31,9 @@ export default function useProgArrowKeyToMatrix(
applyChange(m);
}

useProgArrowKeyHandler(moveWithArrowKey, active, PIXEL_LIST, false);
useProgArrowKeyHandler(
moveWithArrowKey,
active,
false,
);
}