@1agh/maude 0.29.0 → 0.30.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.
- package/README.md +1 -1
- package/apps/studio/ai-banner.tsx +0 -1
- package/apps/studio/annotations-align.ts +149 -0
- package/apps/studio/annotations-bindings.ts +197 -0
- package/apps/studio/annotations-context-toolbar.tsx +436 -186
- package/apps/studio/annotations-groups.ts +270 -0
- package/apps/studio/annotations-layer.tsx +1708 -1660
- package/apps/studio/annotations-model.ts +2077 -0
- package/apps/studio/annotations-snap.ts +125 -0
- package/apps/studio/api.ts +208 -116
- package/apps/studio/bin/_png-playwright.mjs +1 -1
- package/apps/studio/bin/annotate.mjs +732 -0
- package/apps/studio/bin/annotate.sh +17 -0
- package/apps/studio/bin/read-annotations.mjs +152 -17
- package/apps/studio/build.ts +1 -1
- package/apps/studio/canvas-arrowheads.ts +78 -9
- package/apps/studio/canvas-cursors.ts +2 -0
- package/apps/studio/canvas-edit.ts +257 -7
- package/apps/studio/canvas-icons.tsx +105 -0
- package/apps/studio/canvas-shell.tsx +305 -8
- package/apps/studio/client/app.jsx +2812 -238
- package/apps/studio/client/index.html +1 -1
- package/apps/studio/client/styles/3-shell-maude.css +713 -11
- package/apps/studio/client/styles/4-components.css +39 -0
- package/apps/studio/client/styles/5-maude-overrides.css +16 -2
- package/apps/studio/client/tour/overlay.jsx +105 -17
- package/apps/studio/client/tour/usage-tour.js +23 -10
- package/apps/studio/commands/annotation-strokes-command.ts +1 -1
- package/apps/studio/commands/move-artboards-command.ts +1 -1
- package/apps/studio/comments-overlay.tsx +7 -5
- package/apps/studio/context-menu.tsx +4 -3
- package/apps/studio/contextual-toolbar.tsx +14 -0
- package/apps/studio/cursors-overlay.tsx +20 -20
- package/apps/studio/dist/client.bundle.js +3322 -602
- package/apps/studio/dist/comment-mount.js +95 -9
- package/apps/studio/dist/styles.css +2490 -167
- package/apps/studio/dom-selection.ts +115 -0
- package/apps/studio/export-dialog.tsx +3 -3
- package/apps/studio/history.ts +20 -3
- package/apps/studio/http.ts +124 -5
- package/apps/studio/input-router.tsx +7 -0
- package/apps/studio/inspect.ts +37 -8
- package/apps/studio/participants-chrome.tsx +1 -7
- package/apps/studio/server.ts +5 -2
- package/apps/studio/sync/agent.ts +231 -57
- package/apps/studio/sync/codec.ts +45 -0
- package/apps/studio/sync/cold-start.ts +158 -0
- package/apps/studio/sync/connection-state.ts +58 -2
- package/apps/studio/sync/index.ts +563 -221
- package/apps/studio/sync/journal.ts +190 -0
- package/apps/studio/sync/migrate-seed.ts +202 -44
- package/apps/studio/sync/projection.ts +22 -3
- package/apps/studio/sync/status.ts +15 -4
- package/apps/studio/test/activity.test.ts +1 -7
- package/apps/studio/test/annotate-write.test.ts +184 -0
- package/apps/studio/test/annotations-align.test.ts +88 -0
- package/apps/studio/test/annotations-bindings.test.ts +124 -0
- package/apps/studio/test/annotations-groups.test.ts +231 -0
- package/apps/studio/test/annotations-snap.test.ts +79 -0
- package/apps/studio/test/canvas-edit.test.ts +181 -1
- package/apps/studio/test/canvas-origin-gate.test.ts +8 -0
- package/apps/studio/test/collab-bridge.test.ts +0 -2
- package/apps/studio/test/collab-room.test.ts +2 -7
- package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
- package/apps/studio/test/csrf-write-guard.test.ts +52 -0
- package/apps/studio/test/figjam-v3-model.test.ts +342 -0
- package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
- package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
- package/apps/studio/test/sync-agent.test.ts +272 -2
- package/apps/studio/test/sync-codec.test.ts +65 -0
- package/apps/studio/test/sync-cold-start.test.ts +244 -0
- package/apps/studio/test/sync-connection-state.test.ts +68 -0
- package/apps/studio/test/sync-hardening.test.ts +0 -8
- package/apps/studio/test/sync-hubs-config.test.ts +0 -1
- package/apps/studio/test/sync-incident-replay.test.ts +211 -0
- package/apps/studio/test/sync-journal.test.ts +176 -0
- package/apps/studio/test/sync-runtime.test.ts +360 -5
- package/apps/studio/test/sync-status.test.ts +66 -0
- package/apps/studio/test/use-annotation-resize.test.ts +159 -0
- package/apps/studio/test/use-artboard-drag.test.ts +0 -1
- package/apps/studio/test/use-collab.test.ts +0 -0
- package/apps/studio/test/use-tool-mode.test.tsx +5 -2
- package/apps/studio/tool-palette.tsx +12 -2
- package/apps/studio/undo-hud.tsx +0 -1
- package/apps/studio/use-agent-presence.tsx +4 -2
- package/apps/studio/use-annotation-resize.tsx +484 -54
- package/apps/studio/use-annotation-selection.tsx +16 -4
- package/apps/studio/use-canvas-activity.tsx +2 -2
- package/apps/studio/use-collab.tsx +20 -15
- package/apps/studio/use-selection-set.tsx +9 -0
- package/apps/studio/use-tool-mode.tsx +5 -1
- package/apps/studio/whats-new.json +135 -10
- package/apps/studio/whats-new.schema.json +18 -2
- package/cli/commands/design-link.test.mjs +84 -0
- package/cli/commands/design.mjs +2 -1
- package/cli/commands/design.test.mjs +5 -1
- package/cli/lib/design-link.mjs +51 -1
- package/package.json +8 -8
- package/plugins/design/templates/_shell.html +28 -4
package/README.md
CHANGED
|
@@ -106,7 +106,7 @@ Plugin slash-commands reach all executable logic through the on-PATH `maude` bin
|
|
|
106
106
|
Two clean paths, no middle ground ([DDR-047](.ai/decisions/DDR-047-collab-scope-cut-no-lan-mode-hub-admin-ui.md)):
|
|
107
107
|
|
|
108
108
|
- **v1.0 — git handoff OR loopback multi-tab.** Push / pull is the cross-machine story. On a single machine, two browser tabs (or two Claude Code instances editing the same repo) sync cursors, comments, and annotations live over loopback WebSocket. The dev server refuses any non-loopback `host` header on the collab WS endpoint.
|
|
109
|
-
- **v1.1 — deploy a hub** (Phase 9, in-flight). Cross-machine live collab needs a hub binary you deploy yourself. No tunnel mode; no shared cloud.
|
|
109
|
+
- **v1.1 — deploy a hub** (Phase 9, in-flight). Cross-machine live collab needs a hub binary you deploy yourself. No tunnel mode; no shared cloud. **Boot order can't eat your work** ([DDR-102](.ai/decisions/DDR-102-cold-start-divergence-resolution.md)): a per-machine journal tells clean catch-ups apart from genuine divergence; diverged canvases snapshot **both** versions to `_history/` before the newer one wins, so the loser is one `/design:rollback` away — and `maude design status` reports per-canvas sync state honestly (synced / pending / auth-rejected).
|
|
110
110
|
|
|
111
111
|
## Security
|
|
112
112
|
|
|
@@ -183,7 +183,6 @@ export function AiBanner(): JSX.Element | null {
|
|
|
183
183
|
|
|
184
184
|
if (!entry) return null;
|
|
185
185
|
return (
|
|
186
|
-
// biome-ignore lint/a11y/useSemanticElements: <output> is a form-result element; role="status" on a div is the canonical live-region banner pattern (APG).
|
|
187
186
|
<div className="dc-ai-banner" role="status" aria-live="polite">
|
|
188
187
|
<span className="dc-ai-banner__dot" aria-hidden="true" />
|
|
189
188
|
<span>{entry.author} is editing this canvas — your changes may conflict</span>
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file annotations-align.ts — FigJam v3 align + distribute
|
|
3
|
+
* @scope apps/studio/annotations-align.ts
|
|
4
|
+
* @purpose Pure bbox math (no React, no DOM) for the multi-select Align
|
|
5
|
+
* cluster. Units follow FigJam: a whole group aligns as ONE box
|
|
6
|
+
* (its members translate together), an ungrouped stroke is its own
|
|
7
|
+
* unit. Align needs ≥ 2 units, distribute ≥ 3 (first/last pinned,
|
|
8
|
+
* gaps equalized — Figma "distribute spacing" semantics).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { expandIdsToGroups, outermostGroupOf } from './annotations-groups.ts';
|
|
12
|
+
import { type AnchorHost, type Stroke, strokeBBox, translateOne } from './annotations-model.ts';
|
|
13
|
+
|
|
14
|
+
export type AlignEdge = 'left' | 'h-center' | 'right' | 'top' | 'v-center' | 'bottom';
|
|
15
|
+
export type DistributeAxis = 'h' | 'v';
|
|
16
|
+
|
|
17
|
+
interface BBox {
|
|
18
|
+
x: number;
|
|
19
|
+
y: number;
|
|
20
|
+
w: number;
|
|
21
|
+
h: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface AlignUnit {
|
|
25
|
+
ids: Set<string>;
|
|
26
|
+
bbox: BBox;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function unionBox(a: BBox, b: BBox): BBox {
|
|
30
|
+
const x = Math.min(a.x, b.x);
|
|
31
|
+
const y = Math.min(a.y, b.y);
|
|
32
|
+
return {
|
|
33
|
+
x,
|
|
34
|
+
y,
|
|
35
|
+
w: Math.max(a.x + a.w, b.x + b.w) - x,
|
|
36
|
+
h: Math.max(a.y + a.h, b.y + b.h) - y,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Partition the (expanded) selection into alignment units. Anchored text is
|
|
42
|
+
* skipped (it has no independent bbox and rides its host via `translateOne`'s
|
|
43
|
+
* no-op); arrows participate — bound endpoints get re-pinned by the caller's
|
|
44
|
+
* `recomputeBoundArrows` pass after the translate.
|
|
45
|
+
*/
|
|
46
|
+
function unitsOf(strokes: readonly Stroke[], ids: readonly string[]): AlignUnit[] {
|
|
47
|
+
const anchors = new Map<string, AnchorHost>();
|
|
48
|
+
for (const s of strokes) {
|
|
49
|
+
if (s.tool === 'rect' || s.tool === 'ellipse' || s.tool === 'polygon') anchors.set(s.id, s);
|
|
50
|
+
}
|
|
51
|
+
const sel = new Set(expandIdsToGroups(ids, strokes));
|
|
52
|
+
const byGroup = new Map<string, AlignUnit>();
|
|
53
|
+
const units: AlignUnit[] = [];
|
|
54
|
+
for (const s of strokes) {
|
|
55
|
+
if (!sel.has(s.id)) continue;
|
|
56
|
+
if (s.tool === 'text' && s.anchorId) continue; // rides its host
|
|
57
|
+
const bb = strokeBBox(s, anchors);
|
|
58
|
+
if (!bb) continue;
|
|
59
|
+
const g = outermostGroupOf(s);
|
|
60
|
+
if (g != null) {
|
|
61
|
+
const existing = byGroup.get(g);
|
|
62
|
+
if (existing) {
|
|
63
|
+
existing.ids.add(s.id);
|
|
64
|
+
existing.bbox = unionBox(existing.bbox, bb);
|
|
65
|
+
} else {
|
|
66
|
+
const unit: AlignUnit = { ids: new Set([s.id]), bbox: bb };
|
|
67
|
+
byGroup.set(g, unit);
|
|
68
|
+
units.push(unit);
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
71
|
+
units.push({ ids: new Set([s.id]), bbox: bb });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return units;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function applyDeltas(
|
|
78
|
+
strokes: readonly Stroke[],
|
|
79
|
+
deltas: Map<string, readonly [number, number]>
|
|
80
|
+
): Stroke[] {
|
|
81
|
+
if (deltas.size === 0) return strokes as Stroke[];
|
|
82
|
+
return strokes.map((s) => {
|
|
83
|
+
const d = deltas.get(s.id);
|
|
84
|
+
return d ? translateOne(s, d[0], d[1]) : s;
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Align every unit's edge/center to the selection's union bbox. */
|
|
89
|
+
export function alignStrokes(
|
|
90
|
+
strokes: readonly Stroke[],
|
|
91
|
+
ids: readonly string[],
|
|
92
|
+
edge: AlignEdge
|
|
93
|
+
): Stroke[] {
|
|
94
|
+
const units = unitsOf(strokes, ids);
|
|
95
|
+
if (units.length < 2) return strokes as Stroke[];
|
|
96
|
+
let sel = units[0]?.bbox;
|
|
97
|
+
if (!sel) return strokes as Stroke[];
|
|
98
|
+
for (let i = 1; i < units.length; i++) {
|
|
99
|
+
const u = units[i];
|
|
100
|
+
if (u) sel = unionBox(sel, u.bbox);
|
|
101
|
+
}
|
|
102
|
+
const deltas = new Map<string, readonly [number, number]>();
|
|
103
|
+
for (const u of units) {
|
|
104
|
+
let dx = 0;
|
|
105
|
+
let dy = 0;
|
|
106
|
+
if (edge === 'left') dx = sel.x - u.bbox.x;
|
|
107
|
+
else if (edge === 'h-center') dx = sel.x + sel.w / 2 - (u.bbox.x + u.bbox.w / 2);
|
|
108
|
+
else if (edge === 'right') dx = sel.x + sel.w - (u.bbox.x + u.bbox.w);
|
|
109
|
+
else if (edge === 'top') dy = sel.y - u.bbox.y;
|
|
110
|
+
else if (edge === 'v-center') dy = sel.y + sel.h / 2 - (u.bbox.y + u.bbox.h / 2);
|
|
111
|
+
else dy = sel.y + sel.h - (u.bbox.y + u.bbox.h);
|
|
112
|
+
if (dx !== 0 || dy !== 0) {
|
|
113
|
+
for (const id of u.ids) deltas.set(id, [dx, dy] as const);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return applyDeltas(strokes, deltas);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Equalize the gaps between units along one axis. First and last unit (by
|
|
121
|
+
* leading edge) stay pinned; everything between shifts so every gap is equal.
|
|
122
|
+
*/
|
|
123
|
+
export function distributeStrokes(
|
|
124
|
+
strokes: readonly Stroke[],
|
|
125
|
+
ids: readonly string[],
|
|
126
|
+
axis: DistributeAxis
|
|
127
|
+
): Stroke[] {
|
|
128
|
+
const units = unitsOf(strokes, ids);
|
|
129
|
+
if (units.length < 3) return strokes as Stroke[];
|
|
130
|
+
const lead = (u: AlignUnit): number => (axis === 'h' ? u.bbox.x : u.bbox.y);
|
|
131
|
+
const size = (u: AlignUnit): number => (axis === 'h' ? u.bbox.w : u.bbox.h);
|
|
132
|
+
const sorted = [...units].sort((a, b) => lead(a) - lead(b));
|
|
133
|
+
const first = sorted[0];
|
|
134
|
+
const last = sorted[sorted.length - 1];
|
|
135
|
+
if (!first || !last) return strokes as Stroke[];
|
|
136
|
+
const span = lead(last) + size(last) - lead(first);
|
|
137
|
+
const total = sorted.reduce((m, u) => m + size(u), 0);
|
|
138
|
+
const gap = (span - total) / (sorted.length - 1);
|
|
139
|
+
const deltas = new Map<string, readonly [number, number]>();
|
|
140
|
+
let cursor = lead(first);
|
|
141
|
+
for (const u of sorted) {
|
|
142
|
+
const d = cursor - lead(u);
|
|
143
|
+
if (d !== 0) {
|
|
144
|
+
for (const id of u.ids) deltas.set(id, axis === 'h' ? ([d, 0] as const) : ([0, d] as const));
|
|
145
|
+
}
|
|
146
|
+
cursor += size(u) + gap;
|
|
147
|
+
}
|
|
148
|
+
return applyDeltas(strokes, deltas);
|
|
149
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file annotations-bindings.ts — FigJam v3 connector binding
|
|
3
|
+
* @scope apps/studio/annotations-bindings.ts
|
|
4
|
+
* @purpose Pure helpers (no React, no DOM) for arrow ↔ shape magnetic
|
|
5
|
+
* binding. Semantics follow Excalidraw's binding system + FigJam's
|
|
6
|
+
* connector magnets: bind on proximity (~15 px at 100 % zoom,
|
|
7
|
+
* callers scale by 1/zoom), anchors snap to the {0, 0.5, 1} ×
|
|
8
|
+
* {0, 0.5, 1} magnet grid over the host bbox, bound endpoints are
|
|
9
|
+
* DERIVED from the host on every mutation (`recomputeBoundArrows`),
|
|
10
|
+
* and deleting a host strips the bind but keeps the arrow with the
|
|
11
|
+
* endpoint frozen in place (Excalidraw `fixBindingsAfterDeletion`).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
type ArrowBind,
|
|
16
|
+
type ArrowStroke,
|
|
17
|
+
rotatePoint,
|
|
18
|
+
type Stroke,
|
|
19
|
+
strokeBBox,
|
|
20
|
+
strokeRotation,
|
|
21
|
+
} from './annotations-model.ts';
|
|
22
|
+
|
|
23
|
+
/** Hosts an arrow endpoint can attach to (shapes + cards, FigJam set). */
|
|
24
|
+
export function isBindable(s: Stroke): boolean {
|
|
25
|
+
return (
|
|
26
|
+
s.tool === 'rect' ||
|
|
27
|
+
s.tool === 'ellipse' ||
|
|
28
|
+
s.tool === 'polygon' ||
|
|
29
|
+
s.tool === 'sticky' ||
|
|
30
|
+
s.tool === 'image'
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Bind proximity threshold in world px at zoom 1 (Excalidraw's 15 px). */
|
|
35
|
+
export const BIND_THRESHOLD_PX = 15;
|
|
36
|
+
|
|
37
|
+
const MAGNETS = [0, 0.5, 1] as const;
|
|
38
|
+
|
|
39
|
+
function snapMagnet(v: number): number {
|
|
40
|
+
let best: number = MAGNETS[0];
|
|
41
|
+
let bestDist = Number.POSITIVE_INFINITY;
|
|
42
|
+
for (const m of MAGNETS) {
|
|
43
|
+
const d = Math.abs(v - m);
|
|
44
|
+
if (d < bestDist) {
|
|
45
|
+
bestDist = d;
|
|
46
|
+
best = m;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return best;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function clamp01(v: number): number {
|
|
53
|
+
return Math.max(0, Math.min(1, v));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Topmost bindable host within `threshold` of the world point, with the anchor
|
|
58
|
+
* snapped to the nearest side/center magnet. `excludeIds` keeps an arrow from
|
|
59
|
+
* binding to a host that is itself part of the current gesture.
|
|
60
|
+
*/
|
|
61
|
+
export function bindCandidate(
|
|
62
|
+
wx: number,
|
|
63
|
+
wy: number,
|
|
64
|
+
strokes: readonly Stroke[],
|
|
65
|
+
threshold: number,
|
|
66
|
+
excludeIds?: ReadonlySet<string>
|
|
67
|
+
): ArrowBind | null {
|
|
68
|
+
for (let i = strokes.length - 1; i >= 0; i--) {
|
|
69
|
+
const s = strokes[i];
|
|
70
|
+
if (!s || !isBindable(s) || excludeIds?.has(s.id)) continue;
|
|
71
|
+
const bb = strokeBBox(s);
|
|
72
|
+
if (!bb || bb.w <= 0 || bb.h <= 0) continue;
|
|
73
|
+
if (
|
|
74
|
+
wx < bb.x - threshold ||
|
|
75
|
+
wx > bb.x + bb.w + threshold ||
|
|
76
|
+
wy < bb.y - threshold ||
|
|
77
|
+
wy > bb.y + bb.h + threshold
|
|
78
|
+
) {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
hostId: s.id,
|
|
83
|
+
nx: snapMagnet(clamp01((wx - bb.x) / bb.w)),
|
|
84
|
+
ny: snapMagnet(clamp01((wy - bb.y) / bb.h)),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** World position of a normalized anchor over the host's bbox. A rotated host
|
|
91
|
+
* carries its magnets with it (the anchor rotates around the bbox center). */
|
|
92
|
+
export function anchorPoint(host: Stroke, nx: number, ny: number): [number, number] | null {
|
|
93
|
+
const bb = strokeBBox(host);
|
|
94
|
+
if (!bb) return null;
|
|
95
|
+
const px = bb.x + nx * bb.w;
|
|
96
|
+
const py = bb.y + ny * bb.h;
|
|
97
|
+
const rot = strokeRotation(host);
|
|
98
|
+
if (rot === 0) return [px, py];
|
|
99
|
+
return rotatePoint(px, py, bb.x + bb.w / 2, bb.y + bb.h / 2, rot);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Re-derive every bound arrow endpoint from its host. Idempotent — an endpoint
|
|
104
|
+
* already at its anchor is left referentially untouched, and the ORIGINAL
|
|
105
|
+
* array is returned when nothing changed (so the drag-commit no-op checks keep
|
|
106
|
+
* working). A bind whose host no longer exists (or stopped being bindable) is
|
|
107
|
+
* stripped with the endpoint frozen in place — the arrow survives unbound.
|
|
108
|
+
*
|
|
109
|
+
* FigJam v3 auto-routing: an AUTO (non-pinned) bind re-picks the side magnet
|
|
110
|
+
* FACING the arrow's other end on every recompute, so a connector keeps a
|
|
111
|
+
* sensible direction as the shapes move around each other. A `pinned` bind
|
|
112
|
+
* (explicit user re-anchor) keeps its magnet.
|
|
113
|
+
*/
|
|
114
|
+
export function recomputeBoundArrows(strokes: readonly Stroke[]): Stroke[] {
|
|
115
|
+
const byId = new Map<string, Stroke>();
|
|
116
|
+
for (const s of strokes) byId.set(s.id, s);
|
|
117
|
+
let mutated = false;
|
|
118
|
+
const out = strokes.map((s) => {
|
|
119
|
+
if (s.tool !== 'arrow' || (!s.startBind && !s.endBind)) return s;
|
|
120
|
+
let next: ArrowStroke = s;
|
|
121
|
+
// Opposite-end reference points for the facing computation. When both
|
|
122
|
+
// ends are bound, face the OTHER HOST's center (stable under endpoint
|
|
123
|
+
// churn); a free end is its literal coordinate.
|
|
124
|
+
const startHost = s.startBind ? byId.get(s.startBind.hostId) : undefined;
|
|
125
|
+
const endHost = s.endBind ? byId.get(s.endBind.hostId) : undefined;
|
|
126
|
+
const centerOf = (host: Stroke | undefined): [number, number] | null => {
|
|
127
|
+
if (!host) return null;
|
|
128
|
+
const bb = strokeBBox(host);
|
|
129
|
+
return bb ? [bb.x + bb.w / 2, bb.y + bb.h / 2] : null;
|
|
130
|
+
};
|
|
131
|
+
const startRef: [number, number] = centerOf(endHost) ?? [s.x2, s.y2];
|
|
132
|
+
const endRef: [number, number] = centerOf(startHost) ?? [s.x1, s.y1];
|
|
133
|
+
const applyEnd = (
|
|
134
|
+
bind: ArrowBind | undefined,
|
|
135
|
+
end: 'start' | 'end',
|
|
136
|
+
ref: [number, number]
|
|
137
|
+
): void => {
|
|
138
|
+
if (!bind) return;
|
|
139
|
+
const host = byId.get(bind.hostId);
|
|
140
|
+
if (!host || !isBindable(host)) {
|
|
141
|
+
const copy: ArrowStroke = { ...next };
|
|
142
|
+
delete copy[end === 'start' ? 'startBind' : 'endBind'];
|
|
143
|
+
next = copy;
|
|
144
|
+
mutated = true;
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
let effective = bind;
|
|
148
|
+
if (!bind.pinned) {
|
|
149
|
+
const facing = facingAnchor(host, ref[0], ref[1]);
|
|
150
|
+
if (facing && (facing.nx !== bind.nx || facing.ny !== bind.ny)) {
|
|
151
|
+
effective = { ...bind, nx: facing.nx, ny: facing.ny };
|
|
152
|
+
next =
|
|
153
|
+
end === 'start' ? { ...next, startBind: effective } : { ...next, endBind: effective };
|
|
154
|
+
mutated = true;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const pt = anchorPoint(host, effective.nx, effective.ny);
|
|
158
|
+
if (!pt) return;
|
|
159
|
+
if (end === 'start') {
|
|
160
|
+
if (next.x1 !== pt[0] || next.y1 !== pt[1]) {
|
|
161
|
+
next = { ...next, x1: pt[0], y1: pt[1] };
|
|
162
|
+
mutated = true;
|
|
163
|
+
}
|
|
164
|
+
} else if (next.x2 !== pt[0] || next.y2 !== pt[1]) {
|
|
165
|
+
next = { ...next, x2: pt[0], y2: pt[1] };
|
|
166
|
+
mutated = true;
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
applyEnd(s.startBind, 'start', startRef);
|
|
170
|
+
applyEnd(next.endBind, 'end', endRef);
|
|
171
|
+
return next;
|
|
172
|
+
});
|
|
173
|
+
return mutated ? out : (strokes as Stroke[]);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Side-magnet anchor on `host` facing the world point `(tx, ty)` — used by the
|
|
178
|
+
* AI annotate verb + quick-create to pick a sensible attachment side without a
|
|
179
|
+
* pointer position: the dominant axis of the direction vector decides the
|
|
180
|
+
* side, the cross axis centers.
|
|
181
|
+
*/
|
|
182
|
+
export function facingAnchor(host: Stroke, tx: number, ty: number): ArrowBind | null {
|
|
183
|
+
const bb = strokeBBox(host);
|
|
184
|
+
if (!bb || bb.w <= 0 || bb.h <= 0) return null;
|
|
185
|
+
const cx = bb.x + bb.w / 2;
|
|
186
|
+
const cy = bb.y + bb.h / 2;
|
|
187
|
+
const dx = tx - cx;
|
|
188
|
+
const dy = ty - cy;
|
|
189
|
+
// Normalize by the half-extents so a wide-but-short card still picks the
|
|
190
|
+
// side the target visually faces.
|
|
191
|
+
const rx = Math.abs(dx) / Math.max(1, bb.w / 2);
|
|
192
|
+
const ry = Math.abs(dy) / Math.max(1, bb.h / 2);
|
|
193
|
+
if (rx >= ry) {
|
|
194
|
+
return { hostId: host.id, nx: dx >= 0 ? 1 : 0, ny: 0.5 };
|
|
195
|
+
}
|
|
196
|
+
return { hostId: host.id, nx: 0.5, ny: dy >= 0 ? 1 : 0 };
|
|
197
|
+
}
|