@1agh/maude 0.29.0 → 0.31.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.
Files changed (162) hide show
  1. package/README.md +6 -6
  2. package/apps/studio/acp/bridge.ts +285 -0
  3. package/apps/studio/acp/env.ts +48 -0
  4. package/apps/studio/acp/index.ts +132 -0
  5. package/apps/studio/acp/probe.ts +112 -0
  6. package/apps/studio/acp/transcript.ts +149 -0
  7. package/apps/studio/ai-banner.tsx +0 -1
  8. package/apps/studio/annotations-align.ts +149 -0
  9. package/apps/studio/annotations-bindings.ts +197 -0
  10. package/apps/studio/annotations-context-toolbar.tsx +436 -186
  11. package/apps/studio/annotations-groups.ts +270 -0
  12. package/apps/studio/annotations-layer.tsx +1714 -1661
  13. package/apps/studio/annotations-model.ts +2077 -0
  14. package/apps/studio/annotations-snap.ts +125 -0
  15. package/apps/studio/api.ts +433 -182
  16. package/apps/studio/bin/_png-playwright.mjs +1 -1
  17. package/apps/studio/bin/annotate.mjs +732 -0
  18. package/apps/studio/bin/annotate.sh +17 -0
  19. package/apps/studio/bin/chat-open.sh +44 -0
  20. package/apps/studio/bin/read-annotations.mjs +152 -17
  21. package/apps/studio/build.ts +1 -1
  22. package/apps/studio/canvas-arrowheads.ts +78 -9
  23. package/apps/studio/canvas-cursors.ts +2 -0
  24. package/apps/studio/canvas-edit.ts +257 -7
  25. package/apps/studio/canvas-icons.tsx +105 -0
  26. package/apps/studio/canvas-lib.tsx +112 -19
  27. package/apps/studio/canvas-list-watch.ts +177 -0
  28. package/apps/studio/canvas-shell.tsx +326 -9
  29. package/apps/studio/client/app.jsx +3579 -250
  30. package/apps/studio/client/canvas-url.js +5 -0
  31. package/apps/studio/client/github.js +99 -0
  32. package/apps/studio/client/index.html +1 -1
  33. package/apps/studio/client/panels/ChatPanel.jsx +770 -0
  34. package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
  35. package/apps/studio/client/panels/CreateProject.jsx +334 -0
  36. package/apps/studio/client/panels/DiffView.jsx +590 -0
  37. package/apps/studio/client/panels/GitPanel.jsx +767 -0
  38. package/apps/studio/client/panels/IdentityBar.jsx +294 -0
  39. package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
  40. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
  41. package/apps/studio/client/panels/acp-runtime.js +286 -0
  42. package/apps/studio/client/panels/chat-markdown.jsx +138 -0
  43. package/apps/studio/client/panels/git-grouping.js +86 -0
  44. package/apps/studio/client/styles/0-reset.css +4 -0
  45. package/apps/studio/client/styles/3-shell-maude.css +1326 -14
  46. package/apps/studio/client/styles/4-components.css +39 -0
  47. package/apps/studio/client/styles/5-maude-overrides.css +41 -2
  48. package/apps/studio/client/styles/6-acp-chat.css +771 -0
  49. package/apps/studio/client/styles/_index.css +2 -0
  50. package/apps/studio/client/tour/collab-tour.js +61 -0
  51. package/apps/studio/client/tour/overlay.jsx +116 -18
  52. package/apps/studio/client/tour/usage-tour.js +23 -10
  53. package/apps/studio/client/whats-new.jsx +25 -10
  54. package/apps/studio/collab/registry.ts +13 -0
  55. package/apps/studio/collab/room.ts +36 -0
  56. package/apps/studio/commands/annotation-strokes-command.ts +1 -1
  57. package/apps/studio/commands/move-artboards-command.ts +1 -1
  58. package/apps/studio/comments-overlay.tsx +7 -5
  59. package/apps/studio/context-menu.tsx +4 -3
  60. package/apps/studio/contextual-toolbar.tsx +14 -0
  61. package/apps/studio/cursors-overlay.tsx +37 -21
  62. package/apps/studio/dist/client.bundle.js +31795 -1829
  63. package/apps/studio/dist/comment-mount.js +97 -9
  64. package/apps/studio/dist/styles.css +7938 -1599
  65. package/apps/studio/dom-selection.ts +115 -0
  66. package/apps/studio/export-dialog.tsx +3 -3
  67. package/apps/studio/git/endpoints.ts +338 -0
  68. package/apps/studio/git/service.ts +1334 -0
  69. package/apps/studio/git/watch.ts +97 -0
  70. package/apps/studio/github/endpoints.ts +358 -0
  71. package/apps/studio/github/service.ts +231 -0
  72. package/apps/studio/github/token.ts +53 -0
  73. package/apps/studio/history.ts +20 -3
  74. package/apps/studio/hmr-broadcast.ts +9 -2
  75. package/apps/studio/http.ts +508 -6
  76. package/apps/studio/input-router.tsx +7 -0
  77. package/apps/studio/inspect.ts +37 -8
  78. package/apps/studio/participants-chrome.tsx +70 -16
  79. package/apps/studio/paths.ts +12 -0
  80. package/apps/studio/scaffold-design.ts +57 -0
  81. package/apps/studio/server.ts +70 -4
  82. package/apps/studio/sync/agent.ts +311 -57
  83. package/apps/studio/sync/codec.ts +69 -0
  84. package/apps/studio/sync/cold-start.ts +198 -0
  85. package/apps/studio/sync/connection-state.ts +58 -2
  86. package/apps/studio/sync/hub-link.ts +137 -0
  87. package/apps/studio/sync/index.ts +563 -221
  88. package/apps/studio/sync/journal.ts +190 -0
  89. package/apps/studio/sync/migrate-seed.ts +202 -44
  90. package/apps/studio/sync/projection.ts +22 -3
  91. package/apps/studio/sync/status.ts +15 -4
  92. package/apps/studio/test/acp-bridge.test.ts +127 -0
  93. package/apps/studio/test/acp-env.test.ts +65 -0
  94. package/apps/studio/test/acp-origin-gate.test.ts +78 -0
  95. package/apps/studio/test/acp-transcript.test.ts +112 -0
  96. package/apps/studio/test/activity.test.ts +1 -7
  97. package/apps/studio/test/annotate-write.test.ts +184 -0
  98. package/apps/studio/test/annotations-align.test.ts +88 -0
  99. package/apps/studio/test/annotations-bindings.test.ts +124 -0
  100. package/apps/studio/test/annotations-groups.test.ts +231 -0
  101. package/apps/studio/test/annotations-snap.test.ts +79 -0
  102. package/apps/studio/test/canvas-create-api.test.ts +72 -0
  103. package/apps/studio/test/canvas-edit.test.ts +181 -1
  104. package/apps/studio/test/canvas-list-watch.test.ts +322 -0
  105. package/apps/studio/test/canvas-meta-api.test.ts +161 -27
  106. package/apps/studio/test/canvas-origin-gate.test.ts +43 -0
  107. package/apps/studio/test/chat-markdown.test.tsx +58 -0
  108. package/apps/studio/test/collab-bridge.test.ts +0 -2
  109. package/apps/studio/test/collab-room.test.ts +2 -7
  110. package/apps/studio/test/collab-session-survival.test.tsx +176 -0
  111. package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
  112. package/apps/studio/test/csrf-write-guard.test.ts +78 -0
  113. package/apps/studio/test/editing-presence.test.ts +103 -0
  114. package/apps/studio/test/figjam-v3-model.test.ts +342 -0
  115. package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
  116. package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
  117. package/apps/studio/test/git-api.test.ts +0 -0
  118. package/apps/studio/test/git-branches.test.ts +106 -0
  119. package/apps/studio/test/git-grouping.test.ts +106 -0
  120. package/apps/studio/test/git-watch.test.ts +97 -0
  121. package/apps/studio/test/github-api.test.ts +465 -0
  122. package/apps/studio/test/hub-link.test.ts +69 -0
  123. package/apps/studio/test/participants-chrome.test.ts +36 -1
  124. package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
  125. package/apps/studio/test/sync-agent.test.ts +272 -2
  126. package/apps/studio/test/sync-codec.test.ts +65 -0
  127. package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
  128. package/apps/studio/test/sync-cold-start.test.ts +304 -0
  129. package/apps/studio/test/sync-connection-state.test.ts +68 -0
  130. package/apps/studio/test/sync-hardening.test.ts +0 -8
  131. package/apps/studio/test/sync-hubs-config.test.ts +0 -1
  132. package/apps/studio/test/sync-incident-replay.test.ts +211 -0
  133. package/apps/studio/test/sync-journal.test.ts +176 -0
  134. package/apps/studio/test/sync-runtime.test.ts +360 -5
  135. package/apps/studio/test/sync-status.test.ts +66 -0
  136. package/apps/studio/test/tour-overlay.test.tsx +18 -0
  137. package/apps/studio/test/use-annotation-resize.test.ts +159 -0
  138. package/apps/studio/test/use-artboard-drag.test.ts +0 -1
  139. package/apps/studio/test/use-collab.test.ts +0 -0
  140. package/apps/studio/test/use-tool-mode.test.tsx +5 -2
  141. package/apps/studio/tool-palette.tsx +30 -11
  142. package/apps/studio/undo-hud.tsx +0 -1
  143. package/apps/studio/use-agent-presence.tsx +4 -2
  144. package/apps/studio/use-annotation-resize.tsx +484 -54
  145. package/apps/studio/use-annotation-selection.tsx +16 -4
  146. package/apps/studio/use-canvas-activity.tsx +2 -2
  147. package/apps/studio/use-chrome-visibility.tsx +66 -0
  148. package/apps/studio/use-collab.tsx +434 -202
  149. package/apps/studio/use-selection-set.tsx +9 -0
  150. package/apps/studio/use-tool-mode.tsx +5 -1
  151. package/apps/studio/whats-new.json +208 -10
  152. package/apps/studio/whats-new.schema.json +18 -2
  153. package/apps/studio/ws.ts +44 -1
  154. package/cli/commands/design-link.test.mjs +84 -0
  155. package/cli/commands/design.mjs +3 -1
  156. package/cli/commands/design.test.mjs +5 -1
  157. package/cli/lib/design-link.mjs +51 -1
  158. package/cli/lib/gitignore-block.mjs +16 -3
  159. package/cli/lib/gitignore-block.test.mjs +13 -1
  160. package/package.json +11 -9
  161. package/plugins/design/dependencies.json +17 -0
  162. package/plugins/design/templates/_shell.html +58 -12
