From 678819ba0ebbf9a8289e97063774880cf87b488c Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 10 May 2026 12:01:27 +0200 Subject: [PATCH 1/4] feat(canvas): alt+click cycles selection through stacked objects Inverted shapes let users see objects layered behind them, but normal click-to-select can only reach the topmost object at a point. Alt+click now cycles through the stack: first click selects the topmost hit, each subsequent Alt+click at (approximately) the same pointer position dives one layer deeper, wrapping at the bottom. Implemented with a native capture-phase mousedown listener on the canvas container. Konva's getAllIntersections drives the hit-graph (handles view rotation, pan offset, per-shape transforms transparently). A pure nextCycleIndex helper holds the cycle state machine for unit testing. --- src/components/Canvas/LabelCanvas.tsx | 5 ++ src/components/Canvas/altClickCycle.test.ts | 36 ++++++++++ src/components/Canvas/altClickCycle.ts | 50 +++++++++++++ .../Canvas/hooks/useAltClickCycle.ts | 71 +++++++++++++++++++ 4 files changed, 162 insertions(+) create mode 100644 src/components/Canvas/altClickCycle.test.ts create mode 100644 src/components/Canvas/altClickCycle.ts create mode 100644 src/components/Canvas/hooks/useAltClickCycle.ts diff --git a/src/components/Canvas/LabelCanvas.tsx b/src/components/Canvas/LabelCanvas.tsx index 7f0fb1e..91470e3 100644 --- a/src/components/Canvas/LabelCanvas.tsx +++ b/src/components/Canvas/LabelCanvas.tsx @@ -38,6 +38,7 @@ import { nextRotation, type ViewRotation, } from "./rotationGeometry"; +import { useAltClickCycle } from "./hooks/useAltClickCycle"; const PADDING = 40; @@ -123,6 +124,10 @@ export const LabelCanvas = forwardRef(function LabelCa return () => observer.disconnect(); }, []); + // Alt+click cycles selection through stacked objects so users can reach + // shapes hidden behind a filled (or inverted) form. + useAltClickCycle({ containerRef, stageRef, selectObject }); + // Delete/Backspace removes all selected objects; ignored when focus is inside an input useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { diff --git a/src/components/Canvas/altClickCycle.test.ts b/src/components/Canvas/altClickCycle.test.ts new file mode 100644 index 0000000..690df0a --- /dev/null +++ b/src/components/Canvas/altClickCycle.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; +import { nextCycleIndex } from "./altClickCycle"; + +describe("nextCycleIndex", () => { + const hits = ["a", "b", "c"]; + const tol = 5; + + it("returns -1 when there are no hits", () => { + expect(nextCycleIndex([], null, { x: 0, y: 0 }, tol)).toBe(-1); + }); + + it("starts at the topmost hit when no anchor exists", () => { + expect(nextCycleIndex(hits, null, { x: 0, y: 0 }, tol)).toBe(0); + }); + + it("advances one layer deeper at (approximately) the same point", () => { + const anchor = { x: 100, y: 100, id: "a" }; + expect(nextCycleIndex(hits, anchor, { x: 102, y: 99 }, tol)).toBe(1); + }); + + it("wraps from the bottom hit back to the top", () => { + const anchor = { x: 100, y: 100, id: "c" }; + expect(nextCycleIndex(hits, anchor, { x: 100, y: 100 }, tol)).toBe(0); + }); + + it("resets to topmost when the click moves outside the tolerance window", () => { + const anchor = { x: 100, y: 100, id: "a" }; + expect(nextCycleIndex(hits, anchor, { x: 200, y: 100 }, tol)).toBe(0); + }); + + it("resets when the previously cycled object is no longer a hit", () => { + // e.g. the user deleted object 'a' between clicks + const anchor = { x: 100, y: 100, id: "a" }; + expect(nextCycleIndex(["b", "c"], anchor, { x: 100, y: 100 }, tol)).toBe(0); + }); +}); diff --git a/src/components/Canvas/altClickCycle.ts b/src/components/Canvas/altClickCycle.ts new file mode 100644 index 0000000..fbce549 --- /dev/null +++ b/src/components/Canvas/altClickCycle.ts @@ -0,0 +1,50 @@ +/** + * Pure helper for Alt+click "select-below" cycling. + * + * Inverted shapes let users see objects layered behind them, but normal + * click-to-select can only reach the topmost object at a point. Alt+click + * cycles through the stack at that point: the first click selects the + * topmost hit, each subsequent Alt+click at (approximately) the same + * pointer position advances one layer deeper, wrapping at the bottom. + * + * The cycling is anchored to a tolerance window in screen-pixel space + * (`tol`) so hand-tremor between clicks does not reset the cycle, while + * a deliberate move to a different stack does. + */ + +export interface CycleAnchor { + x: number; + y: number; + id: string; +} + +/** Pointer-distance window (CSS px) within which two consecutive Alt+clicks + * are treated as the *same* cycle position. Picked empirically as the + * smallest value that absorbs hand-tremor without merging deliberately + * different click points. */ +export const ALT_CYCLE_TOL_PX = 5; + +/** + * Pick the next index into `hits` for an Alt+click cycle. + * + * - When `anchor` is null, the cycle starts at the topmost hit (index 0). + * - When `anchor` is set but the new `point` is outside `tol`, the cycle + * resets to 0 (user clicked a different stack). + * - When `anchor.id` is no longer in `hits` (e.g. the object was deleted + * between clicks), the cycle restarts at 0. + * - Otherwise the cycle advances by one, wrapping modulo `hits.length`. + */ +export function nextCycleIndex( + hits: readonly string[], + anchor: CycleAnchor | null, + point: { x: number; y: number }, + tol: number, +): number { + if (hits.length === 0) return -1; + if (!anchor) return 0; + if (Math.abs(anchor.x - point.x) > tol) return 0; + if (Math.abs(anchor.y - point.y) > tol) return 0; + const lastIdx = hits.indexOf(anchor.id); + if (lastIdx < 0) return 0; + return (lastIdx + 1) % hits.length; +} diff --git a/src/components/Canvas/hooks/useAltClickCycle.ts b/src/components/Canvas/hooks/useAltClickCycle.ts new file mode 100644 index 0000000..ebf8b28 --- /dev/null +++ b/src/components/Canvas/hooks/useAltClickCycle.ts @@ -0,0 +1,71 @@ +import { useEffect, useRef } from "react"; +import type Konva from "konva"; +import { getCurrentObjects } from "../../../store/labelStore"; +import { + ALT_CYCLE_TOL_PX, + nextCycleIndex, + type CycleAnchor, +} from "../altClickCycle"; + +interface Options { + containerRef: React.RefObject; + stageRef: React.RefObject; + selectObject: (id: string | null) => void; +} + +/** + * Alt+click cycles selection through stacked objects so users can reach + * shapes hidden behind a filled (or inverted) form. + * + * Implemented as a native capture-phase mousedown listener: it runs before + * Konva dispatches the click to children, so `stopPropagation` cleanly + * takes over selection without competing with the per-object onClick + * handlers in KonvaObject. + */ +export function useAltClickCycle({ containerRef, stageRef, selectObject }: Options): void { + const anchorRef = useRef(null); + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const handler = (e: MouseEvent) => { + if (!e.altKey) return; + if (e.button !== 0) return; + const stage = stageRef.current; + if (!stage) return; + const rect = el.getBoundingClientRect(); + const point = { x: e.clientX - rect.left, y: e.clientY - rect.top }; + // Use Konva's own hit-graph: it accounts for view rotation, pan + // offset, per-shape transforms and the listening flag, all of + // which our own bbox math would have to mirror by hand. + const intersections = stage.getAllIntersections(point); + const objIds = new Set(getCurrentObjects().map((o) => o.id)); + const hits: string[] = []; + const seen = new Set(); + for (const shape of intersections) { + // Walk up to the registered object Group (each KonvaObject sets + // `id={obj.id}` on its outer Group; intersections may land on a + // child Rect/Text/etc.). + let n: Konva.Node | null = shape; + while (n) { + const id = n.id(); + if (id && objIds.has(id) && !seen.has(id)) { + hits.push(id); + seen.add(id); + break; + } + n = n.getParent(); + } + } + if (hits.length === 0) return; + e.stopPropagation(); + e.preventDefault(); + const idx = nextCycleIndex(hits, anchorRef.current, point, ALT_CYCLE_TOL_PX); + const nextId = hits[idx]; + if (!nextId) return; + anchorRef.current = { x: point.x, y: point.y, id: nextId }; + selectObject(nextId); + }; + el.addEventListener("mousedown", handler, { capture: true }); + return () => el.removeEventListener("mousedown", handler, { capture: true }); + }, [containerRef, stageRef, selectObject]); +} From fabeced296011e153c54f4c9e378f082c0efb008 Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 10 May 2026 12:01:40 +0200 Subject: [PATCH 2/4] docs(readme): document alt+click select-below Adds a hint in the Edit-properties section explaining the use case (reaching objects hidden behind a filled or inverted shape) and a row in the shortcut table for discoverability. --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index b37e9b7..165ee57 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ File menu → **Add page** creates a new page. With multiple pages, the control | `S` | Toggle snap | | `R` | Rotate view (0° → 90° → 180° → 270°) | | `Page Up` / `Page Down` | Previous / Next page | +| `Alt`+click | Cycle selection through stacked objects (select-below) | | Middle mouse / Space+drag | Pan canvas | | Scroll | Pan canvas | | Ctrl+Scroll | Zoom | From de8891ac409d328015a53a9e2c6330c7856c14c1 Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 10 May 2026 12:26:34 +0200 Subject: [PATCH 3/4] perf(alt-cycle): skip objIds Set when no intersections, document TS guard Reviewer-spotted (gemini-code-assist on PR #46): - Build the objIds lookup only after we know intersections is non-empty, so empty-canvas Alt+clicks skip the per-object map allocation. - Add a comment on the `if (!nextId) return` guard noting that it is required by noUncheckedIndexedAccess even though the index is logically guaranteed valid. --- src/components/Canvas/hooks/useAltClickCycle.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/Canvas/hooks/useAltClickCycle.ts b/src/components/Canvas/hooks/useAltClickCycle.ts index ebf8b28..f59b8be 100644 --- a/src/components/Canvas/hooks/useAltClickCycle.ts +++ b/src/components/Canvas/hooks/useAltClickCycle.ts @@ -38,6 +38,7 @@ export function useAltClickCycle({ containerRef, stageRef, selectObject }: Optio // offset, per-shape transforms and the listening flag, all of // which our own bbox math would have to mirror by hand. const intersections = stage.getAllIntersections(point); + if (intersections.length === 0) return; const objIds = new Set(getCurrentObjects().map((o) => o.id)); const hits: string[] = []; const seen = new Set(); @@ -60,6 +61,8 @@ export function useAltClickCycle({ containerRef, stageRef, selectObject }: Optio e.stopPropagation(); e.preventDefault(); const idx = nextCycleIndex(hits, anchorRef.current, point, ALT_CYCLE_TOL_PX); + // `hits[idx]` is `string | undefined` under noUncheckedIndexedAccess + // even though idx is guaranteed valid here — the guard satisfies TS. const nextId = hits[idx]; if (!nextId) return; anchorRef.current = { x: point.x, y: point.y, id: nextId }; From 3ed035e79e8e71ae840d170be483a4787db3db0c Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 10 May 2026 12:27:15 +0200 Subject: [PATCH 4/4] docs(readme): annotate shortcuts with Mac Cmd/Option equivalents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app already accepts both metaKey and ctrlKey (useGlobalShortcuts), but the README documented only Ctrl-bindings, which is misleading for Mac users. Inline `Ctrl/⌘` (and `Alt/⌥` for the new alt+click row) keeps each shortcut self-contained without splitting the table. --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 165ee57..775b267 100644 --- a/README.md +++ b/README.md @@ -76,19 +76,19 @@ File menu → **Add page** creates a new page. With multiple pages, the control | Shortcut | Action | |---|---| -| `Ctrl+Z` / `Ctrl+Shift+Z` | Undo / Redo | -| `Ctrl+A` | Select all | -| `Ctrl+C` / `Ctrl+V` | Copy / Paste | -| `Ctrl+D` | Duplicate selection | +| `Ctrl/⌘+Z` / `Ctrl/⌘+Shift+Z` | Undo / Redo | +| `Ctrl/⌘+A` | Select all | +| `Ctrl/⌘+C` / `Ctrl/⌘+V` | Copy / Paste | +| `Ctrl/⌘+D` | Duplicate selection | | `Del` / `Backspace` | Delete selection | | `G` | Toggle grid | | `S` | Toggle snap | | `R` | Rotate view (0° → 90° → 180° → 270°) | | `Page Up` / `Page Down` | Previous / Next page | -| `Alt`+click | Cycle selection through stacked objects (select-below) | +| `Alt/⌥`+click | Cycle selection through stacked objects (select-below) | | Middle mouse / Space+drag | Pan canvas | | Scroll | Pan canvas | -| Ctrl+Scroll | Zoom | +| `Ctrl/⌘`+Scroll | Zoom | ### Saving and loading