Skip to content
Merged
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
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions src/components/Canvas/LabelCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
nextRotation,
type ViewRotation,
} from "./rotationGeometry";
import { useAltClickCycle } from "./hooks/useAltClickCycle";

const PADDING = 40;

Expand Down Expand Up @@ -123,6 +124,10 @@ export const LabelCanvas = forwardRef<LabelCanvasHandle, Props>(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) => {
Expand Down
36 changes: 36 additions & 0 deletions src/components/Canvas/altClickCycle.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
50 changes: 50 additions & 0 deletions src/components/Canvas/altClickCycle.ts
Original file line number Diff line number Diff line change
@@ -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;
}
74 changes: 74 additions & 0 deletions src/components/Canvas/hooks/useAltClickCycle.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement | null>;
stageRef: React.RefObject<Konva.Stage | null>;
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<CycleAnchor | null>(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));
Comment thread
u8array marked this conversation as resolved.
const hits: string[] = [];
const seen = new Set<string>();
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;
Comment thread
u8array marked this conversation as resolved.
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]);
}