Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
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;
}
71 changes: 71 additions & 0 deletions src/components/Canvas/hooks/useAltClickCycle.ts
Original file line number Diff line number Diff line change
@@ -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<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);
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);
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]);
}