cursedbelt 5.81.2 → 5.82.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,26 @@
1
+ import { type RefObject } from 'react';
2
+ import { type AlignmentGuide } from './alignment.js';
3
+ export interface AlignmentGuidesProps {
4
+ /** What `snapToAlignment` returned for the drag in progress; empty draws nothing. */
5
+ guides: readonly AlignmentGuide[];
6
+ /** The view's scale, so each line stays one screen pixel wide at every zoom. */
7
+ scale: number;
8
+ }
9
+ /**
10
+ * CNV-12's lines — Mural's thin accent hairlines, drawn while an item is dragged and gone the moment
11
+ * it is let go. Place it INSIDE the canvas's transformed stage (React Flow's `ViewportPortal`, or the
12
+ * element a canvas puts `translate(x, y) scale(k)` on): it draws in world units from the stage's
13
+ * origin, so it pans and zooms with the content and never needs the view's offset.
14
+ *
15
+ * `aria-hidden` and `pointer-events: none`: the lines are a hint about where the item will land,
16
+ * which the item itself already shows; they must never take a pointer from the item underneath.
17
+ */
18
+ export declare function AlignmentGuides({ guides, scale }: AlignmentGuidesProps): import("react").JSX.Element | null;
19
+ /**
20
+ * Whether ⌘ or Ctrl is held right now — CNV-12's way to drag an item off the lines. A ref, not
21
+ * state: React Flow reports a drag as a stream of changes with no pointer event on them, so the
22
+ * canvas reads this inside its change handler, between renders. Kept current from the pointer's
23
+ * own moves and from the keys, so pressing ⌘ mid-drag takes effect on the next move.
24
+ */
25
+ export declare function useSnapSuspended(): RefObject<boolean>;
26
+ //# sourceMappingURL=AlignmentGuides.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"AlignmentGuides.d.ts","sourceRoot":"","sources":["../../../src/react/canvas-hud/AlignmentGuides.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,SAAS,EAAqB,MAAM,OAAO,CAAC;AAC1D,OAAO,EAAE,KAAK,cAAc,EAAiB,MAAM,gBAAgB,CAAC;AAEpE,MAAM,WAAW,oBAAoB;IACnC,qFAAqF;IACrF,MAAM,EAAE,SAAS,cAAc,EAAE,CAAC;IAClC,gFAAgF;IAChF,KAAK,EAAE,MAAM,CAAC;CACf;AAED;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,EAAE,MAAM,EAAE,KAAK,EAAE,EAAE,oBAAoB,sCA2CtE;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,IAAI,SAAS,CAAC,OAAO,CAAC,CAuBrD"}
@@ -0,0 +1,51 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useRef } from 'react';
3
+ import { snapSuspended } from './alignment.js';
4
+ /**
5
+ * CNV-12's lines — Mural's thin accent hairlines, drawn while an item is dragged and gone the moment
6
+ * it is let go. Place it INSIDE the canvas's transformed stage (React Flow's `ViewportPortal`, or the
7
+ * element a canvas puts `translate(x, y) scale(k)` on): it draws in world units from the stage's
8
+ * origin, so it pans and zooms with the content and never needs the view's offset.
9
+ *
10
+ * `aria-hidden` and `pointer-events: none`: the lines are a hint about where the item will land,
11
+ * which the item itself already shows; they must never take a pointer from the item underneath.
12
+ */
13
+ export function AlignmentGuides({ guides, scale }) {
14
+ if (guides.length === 0 || !(scale > 0))
15
+ return null;
16
+ const width = 1 / scale;
17
+ return (_jsx("svg", { "data-testid": 'canvas-alignment-guides', "aria-hidden": 'true', width: 1, height: 1,
18
+ // At the stage's origin, above the items it is drawn among (React Flow's nodes reach z ~2000).
19
+ className: 'pointer-events-none absolute top-0 left-0 z-[5000] overflow-visible', children: guides.map((guide) => guide.axis === 'x' ? (_jsx("line", { "data-guide": 'x', x1: guide.at, x2: guide.at, y1: guide.from, y2: guide.to, stroke: 'var(--accent)', strokeOpacity: 0.85, strokeWidth: width, shapeRendering: 'crispEdges' }, `x${guide.at}`)) : (_jsx("line", { "data-guide": 'y', x1: guide.from, x2: guide.to, y1: guide.at, y2: guide.at, stroke: 'var(--accent)', strokeOpacity: 0.85, strokeWidth: width, shapeRendering: 'crispEdges' }, `y${guide.at}`))) }));
20
+ }
21
+ /**
22
+ * Whether ⌘ or Ctrl is held right now — CNV-12's way to drag an item off the lines. A ref, not
23
+ * state: React Flow reports a drag as a stream of changes with no pointer event on them, so the
24
+ * canvas reads this inside its change handler, between renders. Kept current from the pointer's
25
+ * own moves and from the keys, so pressing ⌘ mid-drag takes effect on the next move.
26
+ */
27
+ export function useSnapSuspended() {
28
+ const held = useRef(false);
29
+ useEffect(() => {
30
+ const read = (event) => {
31
+ held.current = snapSuspended(event);
32
+ };
33
+ const clear = () => {
34
+ held.current = false;
35
+ };
36
+ window.addEventListener('keydown', read, true);
37
+ window.addEventListener('keyup', read, true);
38
+ window.addEventListener('pointermove', read, true);
39
+ window.addEventListener('pointerdown', read, true);
40
+ window.addEventListener('blur', clear);
41
+ return () => {
42
+ window.removeEventListener('keydown', read, true);
43
+ window.removeEventListener('keyup', read, true);
44
+ window.removeEventListener('pointermove', read, true);
45
+ window.removeEventListener('pointerdown', read, true);
46
+ window.removeEventListener('blur', clear);
47
+ };
48
+ }, []);
49
+ return held;
50
+ }
51
+ //# sourceMappingURL=AlignmentGuides.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"AlignmentGuides.js","sourceRoot":"","sources":["../../../src/react/canvas-hud/AlignmentGuides.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAkB,SAAS,EAAE,MAAM,EAAE,MAAM,OAAO,CAAC;AAC1D,OAAO,EAAuB,aAAa,EAAE,MAAM,gBAAgB,CAAC;AASpE;;;;;;;;GAQG;AACH,MAAM,UAAU,eAAe,CAAC,EAAE,MAAM,EAAE,KAAK,EAAwB;IACrE,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC,IAAI,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC;QAAE,OAAO,IAAI,CAAC;IACrD,MAAM,KAAK,GAAG,CAAC,GAAG,KAAK,CAAC;IACxB,OAAO,CACL,6BACc,yBAAyB,iBACzB,MAAM,EAClB,KAAK,EAAE,CAAC,EACR,MAAM,EAAE,CAAC;QACT,+FAA+F;QAC/F,SAAS,EAAC,qEAAqE,YAE9E,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CACpB,KAAK,CAAC,IAAI,KAAK,GAAG,CAAC,CAAC,CAAC,CACnB,6BAEa,GAAG,EACd,EAAE,EAAE,KAAK,CAAC,EAAE,EACZ,EAAE,EAAE,KAAK,CAAC,EAAE,EACZ,EAAE,EAAE,KAAK,CAAC,IAAI,EACd,EAAE,EAAE,KAAK,CAAC,EAAE,EACZ,MAAM,EAAC,eAAe,EACtB,aAAa,EAAE,IAAI,EACnB,WAAW,EAAE,KAAK,EAClB,cAAc,EAAC,YAAY,IATtB,IAAI,KAAK,CAAC,EAAE,EAAE,CAUnB,CACH,CAAC,CAAC,CAAC,CACF,6BAEa,GAAG,EACd,EAAE,EAAE,KAAK,CAAC,IAAI,EACd,EAAE,EAAE,KAAK,CAAC,EAAE,EACZ,EAAE,EAAE,KAAK,CAAC,EAAE,EACZ,EAAE,EAAE,KAAK,CAAC,EAAE,EACZ,MAAM,EAAC,eAAe,EACtB,aAAa,EAAE,IAAI,EACnB,WAAW,EAAE,KAAK,EAClB,cAAc,EAAC,YAAY,IATtB,IAAI,KAAK,CAAC,EAAE,EAAE,CAUnB,CACH,CACF,GACG,CACP,CAAC;AACJ,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,gBAAgB;IAC9B,MAAM,IAAI,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC;IAC3B,SAAS,CAAC,GAAG,EAAE;QACb,MAAM,IAAI,GAAG,CAAC,KAAmC,EAAE,EAAE;YACnD,IAAI,CAAC,OAAO,GAAG,aAAa,CAAC,KAAK,CAAC,CAAC;QACtC,CAAC,CAAC;QACF,MAAM,KAAK,GAAG,GAAG,EAAE;YACjB,IAAI,CAAC,OAAO,GAAG,KAAK,CAAC;QACvB,CAAC,CAAC;QACF,MAAM,CAAC,gBAAgB,CAAC,SAAS,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC;QAC/C,MAAM,CAAC,gBAAgB,CAAC,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC;QAC7C,MAAM,CAAC,gBAAgB,CAAC,aAAa,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC;QACnD,MAAM,CAAC,gBAAgB,CAAC,aAAa,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC;QACnD,MAAM,CAAC,gBAAgB,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;QACvC,OAAO,GAAG,EAAE;YACV,MAAM,CAAC,mBAAmB,CAAC,SAAS,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC;YAClD,MAAM,CAAC,mBAAmB,CAAC,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC;YAChD,MAAM,CAAC,mBAAmB,CAAC,aAAa,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC;YACtD,MAAM,CAAC,mBAAmB,CAAC,aAAa,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC;YACtD,MAAM,CAAC,mBAAmB,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;QAC5C,CAAC,CAAC;IACJ,CAAC,EAAE,EAAE,CAAC,CAAC;IACP,OAAO,IAAI,CAAC;AACd,CAAC"}
@@ -0,0 +1,56 @@
1
+ /**
2
+ * CNV-12 — alignment guides while dragging (forge task 3437). The owner asked for Mural's lines: as
3
+ * an item is dragged, a hairline shows when one of its edges or its middle lines up with another
4
+ * item's, and the item settles onto that line. Figma, Miro and Mural all draw the same thing.
5
+ *
6
+ * Pure — no React, no DOM — so a canvas built on React Flow (desk, arch) and one built on its own
7
+ * stage use the same rule, pinned by `alignment.spec.tsx`. Everything is in WORLD units; the caller
8
+ * turns its screen tolerance into world units by dividing by the view's scale, so the pull feels the
9
+ * same at every zoom.
10
+ */
11
+ import type { CanvasBounds } from './viewport.js';
12
+ /** How close, in SCREEN pixels, an edge or a middle must come to another's before it snaps. */
13
+ export declare const ALIGNMENT_TOLERANCE = 6;
14
+ /**
15
+ * One line to draw. `axis: 'x'` is a VERTICAL line at `x = at`, running from `y = from` to
16
+ * `y = to`; `axis: 'y'` is a horizontal one at `y = at`, running along x.
17
+ */
18
+ export interface AlignmentGuide {
19
+ axis: 'x' | 'y';
20
+ at: number;
21
+ from: number;
22
+ to: number;
23
+ }
24
+ export interface AlignmentSnap {
25
+ /** What to add to the dragged item's position so it sits on the nearest line — 0 when none is near. */
26
+ dx: number;
27
+ dy: number;
28
+ /** Every line the item rests on AFTER the snap, merged per position, spanning every item it meets. */
29
+ guides: AlignmentGuide[];
30
+ }
31
+ /**
32
+ * Where `moving` should settle, and the guides to draw there. `others` is every item it may line up
33
+ * with — never the item itself, nor anything travelling with it (a multi-selection, a group's
34
+ * contents). `tolerance` is in world units: `ALIGNMENT_TOLERANCE / view.scale`.
35
+ *
36
+ * Each axis snaps on its own, to the closest line within tolerance, so an item can settle on a
37
+ * vertical and a horizontal guide at once. A guide is drawn for every line that matches after the
38
+ * snap — two neighbours sharing a left edge make one line through all three — and runs from the
39
+ * farthest item it touches to the farthest on the other side, the way Mural draws it.
40
+ *
41
+ * `axis` limits it to one direction for an item that only moves one way — family's lineages slide
42
+ * sideways and never change row, so `'x'` (vertical lines only): a row of cards would otherwise draw
43
+ * a horizontal line along every generation.
44
+ */
45
+ export declare function snapToAlignment(moving: CanvasBounds, others: readonly CanvasBounds[], tolerance: number, axis?: 'x' | 'y' | 'both'): AlignmentSnap;
46
+ /** The box around several — what a multi-selection drags as one. `null` for none. */
47
+ export declare function unionBounds(boxes: readonly CanvasBounds[]): CanvasBounds | null;
48
+ /**
49
+ * Snapping is suspended while ⌘ (or Ctrl) is held — Figma's convention, so an item can still be
50
+ * put a few pixels off a line on purpose. Read from the drag's own pointer event.
51
+ */
52
+ export declare function snapSuspended(event: {
53
+ metaKey?: boolean;
54
+ ctrlKey?: boolean;
55
+ } | null | undefined): boolean;
56
+ //# sourceMappingURL=alignment.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"alignment.d.ts","sourceRoot":"","sources":["../../../src/react/canvas-hud/alignment.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAElD,+FAA+F;AAC/F,eAAO,MAAM,mBAAmB,IAAI,CAAC;AAErC;;;GAGG;AACH,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,GAAG,GAAG,GAAG,CAAC;IAChB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;CACZ;AAED,MAAM,WAAW,aAAa;IAC5B,uGAAuG;IACvG,EAAE,EAAE,MAAM,CAAC;IACX,EAAE,EAAE,MAAM,CAAC;IACX,sGAAsG;IACtG,MAAM,EAAE,cAAc,EAAE,CAAC;CAC1B;AAuBD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,eAAe,CAC7B,MAAM,EAAE,YAAY,EACpB,MAAM,EAAE,SAAS,YAAY,EAAE,EAC/B,SAAS,EAAE,MAAM,EACjB,IAAI,GAAE,GAAG,GAAG,GAAG,GAAG,MAAe,GAChC,aAAa,CA4Cf;AAED,qFAAqF;AACrF,wBAAgB,WAAW,CAAC,KAAK,EAAE,SAAS,YAAY,EAAE,GAAG,YAAY,GAAG,IAAI,CAa/E;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAC;IAAC,OAAO,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAEzG"}
@@ -0,0 +1,100 @@
1
+ /** How close, in SCREEN pixels, an edge or a middle must come to another's before it snaps. */
2
+ export const ALIGNMENT_TOLERANCE = 6;
3
+ const NONE = { dx: 0, dy: 0, guides: [] };
4
+ /** Start, middle, end — the three lines of a box along one axis. */
5
+ function lines(start, size) {
6
+ return [start, start + size / 2, start + size];
7
+ }
8
+ /** The smallest move along one axis that puts any of `moving`'s lines on any of `targets`' lines. */
9
+ function nearest(moving, targets, tolerance) {
10
+ let best = Number.POSITIVE_INFINITY;
11
+ for (const target of targets) {
12
+ for (const want of target) {
13
+ for (const have of moving) {
14
+ const delta = want - have;
15
+ if (Math.abs(delta) <= tolerance && Math.abs(delta) < Math.abs(best))
16
+ best = delta;
17
+ }
18
+ }
19
+ }
20
+ return Number.isFinite(best) ? best : 0;
21
+ }
22
+ /**
23
+ * Where `moving` should settle, and the guides to draw there. `others` is every item it may line up
24
+ * with — never the item itself, nor anything travelling with it (a multi-selection, a group's
25
+ * contents). `tolerance` is in world units: `ALIGNMENT_TOLERANCE / view.scale`.
26
+ *
27
+ * Each axis snaps on its own, to the closest line within tolerance, so an item can settle on a
28
+ * vertical and a horizontal guide at once. A guide is drawn for every line that matches after the
29
+ * snap — two neighbours sharing a left edge make one line through all three — and runs from the
30
+ * farthest item it touches to the farthest on the other side, the way Mural draws it.
31
+ *
32
+ * `axis` limits it to one direction for an item that only moves one way — family's lineages slide
33
+ * sideways and never change row, so `'x'` (vertical lines only): a row of cards would otherwise draw
34
+ * a horizontal line along every generation.
35
+ */
36
+ export function snapToAlignment(moving, others, tolerance, axis = 'both') {
37
+ if (others.length === 0 || !(tolerance > 0))
38
+ return NONE;
39
+ const near = others.filter((other) => other.width >= 0 && other.height >= 0 && Number.isFinite(other.x) && Number.isFinite(other.y));
40
+ const dx = axis === 'y' ? 0 : nearest(lines(moving.x, moving.width), near.map((other) => lines(other.x, other.width)), tolerance);
41
+ const dy = axis === 'x' ? 0 : nearest(lines(moving.y, moving.height), near.map((other) => lines(other.y, other.height)), tolerance);
42
+ const placed = { x: moving.x + dx, y: moving.y + dy, width: moving.width, height: moving.height };
43
+ // Lines that already coincide count; a hair of float error must not hide one.
44
+ const epsilon = Math.min(tolerance, 0.5);
45
+ const guides = [];
46
+ const add = (axis, at, from, to) => {
47
+ const same = guides.find((guide) => guide.axis === axis && Math.abs(guide.at - at) <= epsilon);
48
+ if (same) {
49
+ same.from = Math.min(same.from, from);
50
+ same.to = Math.max(same.to, to);
51
+ }
52
+ else
53
+ guides.push({ axis, at, from, to });
54
+ };
55
+ const ownX = lines(placed.x, placed.width);
56
+ const ownY = lines(placed.y, placed.height);
57
+ /** The lines of `theirs` that `own` sits on — without the middle when both edges match too,
58
+ * since a box the same size as its neighbour would otherwise draw three parallel lines. */
59
+ const matched = (own, theirs) => {
60
+ const hits = theirs.map((at) => own.some((have) => Math.abs(have - at) <= epsilon));
61
+ return theirs.filter((_, index) => hits[index] && !(index === 1 && hits[0] && hits[2]));
62
+ };
63
+ for (const other of near) {
64
+ if (axis !== 'y')
65
+ for (const at of matched(ownX, lines(other.x, other.width))) {
66
+ add('x', at, Math.min(placed.y, other.y), Math.max(placed.y + placed.height, other.y + other.height));
67
+ }
68
+ if (axis !== 'x')
69
+ for (const at of matched(ownY, lines(other.y, other.height))) {
70
+ add('y', at, Math.min(placed.x, other.x), Math.max(placed.x + placed.width, other.x + other.width));
71
+ }
72
+ }
73
+ if (guides.length === 0 && dx === 0 && dy === 0)
74
+ return NONE;
75
+ return { dx, dy, guides };
76
+ }
77
+ /** The box around several — what a multi-selection drags as one. `null` for none. */
78
+ export function unionBounds(boxes) {
79
+ if (boxes.length === 0)
80
+ return null;
81
+ let left = Number.POSITIVE_INFINITY;
82
+ let top = Number.POSITIVE_INFINITY;
83
+ let right = Number.NEGATIVE_INFINITY;
84
+ let bottom = Number.NEGATIVE_INFINITY;
85
+ for (const box of boxes) {
86
+ left = Math.min(left, box.x);
87
+ top = Math.min(top, box.y);
88
+ right = Math.max(right, box.x + box.width);
89
+ bottom = Math.max(bottom, box.y + box.height);
90
+ }
91
+ return { x: left, y: top, width: right - left, height: bottom - top };
92
+ }
93
+ /**
94
+ * Snapping is suspended while ⌘ (or Ctrl) is held — Figma's convention, so an item can still be
95
+ * put a few pixels off a line on purpose. Read from the drag's own pointer event.
96
+ */
97
+ export function snapSuspended(event) {
98
+ return !!(event?.metaKey || event?.ctrlKey);
99
+ }
100
+ //# sourceMappingURL=alignment.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"alignment.js","sourceRoot":"","sources":["../../../src/react/canvas-hud/alignment.ts"],"names":[],"mappings":"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"}
@@ -1,3 +1,7 @@
1
+ export type { AlignmentGuide, AlignmentSnap } from './alignment.js';
2
+ export { ALIGNMENT_TOLERANCE, snapSuspended, snapToAlignment, unionBounds } from './alignment.js';
3
+ export type { AlignmentGuidesProps } from './AlignmentGuides.js';
4
+ export { AlignmentGuides, useSnapSuspended } from './AlignmentGuides.js';
1
5
  export type { CanvasMinimapProps } from './CanvasMinimap.js';
