@1agh/maude 1.4.5 → 1.5.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 (106) hide show
  1. package/apps/studio/annotations/ai-read.ts +288 -0
  2. package/apps/studio/annotations/ai-write.ts +533 -0
  3. package/apps/studio/annotations/board-io.ts +94 -0
  4. package/apps/studio/annotations/board-text.ts +45 -0
  5. package/apps/studio/annotations/constants.ts +58 -0
  6. package/apps/studio/annotations/elements/_shared.ts +125 -0
  7. package/apps/studio/annotations/elements/arrow.model.ts +196 -0
  8. package/apps/studio/annotations/elements/media.model.ts +79 -0
  9. package/apps/studio/annotations/elements/pen.model.ts +82 -0
  10. package/apps/studio/annotations/elements/section.model.ts +70 -0
  11. package/apps/studio/annotations/elements/shape.model.ts +141 -0
  12. package/apps/studio/annotations/elements/sticky.model.ts +48 -0
  13. package/apps/studio/annotations/elements/text.model.ts +52 -0
  14. package/apps/studio/annotations/fields.ts +347 -0
  15. package/apps/studio/annotations/fractional-index.ts +234 -0
  16. package/apps/studio/annotations/legacy/mini-dom.ts +207 -0
  17. package/apps/studio/annotations/migrate-boot.ts +172 -0
  18. package/apps/studio/annotations/migrate-cli.ts +37 -0
  19. package/apps/studio/annotations/migrate-v1.ts +383 -0
  20. package/apps/studio/annotations/ops.ts +474 -0
  21. package/apps/studio/annotations/registry.ts +159 -0
  22. package/apps/studio/annotations/replica.ts +264 -0
  23. package/apps/studio/annotations/scene.ts +235 -0
  24. package/apps/studio/annotations/schema.ts +188 -0
  25. package/apps/studio/annotations/types.ts +77 -0
  26. package/apps/studio/annotations/ui/board.ts +126 -0
  27. package/apps/studio/annotations/ui/containment.ts +118 -0
  28. package/apps/studio/annotations/ui/edit-actions.ts +551 -0
  29. package/apps/studio/annotations/ui/editor-channel.ts +62 -0
  30. package/apps/studio/annotations/ui/element-node.tsx +993 -0
  31. package/apps/studio/annotations/ui/pipeline-context.ts +17 -0
  32. package/apps/studio/annotations/ui/pointer-pipeline.ts +150 -0
  33. package/apps/studio/annotations/ui/render-model.ts +264 -0
  34. package/apps/studio/annotations/ui/scene.tsx +63 -0
  35. package/apps/studio/annotations/ui/text-editor.tsx +420 -0
  36. package/apps/studio/annotations/ui/text-session.ts +140 -0
  37. package/apps/studio/annotations/ui/text-style.ts +111 -0
  38. package/apps/studio/annotations/ui/world.ts +47 -0
  39. package/apps/studio/annotations/v1-adapter.ts +428 -0
  40. package/apps/studio/annotations-align.ts +21 -6
  41. package/apps/studio/annotations-bindings.ts +2 -0
  42. package/apps/studio/annotations-context-toolbar.tsx +9 -13
  43. package/apps/studio/annotations-groups.ts +3 -0
  44. package/apps/studio/annotations-layer.tsx +923 -1925
  45. package/apps/studio/annotations-model.ts +97 -4
  46. package/apps/studio/annotations-sync.ts +4 -47
  47. package/apps/studio/api.ts +262 -68
  48. package/apps/studio/bin/_import-figma.mjs +22 -12
  49. package/apps/studio/bin/annotate.mjs +331 -838
  50. package/apps/studio/bin/annotate.sh +4 -4
  51. package/apps/studio/bin/perf.sh +21 -7
  52. package/apps/studio/bin/read-annotations.mjs +184 -666
  53. package/apps/studio/bin/read-annotations.sh +9 -5
  54. package/apps/studio/canvas-artifacts.ts +9 -0
  55. package/apps/studio/canvas-comment-mount.tsx +35 -2
  56. package/apps/studio/canvas-lib.tsx +18 -1
  57. package/apps/studio/canvas-shell.tsx +54 -1
  58. package/apps/studio/client/app.jsx +101 -35
  59. package/apps/studio/client/comments-overlay.css +10 -0
  60. package/apps/studio/client/hmr.mjs +1 -1
  61. package/apps/studio/client/panels/git-grouping.js +2 -2
  62. package/apps/studio/client/tree-expansion.js +217 -0
  63. package/apps/studio/collab/index.ts +58 -5
  64. package/apps/studio/collab/persistence.ts +80 -11
  65. package/apps/studio/collab/registry.ts +63 -26
  66. package/apps/studio/commands/annotation-ops-command.ts +72 -0
  67. package/apps/studio/comment-anchor.ts +116 -0
  68. package/apps/studio/comments-overlay.tsx +78 -35
  69. package/apps/studio/context.ts +1 -0
  70. package/apps/studio/cursors-overlay.tsx +337 -109
  71. package/apps/studio/dist/client.bundle.js +850 -850
  72. package/apps/studio/dist/comment-mount.js +2 -2
  73. package/apps/studio/figma/to-strokes.ts +31 -10
  74. package/apps/studio/git/endpoints.ts +1 -1
  75. package/apps/studio/git/service.ts +1 -1
  76. package/apps/studio/git/watch.ts +1 -1
  77. package/apps/studio/http.ts +90 -15
  78. package/apps/studio/server.ts +16 -0
  79. package/apps/studio/sync/accepted-cold-start.ts +72 -8
  80. package/apps/studio/sync/agent.ts +36 -6
  81. package/apps/studio/sync/codec.ts +95 -40
  82. package/apps/studio/sync/comment-ledger.ts +229 -0
  83. package/apps/studio/sync/file-membership.ts +12 -2
  84. package/apps/studio/sync/file-plane.ts +78 -2
  85. package/apps/studio/sync/index.ts +75 -16
  86. package/apps/studio/sync/journal-client.ts +5 -0
  87. package/apps/studio/sync/limits.ts +7 -2
  88. package/apps/studio/sync/migrate-seed.ts +17 -7
  89. package/apps/studio/sync/projection.ts +7 -2
  90. package/apps/studio/sync/remote-docs.ts +34 -0
  91. package/apps/studio/sync/writer-registry.ts +10 -0
  92. package/apps/studio/text-caret.ts +11 -2
  93. package/apps/studio/tree-state.ts +45 -0
  94. package/apps/studio/undo-stack.ts +2 -2
  95. package/apps/studio/use-annotation-resize.tsx +48 -23
  96. package/apps/studio/use-annotation-selection.tsx +9 -2
  97. package/apps/studio/use-collab.tsx +104 -1
  98. package/apps/studio/use-selection-set.tsx +4 -0
  99. package/apps/studio/whats-new.json +63 -0
  100. package/cli/lib/design-link.mjs +5 -1
  101. package/cli/lib/gitignore-block.mjs +1 -1
  102. package/cli/lib/gitignore-drift.mjs +2 -1
  103. package/package.json +9 -8
  104. package/plugins/design/templates/brief-board.tsx.template +1 -1
  105. package/apps/studio/annotation-edit-base.ts +0 -36
  106. package/apps/studio/commands/annotation-strokes-command.ts +0 -137
