-
Notifications
You must be signed in to change notification settings - Fork 0
Feat/alt click cycle select #46
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
678819b
feat(canvas): alt+click cycles selection through stacked objects
u8array fabeced
docs(readme): document alt+click select-below
u8array de8891a
perf(alt-cycle): skip objIds Set when no intersections, document TS g…
u8array 3ed035e
docs(readme): annotate shortcuts with Mac Cmd/Option equivalents
u8array File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)); | ||
| 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; | ||
|
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]); | ||
| } | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.