2
6
  export { CanvasMinimap } from './CanvasMinimap.js';
3
7
  export type { CanvasNodeMenuItem, CanvasNodeMenuProps, CanvasNodeMenuSection } from './CanvasNodeMenu.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/react/canvas-hud/index.ts"],"names":[],"mappings":"AAGA,YAAY,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AAC7D,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACnD,YAAY,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAC1G,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,YAAY,EAAE,sBAAsB,EAAE,MAAM,wBAAwB,CAAC;AACrE,OAAO,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAC;AAC3D,YAAY,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AAC7D,OAAO,EAAE,aAAa,EAAE,oBAAoB,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACxF,YAAY,EAAE,mBAAmB,EAAE,cAAc,EAAE,oBAAoB,EAAE,MAAM,kBAAkB,CAAC;AAClG,OAAO,EAAE,cAAc,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAC9E,YAAY,EAAE,eAAe,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAC/E,OAAO,EAAE,SAAS,EAAE,cAAc,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAClF,YAAY,EACV,YAAY,EACZ,WAAW,EACX,UAAU,EACV,UAAU,EACV,UAAU,EACV,aAAa,EACb,uBAAuB,GACxB,MAAM,eAAe,CAAC;AACvB,OAAO,EACL,UAAU,EACV,UAAU,EACV,QAAQ,EACR,OAAO,EACP,eAAe,EACf,aAAa,EACb,aAAa,EACb,eAAe,EACf,MAAM,GACP,MAAM,eAAe,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/react/canvas-hud/index.ts"],"names":[],"mappings":"AAIA,YAAY,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AACpE,OAAO,EAAE,mBAAmB,EAAE,aAAa,EAAE,eAAe,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAClG,YAAY,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AACjE,OAAO,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AACzE,YAAY,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AAC7D,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACnD,YAAY,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAC1G,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,YAAY,EAAE,sBAAsB,EAAE,MAAM,wBAAwB,CAAC;AACrE,OAAO,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAC;AAC3D,YAAY,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AAC7D,OAAO,EAAE,aAAa,EAAE,oBAAoB,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACxF,YAAY,EAAE,mBAAmB,EAAE,cAAc,EAAE,oBAAoB,EAAE,MAAM,kBAAkB,CAAC;AAClG,OAAO,EAAE,cAAc,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAC9E,YAAY,EAAE,eAAe,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAC/E,OAAO,EAAE,SAAS,EAAE,cAAc,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAClF,YAAY,EACV,YAAY,EACZ,WAAW,EACX,UAAU,EACV,UAAU,EACV,UAAU,EACV,aAAa,EACb,uBAAuB,GACxB,MAAM,eAAe,CAAC;AACvB,OAAO,EACL,UAAU,EACV,UAAU,EACV,QAAQ,EACR,OAAO,EACP,eAAe,EACf,aAAa,EACb,aAAa,EACb,eAAe,EACf,MAAM,GACP,MAAM,eAAe,CAAC"}
