@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,993 @@
1
+ /**
2
+ * @file annotations/ui/element-node.tsx — one DOM node per annotation element
3
+ * @scope apps/studio/annotations/ui/element-node.tsx
4
+ * @purpose DDR-242 AD7 + AD8 (Tasks 17 + 23). Every element is its own
5
+ * absolutely positioned node in the world, in paint order:
6
+ * geometry in a small SVG drawn in WORLD coordinates (so the shared
7
+ * geometry helpers apply unchanged), text as HTML. Keeping each
8
+ * element's text inside its own node keeps z-order exact — a pen
9
+ * stroke drawn over a sticky still covers its text — which one
10
+ * text layer over all the ink could not.
11
+ *
12
+ * `ElementNode` is memoized on its record and resolved geometry, so
13
+ * a change re-renders only the element that changed. The editor
14
+ * (text-editor.tsx) replaces an element's text block in place, with
15
+ * the same class and style.
16
+ *
17
+ * DOM contract kept for hit-testing, comments and tooling:
18
+ * `[data-id][data-tool]` on the node (`data-tool` = the v1 tool
19
+ * name; `data-type` = the v2 type). Painted parts claim pointer
20
+ * events only while the layer is interactive.
21
+ */
22
+
23
+ import { Component, type CSSProperties, memo, type ReactNode } from 'react';
24
+ import {
25
+ clampLinkTitle,
26
+ LINK_CARD_FILL,
27
+ LINK_CARD_STROKE,
28
+ LINK_DOMAIN_FILL,
29
+ LINK_GLYPH_D1,
30
+ LINK_GLYPH_D2,
31
+ LINK_GLYPH_STROKE,
32
+ LINK_TITLE_FILL,
33
+ linkCardLayout,
34
+ MEDIAREF_AUDIO_GLYPH,
35
+ MEDIAREF_VIDEO_GLYPH,
36
+ penPathD,
37
+ polygonPoints,
38
+ SECTION_CORNER_RADIUS,
39
+ SECTION_LABEL_FONT,
40
+ SECTION_LABEL_H,
41
+ STICKY_CORNER_RADIUS,
42
+ stickyCornerPath,
43
+ type WorldPoint,
44
+ } from '../../annotations-model.ts';
45
+ import { arrowPrimitives, type SvgPrimitive } from '../../canvas-arrowheads.ts';
46
+ import { countRender, useLiveViewport } from '../../canvas-lib.tsx';
47
+ import { colorForName } from '../../use-collab.tsx';
48
+ import type { AnnotationElement } from '../types.ts';
49
+ import type { ResolvedEnds } from './render-model.ts';
50
+ import { type CommitInfo, type SizeMode, TextEditor } from './text-editor.tsx';
51
+ import {
52
+ anchorShift,
53
+ type ListStyle,
54
+ slotTextStyle,
55
+ TEXT_FONT,
56
+ type TextAlign,
57
+ type TextSlot,
58
+ withListMarkers,
59
+ } from './text-style.ts';
60
+
61
+ // ─────────────────────────────────────────────────────────────────────────────
62
+ // Props
63
+
64
+ /** An open edit session on this element's text slot (only the edited node gets one). */
65
+ export interface EditRequest {
66
+ caretPoint: { x: number; y: number } | null;
67
+ /** A collaborator changed (merged on commit) or deleted (commit restores) the element. */
68
+ notice?: 'edited' | 'deleted' | null;
69
+ onCommit: (info: CommitInfo) => void;
70
+ onCancel: () => void;
71
+ onDraft?: (text: string) => void;
72
+ }
73
+
74
+ export interface ElementNodeProps {
75
+ el: AnnotationElement;
76
+ /** Resolved arrow endpoints (arrows only). */
77
+ ends?: ResolvedEnds;
78
+ interactive: boolean;
79
+ edit: EditRequest | null;
80
+ /** Maps a stored `assets/…` path to a URL the canvas can load. */
81
+ resolveAsset: (href: string) => string;
82
+ }
83
+
84
+ // ─────────────────────────────────────────────────────────────────────────────
85
+ // Field readers (records are validated or come from the v1 adapter; be lenient)
86
+
87
+ function num(v: unknown, def = 0): number {
88
+ return typeof v === 'number' && Number.isFinite(v) ? v : def;
89
+ }
90
+ function str(v: unknown, def = ''): string {
91
+ return typeof v === 'string' ? v : def;
92
+ }
93
+ function align(v: unknown, def: TextAlign): TextAlign {
94
+ return v === 'left' || v === 'center' || v === 'right' ? v : def;
95
+ }
96
+ function list(v: unknown): ListStyle | undefined {
97
+ return v === 'bullet' || v === 'number' ? v : undefined;
98
+ }
99
+ function textFmt(r: Record<string, unknown>, defAlign: TextAlign, defColor?: string) {
100
+ return {
101
+ fontSize: num(r.fontSize, 14),
102
+ color: typeof r.color === 'string' ? r.color : defColor,
103
+ bold: r.bold === true,
104
+ italic: r.italic === true,
105
+ strike: r.strike === true,
106
+ underline: r.underline === true,
107
+ align: align(r.align, defAlign),
108
+ };
109
+ }
110
+
111
+ /** The v1 tool name hit-testing and tooling key on (`data-tool`). */
112
+ export function v1ToolOf(el: AnnotationElement): string {
113
+ if (el.type === 'shape') {
114
+ const k = str(el.kind, 'rect');
115
+ return k === 'rect' || k === 'ellipse' ? k : 'polygon';
116
+ }
117
+ return el.type;
118
+ }
119
+
120
+ // ─────────────────────────────────────────────────────────────────────────────
121
+ // Shared pieces
122
+
123
+ function Node({
124
+ el,
125
+ x,
126
+ y,
127
+ w,
128
+ h,
129
+ rot = 0,
130
+ raised = false,
131
+ extraStyle,
132
+ children,
133
+ }: {
134
+ el: AnnotationElement;
135
+ x: number;
136
+ y: number;
137
+ w: number | 'auto';
138
+ h: number | 'auto';
139
+ rot?: number;
140
+ /** An open editor floats above the elements painted after it. */
141
+ raised?: boolean;
142
+ extraStyle?: CSSProperties;
143
+ children: ReactNode;
144
+ }) {
145
+ const tool = v1ToolOf(el);
146
+ return (
147
+ <div
148
+ className="dc-annot-el"
149
+ data-id={el.id}
150
+ data-tool={tool}
151
+ data-type={el.type}
152
+ {...(tool === 'polygon' ? { 'data-shape': str(el.kind) } : {})}
153
+ style={{
154
+ left: x,
155
+ top: y,
156
+ width: w === 'auto' ? undefined : w,
157
+ height: h === 'auto' ? undefined : h,
158
+ ...(rot ? { transform: `rotate(${rot}deg)` } : {}),
159
+ ...(raised ? { zIndex: 3 } : {}),
160
+ ...extraStyle,
161
+ }}
162
+ >
163
+ {children}
164
+ </div>
165
+ );
166
+ }
167
+
168
+ /** Geometry drawn in WORLD coords, mapped onto the node's box. */
169
+ function Geo({
170
+ x,
171
+ y,
172
+ w,
173
+ h,
174
+ children,
175
+ }: {
176
+ x: number;
177
+ y: number;
178
+ w: number;
179
+ h: number;
180
+ children: ReactNode;
181
+ }) {
182
+ const vw = Math.max(w, 1);
183
+ const vh = Math.max(h, 1);
184
+ return (
185
+ <svg
186
+ className="dc-annot-geo"
187
+ xmlns="http://www.w3.org/2000/svg"
188
+ width={vw}
189
+ height={vh}
190
+ viewBox={`${x} ${y} ${vw} ${vh}`}
191
+ aria-hidden="true"
192
+ >
193
+ {children}
194
+ </svg>
195
+ );
196
+ }
197
+
198
+ function hit(interactive: boolean, mode: 'visiblePainted' | 'stroke' = 'visiblePainted') {
199
+ return interactive ? mode : ('none' as const);
200
+ }
201
+
202
+ /** Marker above an open editor when a collaborator changed the element under it. */
203
+ function EditNotice({ notice }: { notice: EditRequest['notice'] }) {
204
+ if (!notice) return null;
205
+ const text =
206
+ notice === 'deleted'
207
+ ? 'Deleted by a collaborator — Enter keeps your text, Esc discards it'
208
+ : 'Edited by a collaborator — both changes are kept';
209
+ return (
210
+ <div
211
+ className="dc-annot-edit-notice"
212
+ data-edit-notice={notice}
213
+ role="status"
214
+ style={{
215
+ position: 'absolute',
216
+ left: 0,
217
+ bottom: '100%',
218
+ marginBottom: 6,
219
+ whiteSpace: 'nowrap',
220
+ font: `11px ${TEXT_FONT}`,
221
+ color: '#1f1f1f',
222
+ background: notice === 'deleted' ? '#fde2e1' : '#fff3c4',
223
+ border: '1px solid rgba(0,0,0,0.12)',
224
+ borderRadius: 6,
225
+ padding: '2px 8px',
226
+ zIndex: 4,
227
+ }}
228
+ >
229
+ {text}
230
+ </div>
231
+ );
232
+ }
233
+
234
+ // ─────────────────────────────────────────────────────────────────────────────
235
+ // Per-type views
236
+
237
+ function StickyView({ el, interactive, edit }: ElementNodeProps) {
238
+ const x = num(el.x);
239
+ const y = num(el.y);
240
+ const w = num(el.w);
241
+ const h = num(el.h);
242
+ const r = num(el.radius, STICKY_CORNER_RADIUS);
243
+ const fill = str(el.fill, '#fce8a6');
244
+ const style = {
245
+ ...slotTextStyle('sticky', textFmt(el, 'left')),
246
+ color: '#2a2a28',
247
+ padding: '14px 16px',
248
+ };
249
+ const text = str(el.text);
250
+ const lst = list(el.list);
251
+ const authorName =
252
+ el.author && typeof el.author === 'object' ? str((el.author as { name?: unknown }).name) : '';
253
+ return (
254
+ <Node el={el} x={x} y={y} w={w} h={h} rot={num(el.rot)} raised={!!edit}>
255
+ <Geo x={x} y={y} w={w} h={h}>
256
+ <path
257
+ d={stickyCornerPath(x, y, w, h, r)}
258
+ fill={fill}
259
+ stroke="rgba(0,0,0,0.05)"
260
+ strokeWidth={1}
261
+ vectorEffect="non-scaling-stroke"
262
+ filter="url(#dc-sticky-shadow)"
263
+ pointerEvents={hit(interactive)}
264
+ />
265
+ </Geo>
266
+ {edit ? (
267
+ <div
268
+ style={{
269
+ position: 'absolute',
270
+ left: 0,
271
+ top: 0,
272
+ width: '100%',
273
+ minHeight: h,
274
+ // Grows downward while typing past the card; paints its own paper
275
+ // so the overflow still reads as the note. `backgroundColor`, never
276
+ // the `background` shorthand: the fill is peer-controlled and the
277
+ // shorthand would take a `url(…)`.
278
+ backgroundColor: fill,
279
+ borderRadius: `${r}px ${r}px 0 ${r}px`,
280
+ zIndex: 1,
281
+ }}
282
+ >
283
+ <TextEditor
284
+ initialText={text}
285
+ list={lst}
286
+ className="dc-annot-text"
287
+ style={{ ...style, width: '100%' }}
288
+ size={{ kind: 'grow-height', minHeight: h }}
289
+ fmt={textFmt(el, 'left')}
290
+ caretPoint={edit.caretPoint}
291
+ ariaLabel="Edit sticky note text"
292
+ onCommit={edit.onCommit}
293
+ onCancel={edit.onCancel}
294
+ onDraft={edit.onDraft}
295
+ />
296
+ </div>
297
+ ) : (
298
+ <div
299
+ className="dc-annot-text"
300
+ style={{ ...style, position: 'absolute', inset: 0, overflow: 'hidden' }}
301
+ >
302
+ {withListMarkers(text, lst)}
303
+ </div>
304
+ )}
305
+ {edit ? <EditNotice notice={edit.notice} /> : null}
306
+ {authorName ? (
307
+ <span
308
+ title={authorName}
309
+ style={{
310
+ position: 'absolute',
311
+ right: 4,
312
+ bottom: 3,
313
+ maxWidth: 156,
314
+ overflow: 'hidden',
315
+ textOverflow: 'ellipsis',
316
+ whiteSpace: 'nowrap',
317
+ fontSize: 9,
318
+ fontWeight: 600,
319
+ fontFamily: TEXT_FONT,
320
+ color: colorForName(authorName),
321
+ background: 'rgba(255,255,255,0.78)',
322
+ padding: '1px 5px',
323
+ borderRadius: 8,
324
+ lineHeight: 1.4,
325
+ }}
326
+ >
327
+ {authorName}
328
+ </span>
329
+ ) : null}
330
+ </Node>
331
+ );
332
+ }
333
+
334
+ function TextView({ el, interactive, edit }: ElementNodeProps) {
335
+ const f = textFmt(el, 'left', '#1a1a1a');
336
+ const style: CSSProperties = { ...slotTextStyle('text', f), color: f.color, padding: '0 2px' };
337
+ const text = str(el.text);
338
+ const lst = list(el.list);
339
+ return (
340
+ <Node
341
+ el={el}
342
+ x={num(el.x)}
343
+ y={num(el.y)}
344
+ w="auto"
345
+ h="auto"
346
+ rot={num(el.rot)}
347
+ raised={!!edit}
348
+ extraStyle={{
349
+ transform:
350
+ [anchorShift(f.align), el.rot ? `rotate(${num(el.rot)}deg)` : undefined]
351
+ .filter(Boolean)
352
+ .join(' ') || undefined,
353
+ }}
354
+ >
355
+ {edit ? (
356
+ <TextEditor
357
+ initialText={text}
358
+ list={lst}
359
+ className="dc-annot-text dc-annot-text--nowrap"
360
+ style={style}
361
+ size={{ kind: 'fit-both' }}
362
+ fmt={f}
363
+ caretPoint={edit.caretPoint}
364
+ ariaLabel="Edit text"
365
+ onCommit={edit.onCommit}
366
+ onCancel={edit.onCancel}
367
+ onDraft={edit.onDraft}
368
+ />
369
+ ) : (
370
+ <div
371
+ className="dc-annot-text dc-annot-text--nowrap"
372
+ style={{ ...style, pointerEvents: interactive ? 'auto' : 'none', cursor: 'text' }}
373
+ >
374
+ {withListMarkers(text, lst)}
375
+ </div>
376
+ )}
377
+ {edit ? <EditNotice notice={edit.notice} /> : null}
378
+ </Node>
379
+ );
380
+ }
381
+
382
+ function ShapeView({ el, interactive, edit }: ElementNodeProps) {
383
+ const x = num(el.x);
384
+ const y = num(el.y);
385
+ const w = num(el.w);
386
+ const h = num(el.h);
387
+ const kind = str(el.kind, 'rect');
388
+ const common = {
389
+ stroke: str(el.color, '#1a1a1a'),
390
+ strokeWidth: num(el.width, 2),
391
+ strokeLinecap: 'round' as const,
392
+ strokeLinejoin: 'round' as const,
393
+ fill: typeof el.fill === 'string' ? el.fill : 'none',
394
+ strokeDasharray: el.dashed === true ? '6 4' : undefined,
395
+ pointerEvents: hit(interactive),
396
+ };
397
+ let geo: ReactNode;
398
+ if (kind === 'rect') {
399
+ const r = num(el.radius);
400
+ geo = <rect {...common} x={x} y={y} width={w} height={h} rx={r} ry={r} />;
401
+ } else if (kind === 'ellipse') {
402
+ geo = <ellipse {...common} cx={x + w / 2} cy={y + h / 2} rx={w / 2} ry={h / 2} />;
403
+ } else {
404
+ geo = (
405
+ <polygon
406
+ {...common}
407
+ points={polygonPoints(kind as Parameters<typeof polygonPoints>[0], x, y, w, h)}
408
+ />
409
+ );
410
+ }
411
+ const label = (el.label ?? {}) as Record<string, unknown>;
412
+ const hasLabel = typeof label.text === 'string';
413
+ const f = textFmt(label, 'center', '#1a1a1a');
414
+ const style: CSSProperties = { ...slotTextStyle('label', f), color: f.color };
415
+ return (
416
+ <Node el={el} x={x} y={y} w={w} h={h} rot={num(el.rot)} raised={!!edit}>
417
+ <Geo x={x} y={y} w={w} h={h}>
418
+ {geo}
419
+ </Geo>
420
+ {edit ? (
421
+ <div className="dc-annot-label" style={{ zIndex: 1 }}>
422
+ <TextEditor
423
+ initialText={str(label.text)}
424
+ list={list(label.list)}
425
+ className="dc-annot-text"
426
+ style={style}
427
+ size={{ kind: 'fit-height' }}
428
+ fmt={f}
429
+ caretPoint={edit.caretPoint}
430
+ ariaLabel="Edit annotation text"
431
+ onCommit={edit.onCommit}
432
+ onCancel={edit.onCancel}
433
+ onDraft={edit.onDraft}
434
+ />
435
+ </div>
436
+ ) : hasLabel ? (
437
+ <div className="dc-annot-label">
438
+ <div
439
+ className="dc-annot-text"
440
+ style={{ ...style, pointerEvents: interactive ? 'auto' : 'none' }}
441
+ >
442
+ {withListMarkers(str(label.text), list(label.list))}
443
+ </div>
444
+ </div>
445
+ ) : null}
446
+ {edit ? <EditNotice notice={edit.notice} /> : null}
447
+ </Node>
448
+ );
449
+ }
450
+
451
+ function renderArrowPrimitive(p: SvgPrimitive, key: number): ReactNode {
452
+ switch (p.el) {
453
+ case 'line':
454
+ return (
455
+ <line
456
+ key={key}
457
+ x1={p.x1}
458
+ y1={p.y1}
459
+ x2={p.x2}
460
+ y2={p.y2}
461
+ strokeDasharray={p.dash ? '6 4' : undefined}
462
+ />
463
+ );
464
+ case 'path':
465
+ return <path key={key} d={p.d} strokeDasharray={p.dash ? '6 4' : undefined} />;
466
+ case 'polyline':
467
+ return <polyline key={key} points={p.points} fill={p.fill} />;
468
+ case 'polygon':
469
+ return <polygon key={key} points={p.points} fill={p.fill} />;
470
+ case 'circle':
471
+ return <circle key={key} cx={p.cx} cy={p.cy} r={p.r} fill={p.fill} />;
472
+ default:
473
+ return null;
474
+ }
475
+ }
476
+
477
+ export function arrowGeom(el: AnnotationElement, ends: ResolvedEnds) {
478
+ return {
479
+ x1: ends.x1,
480
+ y1: ends.y1,
481
+ x2: ends.x2,
482
+ y2: ends.y2,
483
+ width: num(el.width, 2),
484
+ color: str(el.color, '#1a1a1a'),
485
+ ...(typeof el.startHead === 'string' ? { startHead: el.startHead } : {}),
486
+ ...(typeof el.endHead === 'string' ? { endHead: el.endHead } : {}),
487
+ ...(el.dashed === true ? { dashed: true } : {}),
488
+ ...(typeof el.line === 'string' ? { lineType: el.line } : {}),
489
+ ...(ends.startAnchor
490
+ ? {
491
+ startBind: {
492
+ hostId: ends.startAnchor.el,
493
+ nx: ends.startAnchor.nx,
494
+ ny: ends.startAnchor.ny,
495
+ },
496
+ }
497
+ : {}),
498
+ ...(ends.endAnchor
499
+ ? { endBind: { hostId: ends.endAnchor.el, nx: ends.endAnchor.nx, ny: ends.endAnchor.ny } }
500
+ : {}),
501
+ } as Parameters<typeof arrowPrimitives>[0];
502
+ }
503
+
504
+ function ArrowView({ el, ends, interactive }: ElementNodeProps) {
505
+ if (!ends) return null;
506
+ const width = num(el.width, 2);
507
+ const pad = width * 4 + 24; // heads + curve bulge stay inside the node's box
508
+ const x = Math.min(ends.x1, ends.x2) - pad;
509
+ const y = Math.min(ends.y1, ends.y2) - pad;
510
+ const w = Math.abs(ends.x2 - ends.x1) + pad * 2;
511
+ const h = Math.abs(ends.y2 - ends.y1) + pad * 2;
512
+ return (
513
+ <Node el={el} x={x} y={y} w={w} h={h}>
514
+ <Geo x={x} y={y} w={w} h={h}>
515
+ <g
516
+ stroke={str(el.color, '#1a1a1a')}
517
+ strokeWidth={width}
518
+ strokeLinecap="round"
519
+ strokeLinejoin="round"
520
+ fill="none"
521
+ pointerEvents={hit(interactive)}
522
+ >
523
+ {arrowPrimitives(arrowGeom(el, ends)).map((p, i) => renderArrowPrimitive(p, i))}
524
+ </g>
525
+ </Geo>
526
+ </Node>
527
+ );
528
+ }
529
+
530
+ function PenView({ el, interactive }: ElementNodeProps) {
531
+ const flat = Array.isArray(el.points) ? (el.points as number[]) : [];
532
+ const pts: WorldPoint[] = [];
533
+ let x0 = Number.POSITIVE_INFINITY;
534
+ let y0 = Number.POSITIVE_INFINITY;
535
+ let x1 = Number.NEGATIVE_INFINITY;
536
+ let y1 = Number.NEGATIVE_INFINITY;
537
+ for (let i = 0; i + 1 < flat.length; i += 2) {
538
+ const px = flat[i] as number;
539
+ const py = flat[i + 1] as number;
540
+ pts.push([px, py]);
541
+ if (px < x0) x0 = px;
542
+ if (py < y0) y0 = py;
543
+ if (px > x1) x1 = px;
544
+ if (py > y1) y1 = py;
545
+ }
546
+ if (!pts.length) return null;
547
+ const width = num(el.width, 2);
548
+ const pad = width;
549
+ const highlighter = el.highlighter === true;
550
+ return (
551
+ <Node
552
+ el={el}
553
+ x={x0 - pad}
554
+ y={y0 - pad}
555
+ w={x1 - x0 + pad * 2}
556
+ h={y1 - y0 + pad * 2}
557
+ // Overlapping highlighter ink darkens (multiply) against everything below.
558
+ extraStyle={highlighter ? { mixBlendMode: 'multiply' } : undefined}
559
+ >
560
+ <Geo x={x0 - pad} y={y0 - pad} w={x1 - x0 + pad * 2} h={y1 - y0 + pad * 2}>
561
+ {/* No vector-effect: drawn ink is world content and scales with zoom. */}
562
+ <path
563
+ d={penPathD(pts)}
564
+ fill="none"
565
+ stroke={str(el.color, '#1a1a1a')}
566
+ strokeWidth={width}
567
+ strokeLinecap="round"
568
+ strokeLinejoin="round"
569
+ pointerEvents={hit(interactive, 'stroke')}
570
+ />
571
+ </Geo>
572
+ </Node>
573
+ );
574
+ }
575
+
576
+ function ImageView({ el, interactive, resolveAsset }: ElementNodeProps) {
577
+ const x = num(el.x);
578
+ const y = num(el.y);
579
+ const w = num(el.w);
580
+ const h = num(el.h);
581
+ return (
582
+ <Node el={el} x={x} y={y} w={w} h={h} rot={num(el.rot)}>
583
+ <Geo x={x} y={y} w={w} h={h}>
584
+ <image
585
+ x={x}
586
+ y={y}
587
+ width={w}
588
+ height={h}
589
+ href={resolveAsset(str(el.href))}
590
+ preserveAspectRatio="xMidYMid meet"
591
+ aria-label={str(el.alt) || undefined}
592
+ pointerEvents={hit(interactive)}
593
+ />
594
+ </Geo>
595
+ </Node>
596
+ );
597
+ }
598
+
599
+ function LinkView({ el, interactive }: ElementNodeProps) {
600
+ const x = num(el.x);
601
+ const y = num(el.y);
602
+ const w = num(el.w);
603
+ const h = num(el.h);
604
+ const lay = linkCardLayout(x, y, w, h);
605
+ const font = { fontFamily: TEXT_FONT } as const;
606
+ return (
607
+ <Node el={el} x={x} y={y} w={w} h={h} rot={num(el.rot)}>
608
+ <Geo x={x} y={y} w={w} h={h}>
609
+ <g
610
+ data-url={str(el.url)}
611
+ data-title={str(el.title)}
612
+ data-domain={str(el.domain)}
613
+ pointerEvents={hit(interactive)}
614
+ >
615
+ <rect
616
+ x={x}
617
+ y={y}
618
+ width={w}
619
+ height={h}
620
+ rx={8}
621
+ ry={8}
622
+ fill={LINK_CARD_FILL}
623
+ stroke={LINK_CARD_STROKE}
624
+ strokeWidth={1}
625
+ vectorEffect="non-scaling-stroke"
626
+ filter="url(#dc-sticky-shadow)"
627
+ />
628
+ <svg
629
+ x={lay.glyph.x}
630
+ y={lay.glyph.y}
631
+ width={lay.glyph.size}
632
+ height={lay.glyph.size}
633
+ viewBox="0 0 24 24"
634
+ fill="none"
635
+ stroke={LINK_GLYPH_STROKE}
636
+ strokeWidth={2}
637
+ strokeLinecap="round"
638
+ strokeLinejoin="round"
639
+ aria-hidden="true"
640
+ >
641
+ <path d={LINK_GLYPH_D1} />
642
+ <path d={LINK_GLYPH_D2} />
643
+ </svg>
644
+ <text
645
+ x={lay.textX}
646
+ y={lay.domain.y}
647
+ fontSize={lay.domain.fontSize}
648
+ fill={LINK_DOMAIN_FILL}
649
+ dominantBaseline="hanging"
650
+ style={font}
651
+ >
652
+ {str(el.domain)}
653
+ </text>
654
+ <text
655
+ x={lay.textX}
656
+ y={lay.title.y}
657
+ fontSize={lay.title.fontSize}
658
+ fill={LINK_TITLE_FILL}
659
+ fontWeight={600}
660
+ dominantBaseline="hanging"
661
+ style={font}
662
+ >
663
+ {clampLinkTitle(str(el.title), lay.textMaxChars)}
664
+ </text>
665
+ </g>
666
+ </Geo>
667
+ </Node>
668
+ );
669
+ }
670
+
671
+ function MediaRefView({ el, interactive }: ElementNodeProps) {
672
+ const x = num(el.x);
673
+ const y = num(el.y);
674
+ const w = num(el.w);
675
+ const h = num(el.h);
676
+ const HEADER = 26;
677
+ const isAudio = el.media === 'audio';
678
+ const src = str(el.src);
679
+ const font = { fontFamily: TEXT_FONT } as const;
680
+ return (
681
+ <Node el={el} x={x} y={y} w={w} h={h} rot={num(el.rot)}>
682
+ <Geo x={x} y={y} w={w} h={h}>
683
+ <g
684
+ data-src={src}
685
+ data-media-kind={isAudio ? 'audio' : 'video'}
686
+ data-title={str(el.title)}
687
+ pointerEvents={hit(interactive)}
688
+ >
689
+ <rect
690
+ x={x}
691
+ y={y}
692
+ width={w}
693
+ height={h}
694
+ rx={8}
695
+ ry={8}
696
+ fill={LINK_CARD_FILL}
697
+ stroke={LINK_CARD_STROKE}
698
+ strokeWidth={1}
699
+ vectorEffect="non-scaling-stroke"
700
+ filter="url(#dc-sticky-shadow)"
701
+ />
702
+ <svg
703
+ x={x + 8}
704
+ y={y + 5}
705
+ width={16}
706
+ height={16}
707
+ viewBox="0 0 24 24"
708
+ fill={LINK_GLYPH_STROKE}
709
+ stroke="none"
710
+ aria-hidden="true"
711
+ >
712
+ <path d={isAudio ? MEDIAREF_AUDIO_GLYPH : MEDIAREF_VIDEO_GLYPH} />
713
+ </svg>
714
+ <text
715
+ x={x + 30}
716
+ y={y + 9}
717
+ fontSize={11}
718
+ fill={LINK_TITLE_FILL}
719
+ fontWeight={600}
720
+ dominantBaseline="hanging"
721
+ style={font}
722
+ >
723
+ {clampLinkTitle(str(el.title), Math.max(8, Math.floor((w - 40) / 7)))}
724
+ </text>
725
+ {src ? null : (
726
+ <text
727
+ x={x + 30}
728
+ y={y + HEADER + 10}
729
+ fontSize={10}
730
+ fill={LINK_DOMAIN_FILL}
731
+ dominantBaseline="hanging"
732
+ style={font}
733
+ >
734
+ (missing media reference)
735
+ </text>
736
+ )}
737
+ </g>
738
+ </Geo>
739
+ </Node>
740
+ );
741
+ }
742
+
743
+ /** Section title chip geometry — screen-size-constant (counter-scaled by zoom). */
744
+ export function sectionChip(label: string, zoom: number) {
745
+ const fontSize = SECTION_LABEL_FONT / zoom;
746
+ const chipH = SECTION_LABEL_H / zoom;
747
+ const gap = 4 / zoom;
748
+ const padX = 9 / zoom;
749
+ const chipW = Math.max(56 / zoom, label.length * fontSize * 0.62 + 18 / zoom);
750
+ return { fontSize, chipH, gap, padX, chipW };
751
+ }
752
+
753
+ function SectionChip({
754
+ el,
755
+ interactive,
756
+ edit,
757
+ }: {
758
+ el: AnnotationElement;
759
+ interactive: boolean;
760
+ edit: EditRequest | null;
761
+ }) {
762
+ // Counter-scaled chrome needs the live zoom, not the settle-cadence one.
763
+ const zoom = useLiveViewport().zoom || 1;
764
+ const label = str(el.label, 'Section');
765
+ const color = str(el.color, '#8a8a8a');
766
+ const g = sectionChip(label, zoom);
767
+ const style: CSSProperties = {
768
+ fontFamily: TEXT_FONT,
769
+ fontSize: `${g.fontSize}px`,
770
+ lineHeight: `${g.chipH}px`,
771
+ color,
772
+ padding: `0 ${g.padX}px`,
773
+ textAlign: 'left',
774
+ };
775
+ const box: CSSProperties = {
776
+ top: -(g.chipH + g.gap),
777
+ minWidth: g.chipW,
778
+ height: g.chipH,
779
+ borderRadius: 5 / zoom,
780
+ // Same wash the SVG chip had (fill-opacity 0.16).
781
+ backgroundColor: `color-mix(in oklab, ${color} 16%, transparent)`,
782
+ pointerEvents: interactive ? 'auto' : 'none',
783
+ };
784
+ if (edit) {
785
+ return (
786
+ <div className="dc-annot-chip" style={{ ...box, zIndex: 1 }}>
787
+ <TextEditor
788
+ initialText={label}
789
+ singleLine
790
+ className="dc-annot-text dc-annot-text--nowrap"
791
+ style={style}
792
+ size={{ kind: 'fit-both', minWidth: g.chipW }}
793
+ fmt={{ fontSize: g.fontSize }}
794
+ caretPoint={edit.caretPoint}
795
+ ariaLabel="Rename section"
796
+ onCommit={edit.onCommit}
797
+ onCancel={edit.onCancel}
798
+ onDraft={edit.onDraft}
799
+ />
800
+ </div>
801
+ );
802
+ }
803
+ return (
804
+ <div className="dc-annot-chip" data-section-chip="1" style={{ ...box, width: g.chipW }}>
805
+ <div className="dc-annot-text dc-annot-text--nowrap" style={style}>
806
+ {label}
807
+ </div>
808
+ </div>
809
+ );
810
+ }
811
+
812
+ function SectionView({ el, interactive, edit }: ElementNodeProps) {
813
+ const x = num(el.x);
814
+ const y = num(el.y);
815
+ const w = num(el.w);
816
+ const h = num(el.h);
817
+ const color = str(el.color, '#8a8a8a');
818
+ return (
819
+ <Node el={el} x={x} y={y} w={w} h={h} raised={!!edit}>
820
+ <Geo x={x} y={y} w={w} h={h}>
821
+ {/* Body: pure backdrop, click-through (content on a section selects normally). */}
822
+ <rect
823
+ x={x}
824
+ y={y}
825
+ width={w}
826
+ height={h}
827
+ rx={SECTION_CORNER_RADIUS}
828
+ ry={SECTION_CORNER_RADIUS}
829
+ fill={color}
830
+ fillOpacity={0.07}
831
+ stroke={color}
832
+ strokeOpacity={0.45}
833
+ strokeWidth={1.5}
834
+ vectorEffect="non-scaling-stroke"
835
+ pointerEvents="none"
836
+ />
837
+ {/* Invisible border ring — the grabbable edge. */}
838
+ {interactive ? (
839
+ <rect
840
+ x={x}
841
+ y={y}
842
+ width={w}
843
+ height={h}
844
+ rx={SECTION_CORNER_RADIUS}
845
+ ry={SECTION_CORNER_RADIUS}
846
+ fill="none"
847
+ stroke="transparent"
848
+ strokeWidth={12}
849
+ vectorEffect="non-scaling-stroke"
850
+ pointerEvents="stroke"
851
+ />
852
+ ) : null}
853
+ </Geo>
854
+ <SectionChip el={el} interactive={interactive} edit={edit} />
855
+ {edit ? <EditNotice notice={edit.notice} /> : null}
856
+ </Node>
857
+ );
858
+ }
859
+
860
+ /** A placeholder never grows past this (world units) — a hostile record can't blanket the board. */
861
+ const PLACEHOLDER_MAX = 2000;
862
+ const clampPx = (v: number, lo: number) => Math.min(PLACEHOLDER_MAX, Math.max(lo, v));
863
+
864
+ function PlaceholderView({ el }: ElementNodeProps) {
865
+ const x = num(el.x);
866
+ const y = num(el.y);
867
+ const w = clampPx(num(el.w), 40);
868
+ const h = clampPx(num(el.h), 24);
869
+ return (
870
+ <Node el={el} x={x} y={y} w={w} h={h}>
871
+ <div
872
+ className="dc-annot-placeholder"
873
+ title="Made with a newer version of Maude — kept as is"
874
+ style={{ pointerEvents: 'none' }}
875
+ >
876
+ {el.type}
877
+ </div>
878
+ </Node>
879
+ );
880
+ }
881
+
882
+ // A Map, never a plain object: `type` is peer data, and a lookup on an object
883
+ // would resolve `constructor` & co. to inherited members (security review A1).
884
+ const VIEWS = new Map<string, (p: ElementNodeProps) => ReactNode>([
885
+ ['sticky', StickyView],
886
+ ['text', TextView],
887
+ ['shape', ShapeView],
888
+ ['arrow', ArrowView],
889
+ ['pen', PenView],
890
+ ['image', ImageView],
891
+ ['link', LinkView],
892
+ ['mediaref', MediaRefView],
893
+ ['section', SectionView],
894
+ ]);
895
+
896
+ /**
897
+ * Register how a runtime-registered element type draws (see
898
+ * `registerElementType`). Without one it draws as a labelled placeholder.
899
+ * `Node` is the positioned wrapper every view uses.
900
+ */
901
+ export function registerElementView(type: string, view: (p: ElementNodeProps) => ReactNode): void {
902
+ if (VIEWS.has(type)) throw new Error(`"${type}" already has a view`);
903
+ VIEWS.set(type, view);
904
+ }
905
+
906
+ export { Node as ElementBox };
907
+
908
+ /**
909
+ * One element that fails to render (a malformed peer record, a bug in a view)
910
+ * draws as a placeholder instead of unmounting the whole canvas.
911
+ */
912
+ class ElementBoundary extends Component<ElementNodeProps, { failed: boolean }> {
913
+ override state = { failed: false };
914
+ static getDerivedStateFromError(): { failed: boolean } {
915
+ return { failed: true };
916
+ }
917
+ override componentDidUpdate(prev: ElementNodeProps): void {
918
+ if (this.state.failed && prev.el !== this.props.el) this.setState({ failed: false });
919
+ }
920
+ override render(): ReactNode {
921
+ if (this.state.failed) return <PlaceholderView {...this.props} />;
922
+ const View = VIEWS.get(this.props.el.type) ?? PlaceholderView;
923
+ return <View {...this.props} />;
924
+ }
925
+ }
926
+
927
+ function ElementNodeImpl(props: ElementNodeProps) {
928
+ countRender('annotationNodeRenders');
929
+ return <ElementBoundary {...props} />;
930
+ }
931
+
932
+ function sameEnds(a?: ResolvedEnds, b?: ResolvedEnds): boolean {
933
+ if (a === b) return true;
934
+ if (!a || !b) return false;
935
+ return (
936
+ a.x1 === b.x1 &&
937
+ a.y1 === b.y1 &&
938
+ a.x2 === b.x2 &&
939
+ a.y2 === b.y2 &&
940
+ a.startAnchor?.nx === b.startAnchor?.nx &&
941
+ a.startAnchor?.ny === b.startAnchor?.ny &&
942
+ a.endAnchor?.nx === b.endAnchor?.nx &&
943
+ a.endAnchor?.ny === b.endAnchor?.ny
944
+ );
945
+ }
946
+
947
+ export const ElementNode = memo(
948
+ ElementNodeImpl,
949
+ (a, b) =>
950
+ a.el === b.el &&
951
+ a.interactive === b.interactive &&
952
+ a.edit === b.edit &&
953
+ a.resolveAsset === b.resolveAsset &&
954
+ sameEnds(a.ends, b.ends)
955
+ );
956
+
957
+ /** Slot geometry for a pending (not yet born) standalone text at a world point. */
958
+ export function PendingTextNode({
959
+ x,
960
+ y,
961
+ color,
962
+ fontSize,
963
+ edit,
964
+ }: {
965
+ x: number;
966
+ y: number;
967
+ color: string;
968
+ fontSize: number;
969
+ edit: EditRequest;
970
+ }) {
971
+ const style: CSSProperties = {
972
+ ...slotTextStyle('text', { fontSize, color }),
973
+ color,
974
+ padding: '0 2px',
975
+ };
976
+ return (
977
+ <div className="dc-annot-el" data-pending-text="1" style={{ left: x, top: y }}>
978
+ <TextEditor
979
+ initialText=""
980
+ className="dc-annot-text dc-annot-text--nowrap"
981
+ style={style}
982
+ size={{ kind: 'fit-both' }}
983
+ fmt={{ fontSize }}
984
+ caretPoint={null}
985
+ ariaLabel="Edit text"
986
+ onCommit={edit.onCommit}
987
+ onCancel={edit.onCancel}
988
+ />
989
+ </div>
990
+ );
991
+ }
992
+
993
+ export type { SizeMode, TextSlot };