diff --git a/README.md b/README.md index b37e9b7..775b267 100644 --- a/README.md +++ b/README.md @@ -76,18 +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) | | Middle mouse / Space+drag | Pan canvas | | Scroll | Pan canvas | -| Ctrl+Scroll | Zoom | +| `Ctrl/⌘`+Scroll | Zoom | ### Saving and loading 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..f59b8be --- /dev/null +++ b/src/components/Canvas/hooks/useAltClickCycle.ts @@ -0,0 +1,74 @@ +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); + if (intersections.length === 0) return; + 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); + // `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 }; + selectObject(nextId); + }; + el.addEventListener("mousedown", handler, { capture: true }); + return () => el.removeEventListener("mousedown", handler, { capture: true }); + }, [containerRef, stageRef, selectObject]); +}