@@ -1,3 +1,5 @@
1
+ export { ALIGNMENT_TOLERANCE, snapSuspended, snapToAlignment, unionBounds } from './alignment.js';
2
+ export { AlignmentGuides, useSnapSuspended } from './AlignmentGuides.js';
1
3
  export { CanvasMinimap } from './CanvasMinimap.js';
2
4
  export { CanvasNodeMenu } from './CanvasNodeMenu.js';
3
5
  export { CanvasSearchField } from './CanvasSearchField.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/react/canvas-hud/index.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAEnD,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAErD,OAAO,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAC;AAE3D,OAAO,EAAE,aAAa,EAAE,oBAAoB,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAExF,OAAO,EAAE,cAAc,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAE9E,OAAO,EAAE,SAAS,EAAE,cAAc,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAUlF,OAAO,EACL,UAAU,EACV,UAAU,EACV,QAAQ,EACR,OAAO,EACP,eAAe,EACf,aAAa,EACb,aAAa,EACb,eAAe,EACf,MAAM,GACP,MAAM,eAAe,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/react/canvas-hud/index.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,mBAAmB,EAAE,aAAa,EAAE,eAAe,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAElG,OAAO,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAEzE,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAEnD,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAErD,OAAO,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAC;AAE3D,OAAO,EAAE,aAAa,EAAE,oBAAoB,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAExF,OAAO,EAAE,cAAc,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAE9E,OAAO,EAAE,SAAS,EAAE,cAAc,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAUlF,OAAO,EACL,UAAU,EACV,UAAU,EACV,QAAQ,EACR,OAAO,EACP,eAAe,EACf,aAAa,EACb,aAAa,EACb,eAAe,EACf,MAAM,GACP,MAAM,eAAe,CAAC"}