@@ -0,0 +1,47 @@
1
+ /**
2
+ * @file annotations/ui/world.ts — the board as the editing UI sees it
3
+ * @scope apps/studio/annotations/ui/world.ts
4
+ * @purpose DDR-242 AD8 (Task 26). The board is the element store
5
+ * (board.ts); elements keep PARENT-relative geometry. The editing
6
+ * tools work in world space, on the world-space `Stroke` view of
7
+ * each element. This projects the board into that view, per
8
+ * element and cached, so an unchanged element keeps its identity
9
+ * and its node skips re-rendering. Pure and React-free.
10
+ */
11
+
12
+ import type { Stroke } from '../../annotations-model.ts';
13
+ import type { Scene } from '../scene.ts';
14
+ import type { AnnotationElement } from '../types.ts';
15
+ import { elementStrokes } from '../v1-adapter.ts';
16
+
17
+ export type StrokeCache = WeakMap<AnnotationElement, { key: string; strokes: Stroke[] }>;
18
+
19
+ /**
20
+ * The board as world-space strokes, in paint order. An element whose record
21
+ * and world placement are unchanged keeps its strokes' identity; a bound
22
+ * arrow re-projects whenever its resolved ends move.
23
+ */
24
+ export function projectStrokes(scene: Scene, cache: StrokeCache): Stroke[] {
25
+ const out: Stroke[] = [];
26
+ for (const el of scene.paintOrder()) {
27
+ let key: string;
28
+ if (el.type === 'arrow') {
29
+ const r = scene.arrowWorld(el);
30
+ key = r
31
+ ? `${r.x1},${r.y1},${r.x2},${r.y2},${r.startAnchor?.el ?? ''},${r.endAnchor?.el ?? ''}`
32
+ : '';
33
+ } else {
34
+ const o = scene.originOf(el);
35
+ key = `${o.x},${o.y}`;
36
+ }
37
+ const hit = cache.get(el);
38
+ if (hit && hit.key === key) {
39
+ for (const s of hit.strokes) out.push(s);
40
+ continue;
41
+ }
42
+ const strokes = elementStrokes(scene, el);
43
+ cache.set(el, { key, strokes });
44
+ for (const s of strokes) out.push(s);
45
+ }
46
+ return out;
47
+ }
@@ -0,0 +1,428 @@
1
+ /**
2
+ * @file annotations/v1-adapter.ts — v2 elements ⇄ the v1 `Stroke[]` UI
3
+ * @scope apps/studio/annotations/v1-adapter.ts
4
+ * @purpose v2-adapter: removed in Task 26.
5
+ *
6
+ * Milestone B of the annotations-v2 plan lands storage + transport
7
+ * UNDER the unchanged v1 UI (annotations-layer.tsx works on
8
+ * `Stroke[]`). This adapter is the seam:
9
+ * • elementsToStrokes — the board as the UI draws it (world
10
+ * coordinates, labels as anchored text, arrow endpoints
11
+ * resolved);
12
+ * • strokesToElements — the UI's next `Stroke[]` back to canonical
13
+ * elements, PRESERVING what the UI cannot express: an element
14
+ * that did not move keeps its parent, sibling order keeps its
15
+ * fractional keys where it can, unknown types are carried over.
16
+ * The caller diffs two adapter outputs (`diffToOps`) — the UI's own
17
+ * before/after — so only what the user changed is sent.
18
+ */
19
+
20
+ import type { ArrowBind, Stroke, WorldPoint } from '../annotations-model.ts';
21
+ import {
22
+ DEFAULT_FONT_SIZE,
23
+ DEFAULT_INK,
24
+ DEFAULT_SECTION_COLOR,
25
+ DEFAULT_STICKY_FILL,
26
+ STICKY_RADIUS,
27
+ } from './constants.ts';
28
+ import { keyBetween, orderKeys } from './fractional-index.ts';
29
+ import { v1ToV2 } from './migrate-v1.ts';
30
+ import { defOf, isKnownType } from './registry.ts';
31
+ import { Scene } from './scene.ts';
32
+ import { validateElements } from './schema.ts';
33
+ import type { AnnotationElement, ArrowEnd, Box } from './types.ts';
34
+
35
+ /** Stable id of the v1 anchored-text stroke that shows a shape's label. */
36
+ export function labelStrokeId(shapeId: string): string {
37
+ return `${shapeId}~label`;
38
+ }
39
+
40
+ function num(v: unknown, d = 0): number {
41
+ return typeof v === 'number' ? v : d;
42
+ }
43
+
44
+ function str(v: unknown, d = ''): string {
45
+ return typeof v === 'string' ? v : d;
46
+ }
47
+
48
+ type Fmt = {
49
+ bold?: boolean;
50
+ italic?: boolean;
51
+ strike?: boolean;
52
+ underline?: boolean;
53
+ listType?: 'bullet' | 'number';
54
+ align?: 'left' | 'center' | 'right';
55
+ };
56
+
57
+ function fmtOf(r: Record<string, unknown>, defAlign: 'left' | 'center'): Fmt {
58
+ const out: Fmt = {};
59
+ if (r.bold === true) out.bold = true;
60
+ if (r.italic === true) out.italic = true;
61
+ if (r.strike === true) out.strike = true;
62
+ if (r.underline === true) out.underline = true;
63
+ if (r.list === 'bullet' || r.list === 'number') out.listType = r.list;
64
+ const a = r.align;
65
+ if ((a === 'left' || a === 'center' || a === 'right') && a !== defAlign) out.align = a;
66
+ return out;
67
+ }
68
+
69
+ function shared(el: AnnotationElement): Partial<Stroke> {
70
+ const out: Record<string, unknown> = {};
71
+ if (Array.isArray(el.groups) && el.groups.length) out.groupIds = [...(el.groups as string[])];
72
+ const a = el.author as { kind?: string; name?: string; id?: string } | undefined;
73
+ if (a?.kind === 'ai') out.author = 'ai';
74
+ else if (a?.kind === 'human') {
75
+ if (a.name) out.authorName = a.name;
76
+ if (a.id) out.authorId = a.id;
77
+ }
78
+ if (num(el.rot) !== 0) out.rotation = num(el.rot);
79
+ return out as Partial<Stroke>;
80
+ }
81
+
82
+ /** The board as the v1 UI draws it, in paint order. Unknown types are not drawn by v1. */
83
+ export function elementsToStrokes(elements: Iterable<AnnotationElement>): Stroke[] {
84
+ const scene = new Scene(elements);
85
+ const out: Stroke[] = [];
86
+ for (const el of scene.paintOrder()) out.push(...elementStrokes(scene, el));
87
+ return out;
88
+ }
89
+
90
+ /** One element as the strokes the UI draws for it (a labelled shape is two). */
91
+ export function elementStrokes(scene: Scene, el: AnnotationElement): Stroke[] {
92
+ const out: Stroke[] = [];
93
+ {
94
+ if (!isKnownType(el.type)) return out;
95
+ const o = scene.originOf(el);
96
+ const x = num(el.x) + o.x;
97
+ const y = num(el.y) + o.y;
98
+ const w = num(el.w);
99
+ const h = num(el.h);
100
+ const base = { id: el.id, ...shared(el) };
101
+ switch (el.type) {
102
+ case 'sticky':
103
+ out.push({
104
+ ...base,
105
+ tool: 'sticky',
106
+ color: str(el.fill, DEFAULT_STICKY_FILL),
107
+ x,
108
+ y,
109
+ w,
110
+ h,
111
+ text: str(el.text),
112
+ fontSize: num(el.fontSize, DEFAULT_FONT_SIZE),
113
+ cornerRadius: num(el.radius, STICKY_RADIUS),
114
+ ...fmtOf(el, 'left'),
115
+ } as Stroke);
116
+ break;
117
+ case 'text':
118
+ out.push({
119
+ ...base,
120
+ tool: 'text',
121
+ color: str(el.color, DEFAULT_INK),
122
+ fontSize: num(el.fontSize, DEFAULT_FONT_SIZE),
123
+ text: str(el.text),
124
+ x,
125
+ y,
126
+ ...fmtOf(el, 'left'),
127
+ } as Stroke);
128
+ break;
129
+ case 'shape': {
130
+ const kind = str(el.kind, 'rect');
131
+ const common = {
132
+ ...base,
133
+ color: str(el.color, DEFAULT_INK),
134
+ width: num(el.width, 2),
135
+ fill: typeof el.fill === 'string' ? el.fill : null,
136
+ ...(el.dashed === true ? { dashed: true } : {}),
137
+ };
138
+ if (kind === 'rect')
139
+ out.push({ ...common, tool: 'rect', x, y, w, h, cornerRadius: num(el.radius) } as Stroke);
140
+ else if (kind === 'ellipse')
141
+ out.push({
142
+ ...common,
143
+ tool: 'ellipse',
144
+ cx: x + w / 2,
145
+ cy: y + h / 2,
146
+ rx: w / 2,
147
+ ry: h / 2,
148
+ } as Stroke);
149
+ else out.push({ ...common, tool: 'polygon', shape: kind, x, y, w, h } as Stroke);
150
+ const label = el.label as Record<string, unknown> | undefined;
151
+ if (label && typeof label.text === 'string') {
152
+ const lf = fmtOf(label, 'center');
153
+ out.push({
154
+ id: labelStrokeId(el.id),
155
+ tool: 'text',
156
+ anchorId: el.id,
157
+ color: str(label.color, DEFAULT_INK),
158
+ fontSize: num(label.fontSize, DEFAULT_FONT_SIZE),
159
+ text: label.text,
160
+ ...lf,
161
+ // v1 anchored text defaults to centre; carry a non-centre alignment explicitly.
162
+ ...(label.align === 'left' || label.align === 'right' ? { align: label.align } : {}),
163
+ } as Stroke);
164
+ }
165
+ break;
166
+ }
167
+ case 'arrow': {
168
+ const r = scene.arrowWorld(el);
169
+ if (!r) break;
170
+ const bind = (
171
+ a: typeof r.startAnchor,
172
+ end: ArrowEnd | undefined
173
+ ): ArrowBind | undefined => {
174
+ if (!a) return undefined;
175
+ const pinned = !!end && 'el' in end && end.nx !== undefined;
176
+ return { hostId: a.el, nx: a.nx, ny: a.ny, ...(pinned ? { pinned: true } : {}) };
177
+ };
178
+ const sb = bind(r.startAnchor, el.start as ArrowEnd);
179
+ const eb = bind(r.endAnchor, el.end as ArrowEnd);
180
+ const head = (k: string, def: string) =>
181
+ typeof el[k] === 'string' && el[k] !== def ? { [k]: el[k] } : {};
182
+ out.push({
183
+ ...base,
184
+ tool: 'arrow',
185
+ color: str(el.color, DEFAULT_INK),
186
+ width: num(el.width, 2),
187
+ x1: r.x1,
188
+ y1: r.y1,
189
+ x2: r.x2,
190
+ y2: r.y2,
191
+ ...head('startHead', 'none'),
192
+ ...head('endHead', 'triangle'),
193
+ ...(el.dashed === true ? { dashed: true } : {}),
194
+ ...(typeof el.line === 'string' && el.line !== 'straight' ? { lineType: el.line } : {}),
195
+ ...(sb ? { startBind: sb } : {}),
196
+ ...(eb ? { endBind: eb } : {}),
197
+ } as Stroke);
198
+ break;
199
+ }
200
+ case 'pen': {
201
+ const p = Array.isArray(el.points) ? (el.points as number[]) : [];
202
+ const points: WorldPoint[] = [];
203
+ for (let i = 0; i + 1 < p.length; i += 2)
204
+ points.push([(p[i] as number) + o.x, (p[i + 1] as number) + o.y]);
205
+ out.push({
206
+ ...base,
207
+ tool: 'pen',
208
+ color: str(el.color, DEFAULT_INK),
209
+ width: num(el.width, 2),
210
+ points,
211
+ ...(el.highlighter === true ? { highlighter: true } : {}),
212
+ } as Stroke);
213
+ break;
214
+ }
215
+ case 'image':
216
+ out.push({
217
+ ...base,
218
+ tool: 'image',
219
+ x,
220
+ y,
221
+ w,
222
+ h,
223
+ href: str(el.href),
224
+ ...(el.alt ? { alt: str(el.alt) } : {}),
225
+ } as Stroke);
226
+ break;
227
+ case 'link':
228
+ out.push({
229
+ ...base,
230
+ tool: 'link',
231
+ x,
232
+ y,
233
+ w,
234
+ h,
235
+ url: str(el.url),
236
+ title: str(el.title),
237
+ domain: str(el.domain),
238
+ } as Stroke);
239
+ break;
240
+ case 'mediaref':
241
+ out.push({
242
+ ...base,
243
+ tool: 'mediaref',
244
+ x,
245
+ y,
246
+ w,
247
+ h,
248
+ src: str(el.src),
249
+ mediaKind: el.media === 'audio' ? 'audio' : 'video',
250
+ title: str(el.title),
251
+ } as Stroke);
252
+ break;
253
+ default: {
254
+ // A registry type with no stroke form of its own (Task 25): carried as
255
+ // its world-space record; geometry comes from its definition.
256
+ const def = defOf(el.type);
257
+ if (!def) break;
258
+ const world: Record<string, unknown> = { ...el, ...def.translate(el, o.x, o.y) };
259
+ delete world.parent;
260
+ out.push({ ...base, tool: 'element', el: world as AnnotationElement } as Stroke);
261
+ break;
262
+ }
263
+ case 'section':
264
+ out.push({
265
+ ...base,
266
+ tool: 'section',
267
+ x,
268
+ y,
269
+ w,
270
+ h,
271
+ label: str(el.label, 'Section'),
272
+ color: str(el.color, DEFAULT_SECTION_COLOR),
273
+ } as Stroke);
274
+ break;
275
+ }
276
+ }
277
+ return out;
278
+ }
279
+
280
+ function sameBox(a: Box | null, b: Box | null): boolean {
281
+ if (!a || !b) return false;
282
+ return (
283
+ Math.abs(a.x - b.x) < 0.011 &&
284
+ Math.abs(a.y - b.y) < 0.011 &&
285
+ Math.abs(a.w - b.w) < 0.011 &&
286
+ Math.abs(a.h - b.h) < 0.011
287
+ );
288
+ }
289
+
290
+ /**
291
+ * The UI's `Stroke[]` → canonical elements, reconciled with the `current`
292
+ * board so that what the v1 UI can't express survives: parents of unmoved
293
+ * elements, sibling keys, unknown element types.
294
+ */
295
+ export function strokesToElements(
296
+ strokes: readonly Stroke[],
297
+ current: ReadonlyMap<string, AnnotationElement>
298
+ ): AnnotationElement[] {
299
+ const flat = v1ToV2(strokes, { flat: true }).elements; // world coords, top level
300
+ const world = new Map(flat.map((e) => [e.id, e]));
301
+ const curScene = new Scene(current.values());
302
+ const nextScene = new Scene(flat); // all top-level → world boxes = own boxes
303
+ const boxOf = (id: string) => nextScene.worldBox(id);
304
+ // Parents: keep when the element did not move and its parent survives; else by centre.
305
+ const sections = flat
306
+ .filter((e) => e.type === 'section')
307
+ .map((e) => ({ id: e.id, box: boxOf(e.id) as Box }))
308
+ .filter((s) => s.box);
309
+ const area = (b: Box) => b.w * b.h;
310
+ const parentOf = new Map<string, string>();
311
+ // Same rule as the migration: a section adopts only what paints above it.
312
+ const zOf = new Map(strokes.map((s, i) => [s.id, i]));
313
+ const inside = (box: Box, x: number, y: number) =>
314
+ x >= box.x && x <= box.x + box.w && y >= box.y && y <= box.y + box.h;
315
+ for (const el of flat) {
316
+ const prev = current.get(el.id);
317
+ const b = boxOf(el.id);
318
+ if (!b) continue;
319
+ const cx = b.x + b.w / 2;
320
+ const cy = b.y + b.h / 2;
321
+ const self = el.type === 'section' ? area(b) : null;
322
+ // The smallest section under the element's centre that it painted above
323
+ // (the v1 rule: what sits on a section is in it).
324
+ let best: { id: string; a: number } | null = null;
325
+ for (const s of sections) {
326
+ if (s.id === el.id || (self !== null && area(s.box) <= self)) continue;
327
+ if ((zOf.get(s.id) ?? 0) >= (zOf.get(el.id) ?? Number.POSITIVE_INFINITY)) continue;
328
+ if (!inside(s.box, cx, cy)) continue;
329
+ // Equal size (a copy lying exactly on its original): the one painted
330
+ // later — nearest below the element — is its container.
331
+ if (!best || area(s.box) <= best.a) best = { id: s.id, a: area(s.box) };
332
+ }
333
+ // An element that did not move keeps its container while it still sits
334
+ // in it — unless a smaller section now holds it (one drawn around it, or
335
+ // moved or resized onto it: it adopts what it lands on, as in v1).
336
+ const moved = !prev || !sameBox(curScene.worldBox(prev), b);
337
+ const kept = prev?.parent;
338
+ const keptBox = kept && world.has(kept) ? boxOf(kept) : null;
339
+ if (!moved && kept && keptBox && inside(keptBox, cx, cy)) {
340
+ parentOf.set(el.id, best && best.a < area(keptBox) ? best.id : kept);
341
+ continue;
342
+ }
343
+ if (best) parentOf.set(el.id, best.id);
344
+ }
345
+ // Break any cycle the kept parents could form (walk up; drop the parent that closes a loop).
346
+ for (const id of parentOf.keys()) {
347
+ const seen = new Set<string>([id]);
348
+ let cur = parentOf.get(id);
349
+ while (cur !== undefined) {
350
+ if (seen.has(cur)) {
351
+ parentOf.delete(id);
352
+ break;
353
+ }
354
+ seen.add(cur);
355
+ cur = parentOf.get(cur);
356
+ }
357
+ }
358
+ // World origin of a parent = its world top-left (all `flat` coords are world).
359
+ const originOf = (id: string): { x: number; y: number } => {
360
+ const p = parentOf.get(id);
361
+ if (!p) return { x: 0, y: 0 };
362
+ const pb = world.get(p);
363
+ return { x: num(pb?.x), y: num(pb?.y) };
364
+ };
365
+ // Sibling order from the stroke array; keys reused where still in order.
366
+ const order = new Map(strokes.map((s, i) => [s.id, i]));
367
+ const byParent = new Map<string, string[]>();
368
+ for (const el of flat) {
369
+ const k = parentOf.get(el.id) ?? '';
370
+ const list = byParent.get(k);
371
+ if (list) list.push(el.id);
372
+ else byParent.set(k, [el.id]);
373
+ }
374
+ const indexOf = new Map<string, string>();
375
+ for (const [p, ids] of byParent) {
376
+ ids.sort((a, b) => (order.get(a) ?? 0) - (order.get(b) ?? 0));
377
+ const prevKeys = new Map<string, string>();
378
+ for (const id of ids) {
379
+ const prev = current.get(id);
380
+ if (prev && (prev.parent ?? '') === p) prevKeys.set(id, prev.index);
381
+ }
382
+ for (const [id, k] of orderKeys(ids, prevKeys)) indexOf.set(id, k);
383
+ }
384
+ const out: Record<string, unknown>[] = [];
385
+ for (const el of flat) {
386
+ const o = originOf(el.id);
387
+ const rec: Record<string, unknown> = {
388
+ ...el,
389
+ index: indexOf.get(el.id) ?? keyBetween(null, null),
390
+ };
391
+ const p = parentOf.get(el.id);
392
+ if (p) rec.parent = p;
393
+ else delete rec.parent;
394
+ if (o.x !== 0 || o.y !== 0) {
395
+ if (typeof rec.x === 'number') rec.x = (rec.x as number) - o.x;
396
+ if (typeof rec.y === 'number') rec.y = (rec.y as number) - o.y;
397
+ if (Array.isArray(rec.points))
398
+ rec.points = (rec.points as number[]).map((v, i) => v - (i % 2 === 0 ? o.x : o.y));
399
+ for (const k of ['start', 'end']) {
400
+ const e = rec[k] as ArrowEnd | undefined;
401
+ if (e && !('el' in e)) rec[k] = { x: e.x - o.x, y: e.y - o.y };
402
+ }
403
+ }
404
+ // Standalone text: v1 only knows a character estimate — keep the measured box while the text is unchanged.
405
+ const prev = current.get(el.id);
406
+ if (
407
+ el.type === 'text' &&
408
+ prev?.type === 'text' &&
409
+ prev.text === el.text &&
410
+ typeof prev.w === 'number'
411
+ ) {
412
+ rec.w = prev.w;
413
+ rec.h = prev.h;
414
+ }
415
+ out.push(rec);
416
+ }
417
+ // Unknown types the v1 UI never showed are carried over untouched.
418
+ for (const el of current.values()) if (!isKnownType(el.type)) out.push(el);
419
+ return validateElements(out).elements;
420
+ }
421
+
422
+ /** Map form of `strokesToElements` for `diffToOps`. */
423
+ export function strokesToElementMap(
424
+ strokes: readonly Stroke[],
425
+ current: ReadonlyMap<string, AnnotationElement>
426
+ ): Map<string, AnnotationElement> {
427
+ return new Map(strokesToElements(strokes, current).map((e) => [e.id, e]));
428
+ }
@@ -74,6 +74,19 @@ function unitsOf(strokes: readonly Stroke[], ids: readonly string[]): AlignUnit[
74
74
  return units;
75
75
  }