@@ -0,0 +1,149 @@
1
+ // Repo-level chat transcripts (`<designRoot>/_chat/<chatId>.jsonl`). The bridge
2
+ // appends raw per-update lines; these readers turn them into the chat list (for
3
+ // the switcher) and clean per-turn messages (for hydrating the thread on open).
4
+
5
+ import { existsSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+
8
+ export interface ChatSummary {
9
+ id: string;
10
+ title: string;
11
+ updated: number; // mtime ms
12
+ }
13
+
14
+ export interface ChatMessagePart {
15
+ type: 'text' | 'tool';
16
+ text?: string;
17
+ toolName?: string;
18
+ done?: boolean;
19
+ }
20
+ export interface ChatMessage {
21
+ role: 'user' | 'assistant';
22
+ parts: ChatMessagePart[];
23
+ }
24
+
25
+ function chatDir(designRoot: string): string {
26
+ return join(designRoot, '_chat');
27
+ }
28
+
29
+ function readLines(file: string): Array<Record<string, unknown>> {
30
+ try {
31
+ return readFileSync(file, 'utf8')
32
+ .split('\n')
33
+ .filter(Boolean)
34
+ .map((l) => {
35
+ try {
36
+ return JSON.parse(l) as Record<string, unknown>;
37
+ } catch {
38
+ return null;
39
+ }
40
+ })
41
+ .filter((x): x is Record<string, unknown> => x !== null);
42
+ } catch {
43
+ return [];
44
+ }
45
+ }
46
+
47
+ /** First user line, truncated — the chat's display title. */
48
+ function deriveTitle(lines: Array<Record<string, unknown>>): string {
49
+ const firstUser = lines.find((l) => l.role === 'user' && typeof l.text === 'string');
50
+ const text = (firstUser?.text as string) ?? '';
51
+ const trimmed = text.replace(/\s+/g, ' ').trim();
52
+ return trimmed ? trimmed.slice(0, 60) : 'New chat';
53
+ }
54
+
55
+ /** List chats newest-first. */
56
+ export function listChats(designRoot: string): ChatSummary[] {
57
+ const dir = chatDir(designRoot);
58
+ if (!existsSync(dir)) return [];
59
+ const out: ChatSummary[] = [];
60
+ for (const name of readdirSync(dir)) {
61
+ if (!name.endsWith('.jsonl')) continue;
62
+ const file = join(dir, name);
63
+ let updated = 0;
64
+ try {
65
+ updated = statSync(file).mtimeMs;
66
+ } catch {
67
+ /* skip unreadable */
68
+ }
69
+ const lines = readLines(file);
70
+ if (lines.length === 0) continue;
71
+ out.push({ id: name.replace(/\.jsonl$/, ''), title: deriveTitle(lines), updated });
72
+ }
73
+ return out.sort((a, b) => b.updated - a.updated);
74
+ }
75
+
76
+ /** Delete a chat's transcript. Returns true if a file was removed. */
77
+ export function deleteChat(designRoot: string, chatId: string): boolean {
78
+ const file = join(chatDir(designRoot), `${chatId}.jsonl`);
79
+ if (!existsSync(file)) return false;
80
+ try {
81
+ rmSync(file);
82
+ return true;
83
+ } catch {
84
+ return false;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Convert the raw transcript into clean per-turn messages: user lines become
90
+ * user messages; the agent updates between them aggregate into one assistant
91
+ * message (text + tool parts; `available_commands_update` / `usage_update` /
92
+ * thoughts dropped — chrome noise, not the conversation).
93
+ */
94
+ export function readChatMessages(designRoot: string, chatId: string): ChatMessage[] {
95
+ const file = join(chatDir(designRoot), `${chatId}.jsonl`);
96
+ if (!existsSync(file)) return [];
97
+ const lines = readLines(file);
98
+ const messages: ChatMessage[] = [];
99
+ let assistant: ChatMessage | null = null;
100
+ const toolIndex = new Map<string, number>();
101
+
102
+ const flush = () => {
103
+ if (assistant?.parts.length) messages.push(assistant);
104
+ assistant = null;
105
+ toolIndex.clear();
106
+ };
107
+
108
+ for (const line of lines) {
109
+ if (line.role === 'user' && typeof line.text === 'string') {
110
+ flush();
111
+ messages.push({ role: 'user', parts: [{ type: 'text', text: line.text }] });
112
+ continue;
113
+ }
114
+ if (line.role === 'stop') {
115
+ flush();
116
+ continue;
117
+ }
118
+ if (line.role !== 'agent') continue;
119
+ const update = line.update as Record<string, unknown> | undefined;
120
+ if (!update) continue;
121
+ if (!assistant) assistant = { role: 'assistant', parts: [] };
122
+ const kind = update.sessionUpdate;
123
+ if (kind === 'agent_message_chunk') {
124
+ const content = update.content as { type?: string; text?: string } | undefined;
125
+ if (content?.type !== 'text' || typeof content.text !== 'string') continue;
126
+ const last = assistant.parts[assistant.parts.length - 1];
127
+ if (last && last.type === 'text') last.text = (last.text ?? '') + content.text;
128
+ else assistant.parts.push({ type: 'text', text: content.text });
129
+ } else if (kind === 'tool_call') {
130
+ const id = String(update.toolCallId ?? '');
131
+ toolIndex.set(id, assistant.parts.length);
132
+ assistant.parts.push({
133
+ type: 'tool',
134
+ toolName: String(update.title ?? update.kind ?? 'tool'),
135
+ done: false,
136
+ });
137
+ } else if (kind === 'tool_call_update') {
138
+ const id = String(update.toolCallId ?? '');
139
+ const idx = toolIndex.get(id);
140
+ const status = update.status;
141
+ if (idx != null && (status === 'completed' || status === 'failed')) {
142
+ const part = assistant.parts[idx];
143
+ if (part) part.done = true;
144
+ }
145
+ }
146
+ }
147
+ flush();
148
+ return messages;
149
+ }
@@ -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
+ }