@@ -18,7 +18,7 @@
18
18
  * after an app's responsive variant (task 2124). An app that imports every area gets exactly
19
19
  * what `cursedbelt/styles-utilities.css` emits — proved rule by rule in
20
20
  * src/stylesAreas.spec.ts, which is also what fails when this file is stale.
21
- * 40 candidate(s).
21
+ * 42 candidate(s).
22
22
  *
23
23
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
24
24
  * `bun run build` runs. The map of what is in which area, and why the areas are
@@ -29,4 +29,5 @@
29
29
  @source inline("[scrollbar-width:none] aria-pressed:bg-muted aria-pressed:text-foreground basis-0 basis-full");
30
30
  @source inline("bg-background/40 bg-background/70 bottom-3 bottom-4 focus-visible:border-transparent");
31
31
  @source inline("focus-visible:ring-0 left-3 max-w-48 min-w-10 min-w-26 min-w-40 ml-2 opacity-100 order-1 order-2");
32
- @source inline("order-3 pb-1.5 pl-0 pl-1 place-items-center pr-1 pt-1 px-0.5 right-3 right-4 w-40 w-64");
32
+ @source inline("order-3 overflow-visible pb-1.5 pl-0 pl-1 place-items-center pr-1 pt-1 px-0.5 right-3 right-4");
33
+ @source inline("w-40 w-64 z-[5000]");
@@ -360,6 +360,9 @@
360
360
  .z-\[9\] {
361
361
  z-index: 9;
362
362
  }