76
76
 
77
+ /** A unit moves with everything inside it (its contents follow its delta). */
78
+ function setUnitDelta(
79
+ deltas: Map<string, readonly [number, number]>,
80
+ u: AlignUnit,
81
+ d: readonly [number, number],
82
+ contents?: (id: string) => readonly string[]
83
+ ): void {
84
+ for (const id of u.ids) {
85
+ deltas.set(id, d);
86
+ if (contents) for (const c of contents(id)) if (!deltas.has(c)) deltas.set(c, d);
87
+ }
88
+ }
89
+
77
90
  function applyDeltas(
78
91
  strokes: readonly Stroke[],
79
92
  deltas: Map<string, readonly [number, number]>
@@ -89,7 +102,9 @@ function applyDeltas(
89
102
  export function alignStrokes(
90
103
  strokes: readonly Stroke[],
91
104
  ids: readonly string[],
92
- edge: AlignEdge
105
+ edge: AlignEdge,
106
+ /** A unit's contents (a section's subtree) — moved by the unit's delta. */
107
+ contents?: (id: string) => readonly string[]
93
108
  ): Stroke[] {
94
109
  const units = unitsOf(strokes, ids);
95
110
  if (units.length < 2) return strokes as Stroke[];
@@ -109,9 +124,7 @@ export function alignStrokes(
109
124
  else if (edge === 'top') dy = sel.y - u.bbox.y;
110
125
  else if (edge === 'v-center') dy = sel.y + sel.h / 2 - (u.bbox.y + u.bbox.h / 2);
111
126
  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
- }
127
+ if (dx !== 0 || dy !== 0) setUnitDelta(deltas, u, [dx, dy], contents);
115
128
  }
116
129
  return applyDeltas(strokes, deltas);
117
130
  }
@@ -123,7 +136,9 @@ export function alignStrokes(
123
136
  export function distributeStrokes(
124
137
  strokes: readonly Stroke[],
125
138
  ids: readonly string[],
126
- axis: DistributeAxis
139
+ axis: DistributeAxis,
140
+ /** A unit's contents (a section's subtree) — moved by the unit's delta. */
141
+ contents?: (id: string) => readonly string[]
127
142
  ): Stroke[] {
128
143
  const units = unitsOf(strokes, ids);
129
144
  if (units.length < 3) return strokes as Stroke[];
@@ -141,7 +156,7 @@ export function distributeStrokes(
141
156
  for (const u of sorted) {
142
157
  const d = cursor - lead(u);
143
158
  if (d !== 0) {
144
- for (const id of u.ids) deltas.set(id, axis === 'h' ? ([d, 0] as const) : ([0, d] as const));
159
+ setUnitDelta(deltas, u, axis === 'h' ? ([d, 0] as const) : ([0, d] as const), contents);
145
160
  }
146
161
  cursor += size(u) + gap;
147
162
  }
@@ -11,6 +11,7 @@
11
11
  * endpoint frozen in place (Excalidraw `fixBindingsAfterDeletion`).
12
12
  */
13
13
 
14
+ import { defOf } from './annotations/registry.ts';
14
15
  import {
15
16
  type ArrowBind,
16
17
  type ArrowStroke,
@@ -60,6 +61,7 @@ export const MAX_TEXT_BIND_H = 480;
60
61
  * endpoint falls back to the group's geometric bbox and is reported.
61
62
  */
62
63
  export function isBindable(s: Stroke): boolean {
64
+ if (s.tool === 'element') return !!defOf(s.el.type)?.caps.bindable;
63
65
  if (
64
66
  s.tool === 'rect' ||
65
67
  s.tool === 'ellipse' ||
@@ -21,6 +21,7 @@
21
21
  */
22
22
 
23
23
  import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
24
+ import { type FormatKey, formatCommands, formatState } from './annotations/ui/editor-channel.ts';
24
25
  import type { AlignEdge } from './annotations-align.ts';
25
26
  import {
26
27
  type ArrowHead,
@@ -439,9 +440,9 @@ export function AnnotationContextToolbar({
439
440
  /**
440
441
  * FigJam v3 — while an inline text editor is open, the toolbar flips into
441
442
  * TEXT mode: size / B / I / S / U / align controls that drive the EDITOR
442
- * through `maude:editor-format` events (mutating the stroke mid-edit would
443
- * re-render the contentEditable and clobber typed text). The editor echoes
444
- * its live state back via `maude:editor-format-state`.
443
+ * through editor-channel.ts (mutating the stroke mid-edit would re-render
444
+ * the editor and clobber typed text). The editor echoes its live state back
445
+ * on the same channel.
445
446
  */
446
447
  editingId?: string | null;
447
448
  } = {}) {
@@ -461,14 +462,9 @@ export function AnnotationContextToolbar({
461
462
  align?: string;
462
463
  }>({});
463
464
  useEffect(() => {
464
- if (!editingId || typeof document === 'undefined') return;
465
- const onState = (e: Event) => {
466
- setEditorFmt((e as CustomEvent<Record<string, unknown>>).detail ?? {});
467
- };
468
- document.addEventListener('maude:editor-format-state', onState);
469
- // Ask the already-mounted editor for its current state (mount-order race).
470
- document.dispatchEvent(new CustomEvent('maude:editor-format-request'));
471
- return () => document.removeEventListener('maude:editor-format-state', onState);
465
+ if (!editingId) return;
466
+ // Called at once with the editor's current state (it may have mounted first).
467
+ return formatState.listen((s) => setEditorFmt(s ?? {}));
472
468
  }, [editingId]);
473
469
  // T30 / G_S1 — collapsed Stroke|Fill toggle state. Defaults to 'stroke';
474
470
  // auto-reverts to 'stroke' whenever caps.fill is false so the toggle
@@ -766,8 +762,8 @@ export function AnnotationContextToolbar({
766
762
  // FigJam v3 — TEXT mode while an inline editor is open: a focused strip of
767
763
  // text controls dispatching to the editor (Image-6 FigJam parity).
768
764
  if (editingId) {
769
- const dispatchFmt = (key: string, value?: unknown) => {
770
- document.dispatchEvent(new CustomEvent('maude:editor-format', { detail: { key, value } }));
765
+ const dispatchFmt = (key: FormatKey, value?: unknown) => {
766
+ formatCommands.send({ key, value });
771
767
  };
772
768
  const fmtBtn = (
773
769
  key: 'bold' | 'italic' | 'strike' | 'underline',
@@ -144,6 +144,8 @@ export interface DuplicateResult {
144
144
  /** Ids of the selectable clones (anchored-text clones excluded — they're
145
145
  * selected through their host, FigJam semantics). */
146
146
  newIds: string[];
147
+ /** Original id → its clone's id. */
148
+ idMap: Map<string, string>;
147
149
  }
148
150
 
149
151
  /**
@@ -208,6 +210,7 @@ export function duplicateStrokes(
208
210
  return {
209
211
  strokes: [...strokes, ...clones],
210
212
  newIds: clones.filter((c) => !(c.tool === 'text' && c.anchorId)).map((c) => c.id),
213
+ idMap,
211
214
  };
212
215
  }
213
216