363
+ .z-\[5000\] {
364
+ z-index: 5000;
365
+ }
363
366
  .z-\[var\(--z-popover\)\] {
364
367
  z-index: var(--z-popover);
365
368
  }
@@ -1579,6 +1582,9 @@
1579
1582
  .overflow-hidden {
1580
1583
  overflow: hidden;
1581
1584
  }
1585
+ .overflow-visible {
1586
+ overflow: visible;
1587
+ }
1582
1588
  .overflow-x-auto {
1583
1589
  overflow-x: auto;
1584
1590
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt",
3
- "version": "5.81.2",
3
+ "version": "5.82.0",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "description": "The React design system of the cursedbelt split — components, styles, theme. cursedbelt-core below it; server tier in cursedbelt-server.",
@@ -0,0 +1,94 @@
1
+ import { type RefObject, useEffect, useRef } from 'react';
2
+ import { type AlignmentGuide, snapSuspended } from './alignment.js';
3
+
4
+ export interface AlignmentGuidesProps {
5
+ /** What `snapToAlignment` returned for the drag in progress; empty draws nothing. */
6
+ guides: readonly AlignmentGuide[];
7
+ /** The view's scale, so each line stays one screen pixel wide at every zoom. */
8
+ scale: number;
9
+ }
10
+
11
+ /**
12
+ * CNV-12's lines — Mural's thin accent hairlines, drawn while an item is dragged and gone the moment
13
+ * it is let go. Place it INSIDE the canvas's transformed stage (React Flow's `ViewportPortal`, or the
14
+ * element a canvas puts `translate(x, y) scale(k)` on): it draws in world units from the stage's
15
+ * origin, so it pans and zooms with the content and never needs the view's offset.
16
+ *
17
+ * `aria-hidden` and `pointer-events: none`: the lines are a hint about where the item will land,
18
+ * which the item itself already shows; they must never take a pointer from the item underneath.
19
+ */
20
+ export function AlignmentGuides({ guides, scale }: AlignmentGuidesProps) {
21
+ if (guides.length === 0 || !(scale > 0)) return null;
22
+ const width = 1 / scale;
23
+ return (
24
+ <svg
25
+ data-testid='canvas-alignment-guides'
26
+ aria-hidden='true'
27
+ width={1}
28
+ height={1}
29
+ // At the stage's origin, above the items it is drawn among (React Flow's nodes reach z ~2000).
30
+ className='pointer-events-none absolute top-0 left-0 z-[5000] overflow-visible'
31
+ >
32
+ {guides.map((guide) =>
33
+ guide.axis === 'x' ? (
34
+ <line
35
+ key={`x${guide.at}`}
36
+ data-guide='x'
37
+ x1={guide.at}
38
+ x2={guide.at}
39
+ y1={guide.from}
40
+ y2={guide.to}
41
+ stroke='var(--accent)'
42
+ strokeOpacity={0.85}
43
+ strokeWidth={width}
44
+ shapeRendering='crispEdges'
45
+ />
46
+ ) : (
47
+ <line
48
+ key={`y${guide.at}`}
49
+ data-guide='y'
50
+ x1={guide.from}
51
+ x2={guide.to}
52
+ y1={guide.at}
53
+ y2={guide.at}
54
+ stroke='var(--accent)'
55
+ strokeOpacity={0.85}
56
+ strokeWidth={width}
57
+ shapeRendering='crispEdges'
58
+ />
59
+ ),
60
+ )}
61
+ </svg>
62
+ );
63
+ }
64
+
65
+ /**
66
+ * Whether ⌘ or Ctrl is held right now — CNV-12's way to drag an item off the lines. A ref, not
67
+ * state: React Flow reports a drag as a stream of changes with no pointer event on them, so the
68
+ * canvas reads this inside its change handler, between renders. Kept current from the pointer's
69
+ * own moves and from the keys, so pressing ⌘ mid-drag takes effect on the next move.
70
+ */
71
+ export function useSnapSuspended(): RefObject<boolean> {
72
+ const held = useRef(false);
73
+ useEffect(() => {
74
+ const read = (event: KeyboardEvent | PointerEvent) => {
75
+ held.current = snapSuspended(event);
76
+ };
77
+ const clear = () => {
78
+ held.current = false;
79
+ };
80
+ window.addEventListener('keydown', read, true);
81
+ window.addEventListener('keyup', read, true);
82
+ window.addEventListener('pointermove', read, true);
83
+ window.addEventListener('pointerdown', read, true);
84
+ window.addEventListener('blur', clear);
85
+ return () => {
86
+ window.removeEventListener('keydown', read, true);
87
+ window.removeEventListener('keyup', read, true);
88
+ window.removeEventListener('pointermove', read, true);
89
+ window.removeEventListener('pointerdown', read, true);
90
+ window.removeEventListener('blur', clear);
91
+ };
92
+ }, []);
93
+ return held;
94
+ }
@@ -0,0 +1,149 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+ import { act, cleanup, render } from '@testing-library/react';
3
+ import { snapSuspended, snapToAlignment, unionBounds } from './alignment.js';
4
+ import { AlignmentGuides, useSnapSuspended } from './AlignmentGuides.js';
5
+
6
+ afterEach(cleanup);
7
+
8
+ const box = (x: number, y: number, width = 100, height = 50) => ({ x, y, width, height });
9
+
10
+ describe('snapToAlignment (CNV-12: alignment guides while dragging)', () => {
11
+ test('a left edge within tolerance settles onto its neighbour’s, and one vertical guide spans both', () => {
12
+ const snap = snapToAlignment(box(203, 200, 60, 30), [box(200, 0)], 6);
13
+ expect(snap.dx).toBe(-3);
14
+ expect(snap.dy).toBe(0);
15
+ expect(snap.guides).toEqual([{ axis: 'x', at: 200, from: 0, to: 230 }]);
16
+ });
17
+
18
+ test('a box the same size as its neighbour draws its two edges, not a third line down the middle', () => {
19
+ const snap = snapToAlignment(box(203, 200), [box(200, 0)], 6);
20
+ expect(snap.guides).toEqual([
21
+ { axis: 'x', at: 200, from: 0, to: 250 },
22
+ { axis: 'x', at: 300, from: 0, to: 250 },
23
+ ]);
24
+ });
25
+
26
+ test('outside the tolerance nothing moves and nothing is drawn', () => {
27
+ expect(snapToAlignment(box(207, 400), [box(200, 0)], 6)).toEqual({ dx: 0, dy: 0, guides: [] });
28
+ });
29
+
30
+ test('middles line up with middles, and an edge with a middle of another size', () => {
31
+ // The moving box's middle (54 + 46 = 100) already sits on the other's (50 + 50): no move beats
32
+ // the left edges' 4, and the middle guide is drawn.
33
+ const centred = snapToAlignment(box(54, 300, 92, 20), [box(50, 0, 100, 50)], 6);
34
+ expect(centred.dx).toBe(0);
35
+ expect(centred.guides.some((g) => g.axis === 'x' && g.at === 100)).toBe(true);
36
+ const edgeToMiddle = snapToAlignment(box(97, 300, 40, 20), [box(0, 0, 200, 50)], 6);
37
+ expect(edgeToMiddle.dx).toBe(3);
38
+ expect(edgeToMiddle.guides).toContainEqual({ axis: 'x', at: 100, from: 0, to: 320 });
39
+ });
40
+
41
+ test('each axis snaps on its own, so a corner can settle on two lines at once', () => {
42
+ const snap = snapToAlignment(box(502, 104, 40, 30), [box(500, 0), box(0, 100)], 6);
43
+ expect(snap.dx).toBe(-2);
44
+ expect(snap.dy).toBe(-4);
45
+ expect(snap.guides.map((g) => `${g.axis}${g.at}`).sort()).toEqual(['x500', 'y100']);
46
+ });
47
+
48
+ test('the nearest line wins, not the first one found', () => {
49
+ const snap = snapToAlignment(box(105, 400), [box(100, 0), box(104, 200)], 6);
50
+ expect(snap.dx).toBe(-1);
51
+ });
52
+
53
+ test('three items on one line draw ONE guide through all of them', () => {
54
+ const snap = snapToAlignment(box(301, 500, 40, 20), [box(300, 0), box(300, 200)], 6);
55
+ const vertical = snap.guides.filter((g) => g.axis === 'x' && g.at === 300);
56
+ expect(vertical).toEqual([{ axis: 'x', at: 300, from: 0, to: 520 }]);
57
+ });
58
+
59
+ test('a right edge snaps to a right edge, and the guide sits on that edge', () => {
60
+ const snap = snapToAlignment(box(0, 200, 98, 30), [box(0, 0, 100, 50)], 6);
61
+ // left edges already coincide (0 = 0), so no move — and both the left guide and the right are honest
62
+ expect(snap.dx).toBe(0);
63
+ const shifted = snapToAlignment(box(20, 200, 78, 30), [box(0, 0, 100, 50)], 6);
64
+ expect(shifted.dx).toBe(2);
65
+ expect(shifted.guides).toContainEqual({ axis: 'x', at: 100, from: 0, to: 230 });
66
+ });
67
+
68
+ test("axis 'x' snaps and draws only vertical lines — a row of cards does not draw a line along the row", () => {
69
+ // Same row (y 0) as the neighbour, left edge 2 off: settles sideways, and no horizontal line is drawn.
70
+ const sideways = snapToAlignment(box(202, 0, 60, 50), [box(200, 0)], 6, 'x');
71
+ expect(sideways.dx).toBe(-2);
72
+ expect(sideways.dy).toBe(0);
73
+ expect(sideways.guides.every((g) => g.axis === 'x')).toBe(true);
74
+ expect(snapToAlignment(box(500, 3, 60, 50), [box(0, 0)], 6, 'x')).toEqual({ dx: 0, dy: 0, guides: [] });
75
+ expect(snapToAlignment(box(500, 3, 60, 50), [box(0, 0)], 6, 'y').dy).toBe(-3);
76
+ });
77
+
78
+ test('no neighbours, or a zero tolerance (⌘ held), is a plain drag', () => {
79
+ expect(snapToAlignment(box(3, 3), [], 6)).toEqual({ dx: 0, dy: 0, guides: [] });
80
+ expect(snapToAlignment(box(203, 3), [box(200, 0)], 0)).toEqual({ dx: 0, dy: 0, guides: [] });
81
+ });
82
+ });
83
+
84
+ describe('unionBounds', () => {
85
+ test('the box around a multi-selection', () => {
86
+ expect(unionBounds([box(10, 20, 30, 40), box(-5, 50, 10, 10)])).toEqual({ x: -5, y: 20, width: 45, height: 40 });
87
+ expect(unionBounds([])).toBeNull();
88
+ });
89
+ });
90
+
91
+ describe('snapSuspended', () => {
92
+ test('⌘ or Ctrl suspends snapping; nothing held does not', () => {
93
+ expect(snapSuspended({ metaKey: true })).toBe(true);
94
+ expect(snapSuspended({ ctrlKey: true })).toBe(true);
95
+ expect(snapSuspended({ metaKey: false, ctrlKey: false })).toBe(false);
96
+ expect(snapSuspended(null)).toBe(false);
97
+ });
98
+ });
99
+
100
+ describe('AlignmentGuides', () => {
101
+ test('draws one hairline per guide, one screen pixel wide at the view’s scale, and takes no pointer', () => {
102
+ const { getByTestId } = render(
103
+ <AlignmentGuides
104
+ scale={2}
105
+ guides={[
106
+ { axis: 'x', at: 10, from: 0, to: 90 },
107
+ { axis: 'y', at: 40, from: -5, to: 60 },
108
+ ]}
109
+ />,
110
+ );
111
+ const svg = getByTestId('canvas-alignment-guides');
112
+ expect(svg.getAttribute('aria-hidden')).toBe('true');
113
+ expect(svg.getAttribute('class')).toContain('pointer-events-none');
114
+ const [vertical, horizontal] = Array.from(svg.querySelectorAll('line'));
115
+ expect([vertical.getAttribute('x1'), vertical.getAttribute('x2'), vertical.getAttribute('y1'), vertical.getAttribute('y2')]).toEqual(['10', '10', '0', '90']);
116
+ expect([horizontal.getAttribute('y1'), horizontal.getAttribute('x1'), horizontal.getAttribute('x2')]).toEqual(['40', '-5', '60']);
117
+ expect(vertical.getAttribute('stroke-width')).toBe('0.5');
118
+ });
119
+
120
+ test('nothing to draw renders nothing', () => {
121
+ const { queryByTestId } = render(<AlignmentGuides scale={1} guides={[]} />);
122
+ expect(queryByTestId('canvas-alignment-guides')).toBeNull();
123
+ });
124
+ });
125
+
126
+ describe('useSnapSuspended', () => {
127
+ test('follows ⌘ down and up, and lets go when the window loses focus', () => {
128
+ let held: { current: boolean | null } = { current: null };
129
+ function Probe() {
130
+ held = useSnapSuspended();
131
+ return null;
132
+ }
133
+ render(<Probe />);
134
+ expect(held.current).toBe(false);
135
+ act(() => {
136
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Meta', metaKey: true }));
137
+ });
138
+ expect(held.current).toBe(true);
139
+ act(() => {
140
+ window.dispatchEvent(new KeyboardEvent('keyup', { key: 'Meta', metaKey: false }));
141
+ });
142
+ expect(held.current).toBe(false);
143
+ act(() => {
144
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Control', ctrlKey: true }));
145
+ window.dispatchEvent(new Event('blur'));
146
+ });
147
+ expect(held.current).toBe(false);
148
+ });
149
+ });
@@ -0,0 +1,143 @@
1
+ /**
2
+ * CNV-12 — alignment guides while dragging (forge task 3437). The owner asked for Mural's lines: as
3
+ * an item is dragged, a hairline shows when one of its edges or its middle lines up with another
4
+ * item's, and the item settles onto that line. Figma, Miro and Mural all draw the same thing.
5
+ *
6
+ * Pure — no React, no DOM — so a canvas built on React Flow (desk, arch) and one built on its own
7
+ * stage use the same rule, pinned by `alignment.spec.tsx`. Everything is in WORLD units; the caller
8
+ * turns its screen tolerance into world units by dividing by the view's scale, so the pull feels the
9
+ * same at every zoom.
10
+ */
11
+ import type { CanvasBounds } from './viewport.js';
12
+
13
+ /** How close, in SCREEN pixels, an edge or a middle must come to another's before it snaps. */
14
+ export const ALIGNMENT_TOLERANCE = 6;
15
+
16
+ /**
17
+ * One line to draw. `axis: 'x'` is a VERTICAL line at `x = at`, running from `y = from` to
18
+ * `y = to`; `axis: 'y'` is a horizontal one at `y = at`, running along x.
19
+ */
20
+ export interface AlignmentGuide {
21
+ axis: 'x' | 'y';
22
+ at: number;
23
+ from: number;
24
+ to: number;
25
+ }
26
+
27
+ export interface AlignmentSnap {
28
+ /** What to add to the dragged item's position so it sits on the nearest line — 0 when none is near. */
29
+ dx: number;
30
+ dy: number;
31
+ /** Every line the item rests on AFTER the snap, merged per position, spanning every item it meets. */
32
+ guides: AlignmentGuide[];
33
+ }
34
+
35
+ const NONE: AlignmentSnap = { dx: 0, dy: 0, guides: [] };
36
+
37
+ /** Start, middle, end — the three lines of a box along one axis. */
38
+ function lines(start: number, size: number): [number, number, number] {
39
+ return [start, start + size / 2, start + size];
40
+ }
41
+
42
+ /** The smallest move along one axis that puts any of `moving`'s lines on any of `targets`' lines. */
43
+ function nearest(moving: readonly number[], targets: readonly (readonly number[])[], tolerance: number): number {
44
+ let best = Number.POSITIVE_INFINITY;
45
+ for (const target of targets) {
46
+ for (const want of target) {
47
+ for (const have of moving) {
48
+ const delta = want - have;
49
+ if (Math.abs(delta) <= tolerance && Math.abs(delta) < Math.abs(best)) best = delta;
50
+ }
51
+ }
52
+ }
53
+ return Number.isFinite(best) ? best : 0;
54
+ }
55
+
56
+ /**
57
+ * Where `moving` should settle, and the guides to draw there. `others` is every item it may line up
58
+ * with — never the item itself, nor anything travelling with it (a multi-selection, a group's
59
+ * contents). `tolerance` is in world units: `ALIGNMENT_TOLERANCE / view.scale`.
60
+ *
61
+ * Each axis snaps on its own, to the closest line within tolerance, so an item can settle on a
62
+ * vertical and a horizontal guide at once. A guide is drawn for every line that matches after the
63
+ * snap — two neighbours sharing a left edge make one line through all three — and runs from the
64
+ * farthest item it touches to the farthest on the other side, the way Mural draws it.
65
+ *
66
+ * `axis` limits it to one direction for an item that only moves one way — family's lineages slide
67
+ * sideways and never change row, so `'x'` (vertical lines only): a row of cards would otherwise draw
68
+ * a horizontal line along every generation.
69
+ */
70
+ export function snapToAlignment(
71
+ moving: CanvasBounds,
72
+ others: readonly CanvasBounds[],
73
+ tolerance: number,
74
+ axis: 'x' | 'y' | 'both' = 'both',
75
+ ): AlignmentSnap {
76
+ if (others.length === 0 || !(tolerance > 0)) return NONE;
77
+ const near = others.filter(
78
+ (other) => other.width >= 0 && other.height >= 0 && Number.isFinite(other.x) && Number.isFinite(other.y),
79
+ );
80
+ const dx = axis === 'y' ? 0 : nearest(
81
+ lines(moving.x, moving.width),
82
+ near.map((other) => lines(other.x, other.width)),
83
+ tolerance,
84
+ );
85
+ const dy = axis === 'x' ? 0 : nearest(
86
+ lines(moving.y, moving.height),
87
+ near.map((other) => lines(other.y, other.height)),
88
+ tolerance,
89
+ );
90
+ const placed = { x: moving.x + dx, y: moving.y + dy, width: moving.width, height: moving.height };
91
+ // Lines that already coincide count; a hair of float error must not hide one.
92
+ const epsilon = Math.min(tolerance, 0.5);
93
+ const guides: AlignmentGuide[] = [];
94
+ const add = (axis: 'x' | 'y', at: number, from: number, to: number) => {
95
+ const same = guides.find((guide) => guide.axis === axis && Math.abs(guide.at - at) <= epsilon);
96
+ if (same) {
97
+ same.from = Math.min(same.from, from);
98
+ same.to = Math.max(same.to, to);
99
+ } else guides.push({ axis, at, from, to });
100
+ };
101
+ const ownX = lines(placed.x, placed.width);
102
+ const ownY = lines(placed.y, placed.height);
103
+ /** The lines of `theirs` that `own` sits on — without the middle when both edges match too,
104
+ * since a box the same size as its neighbour would otherwise draw three parallel lines. */
105
+ const matched = (own: readonly number[], theirs: readonly number[]) => {
106
+ const hits = theirs.map((at) => own.some((have) => Math.abs(have - at) <= epsilon));
107
+ return theirs.filter((_, index) => hits[index] && !(index === 1 && hits[0] && hits[2]));
108
+ };
109
+ for (const other of near) {
110
+ if (axis !== 'y') for (const at of matched(ownX, lines(other.x, other.width))) {
111
+ add('x', at, Math.min(placed.y, other.y), Math.max(placed.y + placed.height, other.y + other.height));
112
+ }
113
+ if (axis !== 'x') for (const at of matched(ownY, lines(other.y, other.height))) {
114
+ add('y', at, Math.min(placed.x, other.x), Math.max(placed.x + placed.width, other.x + other.width));
115
+ }
116
+ }
117
+ if (guides.length === 0 && dx === 0 && dy === 0) return NONE;
118
+ return { dx, dy, guides };
119
+ }
120
+
121
+ /** The box around several — what a multi-selection drags as one. `null` for none. */
122
+ export function unionBounds(boxes: readonly CanvasBounds[]): CanvasBounds | null {
123
+ if (boxes.length === 0) return null;
124
+ let left = Number.POSITIVE_INFINITY;
125
+ let top = Number.POSITIVE_INFINITY;
126
+ let right = Number.NEGATIVE_INFINITY;
127
+ let bottom = Number.NEGATIVE_INFINITY;
128
+ for (const box of boxes) {
129
+ left = Math.min(left, box.x);
130
+ top = Math.min(top, box.y);
131
+ right = Math.max(right, box.x + box.width);
132
+ bottom = Math.max(bottom, box.y + box.height);
133
+ }
134
+ return { x: left, y: top, width: right - left, height: bottom - top };
135
+ }
136
+
137
+ /**
138
+ * Snapping is suspended while ⌘ (or Ctrl) is held — Figma's convention, so an item can still be
139
+ * put a few pixels off a line on purpose. Read from the drag's own pointer event.
140
+ */
141
+ export function snapSuspended(event: { metaKey?: boolean; ctrlKey?: boolean } | null | undefined): boolean {
142
+ return !!(event?.metaKey || event?.ctrlKey);
143
+ }
@@ -1,6 +1,11 @@
1
1
  // cursedbelt/react/canvas-hud — the viewport arithmetic and the floating chrome every pan-and-zoom
2
- // canvas carries (docs/design/canvas.md, CNV-1…CNV-5; forge task 2401). Its own subpath, so a
2
+ // canvas carries (docs/design/canvas.md, CNV-1…CNV-5; forge task 2401), and CNV-12's alignment
3
+ // guides while an item is dragged (forge task 3437). Its own subpath, so a
3
4
  // page with no canvas never pulls it in.
5
+ export type { AlignmentGuide, AlignmentSnap } from './alignment.js';
6
+ export { ALIGNMENT_TOLERANCE, snapSuspended, snapToAlignment, unionBounds } from './alignment.js';
7
+ export type { AlignmentGuidesProps } from './AlignmentGuides.js';
8
+ export { AlignmentGuides, useSnapSuspended } from './AlignmentGuides.js';
4
9
  export type { CanvasMinimapProps } from './CanvasMinimap.js';
5
10
  export { CanvasMinimap } from './CanvasMinimap.js';
6
11
  export type { CanvasNodeMenuItem, CanvasNodeMenuProps, CanvasNodeMenuSection } from './CanvasNodeMenu.js';
@@ -18,7 +18,7 @@
18
18
  * after an app's responsive variant (task 2124). An app that imports every area gets exactly
19
19
  * what `cursedbelt/styles-utilities.css` emits — proved rule by rule in
20
20
  * src/stylesAreas.spec.ts, which is also what fails when this file is stale.
21
- * 40 candidate(s).
21
+ * 42 candidate(s).
22
22
  *
23
23
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
24
24
  * `bun run build` runs. The map of what is in which area, and why the areas are
@@ -29,4 +29,5 @@
29
29
  @source inline("[scrollbar-width:none] aria-pressed:bg-muted aria-pressed:text-foreground basis-0 basis-full");
30
30
  @source inline("bg-background/40 bg-background/70 bottom-3 bottom-4 focus-visible:border-transparent");
31
31
  @source inline("focus-visible:ring-0 left-3 max-w-48 min-w-10 min-w-26 min-w-40 ml-2 opacity-100 order-1 order-2");
32
- @source inline("order-3 pb-1.5 pl-0 pl-1 place-items-center pr-1 pt-1 px-0.5 right-3 right-4 w-40 w-64");
32
+ @source inline("order-3 overflow-visible pb-1.5 pl-0 pl-1 place-items-center pr-1 pt-1 px-0.5 right-3 right-4");
33
+ @source inline("w-40 w-64 z-[5000]");
@@ -360,6 +360,9 @@
360
360
  .z-\[9\] {
361
361
  z-index: 9;
362
362
  }
363
+ .z-\[5000\] {
364
+ z-index: 5000;
365
+ }
363
366
  .z-\[var\(--z-popover\)\] {
364
367
  z-index: var(--z-popover);
365
368
  }
@@ -1579,6 +1582,9 @@
1579
1582
  .overflow-hidden {
1580
1583
  overflow: hidden;
1581
1584
  }
1585
+ .overflow-visible {
1586
+ overflow: visible;
1587
+ }
1582
1588
  .overflow-x-auto {
1583
1589
  overflow-x: auto;
1584
1590
  }