@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
@@ -1,9 +1,35 @@
1
+ import { MAX_BOARD_BYTES, MAX_ELEMENTS } from './annotations/constants.ts';
2
+ import { v1ToV2 } from './annotations/migrate-v1.ts';
3
+ import { type Op as AnnotationOp, applyOps, diffToOps } from './annotations/ops.ts';
4
+ import { defOf } from './annotations/registry.ts';
5
+ import { observeReplica } from './annotations/replica.ts';
6
+ import { parseBoard, validateElements } from './annotations/schema.ts';
7
+ import type { AnnotationElement } from './annotations/types.ts';
8
+ import { BoardStore } from './annotations/ui/board.ts';
1
9
  import {
2
- annotationEditBase,
3
- type ReceivedAnnotations,
4
- receivedAnnotations,
5
- } from './annotation-edit-base.ts';
6
- import { createAnnotationEchoGuard, observeAnnotationSnapshots } from './annotations-sync.ts';
10
+ Containment,
11
+ expandForOp,
12
+ insertSection,
13
+ type MarqueeItem,
14
+ marqueeHits,
15
+ } from './annotations/ui/containment.ts';
16
+ import type { EditRequest } from './annotations/ui/element-node.tsx';
17
+ import { AnnotationPipelineContext } from './annotations/ui/pipeline-context.ts';
18
+ import { type Claim, PointerPipeline } from './annotations/ui/pointer-pipeline.ts';
19
+ import { type RenderItem, renderItemsFromStrokes } from './annotations/ui/render-model.ts';
20
+ import { AnnotationScene } from './annotations/ui/scene.tsx';
21
+ import {
22
+ aimCommitOps,
23
+ draftOp,
24
+ markSent,
25
+ openSession,
26
+ remotelyEdited,
27
+ restoreOp,
28
+ type TextSession,
29
+ } from './annotations/ui/text-session.ts';
30
+ import { TEXT_LAYER_CSS } from './annotations/ui/text-style.ts';
31
+ import { projectStrokes, type StrokeCache } from './annotations/ui/world.ts';
32
+ import { elementsToStrokes, strokesToElementMap } from './annotations/v1-adapter.ts';
7
33
  /**
8
34
  * @file annotations-layer.tsx — FigJam-style annotation overlay
9
35
  * @scope apps/studio/annotations-layer.tsx
@@ -40,6 +66,7 @@ import {
40
66
  useMemo,
41
67
  useRef,
42
68
  useState,
69
+ useSyncExternalStore,
43
70
  } from 'react';
44
71
  import { createPortal } from 'react-dom';
45
72
  import {
@@ -146,8 +173,6 @@ import {
146
173
  strokeHitTest,
147
174
  strokeRotation,
148
175
  strokesShallowEqual,
149
- strokesToSvg,
150
- svgToStrokes,
151
176
  TEXT_LINE_HEIGHT,
152
177
  type TextAlign,
153
178
  type TextStroke,
@@ -172,11 +197,10 @@ import {
172
197
  useViewportControllerContext,
173
198
  useWorldRefContext,
174
199
  } from './canvas-lib.tsx';
175
- import { buildAnnotationStrokesRecord } from './commands/annotation-strokes-command.ts';
200
+ import { buildAnnotationOpsRecord } from './commands/annotation-ops-command.ts';
176
201
  import { ensureMenuStyles as ensureCtxMenuStyles } from './context-menu.tsx';
177
202
  import { crossedDragThreshold, type Tool } from './input-router.tsx';
178
203
  import { createMediaCommitChain, type MediaCommitResult } from './media-commit-chain.ts';
179
- import { collapseEntrySelectAll, mountCaret, placeCaretAt } from './text-caret.ts';
180
204
  import {
181
205
  AnnotationResizeOverlay,
182
206
  bboxResize,
@@ -197,7 +221,7 @@ import {
197
221
  useCanvasMediaDrop,
198
222
  } from './use-canvas-media-drop.tsx';
199
223
  import { useChromeVisibility } from './use-chrome-visibility.tsx';
200
- import { colorForName, useCollab } from './use-collab.tsx';
224
+ import { type AnnotationGesture, colorForName, useCollab } from './use-collab.tsx';
201
225
  import { useSelectionSetOptional } from './use-selection-set.tsx';
202
226
  import { type ShapeKind, useToolMode } from './use-tool-mode.tsx';
203
227
  import { useUndoSinks, useUndoStackOptional } from './use-undo-stack.tsx';
@@ -208,6 +232,10 @@ import { useUndoSinks, useUndoStackOptional } from './use-undo-stack.tsx';
208
232
  // re-exports the whole model so every existing
209
233
  // `from './annotations-layer.tsx'` import keeps working unchanged.
210
234
  export * from './annotations-model.ts';
235
+ // Arrow style enums are owned by canvas-arrowheads.ts (no cycle, DDR-067).
236
+ export type { ArrowHead, ArrowLineType } from './canvas-arrowheads.ts';
237
+ export { arrowHeadPoints } from './canvas-arrowheads.ts';
238
+ export { useAnnotationsVisibility } from './use-annotations-visibility.tsx';
211
239
 
212
240
  // ─────────────────────────────────────────────────────────────────────────────
213
241
  // Types
@@ -215,7 +243,6 @@ export * from './annotations-model.ts';
215
243
  // Phase 24 — arrow style enums are OWNED by canvas-arrowheads.ts (so that
216
244
  // module imports nothing back from here — no cycle, see DDR-067) and re-exported
217
245
  // here for back-compat (context-toolbar etc. import them from this module).
218
- export type { ArrowHead, ArrowLineType } from './canvas-arrowheads.ts';
219
246
 
220
247
  /** Phase 24 — cursor-following ghost placeholder descriptor (pure chrome). */
221
248
  type GhostDescriptor =
@@ -266,97 +293,17 @@ function useCanvasChromeTheme(): 'light' | 'dark' {
266
293
  return theme;
267
294
  }
268
295
 
269
- /**
270
- * Shared inline-formatting state for the three text editors (sticky / anchored /
271
- * standalone) — the unification surface (item 4d). Cmd/Ctrl + B / I / U toggle
272
- * bold / italic / underline WHILE editing (preventing the browser's native
273
- * execCommand, which would inject markup the model can't read), preview live via
274
- * `style`, and commit on the stroke via `fmtRef`. `strike` rides along unchanged
275
- * (toolbar-only — no universal shortcut). One hook = identical behaviour across
276
- * all three editors.
277
- */
278
- function useEditorFormat(initial: EditorFmt): {
279
- fmtRef: { current: EditorFmt };
280
- style: CSSProperties;
281
- onFormatKey: (e: ReactKeyboardEvent) => boolean;
282
- } {
283
- const [bold, setBold] = useState(!!initial.bold);
284
- const [italic, setItalic] = useState(!!initial.italic);
285
- const [underline, setUnderline] = useState(!!initial.underline);
286
- const [strike, setStrike] = useState(!!initial.strike);
287
- // FigJam v3 — edit-mode toolbar extensions: size + alignment preview live in
288
- // the editor and commit with the text (normFmt carries them through).
289
- const [fontSize, setFontSize] = useState<number | undefined>(initial.fontSize);
290
- const [align, setAlign] = useState<TextAlign | undefined>(initial.align);
291
- const fmtRef = useRef<EditorFmt>({ bold, italic, underline, strike, fontSize, align });
292
- fmtRef.current = { bold, italic, underline, strike, fontSize, align };
293
- const style: CSSProperties = {
294
- fontWeight: bold ? 700 : undefined,
295
- fontStyle: italic ? 'italic' : undefined,
296
- textDecoration: textDecoCss(strike, underline),
297
- ...(fontSize != null && fontSize !== initial.fontSize ? { fontSize: `${fontSize}px` } : {}),
298
- ...(align && align !== initial.align ? { textAlign: align } : {}),
299
- };
300
- // FigJam v3 — the edit-mode context toolbar drives the editor through this
301
- // event (mutating the STROKE mid-edit would re-render the contentEditable
302
- // and clobber typed text). The editor echoes its state back so the toolbar's
303
- // pressed-states track live.
304
- useEffect(() => {
305
- if (typeof document === 'undefined') return;
306
- const onFmt = (e: Event) => {
307
- const d = (e as CustomEvent<{ key?: string; value?: unknown }>).detail;
308
- if (!d?.key) return;
309
- if (d.key === 'bold') setBold((v) => !v);
310
- else if (d.key === 'italic') setItalic((v) => !v);
311
- else if (d.key === 'underline') setUnderline((v) => !v);
312
- else if (d.key === 'strike') setStrike((v) => !v);
313
- else if (d.key === 'fontSize' && typeof d.value === 'number') setFontSize(d.value);
314
- else if (d.key === 'align' && typeof d.value === 'string') setAlign(d.value as TextAlign);
315
- };
316
- document.addEventListener('maude:editor-format', onFmt);
317
- return () => document.removeEventListener('maude:editor-format', onFmt);
318
- }, []);
319
- useEffect(() => {
320
- if (typeof document === 'undefined') return;
321
- const broadcast = () => {
322
- document.dispatchEvent(
323
- new CustomEvent('maude:editor-format-state', {
324
- detail: { bold, italic, underline, strike, fontSize, align },
325
- })
326
- );
327
- };
328
- broadcast();
329
- // The toolbar may mount AFTER the editor's first broadcast — it asks.
330
- document.addEventListener('maude:editor-format-request', broadcast);
331
- return () => document.removeEventListener('maude:editor-format-request', broadcast);
332
- }, [bold, italic, underline, strike, fontSize, align]);
333
- const onFormatKey = useCallback((e: ReactKeyboardEvent): boolean => {
334
- if (!(e.metaKey || e.ctrlKey) || e.altKey || e.shiftKey) return false;
335
- const k = e.key.toLowerCase();
336
- if (k === 'b') {
337
- e.preventDefault();
338
- setBold((v) => !v);
339
- return true;
340
- }
341
- if (k === 'i') {
342
- e.preventDefault();
343
- setItalic((v) => !v);
344
- return true;
345
- }
346
- if (k === 'u') {
347
- e.preventDefault();
348
- setUnderline((v) => !v);
349
- return true;
350
- }
351
- return false;
352
- }, []);
353
- return { fmtRef, style, onFormatKey };
354
- }
355
-
356
296
  // Phase 24 — moved to canvas-arrowheads.ts (single source for shaft + heads).
357
- // Re-exported so the existing test import (`from '../annotations-layer.tsx'`)
297
+ // Re-exported so the existing test import (`from './annotations/annotations-layer.tsx'`)
358
298
  // and the byte-identical canary keep working.
359
- export { arrowHeadPoints } from './canvas-arrowheads.ts';
299
+
300
+ /** A whiteboard clipboard payload (v2 elements, or a pre-v2 strokes list). */
301
+ function isBoardClipboard(txt: string): boolean {
302
+ // The clipboard is foreign input (any page the user copied from can set it):
303
+ // nothing larger than a whole board is ever parsed (security review W3/A6).
304
+ if (txt.length > MAX_BOARD_BYTES) return false;
305
+ return txt.startsWith('{"maudeElements"') || txt.startsWith('{"maudeStrokes"');
306
+ }
360
307
 
361
308
  function isEditable(t: EventTarget | null): boolean {
362
309
  if (!t || !(t as HTMLElement).tagName) return false;
@@ -416,85 +363,14 @@ function resolveAssetHref(href: string): string {
416
363
  }
417
364
 
418
365
  /**
419
- * An optimistic ImageStroke's `blob:`/`data:` href must never be PUT to the
420
- * server. `sanitizeAnnotationSvg` (api.ts) strips an href it doesn't
421
- * recognize — keeping the `<image>` element, dropping only the attribute —
422
- * so the server's STORED + broadcast SVG silently diverges from whatever the
423
- * client just sent. That divergence defeats the collab-echo self-suppression
424
- * guard (the `annotationEchoRef` operation history, near `putStrokes` below): the
425
- * echo's content no longer matches anything we recorded as "already
426
- * applied", so a real `setStrokesState` fires from the (href-stripped)
427
- * server copy — which can wipe out a SIBLING stroke's still-in-flight
428
- * optimistic insert that was never itself part of that PUT. This is the
429
- * concrete failure a 3-file concurrent drop hit: one
430
- * image ended up href-stripped (a blank frame) and another was dropped
431
- * entirely once an unrelated commit's echo round-tripped while both were
432
- * still uploading. See media-commit-chain.ts for the sibling accumulator fix
433
- * — this closes the other half, at the persistence layer.
366
+ * An optimistic ImageStroke's `blob:`/`data:` href (an upload in flight) is
367
+ * never sent to the board: the image is shown from `uploading` and committed
368
+ * once, with its `assets/…` href, when the upload lands.
434
369
  */
435
370
  function isEphemeralHref(s: Stroke): boolean {
436
371
  return s.tool === 'image' && /^(?:blob|data):/i.test(s.href);
437
372
  }
438
373
 
439
- /**
440
- * Fold a local mutation's `next` against the live-rendered `prev`, restoring
441
- * only strokes that are genuinely concurrent additions from ANOTHER
442
- * in-flight commit — never reverting THIS mutation's own deletions.
443
- * Disambiguated via `opBefore`, the baseline this mutation's `next` was
444
- * itself computed from: an id present in `prev` but absent from BOTH
445
- * `opBefore` and `next` was added by someone else after `opBefore` was
446
- * captured (fold it in — this is the sibling-insert race this helper was
447
- * originally written for). An id present in `opBefore` but absent from
448
- * `next` was deliberately removed BY THIS MUTATION and must stay removed
449
- * even though `prev` (React's rendered state) hasn't caught up to that
450
- * removal yet.
451
- *
452
- * The prior version of this helper compared only `prev` against `next` —
453
- * exactly the shape of every delete (an id in `prev`, absent from `next`) —
454
- * so it silently folded every erased stroke straight back in locally, while
455
- * the smaller, correct set still went out over PUT. Backspace looked like a
456
- * no-op until a reload picked up the server's already-correct copy.
457
- */
458
- export function reconcileCommit(
459
- prev: readonly Stroke[],
460
- opBefore: readonly Stroke[],
461
- next: readonly Stroke[]
462
- ): Stroke[] {
463
- const beforeIds = new Set(opBefore.map((s) => s.id));
464
- const nextIds = new Set(next.map((s) => s.id));
465
- const extra = prev.filter((s) => !beforeIds.has(s.id) && !nextIds.has(s.id));
466
- return extra.length ? [...next, ...extra] : (next as Stroke[]);
467
- }
468
-
469
- /**
470
- * Fold a FOREIGN collab-echo snapshot against local state. A foreign
471
- * broadcast legitimately omits an id that was deleted (by us or a peer) —
472
- * reviving it just because local `prev` hasn't caught up would make deletes
473
- * unsyncable across tabs/peers. The only strokes worth resurrecting here are
474
- * ones that are still purely local, not-yet-synced optimistic previews (an
475
- * ephemeral blob:/data: href image mid-upload) that a foreign broadcast could
476
- * never have known about in the first place.
477
- *
478
- * issue-106 deliberately does NOT carve out the stroke a local editor has open.
479
- * That was tried: hold back its `text` so a peer's commit could not overwrite
480
- * the editor. But this function's output IS the persisted store — it is
481
- * serialized to `*.annotations.svg` and re-broadcast on the next commit of ANY
482
- * stroke — so holding a field back doesn't shield a view, it silently reverts
483
- * the peer's write and republishes the stale text under the local user's name,
484
- * for as long as the editor stays open. The editor never needed it: the clobber
485
- * was React rewriting an UNCONTROLLED contentEditable, which `useSessionValue`
486
- * fixes at the DOM, and the commit reads `innerText` off the node rather than
487
- * off the stroke. Last-write-wins stays the model here.
488
- */
489
- export function reconcileForeignEcho(
490
- prev: readonly Stroke[],
491
- incoming: readonly Stroke[]
492
- ): Stroke[] {
493
- const incomingIds = new Set(incoming.map((s) => s.id));
494
- const extra = prev.filter((s) => !incomingIds.has(s.id) && isEphemeralHref(s));
495
- return extra.length ? [...incoming, ...extra] : (incoming as Stroke[]);
496
- }
497
-
498
374
  /**
499
375
  * Decide what `createImageFromFile`'s post-upload swap should do with a
500
376
  * given `before` chain link. Extracted as a pure function (no React) so the
@@ -764,26 +640,6 @@ const ANNOT_CSS = `
764
640
  stroke-width: 1;
765
641
  stroke-dasharray: 4 3;
766
642
  }
767
- /* Phase 24 — sticky-note body. Word-wrapped multi-line text inside the card's
768
- foreignObject. Text sits TOP-LEFT (FigJam parity); the editor contentEditable
769
- mirrors the same box metrics so the read-edit swap doesn't shift the text.
770
- text-align is overridden inline per-sticky when align is not left. */
771
- .dc-sticky-body {
772
- width: 100%;
773
- height: 100%;
774
- box-sizing: border-box;
775
- padding: 14px 16px;
776
- display: flex;
777
- align-items: flex-start;
778
- justify-content: flex-start;
779
- text-align: left;
780
- color: #2a2a28;
781
- font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
782
- line-height: 1.35;
783
- white-space: pre-wrap;
784
- overflow-wrap: anywhere;
785
- overflow: hidden;
786
- }
787
643
  /* Phase 24 — while editing an annotation's text (a text label OR a sticky body,
788
644
  both carry the dc-annot-editor class), force the I-beam. The important flag
789
645
  plus the class selector beat use-tool-mode's blanket star-cursor rule (you
@@ -792,13 +648,13 @@ const ANNOT_CSS = `
792
648
  that fight on its own — a non-important inline style loses to !important.
793
649
  See DDR-067. (No backticks in this comment: the whole block is a JS template
794
650
  literal, so a backtick here would terminate the string.) */
795
- .dc-annot-editor, .dc-annot-editor * { cursor: text !important; }
651
+ .dc-annot-editor, .dc-annot-editor *, textarea.dc-annot-text { cursor: text !important; }
796
652
  /* Phase 7 (unified-text-editing) — hover affordance parity with artboard leaf
797
653
  text: a standalone text stroke invites editing with the I-beam in Move mode
798
654
  (double-click / Text-tool click enters its editor in place). Shapes and
799
655
  stickies keep the selection arrow — their whole body is a move/select
800
656
  target first. */
801
- .dc-annot-svg text[data-tool="text"] { cursor: text; }
657
+ .dc-annot-el[data-tool="text"] .dc-annot-text { cursor: text; }
802
658
  /* FigJam v3 — connection dots on a selected bindable shape. The important flag
803
659
  beats use-tool-mode's blanket star-cursor rule (same fight as the editor +
804
660
  resize handles — DDR-067). */
@@ -810,7 +666,7 @@ function ensureAnnotStyles(): void {
810
666
  if (document.getElementById('dc-annot-css')) return;
811
667
  const s = document.createElement('style');
812
668
  s.id = 'dc-annot-css';
813
- s.textContent = ANNOT_CSS;
669
+ s.textContent = `${ANNOT_CSS}\n${TEXT_LAYER_CSS}`;
814
670
  document.head.appendChild(s);
815
671
  }
816
672
 
@@ -818,6 +674,10 @@ function ensureAnnotStyles(): void {
818
674
  // Strokes store — lifted out of the layer so the contextual toolbar (Phase 5.1
819
675
  // Task 8) can mutate strokes without prop-drilling.
820
676
 
677
+ // ─────────────────────────────────────────────────────────────────────────────
678
+ // Strokes store — lifted out of the layer so the contextual toolbar (Phase 5.1
679
+ // Task 8) can mutate strokes without prop-drilling.
680
+
821
681
  export interface StrokesStoreValue {
822
682
  strokes: Stroke[];
823
683
  setStrokes: (next: Stroke[]) => void;
@@ -854,9 +714,15 @@ export interface StrokesStoreValue {
854
714
  previewStroke: (id: string, patch: Partial<Stroke>) => void;
855
715
  /** Wave H — single undo record from a preview gesture's start snapshot. */
856
716
  commitGesture: (before: readonly Stroke[], label?: string) => void;
717
+ /** The host a dragged arrow endpoint would bind to (halo), or null. */
718
+ setBindHint?: (hostId: string | null) => void;
719
+ /** Live size label + dimension-match halos while resizing, or null. */
720
+ setResizeInfo?: (
721
+ info: { box: { x: number; y: number; w: number; h: number }; matchIds: string[] } | null
722
+ ) => void;
857
723
  }
858
724
 
859
- const StrokesStoreContext = createContext<StrokesStoreValue | null>(null);
725
+ export const StrokesStoreContext = createContext<StrokesStoreValue | null>(null);
860
726
 
861
727
  export function useStrokesStore(): StrokesStoreValue | null {
862
728
  return useContext(StrokesStoreContext);
@@ -875,6 +741,9 @@ const CHROME_SELECTOR =
875
741
 
876
742
  const HINTS_KEY = 'maude-annot-hints-v1';
877
743
 
744
+ /** Below this zoom a pointer entry into text editing first zooms to the element (AD7). */
745
+ const EDIT_ZOOM_FLOOR = 0.5;
746
+
878
747
  function showOnceHint(key: string, msg: string): void {
879
748
  if (typeof window === 'undefined') return;
880
749
  try {
@@ -894,7 +763,6 @@ function showOnceHint(key: string, msg: string): void {
894
763
  // Annotations visibility now lives in use-annotations-visibility.tsx so the
895
764
  // ToolPalette (a sibling under CanvasRouter, not a descendant of this layer)
896
765
  // can read the same state. Re-exported here for back-compat.
897
- export { useAnnotationsVisibility } from './use-annotations-visibility.tsx';
898
766
 
899
767
  /**
900
768
  * Phase 2 (whiteboard-improvements) — proportional group resize. Maps one
@@ -971,6 +839,52 @@ function scaleStrokeInGroup(
971
839
  return null;
972
840
  }
973
841
 
842
+ /** The copies of `ids` in a duplicate result (what to select afterwards). */
843
+ function cloneRoots(ids: readonly string[], res: { idMap: ReadonlyMap<string, string> }): string[] {
844
+ return ids.map((id) => res.idMap.get(id)).filter((x): x is string => !!x);
845
+ }
846
+
847
+ /** The element an editing target edits (a shape label edits on the shape). */
848
+ function editTargetId(t: EditingTarget): string | null {
849
+ if (!t) return null;
850
+ if (t.kind === 'anchored') return t.anchorId;
851
+ if (t.kind === 'sticky') return t.sticky.id;
852
+ if (t.kind === 'standalone') return t.text.id;
853
+ if (t.kind === 'section') return t.section.id;
854
+ return null;
855
+ }
856
+
857
+ function editTargetStroke(t: Exclude<EditingTarget, null | { kind: 'pending' }>): Stroke {
858
+ if (t.kind === 'anchored') return t.host;
859
+ if (t.kind === 'sticky') return t.sticky;
860
+ if (t.kind === 'standalone') return t.text;
861
+ return t.section;
862
+ }
863
+
864
+ /** The text a slot keeps for what was typed (the commit writers' own rules). */
865
+ function storedSlotText(kind: string, text: string): string {
866
+ if (kind === 'sticky') return text;
867
+ if (kind === 'section') return text.trim().replace(/\s*\n+\s*/g, ' ') || 'Section';
868
+ return text.trim();
869
+ }
870
+
871
+ /** `strokes` with the session's slot back at its base text — the undo target of a text commit. */
872
+ function withSlotText(strokes: readonly Stroke[], s: TextSession): Stroke[] {
873
+ if (s.kind === 'label-record') {
874
+ const label = strokes.find((x) => x.tool === 'text' && x.anchorId === s.id);
875
+ if (!s.base) return strokes.filter((x) => x !== label);
876
+ return label
877
+ ? strokes.map((x) => (x === label ? { ...label, text: s.base } : x))
878
+ : [...strokes];
879
+ }
880
+ return strokes.map((x) => {
881
+ if (x.id !== s.id) return x;
882
+ if (x.tool === 'section') return { ...x, label: s.base };
883
+ if (x.tool === 'sticky' || x.tool === 'text') return { ...x, text: s.base };
884
+ return x;
885
+ });
886
+ }
887
+
974
888
  // ─────────────────────────────────────────────────────────────────────────────
975
889
  // Component
976
890
 
@@ -981,11 +895,69 @@ export function AnnotationsLayer() {
981
895
  const theme = useCanvasChromeTheme();
982
896
  const controller = useViewportControllerContext();
983
897
  const vp = controller?.viewport ?? null;
898
+ const controllerRef = useRef(controller);
899
+ controllerRef.current = controller;
984
900
  const worldRef = useWorldRefContext();
985
901
  const annotSel = useAnnotationSelectionOptional();
986
902
  const elementSel = useSelectionSetOptional();
903
+ // Task 21 — ONE pointer pipeline for every annotation gesture: stages tried
904
+ // in explicit priority order, one owner per gesture (pointer-pipeline.ts).
905
+ const pipeline = useMemo(() => new PointerPipeline(), []);
906
+ const collab = useCollab();
907
+ const collabRef = useRef(collab);
908
+ collabRef.current = collab;
909
+ /**
910
+ * Task 22 — the gesture in flight, for peers: awareness only (throttled,
911
+ * never storage). The op still commits once at gesture end; null clears.
912
+ */
913
+ const publishGesture = useCallback((g: AnnotationGesture | null) => {
914
+ collabRef.current?.publishAwareness({ annotationGesture: g });
915
+ }, []);
916
+ const [gestureState, setGestureState] = useState('idle');
917
+ useEffect(() => {
918
+ if (typeof document === 'undefined') return;
919
+ pipeline.onState = setGestureState;
920
+ return pipeline.attach(document);
921
+ }, [pipeline]);
922
+ // Chrome owns its own presses (toolbars, handles, editors, menus, the
923
+ // palette, comments, an inline media player): one decision, here, instead of
924
+ // a skip-list copied into every handler (the ce641b18 class of bug).
925
+ useEffect(
926
+ () =>
927
+ pipeline.add({
928
+ priority: 5,
929
+ name: 'chrome',
930
+ down: (e) => {
931
+ const t = e.target as Element | null;
932
+ return isMediaPlayerTarget(e) || t?.closest?.(CHROME_SELECTOR) ? 'pass' : undefined;
933
+ },
934
+ }),
935
+ [pipeline]
936
+ );
987
937
 
988
- const [strokes, setStrokesState] = useState<Stroke[]>([]);
938
+ // DDR-242 AD8 (Task 26) — the board is the element store: what the project
939
+ // delivered plus this tab's optimistic op batches. The editing UI reads it
940
+ // as world-space strokes, projected per element (unchanged elements keep
941
+ // their identity, so only what changed re-renders).
942
+ const boardRef = useRef<BoardStore | null>(null);
943
+ if (!boardRef.current) boardRef.current = new BoardStore();
944
+ const board = boardRef.current;
945
+ const boardVersion = useSyncExternalStore(board.subscribe, board.getVersion, board.getVersion);
946
+ const strokeCacheRef = useRef<StrokeCache>(new WeakMap());
947
+ // biome-ignore lint/correctness/useExhaustiveDependencies: re-projected per board version
948
+ const boardStrokes = useMemo(
949
+ () => projectStrokes(board.scene(), strokeCacheRef.current),
950
+ [board, boardVersion]
951
+ );
952
+ // Images still uploading (a `blob:` href) — shown, never on the board.
953
+ const [uploading, setUploading] = useState<Stroke[]>([]);
954
+ // A gesture in flight (drag, resize): the strokes as the gesture shows them.
955
+ // Never stored; the gesture commits once, as one op batch.
956
+ const [preview, setPreview] = useState<Stroke[] | null>(null);
957
+ const strokes = useMemo(
958
+ () => preview ?? (uploading.length ? [...boardStrokes, ...uploading] : boardStrokes),
959
+ [preview, uploading, boardStrokes]
960
+ );
989
961
  const [drawing, setDrawing] = useState<Stroke | null>(null);
990
962
  // Theme-aware live default ink (items 3/5/6). Initialized from the current
991
963
  // theme; tracked-untouched until the user picks a swatch, after which it
@@ -1035,6 +1007,12 @@ export function AnnotationsLayer() {
1035
1007
  // id of the host a dragged arrow endpoint would bind to (accent halo).
1036
1008
  const [snapGuides, setSnapGuides] = useState<SnapGuide[] | null>(null);
1037
1009
  const [bindHintId, setBindHintId] = useState<string | null>(null);
1010
+ // FigJam v3 — live size label + dimension-match halos while resizing (the
1011
+ // resize overlay sets it through the strokes store; the SVG layer paints).
1012
+ const [resizeInfo, setResizeInfo] = useState<{
1013
+ box: { x: number; y: number; w: number; h: number } | null;
1014
+ matchIds: string[];
1015
+ } | null>(null);
1038
1016
  // Cmd/Ctrl held — suppresses binding at arrow draw-end (FigJam: ⌘ keeps the
1039
1017
  // endpoint free). Tracked here because endStroke (pointerup) carries no
1040
1018
  // modifier state of its own.
@@ -1079,9 +1057,32 @@ export function AnnotationsLayer() {
1079
1057
  // text-tool click-through); keyboard entries (Enter, fresh-create,
1080
1058
  // ⌘Enter chain) leave it null → select-all, the rename convention.
1081
1059
  const [editCaretPoint, setEditCaretPoint] = useState<{ x: number; y: number } | null>(null);
1060
+ /**
1061
+ * Open an element's text editor from a pointer (double-click, Text-tool
1062
+ * click). Below the editing zoom floor the view first zooms to the element
1063
+ * (AD7), so the caret and the text are readable; the entry click then no
1064
+ * longer points at the text, so the editor opens with the text selected.
1065
+ */
1066
+ const openEditorAt = useCallback((id: string, clientX: number, clientY: number) => {
1067
+ const zoom = getLiveViewport()?.zoom ?? 1;
1068
+ const s = strokesRef.current.find((x) => x.id === id);
1069
+ const bb = s ? strokeBBox(s) : null;
1070
+ if (zoom < EDIT_ZOOM_FLOOR && bb && controllerRef.current) {
1071
+ const pad = 48;
1072
+ controllerRef.current.jumpTo({
1073
+ x: bb.x - pad,
1074
+ y: bb.y - pad,
1075
+ w: bb.w + pad * 2,
1076
+ h: bb.h + pad * 2,
1077
+ } as Parameters<NonNullable<typeof controller>['jumpTo']>[0]);
1078
+ setEditCaretPoint(null);
1079
+ } else {
1080
+ setEditCaretPoint({ x: clientX, y: clientY });
1081
+ }
1082
+ setEditingId(id);
1083
+ }, []);
1082
1084
 
1083
1085
  const fileRef = useRef<string | undefined>(undefined);
1084
- const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
1085
1086
  const drawingRef = useRef<Stroke | null>(null);
1086
1087
  drawingRef.current = drawing;
1087
1088
  // Phase 24 — pointer-down anchor + last cursor (world coords) for the active
@@ -1096,6 +1097,8 @@ export function AnnotationsLayer() {
1096
1097
  */
1097
1098
  const strokesRef = useRef<Stroke[]>(strokes);
1098
1099
  strokesRef.current = strokes;
1100
+ const uploadingRef = useRef(uploading);
1101
+ uploadingRef.current = uploading;
1099
1102
  // Phase 23 batch-drop fix — see media-commit-chain.ts. Every async media
1100
1103
  // completion (image upload swap, video/audio upload commit) that can be
1101
1104
  // triggered concurrently (N Finder files dropped at once) enqueues onto
@@ -1158,29 +1161,32 @@ export function AnnotationsLayer() {
1158
1161
  if (!ghostCapable || !visible) setGhost(null);
1159
1162
  }, [ghostCapable, visible]);
1160
1163
 
1161
- // Match specific authored operations, including delayed earlier PUTs. A
1162
- // peer undo/delete can legitimately return to any previously rendered SVG.
1163
- const annotationEchoRef = useRef(createAnnotationEchoGuard());
1164
+ // Op batches applied here but not yet seen back from the replica, in send
1165
+ // order. A snapshot from the replica is rebased onto them, so a
1166
+ // collaborator's change never hides an edit of ours still in flight.
1167
+ const pendingRef = useRef<Array<{ actionId: string; ops: readonly AnnotationOp[] }>>([]);
1164
1168
  const annotationsChangedRef = useRef(false);
1165
- // The annotations as the project last delivered them — the base an edit on
1166
- // exactly that state names (annotation-edit-base.ts).
1167
- const receivedRef = useRef<ReceivedAnnotations | null>(null);
1169
+ // Task 19 — the open text edit session (text-session.ts): its base (undo
1170
+ // target) and the text it last sent (every text op expects it). `commit…`
1171
+ // hands the session to commitStrokes for exactly one commit.
1172
+ const textSessionRef = useRef<TextSession | null>(null);
1173
+ const commitSessionRef = useRef<TextSession | null>(null);
1174
+ const sessionCommittedRef = useRef(false);
1175
+ // The edited element as the board last had it — what "keep my text" restores.
1176
+ const sessionElRef = useRef<AnnotationElement | null>(null);
1177
+ const [remoteEdited, setRemoteEdited] = useState(false);
1168
1178
  useEffect(() => {
1169
1179
  const file = deriveFile();
1170
1180
  fileRef.current = file;
1171
1181
  if (!file) return;
1172
1182
  let cancelled = false;
1173
1183
  void fetch(`/_api/annotations?file=${encodeURIComponent(file)}`, {
1174
- headers: { Accept: 'image/svg+xml' },
1184
+ headers: { Accept: 'application/json' },
1175
1185
  })
1176
1186
  .then((r) => (r.ok ? r.text() : ''))
1177
1187
  .then((text) => {
1178
1188
  if (cancelled || annotationsChangedRef.current) return;
1179
- const loaded = svgToStrokes(text);
1180
- receivedRef.current = receivedAnnotations(text, loaded);
1181
- if (loaded.length) {
1182
- setStrokesState(loaded);
1183
- }
1189
+ board.setAll(parseBoard(text).elements);
1184
1190
  })
1185
1191
  .catch(() => {
1186
1192
  /* network blip — start with an empty annotation set */
@@ -1188,97 +1194,91 @@ export function AnnotationsLayer() {
1188
1194
  return () => {
1189
1195
  cancelled = true;
1190
1196
  };
1191
- }, []);
1197
+ }, [board]);
1192
1198
 
1193
- const collab = useCollab();
1194
1199
  useEffect(() => {
1195
1200
  if (!collab) return;
1196
- return observeAnnotationSnapshots(collab.doc, (svg, writeId) => {
1201
+ return observeReplica(collab.doc, (elements, _changed, actionId) => {
1197
1202
  annotationsChangedRef.current = true;
1198
- const incoming = svgToStrokes(svg);
1199
- receivedRef.current = receivedAnnotations(svg, incoming);
1200
- if (annotationEchoRef.current.isOwn(svg, writeId)) return;
1201
- setStrokesState((prev) => reconcileForeignEcho(prev, incoming));
1203
+ const pending = pendingRef.current;
1204
+ const landed = actionId ? pending.findIndex((p) => p.actionId === actionId) : -1;
1205
+ // Batches reach the board in send order: this one and every earlier one landed.
1206
+ if (landed >= 0) pending.splice(0, landed + 1);
1207
+ const isOwn = landed >= 0;
1208
+ // A collaborator changed the text under an open editor: the editor keeps
1209
+ // the user's text and shows a marker; the commit merges both (Task 19).
1210
+ const session = textSessionRef.current;
1211
+ if (session && !isOwn) {
1212
+ const el = elements.find((e) => e.id === session.id);
1213
+ if (el && remotelyEdited(session, el)) setRemoteEdited(true);
1214
+ }
1215
+ let state = new Map(elements.map((e) => [e.id, e]));
1216
+ for (const p of pending) state = applyOps(state, p.ops).state;
1217
+ board.setAll(state.values());
1202
1218
  });
1203
- }, [collab]);
1219
+ }, [collab, board]);
1204
1220
 
1205
1221
  const undoStack = useUndoStackOptional();
1206
1222
  const undoSinks = useUndoSinks();
1207
1223
  const undoStackRef = useRef(undoStack);
1208
1224
  undoStackRef.current = undoStack;
1209
1225
 
1210
- // feature-bulk-media-insert follow-up — dedicated PUT dispatch queue.
1211
- // The undo-stack's own `inFlightRef` serializes its PUSH TASKS (each
1212
- // awaits `cmd.do()` before the next task starts), but live network
1213
- // capture during a rapid-fire batch drop showed the actual PUT REQUESTS
1214
- // still overlapping (request N+1 starting before request N's response
1215
- // arrived) — some other async hop between "task starts" and "fetch
1216
- // fires" lets them interleave. Since every PUT body here is a strictly
1217
- // growing superset (the media-commit-chain guarantees each commit is at
1218
- // least as complete as the last), overlap is dangerous: if an EARLIER
1219
- // (smaller) request's write lands on disk AFTER a LATER (larger) one's,
1220
- // the smaller snapshot wins and silently erases the larger one's extra
1221
- // strokes. A dedicated chain — mirroring `editApplyChainRef` in
1222
- // client/app.jsx — makes the fetch DISPATCH itself wait for the
1223
- // previous one's response, independent of whatever the undo-stack does.
1226
+ // feature-bulk-media-insert follow-up — dedicated dispatch queue. Rapid-fire
1227
+ // commits (a batch media drop) must reach the server in order: ops are
1228
+ // applied in arrival order, and an undo batch sent before the edit it undoes
1229
+ // would be rejected as `gone`. A dedicated chain makes each POST wait for
1230
+ // the previous one's response.
1224
1231
  const putChainRef = useRef<Promise<void>>(Promise.resolve());
1225
1232
 
1226
1233
  /**
1227
- * Apply a `Stroke[]` snapshot: update local React state AND fire-and-forget
1228
- * PUT to the server. Used as the `putFn` injected into the
1229
- * `AnnotationStrokesCommand` — both the initial push AND every undo/redo
1230
- * replay route through here, so the iframe's `strokes` state always
1231
- * tracks the server. (Without the setStrokesState here, Cmd+Z would
1232
- * silently PUT the prior SVG but the canvas would keep painting the
1233
- * post-edit strokes until the user reloaded.)
1234
- *
1235
- * The 200 ms scheduled-save debounce (legacy path) is cleared the moment
1236
- * we push a command, so the server only sees one PUT per edit instead
1237
- * of two-step racing.
1234
+ * Send one element-op batch to the board (DDR-242 §4), in order after every
1235
+ * batch before it. `local` marks a batch already applied to the store: it
1236
+ * stays pending (re-applied over replica snapshots) until its echo arrives.
1238
1237
  */
1239
- const putStrokes = useCallback((next: readonly Stroke[], before: readonly Stroke[]) => {
1240
- // See reconcileCommit — a direct setStrokesState(next) here can
1241
- // clobber a sibling file's concurrent optimistic insert; folding
1242
- // blindly against `prev` (no baseline) can just as easily revert this
1243
- // very mutation's own delete. `before` (this command's own baseline)
1244
- // disambiguates the two.
1245
- annotationsChangedRef.current = true;
1246
- setStrokesState((prev) => reconcileCommit(prev, before, next));
1238
+ const sendOps = useCallback((ops: readonly AnnotationOp[], local = false): Promise<void> => {
1247
1239
  const file = fileRef.current;
1248
- if (!file) return Promise.resolve();
1249
- const persistable = next.some(isEphemeralHref) ? next.filter((s) => !isEphemeralHref(s)) : next;
1250
- const svg = strokesToSvg(persistable);
1251
- // What this edit was derived from — the project merges a peer's
1252
- // concurrent strokes from it instead of replacing them (DDR-241).
1253
- const persistableBefore = before.some(isEphemeralHref)
1254
- ? before.filter((s) => !isEphemeralHref(s))
1255
- : before;
1256
- const base = annotationEditBase(persistableBefore, receivedRef.current);
1257
- // Phase 8 Task 5 — record the SVG we just authored locally so the
1258
- // server-broadcast echo (PUT → onAnnotationsChanged → syncRoom* →
1259
- // Y.Map.observe) doesn't trigger a redundant setStrokesState.
1260
- const writeId = crypto.randomUUID();
1261
- annotationEchoRef.current.remember(writeId, svg);
1240
+ if (!file || !ops.length) return putChainRef.current;
1241
+ const actionId = crypto.randomUUID();
1242
+ const pending = pendingRef.current;
1243
+ if (local) {
1244
+ pending.push({ actionId, ops });
1245
+ if (pending.length > 64) pending.splice(0, pending.length - 64);
1246
+ }
1247
+ const settle = () => {
1248
+ const i = pending.findIndex((p) => p.actionId === actionId);
1249
+ if (i >= 0) pending.splice(i, 1);
1250
+ };
1262
1251
  const dispatch = () =>
1263
- fetch('/_api/annotations', {
1264
- method: 'PUT',
1252
+ fetch('/_api/annotations/ops', {
1253
+ method: 'POST',
1265
1254
  headers: { 'Content-Type': 'application/json' },
1266
- body: JSON.stringify({ file, svg, writeId, base }),
1255
+ body: JSON.stringify({ file, actionId, ops }),
1267
1256
  })
1268
- .then((r) => {
1257
+ .then(async (r) => {
1269
1258
  // A refused save (403 read-only, 405 at a proxy door) previously
1270
- // dissolved here without a trace — the user kept drawing on state
1271
- // that never reached disk, a peer, or a reload (the cloud
1272
- // canvas-writes RCA). Optimistic local state is still the right
1273
- // UX; a persistence failure being INVISIBLE is not.
1259
+ // dissolved without a trace — the user kept drawing on state that
1260
+ // never reached disk, a peer, or a reload (the cloud canvas-writes
1261
+ // RCA). Optimistic local state is still the right UX; a persistence
1262
+ // failure being INVISIBLE is not.
1274
1263
  if (!r.ok) {
1275
- annotationEchoRef.current.forget(writeId);
1264
+ settle();
1276
1265
  console.warn(`[annotations] save refused (${r.status}) — strokes are local-only`);
1266
+ return undefined;
1277
1267
  }
1268
+ const res = (await r.json().catch(() => null)) as {
1269
+ rejected?: Array<{ id?: string; reason: string }>;
1270
+ } | null;
1271
+ if (res?.rejected?.length) {
1272
+ console.warn('[annotations] some changes were not applied', res.rejected);
1273
+ }
1274
+ // Without a replica no echo will come; with one, the echo settles
1275
+ // it — unless the board had nothing to change (no echo either).
1276
+ if (!collabRef.current || res?.rejected?.length) settle();
1277
+ else setTimeout(settle, 10_000);
1278
1278
  return undefined;
1279
1279
  })
1280
1280
  .catch(() => {
1281
- annotationEchoRef.current.forget(writeId);
1281
+ settle();
1282
1282
  /* Pending persistence UX is handled by the project outbox work. */
1283
1283
  });
1284
1284
  const chained = putChainRef.current.then(dispatch, dispatch);
@@ -1286,36 +1286,95 @@ export function AnnotationsLayer() {
1286
1286
  return chained;
1287
1287
  }, []);
1288
1288
 
1289
- // Register the strokes put sink with the undo provider so the rebuilt
1290
- // AnnotationStrokesCommand (after a canvas switch + return) routes through
1291
- // THIS iframe's React state, not the gone iframe's stale closures.
1289
+ /** Apply a batch here (optimistic) and send it. Returns what undoes it. */
1290
+ const applyOpsLocal = useCallback(
1291
+ (ops: readonly AnnotationOp[]): AnnotationOp[] => {
1292
+ if (!ops.length) return [];
1293
+ annotationsChangedRef.current = true;
1294
+ const r = board.apply(ops);
1295
+ void sendOps(ops, true);
1296
+ return r.inverse;
1297
+ },
1298
+ [board, sendOps]
1299
+ );
1300
+
1301
+ // Undo / redo replay an op batch through THIS iframe's store (the stack is
1302
+ // rebuilt from records after a canvas switch — DDR-050).
1292
1303
  useEffect(() => {
1293
- undoSinks.setSink('strokesPutFn', putStrokes);
1294
- return () => undoSinks.setSink('strokesPutFn', undefined);
1295
- }, [undoSinks, putStrokes]);
1304
+ undoSinks.setSink('annotationOpsFn', (ops: readonly AnnotationOp[], file?: string) => {
1305
+ // A record made on another canvas never applies here (security review A5).
1306
+ if (file !== fileRef.current) return [];
1307
+ setPreview(null);
1308
+ return applyOpsLocal(ops);
1309
+ });
1310
+ return () => undoSinks.setSink('annotationOpsFn', undefined);
1311
+ }, [undoSinks, applyOpsLocal]);
1296
1312
 
1297
1313
  /**
1298
- * Single entry point for every stroke mutation. Builds an undo record
1299
- * and pushes onto the stack — `push()` rebuilds the command via the
1300
- * registered `strokesPutFn` sink and calls `cmd.do() = putStrokes(next)`,
1301
- * which both updates local state and PUTs. Cancels any pending debounced
1302
- * save first — DDR-050 gotcha: a queued auto-save flushing AFTER our PUT
1303
- * would race the stack into a stale state.
1314
+ * Single entry point for every stroke mutation: the change from `prev` to
1315
+ * `next` becomes ONE element-op batch (only what the user changed — a
1316
+ * peer's concurrent edit to another element or another field is never
1317
+ * overwritten, and concurrent typing in the same text is merged), applied
1318
+ * here, sent, and recorded as one undo step whose undo is the batch's
1319
+ * inverse (peer-safe: it reverts only fields still holding our values).
1304
1320
  */
1305
1321
  const commitStrokes = useCallback(
1306
1322
  (prev: readonly Stroke[], next: readonly Stroke[], label?: string) => {
1307
- if (saveTimerRef.current) {
1308
- clearTimeout(saveTimerRef.current);
1309
- saveTimerRef.current = null;
1323
+ setPreview(null);
1324
+ // An upload still in flight is not on the board: it leaves the view
1325
+ // when this commit removes it or when its uploaded form lands.
1326
+ if (uploadingRef.current.length) {
1327
+ const before = new Set(prev.map((s) => s.id));
1328
+ const after = new Map(next.map((s) => [s.id, s]));
1329
+ setUploading((list) =>
1330
+ list.filter((u) => {
1331
+ const n = after.get(u.id);
1332
+ if (n) return isEphemeralHref(n);
1333
+ return !before.has(u.id);
1334
+ })
1335
+ );
1310
1336
  }
1311
- const record = buildAnnotationStrokesRecord({
1312
- before: prev,
1313
- after: next,
1314
- ...(label ? { label } : {}),
1315
- });
1316
- void undoStackRef.current.push(record);
1337
+ const persist = (list: readonly Stroke[]) =>
1338
+ list.some(isEphemeralHref) ? list.filter((s) => !isEphemeralHref(s)) : list;
1339
+ const committed = board.committed;
1340
+ const prevMap = strokesToElementMap(persist(prev), committed);
1341
+ const nextMap = strokesToElementMap(persist(next), committed);
1342
+ const ops = diffToOps(prevMap, nextMap);
1343
+ // A text edit session's commit (Task 19): the board merges against what
1344
+ // the session last SENT (its drafts), while undo goes back to the text
1345
+ // before the edit. Drafts never reach undo.
1346
+ const session = commitSessionRef.current;
1347
+ if (session) {
1348
+ commitSessionRef.current = null;
1349
+ sessionCommittedRef.current = true;
1350
+ applyOpsLocal(aimCommitOps(ops, session));
1351
+ const baseMap = strokesToElementMap(persist(withSlotText(prev, session)), committed);
1352
+ const redo = diffToOps(baseMap, nextMap);
1353
+ if (!redo.length) return;
1354
+ // Undo MERGES back (not a strict inverse): a collaborator's typing
1355
+ // that landed in the same text survives the undo of ours.
1356
+ undoStackRef.current.record(
1357
+ buildAnnotationOpsRecord({
1358
+ ops: redo,
1359
+ inverse: diffToOps(nextMap, baseMap),
1360
+ label: label ?? 'edit text',
1361
+ file: fileRef.current,
1362
+ })
1363
+ );
1364
+ return;
1365
+ }
1366
+ if (!ops.length) return;
1367
+ const inverse = applyOpsLocal(ops);
1368
+ undoStackRef.current.record(
1369
+ buildAnnotationOpsRecord({
1370
+ ops,
1371
+ inverse,
1372
+ label: label ?? 'edit annotations',
1373
+ file: fileRef.current,
1374
+ })
1375
+ );
1317
1376
  },
1318
- []
1377
+ [board, applyOpsLocal]
1319
1378
  );
1320
1379
 
1321
1380
  const setStrokes = useCallback(
@@ -1336,8 +1395,13 @@ export function AnnotationsLayer() {
1336
1395
  );
1337
1396
  commitStrokes(prev, next);
1338
1397
  };
1398
+ // Task 20 — an operation on a section acts on its subtree (containment.ts).
1399
+ const containmentNow = () =>
1400
+ new Containment(strokesToElementMap(strokesRef.current, board.committed).values());
1401
+ const withSubtree = (ids: readonly string[]) =>
1402
+ expandForOp(ids, (x) => expandIdsToGroups(x, strokesRef.current), containmentNow());
1339
1403
  const deleteStrokes = (ids: string[]): void => {
1340
- const set = new Set(ids);
1404
+ const set = new Set(withSubtree(ids));
1341
1405
  const prev = strokesRef.current;
1342
1406
  const filtered = prev.filter(
1343
1407
  (s) => !set.has(s.id) && !(s.tool === 'text' && s.anchorId != null && set.has(s.anchorId))
@@ -1354,7 +1418,7 @@ export function AnnotationsLayer() {
1354
1418
  commitStrokes(prev, recomputeBoundArrows(normalizeGroups(filtered)));
1355
1419
  };
1356
1420
  const translateStrokes = (ids: string[], dx: number, dy: number): void => {
1357
- const set = new Set(ids);
1421
+ const set = new Set(withSubtree(ids));
1358
1422
  const prev = strokesRef.current;
1359
1423
  const next = recomputeBoundArrows(
1360
1424
  prev.map((s) => (set.has(s.id) ? translateOne(s, dx, dy) : s))
@@ -1396,11 +1460,12 @@ export function AnnotationsLayer() {
1396
1460
  };
1397
1461
  const duplicateSelection = (ids: readonly string[], dx: number, dy: number): string[] => {
1398
1462
  const prev = strokesRef.current;
1399
- const res = duplicateStrokes(prev, ids, dx, dy);
1463
+ const res = duplicateStrokes(prev, withSubtree(ids), dx, dy);
1400
1464
  if (res.strokes.length === prev.length) return [];
1401
1465
  const added = res.strokes.length - prev.length;
1402
1466
  commitStrokes(prev, res.strokes, `duplicate ${added} stroke${added === 1 ? '' : 's'}`);
1403
- return res.newIds;
1467
+ // Select the copies of what was selected (their contents ride along).
1468
+ return cloneRoots(ids, res);
1404
1469
  };
1405
1470
  const reorderSelection = (ids: readonly string[], op: ZOrderOp): void => {
1406
1471
  const prev = strokesRef.current;
@@ -1414,13 +1479,19 @@ export function AnnotationsLayer() {
1414
1479
  };
1415
1480
  const alignSelection = (ids: readonly string[], edge: AlignEdge): void => {
1416
1481
  const prev = strokesRef.current;
1417
- const next = recomputeBoundArrows(alignStrokes(prev, ids, edge));
1482
+ const c = containmentNow();
1483
+ const next = recomputeBoundArrows(
1484
+ alignStrokes(prev, c.roots(ids), edge, (id) => c.contentsOf(id))
1485
+ );
1418
1486
  if (strokesShallowEqual(prev, next)) return;
1419
1487
  commitStrokes(prev, next, `align ${edge}`);
1420
1488
  };
1421
1489
  const distributeSelection = (ids: readonly string[], axis: DistributeAxis): void => {
1422
1490
  const prev = strokesRef.current;
1423
- const next = recomputeBoundArrows(distributeStrokes(prev, ids, axis));
1491
+ const c = containmentNow();
1492
+ const next = recomputeBoundArrows(
1493
+ distributeStrokes(prev, c.roots(ids), axis, (id) => c.contentsOf(id))
1494
+ );
1424
1495
  if (strokesShallowEqual(prev, next)) return;
1425
1496
  commitStrokes(prev, next, 'distribute');
1426
1497
  };
@@ -1429,19 +1500,27 @@ export function AnnotationsLayer() {
1429
1500
  // mirroring the move-drag's onMove. `commitGesture` closes it as ONE
1430
1501
  // record (no-op when the gesture ended where it started).
1431
1502
  const previewStroke = (id: string, patch: Partial<Stroke>): void => {
1432
- setStrokesState(
1433
- recomputeBoundArrows(
1434
- strokesRef.current.map((s) => (s.id === id ? ({ ...s, ...patch } as Stroke) : s))
1435
- )
1503
+ const next = recomputeBoundArrows(
1504
+ strokesRef.current.map((s) => (s.id === id ? ({ ...s, ...patch } as Stroke) : s))
1436
1505
  );
1506
+ setPreview(next);
1507
+ const moved = next.find((s) => s.id === id);
1508
+ const box = moved ? strokeBBox(moved) : null;
1509
+ if (box) publishGesture({ kind: 'resize', ids: [id], box });
1437
1510
  };
1438
1511
  const commitGesture = (before: readonly Stroke[], label?: string): void => {
1512
+ publishGesture(null);
1439
1513
  const cur = strokesRef.current;
1440
- if (strokesShallowEqual(before, cur)) return;
1514
+ if (strokesShallowEqual(before, cur)) {
1515
+ setPreview(null);
1516
+ return;
1517
+ }
1441
1518
  commitStrokes(before, cur, label);
1442
1519
  };
1443
1520
  return {
1444
1521
  strokes,
1522
+ setBindHint: setBindHintId,
1523
+ setResizeInfo,
1445
1524
  setStrokes,
1446
1525
  updateStroke,
1447
1526
  deleteStrokes,
@@ -1709,7 +1788,7 @@ export function AnnotationsLayer() {
1709
1788
  // drop fires this for every file independently, so two optimistic
1710
1789
  // inserts landing before a render commit must compose against each
1711
1790
  // other rather than each overwriting the other's array snapshot.
1712
- setStrokesState((prev) => [...prev, optimistic]);
1791
+ setUploading((prev) => [...prev, optimistic]);
1713
1792
  void uploadAsset(file).then((res) => {
1714
1793
  if ('path' in res) {
1715
1794
  // Batch-drop fix (see media-commit-chain.ts) — N concurrent
@@ -1732,7 +1811,7 @@ export function AnnotationsLayer() {
1732
1811
  // never committed), so a plain functional updater (not the
1733
1812
  // chain) is enough: it still composes correctly against any
1734
1813
  // chain-driven setStrokesState queued in the same batch.
1735
- setStrokesState((prev) => prev.filter((s) => s.id !== id));
1814
+ setUploading((prev) => prev.filter((s) => s.id !== id));
1736
1815
  deletedStrokeIdsRef.current.delete(id); // never reached the chain — nothing to consume there
1737
1816
  showCanvasToast(`Image upload failed — ${res.error}`, 'error');
1738
1817
  }
@@ -1997,8 +2076,7 @@ export function AnnotationsLayer() {
1997
2076
  // which owns the DOM/contentEditable side, via maude:enter-text-edit).
1998
2077
  const strokeId = findTextStrokeAt(wx, wy, strokesRef.current, vpRef.current?.zoom || 1);
1999
2078
  if (strokeId) {
2000
- setEditCaretPoint({ x: e.clientX, y: e.clientY });
2001
- setEditingId(strokeId);
2079
+ openEditorAt(strokeId, e.clientX, e.clientY);
2002
2080
  if (annotSel) annotSel.replace(strokeId);
2003
2081
  // DDR-223 — back to the MODE's resting tool (move in edit, browse in
2004
2082
  // preview): editing a text annotation shouldn't exit preview.
@@ -2185,8 +2263,22 @@ export function AnnotationsLayer() {
2185
2263
  const committed = final;
2186
2264
  const prev = strokesRef.current;
2187
2265
  // FigJam — sections are CONTAINERS: they slot in at the BACK of the
2188
- // z-order so content placed on them keeps rendering above.
2189
- const next = committed.tool === 'section' ? [committed, ...prev] : [...prev, committed];
2266
+ // z-order so content placed on them keeps rendering above — and a
2267
+ // section drawn INSIDE another goes right above that one (Task 20), so
2268
+ // it renders in front of its outer section and adopts what sits on it.
2269
+ let next: Stroke[];
2270
+ if (committed.tool === 'section') {
2271
+ const bb = strokeBBox(committed);
2272
+ const outer = bb
2273
+ ? new Containment(strokesToElementMap(prev, board.committed).values()).containerAt(
2274
+ bb.x + bb.w / 2,
2275
+ bb.y + bb.h / 2
2276
+ )
2277
+ : null;
2278
+ next = insertSection(prev, committed, outer);
2279
+ } else {
2280
+ next = [...prev, committed];
2281
+ }
2190
2282
  commitStrokes(prev, next, `draw ${committed.tool}`);
2191
2283
  // T18 — auto-select the freshly drawn shape so the user can immediately
2192
2284
  // see + adjust it. annotSel is optional (some test harnesses mount
@@ -2274,6 +2366,29 @@ export function AnnotationsLayer() {
2274
2366
  () => (drawing ? [...strokes, drawing] : strokes),
2275
2367
  [strokes, drawing]
2276
2368
  );
2369
+ // Task 22 — what is being drawn, live for peers (pen ink tail, or the box).
2370
+ const drawPublishedRef = useRef(false);
2371
+ useEffect(() => {
2372
+ if (!drawing) {
2373
+ if (drawPublishedRef.current) publishGesture(null);
2374
+ drawPublishedRef.current = false;
2375
+ return;
2376
+ }
2377
+ drawPublishedRef.current = true;
2378
+ if (drawing.tool === 'pen') {
2379
+ publishGesture({ kind: 'draw', ids: [], points: drawing.points.slice(-256).flat() });
2380
+ return;
2381
+ }
2382
+ const box = strokeBBox(drawing);
2383
+ if (box) publishGesture({ kind: 'draw', ids: [], box });
2384
+ }, [drawing, publishGesture]);
2385
+ // A newer peer's element types: v1 strokes can't hold them, the board still
2386
+ // does — they are drawn as placeholders (DDR-242: never dropped).
2387
+ // biome-ignore lint/correctness/useExhaustiveDependencies: re-read per board version
2388
+ const unknownElements = useMemo(
2389
+ () => [...board.elements.values()].filter((e) => !defOf(e.type)),
2390
+ [board, boardVersion]
2391
+ );
2277
2392
 
2278
2393
  // Phase 24 — the ghost descriptor handed to the SVG layer. Suppressed while a
2279
2394
  // draw is in progress (the real preview takes over) so the two never overlap.
@@ -2292,6 +2407,22 @@ export function AnnotationsLayer() {
2292
2407
  return map;
2293
2408
  }, [strokes]);
2294
2409
 
2410
+ // Task 19 — an element that vanished (a collaborator deleted it, an undo
2411
+ // removed it) leaves the selection, unless its editor is still open. Only
2412
+ // ids that WERE on the board go: a just-created element selected before its
2413
+ // stroke lands must stay selected.
2414
+ const prevStrokeIdsRef = useRef<Set<string>>(new Set());
2415
+ useEffect(() => {
2416
+ const now = new Set(strokes.map((s) => s.id));
2417
+ const before = prevStrokeIdsRef.current;
2418
+ prevStrokeIdsRef.current = now;
2419
+ if (!annotSel || annotSel.selectedIds.length === 0) return;
2420
+ const gone = annotSel.selectedIds.filter(
2421
+ (id) => before.has(id) && !now.has(id) && id !== editingIdRef.current
2422
+ );
2423
+ if (gone.length) annotSel.remove(gone);
2424
+ }, [strokes, annotSel]);
2425
+
2295
2426
  const strokesById = useMemo(() => {
2296
2427
  const map = new Map<string, Stroke>();
2297
2428
  for (const s of strokes) map.set(s.id, s);
@@ -2379,12 +2510,10 @@ export function AnnotationsLayer() {
2379
2510
  return null;
2380
2511
  };
2381
2512
 
2382
- const onDown = (e: PointerEvent) => {
2383
- if (isMediaPlayerTarget(e)) return; // mediaref inline player owns this event
2513
+ const onDown = (e: PointerEvent): Claim => {
2384
2514
  if (e.button !== 0) return;
2385
2515
  if (e.metaKey || e.ctrlKey) return; // escape hatch into element-selection
2386
2516
  const target = e.target as Element | null;
2387
- if (target?.closest?.(CHROME_SELECTOR)) return; // chrome owns its clicks
2388
2517
  const strokeId = findStrokeId(target);
2389
2518
  const [wx, wy] = screenToWorld(e.clientX, e.clientY);
2390
2519
  const startClientX = e.clientX;
@@ -2414,6 +2543,14 @@ export function AnnotationsLayer() {
2414
2543
  // handler); an already-selected stroke keeps the current selection, so
2415
2544
  // a deep-selected member drags alone without re-expanding.
2416
2545
  const members = expandIdsToGroups([strokeId], strokesStoreRef.current.strokes);
2546
+ if (e.shiftKey && members.every((m) => annotSel.contains(m))) {
2547
+ // Shift-click on something already selected takes it OUT of the
2548
+ // selection (FigJam / Figma toggle) and starts no drag.
2549
+ e.preventDefault();
2550
+ e.stopImmediatePropagation();
2551
+ annotSel.remove(members);
2552
+ return;
2553
+ }
2417
2554
  if (e.shiftKey) {
2418
2555
  annotSel.add(members);
2419
2556
  const merged = new Set([...annotSel.selectedIds, ...members]);
@@ -2446,6 +2583,20 @@ export function AnnotationsLayer() {
2446
2583
  if (ids?.length) {
2447
2584
  e.preventDefault();
2448
2585
  e.stopImmediatePropagation();
2586
+ // preventDefault also stops the press from moving focus — so after the
2587
+ // toolbar (its focused button then unmounted: focus on <body>) Esc,
2588
+ // arrows and ⌘-shortcuts never reached the canvas. A press on the
2589
+ // board gives the canvas its keyboard back, as a plain click would.
2590
+ const canvasEl = target?.closest?.('.dc-canvas') as HTMLElement | null;
2591
+ const focused = document.activeElement as HTMLElement | null;
2592
+ if (
2593
+ canvasEl &&
2594
+ (!focused ||
2595
+ !canvasEl.contains(focused) ||
2596
+ focused.closest?.('.dc-annot-ctx, .dc-context-menu, .dc-annot-chrome'))
2597
+ ) {
2598
+ canvasEl.focus({ preventScroll: true });
2599
+ }
2449
2600
  // Capture a snapshot of all strokes at drag start. Every pointermove
2450
2601
  // re-translates FROM the snapshot using the cumulative cursor delta
2451
2602
  // (NOT a delta-from-last-frame mutation), so dragging back to origin
@@ -2460,12 +2611,21 @@ export function AnnotationsLayer() {
2460
2611
  // Alt+click can't silently mint copies. undoBase stays pre-clone, so
2461
2612
  // clone + move commit as one record.
2462
2613
  if (e.altKey) {
2463
- const res = duplicateStrokes(undoBase, ids, 0, 0);
2614
+ // The section's contents are cloned with it and only the CLONES move.
2615
+ const subtree = expandForOp(
2616
+ ids,
2617
+ (x) => expandIdsToGroups(x, undoBase),
2618
+ new Containment(strokesToElementMap(undoBase, board.committed).values())
2619
+ );
2620
+ const res = duplicateStrokes(undoBase, subtree, 0, 0);
2464
2621
  if (res.newIds.length) {
2465
2622
  altDup = true;
2466
2623
  dragSnapshot = res.strokes;
2467
- setStrokesState(res.strokes);
2468
- annotSel.replace(res.newIds);
2624
+ setPreview(res.strokes);
2625
+ // Select the copies of what was selected; drag EVERY clone — the
2626
+ // copied contents move with the copied section by id, not by
2627
+ // geometry (the copy lies exactly on its original).
2628
+ annotSel.replace(cloneRoots(ids, res));
2469
2629
  ids = res.newIds;
2470
2630
  }
2471
2631
  }
@@ -2479,22 +2639,16 @@ export function AnnotationsLayer() {
2479
2639
  altDup,
2480
2640
  };
2481
2641
  const movedSet = new Set(ids);
2482
- // FigJam v3 — dragging a SECTION carries everything sitting on it
2483
- // (bbox-center containment, captured at gesture start).
2484
- for (const s of dragSnapshot) {
2485
- if (s.tool !== 'section' || !movedSet.has(s.id)) continue;
2486
- const sx = Math.min(s.x, s.x + s.w);
2487
- const sy = Math.min(s.y, s.y + s.h);
2488
- const sx2 = sx + Math.abs(s.w);
2489
- const sy2 = sy + Math.abs(s.h);
2490
- for (const t of dragSnapshot) {
2491
- if (movedSet.has(t.id) || t.tool === 'section') continue;
2492
- const bb = strokeBBox(t);
2493
- if (!bb) continue;
2494
- const ccx = bb.x + bb.w / 2;
2495
- const ccy = bb.y + bb.h / 2;
2496
- if (ccx >= sx && ccx <= sx2 && ccy >= sy && ccy <= sy2) movedSet.add(t.id);
2497
- }
2642
+ // A section carries its whole subtree — nested sections and their
2643
+ // contents too (explicit containment, Task 20), captured at start.
2644
+ if (dragSnapshot.some((x) => x.tool === 'section' && movedSet.has(x.id))) {
2645
+ const c = new Containment(
2646
+ strokesToElementMap(
2647
+ dragSnapshot.filter((x) => !isEphemeralHref(x)),
2648
+ board.committed
2649
+ ).values()
2650
+ );
2651
+ for (const id of c.withContents([...movedSet])) movedSet.add(id);
2498
2652
  }
2499
2653
  // FigJam v3 — snap setup, computed ONCE per gesture: candidates are
2500
2654
  // the bboxes of every non-moved stroke plus the artboard rects (in
@@ -2564,25 +2718,22 @@ export function AnnotationsLayer() {
2564
2718
  st.snapshot.map((s) => (movedSet.has(s.id) ? translateOne(s, dx, dy) : s))
2565
2719
  );
2566
2720
  // Local React state only. No commitStrokes — no PUT, no undo push.
2567
- setStrokesState(next);
2721
+ setPreview(next);
2722
+ publishGesture({ kind: 'move', ids: [...movedSet].slice(0, 256), dx, dy });
2568
2723
  };
2569
2724
  const onUp = (up: PointerEvent) => {
2570
2725
  const st = dragStateRef.current;
2571
2726
  if (!st || up.pointerId !== st.pointerId) return;
2572
2727
  dragStateRef.current = null;
2573
2728
  setSnapGuides(null);
2574
- document.removeEventListener('pointermove', onMove, true);
2575
- document.removeEventListener('pointerup', onUp, true);
2576
- document.removeEventListener('pointercancel', onUp, true);
2729
+ publishGesture(null);
2577
2730
  // Commit the gesture as ONE record. Skip on zero-movement
2578
2731
  // (click without drag past threshold or drag back to origin).
2579
2732
  const final = strokesRef.current;
2580
2733
  if (strokesShallowEqual(st.snapshot, final)) {
2581
- if (st.altDup) {
2582
- // Alt+click without a drag — revert the eager clones.
2583
- setStrokesState(st.undoBase);
2584
- annotSel.replace(preAltIds);
2585
- }
2734
+ // Nothing moved (an Alt+click also drops its eager clones).
2735
+ setPreview(null);
2736
+ if (st.altDup) annotSel.replace(preAltIds);
2586
2737
  return;
2587
2738
  }
2588
2739
  commitStrokes(
@@ -2593,10 +2744,12 @@ export function AnnotationsLayer() {
2593
2744
  }`
2594
2745
  );
2595
2746
  };
2596
- document.addEventListener('pointermove', onMove, true);
2597
- document.addEventListener('pointerup', onUp, true);
2598
- document.addEventListener('pointercancel', onUp, true);
2599
- return;
2747
+ return {
2748
+ kind: altDup ? 'duplicating' : 'dragging',
2749
+ move: onMove,
2750
+ up: onUp,
2751
+ cancel: (ev) => onUp(ev ?? ({ pointerId: e.pointerId } as PointerEvent)),
2752
+ };
2600
2753
  }
2601
2754
 
2602
2755
  // Not a stroke / hull-group drag. When pointerdown lands inside an
@@ -2621,10 +2774,7 @@ export function AnnotationsLayer() {
2621
2774
  const [cwx, cwy] = screenToWorld(mv.clientX, mv.clientY);
2622
2775
  setMarquee({ ax: wx, ay: wy, bx: cwx, by: cwy });
2623
2776
  };
2624
- const onUp = (_up: PointerEvent) => {
2625
- document.removeEventListener('pointermove', onMove, true);
2626
- document.removeEventListener('pointerup', onUp, true);
2627
- document.removeEventListener('pointercancel', onUp, true);
2777
+ const onUp = () => {
2628
2778
  if (!moved) {
2629
2779
  // Click without movement on empty world → clear annotation
2630
2780
  // selection (post-Wave-3 user feedback). Shift-click preserves
@@ -2635,22 +2785,15 @@ export function AnnotationsLayer() {
2635
2785
  const final = marqueeRef.current;
2636
2786
  setMarquee(null);
2637
2787
  if (!final) return;
2638
- const xMin = Math.min(final.ax, final.bx);
2639
- const xMax = Math.max(final.ax, final.bx);
2640
- const yMin = Math.min(final.ay, final.by);
2641
- const yMax = Math.max(final.ay, final.by);
2642
- const hits: string[] = [];
2788
+ // Anchored text rides its host (selected with it); a section is taken
2789
+ // only when the marquee encloses it (containment.ts marqueeHits).
2790
+ const items: MarqueeItem[] = [];
2643
2791
  for (const s of strokesStoreRef.current.strokes) {
2644
- // Anchored text inherits its host's bbox (selected with the host);
2645
- // standalone text (Phase 21) has its own synthetic bbox and IS
2646
- // marquee-selectable.
2647
2792
  if (s.tool === 'text' && s.anchorId != null && s.anchorId !== '') continue;
2648
2793
  const bb = strokeBBox(s);
2649
- if (!bb) continue;
2650
- if (bb.x + bb.w >= xMin && bb.x <= xMax && bb.y + bb.h >= yMin && bb.y <= yMax) {
2651
- hits.push(s.id);
2652
- }
2794
+ if (bb) items.push({ id: s.id, box: bb, container: s.tool === 'section' });
2653
2795
  }
2796
+ const hits = marqueeHits(items, { x1: final.ax, y1: final.ay, x2: final.bx, y2: final.by });
2654
2797
  // Marquee that captured no strokes — preserve existing selection.
2655
2798
  if (hits.length === 0) return;
2656
2799
  // FigJam v3 — a marquee touching any group member selects the whole
@@ -2659,17 +2802,14 @@ export function AnnotationsLayer() {
2659
2802
  if (addToSelection) annotSel.add(expanded);
2660
2803
  else annotSel.replace(expanded);
2661
2804
  };
2662
- document.addEventListener('pointermove', onMove, true);
2663
- document.addEventListener('pointerup', onUp, true);
2664
- document.addEventListener('pointercancel', onUp, true);
2805
+ return { kind: 'marquee', move: onMove, up: onUp, cancel: onUp };
2665
2806
  };
2666
2807
 
2667
- document.addEventListener('pointerdown', onDown, true);
2668
- return () => document.removeEventListener('pointerdown', onDown, true);
2808
+ return pipeline.add({ priority: 100, name: 'select', down: onDown });
2669
2809
  // commitStrokes is included defensively (it is a stable useCallback([]) ref,
2670
- // so this never re-binds the listener) to remove the latent stale-closure
2810
+ // so this never re-registers the stage) to remove the latent stale-closure
2671
2811
  // trap flagged in the Phase 24 frontend review.
2672
- }, [tool, annotSel, elementSel, screenToWorld, strokesStore, commitStrokes]);
2812
+ }, [pipeline, tool, annotSel, elementSel, screenToWorld, strokesStore, commitStrokes]);
2673
2813
 
2674
2814
  // Latest marquee + strokes refs for the doc-level pointerup callback
2675
2815
  // (avoids re-binding the listener on every state tick).
@@ -2694,7 +2834,7 @@ export function AnnotationsLayer() {
2694
2834
  if (tool !== 'move') return;
2695
2835
  if (!annotSel) return;
2696
2836
 
2697
- const onDown = (e: PointerEvent) => {
2837
+ const onDown = (e: PointerEvent): Claim => {
2698
2838
  const target = e.target as Element | null;
2699
2839
  const cornerEl = target?.closest?.('[data-group-resize-corner]') ?? null;
2700
2840
  if (!cornerEl) return;
@@ -2736,6 +2876,12 @@ export function AnnotationsLayer() {
2736
2876
  } catch {
2737
2877
  /* some browsers reject capture on synthetic events */
2738
2878
  }
2879
+ return {
2880
+ kind: 'resizing',
2881
+ move: onMove,
2882
+ up: onUp,
2883
+ cancel: (ev) => onUp(ev ?? ({ pointerId: e.pointerId } as PointerEvent)),
2884
+ };
2739
2885
  };
2740
2886
  const onMove = (e: PointerEvent) => {
2741
2887
  const d = groupResizeRef.current;
@@ -2762,29 +2908,25 @@ export function AnnotationsLayer() {
2762
2908
  return patch ? ({ ...s, ...patch } as Stroke) : s;
2763
2909
  })
2764
2910
  );
2765
- setStrokesState(next);
2911
+ setPreview(next);
2912
+ publishGesture({ kind: 'resize', ids: d.ids.slice(0, 256), box: groupB1 });
2766
2913
  };
2767
2914
  const onUp = (e: PointerEvent) => {
2768
2915
  const d = groupResizeRef.current;
2769
2916
  if (!d || e.pointerId !== d.pointerId) return;
2770
2917
  groupResizeRef.current = null;
2918
+ publishGesture(null);
2771
2919
  const final = strokesRef.current;
2772
2920
  // No-op drag (grabbed a handle, released without moving past the
2773
2921
  // resize's own resolution) skips the undo record.
2774
- if (strokesShallowEqual(d.undoBase, final)) return;
2922
+ if (strokesShallowEqual(d.undoBase, final)) {
2923
+ setPreview(null);
2924
+ return;
2925
+ }
2775
2926
  commitStrokes(d.undoBase, final, `resize ${d.ids.length} strokes`);
2776
2927
  };
2777
- document.addEventListener('pointerdown', onDown, true);
2778
- document.addEventListener('pointermove', onMove, true);
2779
- document.addEventListener('pointerup', onUp, true);
2780
- document.addEventListener('pointercancel', onUp, true);
2781
- return () => {
2782
- document.removeEventListener('pointerdown', onDown, true);
2783
- document.removeEventListener('pointermove', onMove, true);
2784
- document.removeEventListener('pointerup', onUp, true);
2785
- document.removeEventListener('pointercancel', onUp, true);
2786
- };
2787
- }, [tool, annotSel, screenToWorld, anchorsById, commitStrokes]);
2928
+ return pipeline.add({ priority: 3, name: 'group-resize', down: onDown });
2929
+ }, [pipeline, tool, annotSel, screenToWorld, anchorsById, commitStrokes]);
2788
2930
 
2789
2931
  // Double-click enters text-edit mode: rect/ellipse (anchored text), sticky
2790
2932
  // (its own body), or a standalone text node (re-edit in place). Anchored text
@@ -2795,8 +2937,22 @@ export function AnnotationsLayer() {
2795
2937
  const onDbl = (e: MouseEvent) => {
2796
2938
  if (isMediaPlayerTarget(e)) return; // mediaref inline player owns this event
2797
2939
  const target = e.target as Element | null;
2798
- const node = target?.closest?.('[data-id][data-tool]');
2940
+ // Inside an open editor a double-click selects a word (native).
2941
+ if (target?.closest?.('[data-annot-editor]')) return;
2942
+ let node = target?.closest?.('[data-id][data-tool]');
2799
2943
  if (!node) return;
2944
+ // A shape's label is its own <text data-anchor-id=host>: a double-click
2945
+ // on the words of a label edits the SHAPE. It used to be skipped here,
2946
+ // so the event fell through to the canvas-shell dblclick→fit() handler
2947
+ // and the view jumped to "fit all" instead of opening the editor.
2948
+ const hostId =
2949
+ node.getAttribute('data-tool') === 'text' ? node.getAttribute('data-anchor-id') : null;
2950
+ if (hostId) {
2951
+ const host = node.ownerDocument?.querySelector?.(
2952
+ `[data-id="${CSS.escape(hostId)}"][data-tool]`
2953
+ );
2954
+ if (host) node = host;
2955
+ }
2800
2956
  const id = node.getAttribute('data-id');
2801
2957
  const t = node.getAttribute('data-tool');
2802
2958
  if (!id) return;
@@ -2817,15 +2973,13 @@ export function AnnotationsLayer() {
2817
2973
  // a shape's text jumped the viewport to fit().
2818
2974
  e.preventDefault();
2819
2975
  e.stopPropagation();
2820
- setEditCaretPoint({ x: e.clientX, y: e.clientY });
2821
- setEditingId(id);
2976
+ openEditorAt(id, e.clientX, e.clientY);
2822
2977
  return;
2823
2978
  }
2824
2979
  if (t === 'text' && !node.getAttribute('data-anchor-id')) {
2825
2980
  e.preventDefault();
2826
2981
  e.stopPropagation();
2827
- setEditCaretPoint({ x: e.clientX, y: e.clientY });
2828
- setEditingId(id);
2982
+ openEditorAt(id, e.clientX, e.clientY);
2829
2983
  }
2830
2984
  };
2831
2985
  document.addEventListener('dblclick', onDbl, true);
@@ -2953,21 +3107,78 @@ export function AnnotationsLayer() {
2953
3107
  // commit call to the right writer. `editingId` doubles as the host id
2954
3108
  // (anchored) OR the sticky/standalone stroke id; `pendingText` is the
2955
3109
  // not-yet-born text caret.
3110
+ // The last resolved target: when a collaborator deletes the element being
3111
+ // edited, the editor stays open on it (Task 19 — never drop typed text).
3112
+ const lastTargetRef = useRef<EditingTarget>(null);
2956
3113
  const editingTarget = useMemo<EditingTarget>(() => {
2957
3114
  if (pendingText) return { kind: 'pending', x: pendingText.x, y: pendingText.y };
2958
3115
  if (!editingId) return null;
3116
+ let t: EditingTarget = null;
2959
3117
  const host = anchorsById.get(editingId);
2960
- if (host) return { kind: 'anchored', anchorId: editingId, host };
2961
3118
  const s = strokesById.get(editingId);
2962
- if (s?.tool === 'sticky') return { kind: 'sticky', sticky: s };
2963
- if (s?.tool === 'section') return { kind: 'section', section: s };
2964
- if (s?.tool === 'text' && (s.anchorId == null || s.anchorId === ''))
2965
- return { kind: 'standalone', text: s };
2966
- return null;
3119
+ if (host) t = { kind: 'anchored', anchorId: editingId, host };
3120
+ else if (s?.tool === 'sticky') t = { kind: 'sticky', sticky: s };
3121
+ else if (s?.tool === 'section') t = { kind: 'section', section: s };
3122
+ else if (s?.tool === 'text' && (s.anchorId == null || s.anchorId === ''))
3123
+ t = { kind: 'standalone', text: s };
3124
+ if (t) {
3125
+ lastTargetRef.current = t;
3126
+ return t;
3127
+ }
3128
+ const last = lastTargetRef.current;
3129
+ return last && editTargetId(last) === editingId ? last : null;
2967
3130
  }, [pendingText, editingId, anchorsById, strokesById]);
3131
+ const editGone = !!editingId && !strokesById.has(editingId);
2968
3132
 
2969
3133
  const editingTargetRef = useRef(editingTarget);
2970
3134
  editingTargetRef.current = editingTarget;
3135
+ const editingIdRef = useRef(editingId);
3136
+ editingIdRef.current = editingId;
3137
+
3138
+ // One edit session per opened element (Task 19). Opened from the board as
3139
+ // last delivered, so its base is what everyone agreed on at that moment.
3140
+ useEffect(() => {
3141
+ textSessionRef.current = null;
3142
+ setRemoteEdited(false);
3143
+ if (!editingId) {
3144
+ sessionElRef.current = null;
3145
+ return;
3146
+ }
3147
+ const el = board.committed.get(editingId);
3148
+ if (el) {
3149
+ textSessionRef.current = openSession(el);
3150
+ sessionElRef.current = el;
3151
+ }
3152
+ }, [editingId]);
3153
+ if (editingId) {
3154
+ const el = board.committed.get(editingId);
3155
+ if (el) sessionElRef.current = el;
3156
+ }
3157
+
3158
+ /** The session for `id`, opened lazily (an element born a moment ago). */
3159
+ const sessionFor = useCallback((id: string): TextSession | null => {
3160
+ const cur = textSessionRef.current;
3161
+ if (cur && cur.id === id) return cur;
3162
+ const el = board.committed.get(id);
3163
+ const next = el ? openSession(el) : null;
3164
+ textSessionRef.current = next;
3165
+ return next;
3166
+ }, []);
3167
+
3168
+ /** Idle-typing draft: the board (and every peer) sees the text so far. Never undo. */
3169
+ const draftEditing = useCallback(
3170
+ (text: string) => {
3171
+ const id = editingIdRef.current;
3172
+ if (!id) return;
3173
+ const session = sessionFor(id);
3174
+ if (!session) return;
3175
+ const op = draftOp(session, board.committed.get(id), text);
3176
+ if (!op) return;
3177
+ void sendOps([op]);
3178
+ textSessionRef.current = markSent(session, text);
3179
+ },
3180
+ [sessionFor, sendOps]
3181
+ );
2971
3182
 
2972
3183
  const commitEditing = useCallback(
2973
3184
  (text: string, fmt?: EditorFmt, measuredH?: number) => {
@@ -2976,19 +3187,62 @@ export function AnnotationsLayer() {
2976
3187
  setPendingText(null);
2977
3188
  setEditCaretPoint(null);
2978
3189
  if (!target) return;
2979
- if (target.kind === 'anchored') commitText(target.anchorId, text, fmt);
2980
- else if (target.kind === 'sticky') commitStickyText(target.sticky.id, text, fmt, measuredH);
2981
- else if (target.kind === 'standalone') commitStandaloneText(target.text.id, text, fmt);
2982
- else if (target.kind === 'section') {
2983
- const label = text.trim().replace(/\s*\n+\s*/g, ' ') || 'Section';
2984
- if (label !== target.section.label) {
3190
+ if (target.kind === 'pending') {
3191
+ createStandaloneText(target.x, target.y, text, fmt);
3192
+ return;
3193
+ }
3194
+ const id = editTargetId(target) as string;
3195
+ const session = sessionFor(id);
3196
+ const stored = storedSlotText(target.kind, text);
3197
+ // A collaborator deleted it while we typed: keep the text by putting the
3198
+ // element back (same id) — unless the text is empty anyway.
3199
+ if (!strokesRef.current.some((s) => s.id === id)) {
3200
+ const lastKnown = sessionElRef.current;
3201
+ if (!session || !lastKnown || !stored.trim()) return;
3202
+ const parentExists = !!lastKnown.parent && board.committed.has(lastKnown.parent);
3203
+ const bb = strokeBBox(editTargetStroke(target));
3204
+ applyOpsLocal([
3205
+ restoreOp(
3206
+ session,
3207
+ lastKnown,
3208
+ stored,
3209
+ parentExists,
3210
+ bb ? { x: bb.x, y: bb.y } : undefined
3211
+ ),
3212
+ ]);
3213
+ return;
3214
+ }
3215
+ commitSessionRef.current = session;
3216
+ sessionCommittedRef.current = false;
3217
+ try {
3218
+ if (target.kind === 'anchored') commitText(target.anchorId, text, fmt);
3219
+ else if (target.kind === 'sticky') commitStickyText(target.sticky.id, text, fmt, measuredH);
3220
+ else if (target.kind === 'standalone') commitStandaloneText(target.text.id, text, fmt);
3221
+ else if (target.kind === 'section' && stored !== target.section.label) {
2985
3222
  strokesStoreRef.current.updateStroke(target.section.id, {
2986
- label,
3223
+ label: stored,
2987
3224
  } as Partial<Stroke>);
2988
3225
  }
2989
- } else if (target.kind === 'pending') createStandaloneText(target.x, target.y, text, fmt);
3226
+ } finally {
3227
+ commitSessionRef.current = null;
3228
+ }
3229
+ // No change to commit — but drafts may have moved the board: bring it
3230
+ // back to the text the element keeps (no undo step: nothing changed).
3231
+ if (session && !sessionCommittedRef.current) {
3232
+ const op = draftOp(session, board.committed.get(id), stored);
3233
+ if (op) applyOpsLocal([op]);
3234
+ }
3235
+ textSessionRef.current = null;
2990
3236
  },
2991
- [commitText, commitStickyText, commitStandaloneText, createStandaloneText]
3237
+ [
3238
+ commitText,
3239
+ commitStickyText,
3240
+ commitStandaloneText,
3241
+ createStandaloneText,
3242
+ sessionFor,
3243
+ applyOpsLocal,
3244
+ board,
3245
+ ]
2992
3246
  );
2993
3247
 
2994
3248
  const cancelEditing = useCallback(() => {
@@ -2999,7 +3253,7 @@ export function AnnotationsLayer() {
2999
3253
 
3000
3254
  /**
3001
3255
  * FigJam v3 — copy/cut the (expanded) selection to the OS clipboard as a
3002
- * `{"maudeStrokes":1}` JSON text payload. Shared by ⌘C/⌘X and the
3256
+ * `{"maudeElements":2}` JSON text payload (v2 elements, world coordinates). Shared by ⌘C/⌘X and the
3003
3257
  * right-click menu. Returns true when something was copied.
3004
3258
  */
3005
3259
  const copySelection = useCallback(
@@ -3008,7 +3262,13 @@ export function AnnotationsLayer() {
3008
3262
  const sel = annotSel.selectedIds;
3009
3263
  if (sel.length === 0) return false;
3010
3264
  const store = strokesStoreRef.current;
3011
- const expanded = new Set(expandIdsToGroups(sel, store.strokes));
3265
+ const expanded = new Set(
3266
+ expandForOp(
3267
+ sel,
3268
+ (x) => expandIdsToGroups(x, store.strokes),
3269
+ new Containment(strokesToElementMap(store.strokes, board.committed).values())
3270
+ )
3271
+ );
3012
3272
  const payload = store.strokes.filter(
3013
3273
  (s) =>
3014
3274
  expanded.has(s.id) ||
@@ -3017,7 +3277,12 @@ export function AnnotationsLayer() {
3017
3277
  if (payload.length === 0) return false;
3018
3278
  try {
3019
3279
  void navigator.clipboard
3020
- ?.writeText(JSON.stringify({ maudeStrokes: 1, strokes: payload }))
3280
+ ?.writeText(
3281
+ JSON.stringify({
3282
+ maudeElements: 2,
3283
+ elements: v1ToV2(payload, { flat: true }).elements,
3284
+ })
3285
+ )
3021
3286
  .catch(() => {
3022
3287
  /* clipboard permission denied — copy is best-effort */
3023
3288
  });
@@ -3034,24 +3299,45 @@ export function AnnotationsLayer() {
3034
3299
  );
3035
3300
 
3036
3301
  /**
3037
- * FigJam v3 — paste a strokes JSON payload (⌘V or the right-click menu).
3038
- * Round-trips through the serializer + parser so a malformed foreign payload
3039
- * coerces to valid strokes or drops; clones get fresh ids + a +16/+16 offset.
3302
+ * FigJam v3 — paste a board clipboard payload (⌘V or the right-click menu).
3303
+ * Every element is validated like a peer's (DDR-242 — foreign JSON is
3304
+ * untrusted); a malformed one is dropped. Clones get fresh ids and a
3305
+ * +16/+16 offset. A pre-v2 `{"maudeStrokes":1}` payload is upconverted.
3040
3306
  */
3041
3307
  const pasteStrokesText = useCallback(
3042
3308
  (txt: string): boolean => {
3043
- if (!annotSel) return false;
3044
- if (!txt.startsWith('{"maudeStrokes"')) return false;
3045
- let parsed: { maudeStrokes?: number; strokes?: unknown } | null = null;
3046
- try {
3047
- parsed = JSON.parse(txt) as { maudeStrokes?: number; strokes?: unknown };
3048
- } catch {
3049
- return false;
3050
- }
3051
- if (parsed?.maudeStrokes !== 1 || !Array.isArray(parsed.strokes)) return false;
3309
+ if (!annotSel || !isBoardClipboard(txt)) return false;
3052
3310
  let safe: Stroke[] = [];
3053
3311
  try {
3054
- safe = svgToStrokes(strokesToSvg(parsed.strokes as Stroke[]));
3312
+ const parsed = JSON.parse(txt) as {
3313
+ maudeElements?: number;
3314
+ elements?: unknown;
3315
+ maudeStrokes?: number;
3316
+ strokes?: unknown;
3317
+ };
3318
+ const raw =
3319
+ parsed.maudeElements === 2 && Array.isArray(parsed.elements)
3320
+ ? parsed.elements.slice(0, MAX_ELEMENTS)
3321
+ : parsed.maudeStrokes === 1 && Array.isArray(parsed.strokes)
3322
+ ? v1ToV2((parsed.strokes as Stroke[]).slice(0, MAX_ELEMENTS), { flat: true }).elements
3323
+ : null;
3324
+ if (!raw) return false;
3325
+ // What lands is THIS user's paste: it carries their authorship, never a
3326
+ // name the clipboard claims, and a link card's domain comes from its
3327
+ // url (a pastejacked card must not read "accounts.google.com" while
3328
+ // pointing elsewhere — security review A3).
3329
+ const me = collabRef.current?.myName;
3330
+ const author = me
3331
+ ? { kind: 'human', name: me, id: collabRef.current?.myConnId }
3332
+ : undefined;
3333
+ const mine = validateElements(raw).elements.map((el) => {
3334
+ const out: Record<string, unknown> = { ...el };
3335
+ delete out.author;
3336
+ if (author) out.author = author;
3337
+ if (el.type === 'link' && typeof el.url === 'string') out.domain = linkDomain(el.url);
3338
+ return out;
3339
+ });
3340
+ safe = elementsToStrokes(validateElements(mine).elements);
3055
3341
  } catch {
3056
3342
  return false;
3057
3343
  }
@@ -3153,53 +3439,14 @@ export function AnnotationsLayer() {
3153
3439
  );
3154
3440
 
3155
3441
  // FigJam v3 — ⌘Enter pressed INSIDE a sticky/anchored editor commits there
3156
- // and asks the layer (via this event) to chain the next sibling. Deferred a
3157
- // tick so the editor's commit lands in strokesRef first.
3158
- useEffect(() => {
3159
- if (typeof document === 'undefined') return;
3160
- const onChain = (e: Event) => {
3161
- const id = (e as CustomEvent<{ id?: string }>).detail?.id;
3162
- if (!id) return;
3163
- window.setTimeout(() => {
3164
- chainCreate(id);
3165
- }, 0);
3166
- };
3167
- document.addEventListener('maude:chain-create', onChain);
3168
- return () => document.removeEventListener('maude:chain-create', onChain);
3169
- }, [chainCreate]);
3170
-
3171
- // FigJam v3 — the resize overlay (a sibling component that owns the arrow
3172
- // endpoint handles) broadcasts the bind candidate while an endpoint drags;
3173
- // the halo renders here because the SVG layer owns the world overlay.
3174
- useEffect(() => {
3175
- if (typeof document === 'undefined') return;
3176
- const onHint = (e: Event) => {
3177
- setBindHintId((e as CustomEvent<{ hostId?: string | null }>).detail?.hostId ?? null);
3178
- };
3179
- document.addEventListener('maude:bind-hint', onHint);
3180
- return () => document.removeEventListener('maude:bind-hint', onHint);
3181
- }, []);
3182
-
3183
- // FigJam v3 — live size label + dimension-match halos while resizing (the
3184
- // overlay broadcasts; the SVG layer paints).
3185
- const [resizeInfo, setResizeInfo] = useState<{
3186
- box: { x: number; y: number; w: number; h: number } | null;
3187
- matchIds: string[];
3188
- } | null>(null);
3189
- useEffect(() => {
3190
- if (typeof document === 'undefined') return;
3191
- const onInfo = (e: Event) => {
3192
- const detail = (
3193
- e as CustomEvent<{
3194
- box?: { x: number; y: number; w: number; h: number } | null;
3195
- matchIds?: string[];
3196
- }>
3197
- ).detail;
3198
- setResizeInfo(detail?.box ? { box: detail.box, matchIds: detail.matchIds ?? [] } : null);
3199
- };
3200
- document.addEventListener('maude:resize-info', onInfo);
3201
- return () => document.removeEventListener('maude:resize-info', onInfo);
3202
- }, []);
3442
+ // and chains the next sibling. Deferred a tick so the editor's commit lands
3443
+ // in strokesRef (React state) before the chain reads it.
3444
+ const chainAfterCommit = useCallback(
3445
+ (id: string) => {
3446
+ window.setTimeout(() => chainCreate(id), 0);
3447
+ },
3448
+ [chainCreate]
3449
+ );
3203
3450
 
3204
3451
  // FigJam v3 — manipulation shortcuts: ⌘G group / ⌘⇧G ungroup, ⌘D duplicate,
3205
3452
  // ] [ ⌘] ⌘[ z-order, ⌘C/⌘X copy/cut (selection → OS clipboard as a JSON
@@ -3286,7 +3533,7 @@ export function AnnotationsLayer() {
3286
3533
  const onPaste = (e: ClipboardEvent) => {
3287
3534
  if (isEditable(e.target)) return;
3288
3535
  const txt = e.clipboardData?.getData('text/plain') ?? '';
3289
- if (!txt.startsWith('{"maudeStrokes"')) return;
3536
+ if (!isBoardClipboard(txt)) return;
3290
3537
  e.preventDefault();
3291
3538
  e.stopImmediatePropagation();
3292
3539
  pasteStrokesText(txt);
@@ -3380,7 +3627,7 @@ export function AnnotationsLayer() {
3380
3627
  useEffect(() => {
3381
3628
  if (typeof document === 'undefined') return;
3382
3629
  if (tool !== 'move') return;
3383
- const onDown = (e: PointerEvent) => {
3630
+ const onDown = (e: PointerEvent): Claim => {
3384
3631
  if (isMediaPlayerTarget(e)) return; // mediaref inline player owns this event
3385
3632
  if (e.button !== 0) return;
3386
3633
  const dot = (e.target as Element | null)?.closest?.('.dc-annot-conn-dot');
@@ -3424,9 +3671,6 @@ export function AnnotationsLayer() {
3424
3671
  };
3425
3672
  const onUp = (up: PointerEvent) => {
3426
3673
  if (up.pointerId !== pointerId) return;
3427
- document.removeEventListener('pointermove', onMove, true);
3428
- document.removeEventListener('pointerup', onUp, true);
3429
- document.removeEventListener('pointercancel', onUp, true);
3430
3674
  const draft = connDraftRef.current;
3431
3675
  setConnDraft(null);
3432
3676
  setBindHintId(null);
@@ -3456,13 +3700,15 @@ export function AnnotationsLayer() {
3456
3700
  );
3457
3701
  }
3458
3702
  };
3459
- document.addEventListener('pointermove', onMove, true);
3460
- document.addEventListener('pointerup', onUp, true);
3461
- document.addEventListener('pointercancel', onUp, true);
3703
+ return {
3704
+ kind: 'connecting',
3705
+ move: onMove,
3706
+ up: onUp,
3707
+ cancel: (ev) => onUp(ev ?? ({ pointerId } as PointerEvent)),
3708
+ };
3462
3709
  };
3463
- document.addEventListener('pointerdown', onDown, true);
3464
- return () => document.removeEventListener('pointerdown', onDown, true);
3465
- }, [tool, screenToWorld, commitStrokes, annotSel, theme]);
3710
+ return pipeline.add({ priority: 2, name: 'connector', down: onDown });
3711
+ }, [pipeline, tool, screenToWorld, commitStrokes, annotSel, theme]);
3466
3712
 
3467
3713
  // FigJam v3 — right-click on a stroke SELECTS it (keeping a multi-selection
3468
3714
  // the press lands inside) and opens the annotation context menu (z-order,
@@ -3496,19 +3742,20 @@ export function AnnotationsLayer() {
3496
3742
  // capture listener fires before the router's host-capture one and stops
3497
3743
  // propagation WITHOUT preventDefault, so the native contextmenu event
3498
3744
  // (which opens OUR menu above) still follows.
3499
- const onDown = (e: PointerEvent) => {
3500
- if (isMediaPlayerTarget(e)) return; // mediaref inline player owns this event
3745
+ const onDown = (e: PointerEvent): Claim => {
3746
+ if (isMediaPlayerTarget(e)) return;
3501
3747
  if (e.button !== 2) return;
3502
3748
  if (!strokeAt(e.target as Element | null)) return;
3503
3749
  e.stopImmediatePropagation();
3750
+ return 'pass';
3504
3751
  };
3505
3752
  document.addEventListener('contextmenu', onCtx, true);
3506
- document.addEventListener('pointerdown', onDown, true);
3753
+ const unstage = pipeline.add({ priority: 1, name: 'context-menu', down: onDown });
3507
3754
  return () => {
3508
3755
  document.removeEventListener('contextmenu', onCtx, true);
3509
- document.removeEventListener('pointerdown', onDown, true);
3756
+ unstage();
3510
3757
  };
3511
- }, [tool, annotSel]);
3758
+ }, [pipeline, tool, annotSel]);
3512
3759
 
3513
3760
  // Keyboard: arrow nudge + Backspace/Delete remove selected strokes.
3514
3761
  useEffect(() => {
@@ -3678,92 +3925,99 @@ export function AnnotationsLayer() {
3678
3925
  }, [annotSel, strokesById]);
3679
3926
 
3680
3927
  return (
3681
- <StrokesStoreContext.Provider value={strokesStore}>
3682
- <AnnotationsInput
3683
- isActive={isActive}
3684
- visible={visible}
3685
- cursor={tools.find((t) => t.id === tool)?.cursor ?? 'crosshair'}
3686
- beginStroke={beginStroke}
3687
- moveStroke={moveStroke}
3688
- endStroke={endStroke}
3689
- onLeave={() => setGhost(null)}
3690
- />
3691
- {visible ? (
3692
- <AnnotationsSvg
3693
- worldRef={worldRef}
3694
- strokes={renderStrokes}
3695
- anchorsById={anchorsById}
3696
- selectMode={tool === 'move'}
3697
- selectedStrokes={selectedStrokes}
3698
- marquee={marquee}
3699
- snapGuides={snapGuides}
3700
- bindHintId={bindHintId}
3701
- resizeInfo={resizeInfo}
3702
- connDraft={connDraft}
3703
- addTextHintId={editingTarget ? null : addTextHintId}
3704
- ghost={ghostPreview}
3705
- editingTarget={editingTarget}
3706
- editCaretPoint={editCaretPoint}
3707
- inkColor={color}
3708
- onCommitEdit={commitEditing}
3709
- onCancelEdit={cancelEditing}
3928
+ <AnnotationPipelineContext.Provider value={pipeline}>
3929
+ <StrokesStoreContext.Provider value={strokesStore}>
3930
+ <AnnotationsInput
3931
+ isActive={isActive}
3932
+ visible={visible}
3933
+ cursor={tools.find((t) => t.id === tool)?.cursor ?? 'crosshair'}
3934
+ beginStroke={beginStroke}
3935
+ moveStroke={moveStroke}
3936
+ endStroke={endStroke}
3937
+ onLeave={() => setGhost(null)}
3710
3938
  />
3711
- ) : null}
3712
- {/* DDR-150 dogfood #8 — inline players for media-reference chips (HTML
3939
+ {visible ? (
3940
+ <AnnotationsSvg
3941
+ worldRef={worldRef}
3942
+ strokes={renderStrokes}
3943
+ anchorsById={anchorsById}
3944
+ selectMode={tool === 'move'}
3945
+ selectedStrokes={selectedStrokes}
3946
+ marquee={marquee}
3947
+ snapGuides={snapGuides}
3948
+ bindHintId={bindHintId}
3949
+ resizeInfo={resizeInfo}
3950
+ connDraft={connDraft}
3951
+ addTextHintId={editingTarget ? null : addTextHintId}
3952
+ ghost={ghostPreview}
3953
+ editingTarget={editingTarget}
3954
+ editCaretPoint={editCaretPoint}
3955
+ inkColor={color}
3956
+ onCommitEdit={commitEditing}
3957
+ onCancelEdit={cancelEditing}
3958
+ onDraftEdit={draftEditing}
3959
+ onChainEdit={chainAfterCommit}
3960
+ gestureState={gestureState}
3961
+ editNotice={editGone ? 'deleted' : remoteEdited ? 'edited' : null}
3962
+ unknownElements={unknownElements}
3963
+ />
3964
+ ) : null}
3965
+ {/* DDR-150 dogfood #8 — inline players for media-reference chips (HTML
3713
3966
  overlay in the world div; see MediaRefPlayers for why not
3714
3967
  foreignObject). */}
3715
- <MediaRefPlayers worldRef={worldRef} strokes={renderStrokes} visible={visible} />
3716
- <AnnotationContextToolbar
3717
- editingId={
3718
- editingTarget?.kind === 'anchored'
3719
- ? editingTarget.anchorId
3720
- : editingTarget?.kind === 'sticky'
3721
- ? editingTarget.sticky.id
3722
- : editingTarget?.kind === 'standalone'
3723
- ? editingTarget.text.id
3724
- : null
3725
- }
3726
- />
3727
- {ctxMenu && annotSel ? (
3728
- <AnnotationContextMenu
3729
- pos={ctxMenu}
3730
- selCount={annotSel.selectedIds.length}
3731
- canUngroup={selectedStrokes.some((s) => (s.groupIds?.length ?? 0) > 0)}
3732
- canReplace={
3733
- selectedStrokes.length === 1 &&
3734
- (selectedStrokes[0]?.tool === 'image' || selectedStrokes[0]?.tool === 'mediaref')
3968
+ <MediaRefPlayers worldRef={worldRef} strokes={renderStrokes} visible={visible} />
3969
+ <AnnotationContextToolbar
3970
+ editingId={
3971
+ editingTarget?.kind === 'anchored'
3972
+ ? editingTarget.anchorId
3973
+ : editingTarget?.kind === 'sticky'
3974
+ ? editingTarget.sticky.id
3975
+ : editingTarget?.kind === 'standalone'
3976
+ ? editingTarget.text.id
3977
+ : null
3735
3978
  }
3736
- canEditPhoto={
3737
- selectedStrokes.length === 1 &&
3738
- selectedStrokes[0]?.tool === 'image' &&
3739
- /assets\/[0-9a-f]{8}\.[a-z0-9]+/i.test((selectedStrokes[0] as ImageStroke).href || '')
3740
- }
3741
- onAction={onMenuAction}
3742
- onClose={() => setCtxMenu(null)}
3743
- />
3744
- ) : null}
3745
- {visible && tool === 'move' ? <AnnotationResizeOverlay store={strokesStore} /> : null}
3746
- {isActive ? (
3747
- <AnnotationsChrome
3748
- tool={tool}
3749
- theme={theme}
3750
- color={color}
3751
- setColor={setColor}
3752
- stickyColor={stickyColor}
3753
- setStickyColor={setStickyColor}
3754
- highlighterColor={highlighterColor}
3755
- setHighlighterColor={setHighlighterColor}
3756
- highlighterWidth={highlighterWidth}
3757
- setHighlighterWidth={setHighlighterWidth}
3758
- supportsFill={supportsFill}
3759
- fill={fill}
3760
- setFill={setFill}
3761
- supportsThickness={supportsThickness}
3762
- thickness={thickness}
3763
- setThickness={setThickness}
3764
3979
  />
3765
- ) : null}
3766
- </StrokesStoreContext.Provider>
3980
+ {ctxMenu && annotSel ? (
3981
+ <AnnotationContextMenu
3982
+ pos={ctxMenu}
3983
+ selCount={annotSel.selectedIds.length}
3984
+ canUngroup={selectedStrokes.some((s) => (s.groupIds?.length ?? 0) > 0)}
3985
+ canReplace={
3986
+ selectedStrokes.length === 1 &&
3987
+ (selectedStrokes[0]?.tool === 'image' || selectedStrokes[0]?.tool === 'mediaref')
3988
+ }
3989
+ canEditPhoto={
3990
+ selectedStrokes.length === 1 &&
3991
+ selectedStrokes[0]?.tool === 'image' &&
3992
+ /assets\/[0-9a-f]{8}\.[a-z0-9]+/i.test((selectedStrokes[0] as ImageStroke).href || '')
3993
+ }
3994
+ onAction={onMenuAction}
3995
+ onClose={() => setCtxMenu(null)}
3996
+ />
3997
+ ) : null}
3998
+ {visible && tool === 'move' ? <AnnotationResizeOverlay store={strokesStore} /> : null}
3999
+ {isActive ? (
4000
+ <AnnotationsChrome
4001
+ tool={tool}
4002
+ theme={theme}
4003
+ color={color}
4004
+ setColor={setColor}
4005
+ stickyColor={stickyColor}
4006
+ setStickyColor={setStickyColor}
4007
+ highlighterColor={highlighterColor}
4008
+ setHighlighterColor={setHighlighterColor}
4009
+ highlighterWidth={highlighterWidth}
4010
+ setHighlighterWidth={setHighlighterWidth}
4011
+ supportsFill={supportsFill}
4012
+ fill={fill}
4013
+ setFill={setFill}
4014
+ supportsThickness={supportsThickness}
4015
+ thickness={thickness}
4016
+ setThickness={setThickness}
4017
+ />
4018
+ ) : null}
4019
+ </StrokesStoreContext.Provider>
4020
+ </AnnotationPipelineContext.Provider>
3767
4021
  );
3768
4022
  }
3769
4023
  AnnotationsLayer.displayName = 'AnnotationsLayer';
@@ -3869,9 +4123,24 @@ function AnnotationsSvg({
3869
4123
  inkColor,
3870
4124
  onCommitEdit,
3871
4125
  onCancelEdit,
4126
+ onDraftEdit,
4127
+ onChainEdit,
4128
+ editNotice,
4129
+ gestureState,
4130
+ unknownElements,
3872
4131
  }: {
3873
4132
  worldRef: ReturnType<typeof useWorldRefContext>;
3874
4133
  strokes: readonly Stroke[];
4134
+ /** Elements of a type this build does not know — drawn as placeholders, never dropped. */
4135
+ unknownElements: readonly AnnotationElement[];
4136
+ /** Idle-typing draft of the open editor (Task 19). */
4137
+ onDraftEdit: (text: string) => void;
4138
+ /** ⌘Enter in a sticky / shape label: spawn the next sibling. */
4139
+ onChainEdit: (id: string) => void;
4140
+ /** The pointer pipeline's state (`idle`, `dragging`, `marquee`, …) — a tooling hook. */
4141
+ gestureState: string;
4142
+ /** A collaborator edited or deleted the element under the open editor. */
4143
+ editNotice: 'edited' | 'deleted' | null;
3875
4144
  anchorsById: Map<string, AnchorHost>;
3876
4145
  selectMode: boolean;
3877
4146
  selectedStrokes: readonly Stroke[];
@@ -3905,41 +4174,102 @@ function AnnotationsSvg({
3905
4174
  onCommitEdit: (text: string, fmt?: EditorFmt, measuredH?: number) => void;
3906
4175
  onCancelEdit: () => void;
3907
4176
  }) {
4177
+ const annotSel = useAnnotationSelectionOptional();
3908
4178
  const [, force] = useState({});
3909
4179
  useEffect(() => {
3910
4180
  if (worldRef?.current) return;
3911
4181
  const id = setTimeout(() => force({}), 0);
3912
4182
  return () => clearTimeout(id);
3913
4183
  }, [worldRef]);
4184
+ // DDR-242 AD7/AD8 — one node per element, text as HTML (annotations/ui/).
4185
+ const baseItems = useMemo(
4186
+ () => renderItemsFromStrokes(strokes, unknownElements),
4187
+ [strokes, unknownElements]
4188
+ );
4189
+ // The element whose text slot is open: a shape's label edits on the shape,
4190
+ // everything else on itself; `pending` is a caret not yet backed by an element.
4191
+ const editingId =
4192
+ editingTarget?.kind === 'anchored'
4193
+ ? editingTarget.anchorId
4194
+ : editingTarget?.kind === 'sticky'
4195
+ ? editingTarget.sticky.id
4196
+ : editingTarget?.kind === 'standalone'
4197
+ ? editingTarget.text.id
4198
+ : editingTarget?.kind === 'section'
4199
+ ? editingTarget.section.id
4200
+ : null;
4201
+ const pending =
4202
+ editingTarget?.kind === 'pending'
4203
+ ? { x: editingTarget.x, y: editingTarget.y, color: inkColor, fontSize: DEFAULT_FONT_SIZE }
4204
+ : null;
4205
+ const sessionKey = editingTarget
4206
+ ? `${editingTarget.kind}:${editingId ?? `${pending?.x},${pending?.y}`}`
4207
+ : null;
4208
+ // An element deleted by a collaborator mid-edit keeps drawing (its last
4209
+ // known form) while its editor is open, so the typed text is never lost.
4210
+ const lastEditItemRef = useRef<RenderItem | null>(null);
4211
+ let items = baseItems;
4212
+ if (editingId) {
4213
+ const live = baseItems.find((it) => it.el.id === editingId);
4214
+ if (live) lastEditItemRef.current = live;
4215
+ else if (lastEditItemRef.current?.el.id === editingId) {
4216
+ items = [...baseItems, lastEditItemRef.current];
4217
+ }
4218
+ }
4219
+ const draftRef = useRef(onDraftEdit);
4220
+ draftRef.current = onDraftEdit;
4221
+ const chainRef = useRef(onChainEdit);
4222
+ chainRef.current = onChainEdit;
4223
+ const commitRef = useRef(onCommitEdit);
4224
+ commitRef.current = onCommitEdit;
4225
+ const cancelRef = useRef(onCancelEdit);
4226
+ cancelRef.current = onCancelEdit;
4227
+ const chainKindRef = useRef<string | null>(null);
4228
+ chainKindRef.current = editingTarget?.kind ?? null;
4229
+ // One request object per edit session, so only the edited node re-renders.
4230
+ // biome-ignore lint/correctness/useExhaustiveDependencies: keyed on the session, callbacks via refs
4231
+ const edit = useMemo<EditRequest | null>(() => {
4232
+ if (!sessionKey) return null;
4233
+ const id = editingId;
4234
+ return {
4235
+ caretPoint: editCaretPoint,
4236
+ notice: editNotice,
4237
+ onDraft: pending ? undefined : (text: string) => draftRef.current(text),
4238
+ onCommit: (info) => {
4239
+ commitRef.current(info.text, info.fmt, info.measured.h);
4240
+ // ⌘Enter in a sticky or a shape label also spawns the next sibling.
4241
+ const kind = chainKindRef.current;
4242
+ if (info.chain && id && (kind === 'sticky' || kind === 'anchored')) {
4243
+ chainRef.current(id);
4244
+ }
4245
+ },
4246
+ onCancel: () => cancelRef.current(),
4247
+ };
4248
+ }, [sessionKey, editNotice]);
3914
4249
  const target = worldRef?.current ?? null;
3915
4250
  if (!target) return null;
3916
- // A sticky whose body is being edited hides its read-only text so the
3917
- // editor textarea (rendered below at the same bbox) isn't double-painted.
3918
- const editingStickyId = editingTarget?.kind === 'sticky' ? editingTarget.sticky.id : null;
3919
- // Same double-paint issue for a section being renamed — its label chip must
3920
- // hide while the StandaloneTextEditor sits at the same spot, else the old
3921
- // label reads through behind the new text.
3922
- const editingSectionId = editingTarget?.kind === 'section' ? editingTarget.section.id : null;
3923
- const anchoredExisting =
3924
- editingTarget?.kind === 'anchored'
3925
- ? (strokes.find((s) => s.tool === 'text' && s.anchorId === editingTarget.anchorId) as
3926
- | TextStroke
3927
- | undefined)
3928
- : undefined;
3929
- // Same double-paint issue for a shape's anchored text — hide the read-only
3930
- // <text> stroke while TextEditor sits at the same bbox (Phase 1 jump-fix
3931
- // companion). Only applies once the TextStroke exists; a not-yet-created
3932
- // one has nothing to hide.
3933
- const editingAnchoredTextId = anchoredExisting?.id ?? null;
3934
- // A standalone text being re-edited hides its read-only <text> too — the
3935
- // editor paints at the same x/y, so leaving it visible double-paints (the
3936
- // pre-Phase-2 "ghost" under the editor).
3937
- const editingStandaloneTextId =
3938
- editingTarget?.kind === 'standalone' ? editingTarget.text.id : null;
3939
4251
  return (
3940
4252
  <>
3941
4253
  {createPortal(
3942
- <svg className="dc-annot-svg" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
4254
+ <AnnotationScene
4255
+ items={items}
4256
+ interactive={selectMode}
4257
+ editingId={editingId}
4258
+ edit={edit}
4259
+ pending={pending}
4260
+ resolveAsset={resolveAssetHref}
4261
+ />,
4262
+ target
4263
+ )}
4264
+ {createPortal(
4265
+ <svg
4266
+ className="dc-annot-svg"
4267
+ aria-hidden="true"
4268
+ xmlns="http://www.w3.org/2000/svg"
4269
+ // DOM-driven E2E + tooling hook: the current annotation selection.
4270
+ data-selection={annotSel?.selectedIds.join(' ') ?? ''}
4271
+ data-annot-state={gestureState}
4272
+ >
3943
4273
  <defs>
3944
4274
  {/* Phase 21 — soft "lifted paper" drop shadow for sticky notes. */}
3945
4275
  <filter id="dc-sticky-shadow" x="-25%" y="-25%" width="150%" height="170%">
@@ -3952,20 +4282,6 @@ function AnnotationsSvg({
3952
4282
  />
3953
4283
  </filter>
3954
4284
  </defs>
3955
- {strokes.map((s) => (
3956
- <StrokeNode
3957
- key={s.id}
3958
- stroke={s}
3959
- anchorsById={anchorsById}
3960
- interactive={selectMode}
3961
- editing={
3962
- s.id === editingStickyId ||
3963
- s.id === editingSectionId ||
3964
- s.id === editingAnchoredTextId ||
3965
- s.id === editingStandaloneTextId
3966
- }
3967
- />
3968
- ))}
3969
4285
  {selectedStrokes.map((s) => (
3970
4286
  <SelectionHalo
3971
4287
  key={`halo-${s.id}`}
@@ -4077,114 +4393,10 @@ function AnnotationsSvg({
4077
4393
  </svg>,
4078
4394
  target
4079
4395
  )}
4080
- <AnnotEditors
4081
- worldRef={worldRef}
4082
- editingTarget={editingTarget}
4083
- anchoredExisting={anchoredExisting}
4084
- caretPoint={editCaretPoint}
4085
- inkColor={inkColor}
4086
- onCommitEdit={onCommitEdit}
4087
- onCancelEdit={onCancelEdit}
4088
- />
4089
4396
  </>
4090
4397
  );
4091
4398
  }
4092
4399
 
4093
- /**
4094
- * The active annotation text editor, rendered as PLAIN HTML absolutely
4095
- * positioned in the world div — NOT as SVG foreignObject. Same architectural
4096
- * move as MediaRefPlayers below (read its docblock): foreignObject content
4097
- * under the transformed `.dc-world` mis-hit-tests clicks at most zoom levels
4098
- * (WebKit + Chromium), and the `.dc-annot-svg` root's pointer-events:none
4099
- * additionally swallowed in-editor clicks — so caret-at-click could never
4100
- * work. HTML children of the transformed div hit-test correctly by
4101
- * construction. World coords map 1:1 (the div carries the pan/zoom
4102
- * transform), so each editor's old foreignObject x/y/w/h becomes left/top/
4103
- * width/height verbatim. The [data-annot-editor] attr keeps document-capture
4104
- * annotation handlers out (isAnnotEditorTarget guard, mirroring
4105
- * [data-mediaref-player]).
4106
- */
4107
- function AnnotEditors({
4108
- worldRef,
4109
- editingTarget,
4110
- anchoredExisting,
4111
- caretPoint,
4112
- inkColor,
4113
- onCommitEdit,
4114
- onCancelEdit,
4115
- }: {
4116
- worldRef: ReturnType<typeof useWorldRefContext>;
4117
- editingTarget: EditingTarget;
4118
- anchoredExisting: TextStroke | undefined;
4119
- caretPoint: { x: number; y: number } | null;
4120
- inkColor: string;
4121
- onCommitEdit: (text: string, fmt?: EditorFmt, measuredH?: number) => void;
4122
- onCancelEdit: () => void;
4123
- }) {
4124
- const target = worldRef?.current ?? null;
4125
- if (!target || !editingTarget) return null;
4126
- return createPortal(
4127
- <>
4128
- {editingTarget.kind === 'anchored' ? (
4129
- <TextEditor
4130
- anchorId={editingTarget.anchorId}
4131
- host={editingTarget.host}
4132
- existing={anchoredExisting}
4133
- caretPoint={caretPoint}
4134
- onCommit={(_anchorId, text, fmt) => onCommitEdit(text, fmt)}
4135
- onCancel={onCancelEdit}
4136
- />
4137
- ) : null}
4138
- {editingTarget.kind === 'sticky' ? (
4139
- <StickyEditor
4140
- sticky={editingTarget.sticky}
4141
- caretPoint={caretPoint}
4142
- onCommit={onCommitEdit}
4143
- onCancel={onCancelEdit}
4144
- />
4145
- ) : null}
4146
- {editingTarget.kind === 'standalone' ? (
4147
- <StandaloneTextEditor
4148
- x={editingTarget.text.x ?? 0}
4149
- y={editingTarget.text.y ?? 0}
4150
- fontSize={editingTarget.text.fontSize}
4151
- color={editingTarget.text.color}
4152
- initialText={editingTarget.text.text}
4153
- bold={editingTarget.text.bold}
4154
- italic={editingTarget.text.italic}
4155
- strike={editingTarget.text.strike}
4156
- underline={editingTarget.text.underline}
4157
- align={editingTarget.text.align ?? 'left'}
4158
- listType={editingTarget.text.listType}
4159
- caretPoint={caretPoint}
4160
- onCommit={onCommitEdit}
4161
- onCancel={onCancelEdit}
4162
- />
4163
- ) : null}
4164
- {editingTarget.kind === 'pending' ? (
4165
- <StandaloneTextEditor
4166
- x={editingTarget.x}
4167
- y={editingTarget.y}
4168
- fontSize={DEFAULT_FONT_SIZE}
4169
- color={inkColor}
4170
- initialText=""
4171
- onCommit={onCommitEdit}
4172
- onCancel={onCancelEdit}
4173
- />
4174
- ) : null}
4175
- {editingTarget.kind === 'section' ? (
4176
- <SectionTitleEditor
4177
- section={editingTarget.section}
4178
- caretPoint={caretPoint}
4179
- onCommit={onCommitEdit}
4180
- onCancel={onCancelEdit}
4181
- />
4182
- ) : null}
4183
- </>,
4184
- target
4185
- );
4186
- }
4187
-
4188
4400
  /**
4189
4401
  * DDR-150 dogfood #8 — the mediaref chips' inline players, rendered as PLAIN
4190
4402
  * HTML absolutely positioned in the world div (which carries the pan/zoom CSS
@@ -4275,567 +4487,6 @@ function MediaRefPlayers({
4275
4487
  );
4276
4488
  }
4277
4489
 
4278
- // An empty flex-centered contentEditable has no line box for `justifyContent:
4279
- // 'center'` to center — WebKit parks the caret at the box's top, then jumps it
4280
- // to the true centered position the instant a real character exists. A
4281
- // zero-width space gives the box real (invisible) content from mount, so the
4282
- // caret starts centered and never jumps. Stripped back off on commit.
4283
- const JUMP_SENTINEL = '\u200B';
4284
- function stripJumpSentinel(text: string): string {
4285
- return text.startsWith(JUMP_SENTINEL) ? text.slice(JUMP_SENTINEL.length) : text;
4286
- }
4287
-
4288
- // Unified caret style across every contentEditable text surface (annotation
4289
- // editors here + the artboard inline editor's `.dc-text-editing` CSS in
4290
- // canvas-shell.tsx use the SAME `--maude-hud-accent` so the caret reads the
4291
- // same everywhere). An explicit caretColor makes the caret visible against any
4292
- // background. NOTE: deliberately NO `transform: translateZ(0)` / `will-change`
4293
- // here \u2014 promoting a contentEditable onto its own compositing layer is a known
4294
- // WebKit caret-BLINK killer (the compositor caches the layer and never repaints
4295
- // the blink, so the caret shows as a static line). A prior dogfood pass added
4296
- // translateZ(0) to "restore" the caret and instead froze its blink; removing it
4297
- // lets WebKit run the native blink. The editor already lives inside the
4298
- // transformed `.dc-world`, but that ancestor transform alone does not stop the
4299
- // blink \u2014 only a compositing trigger ON the editable does.
4300
- const CARET_FIX_STYLE = {
4301
- caretColor: 'var(--maude-hud-accent, #4a63e7)',
4302
- } as const;
4303
-
4304
- /**
4305
- * Phase 3 (unified-text-editing) — shared caret behavior for every annotation
4306
- * editor. On mount: focus, place a collapsed caret at the entry click point
4307
- * (`placeCaretAt`, the SAME chain the artboard's enterEditModeAt uses;
4308
- * keyboard entry has no point → select-all, the rename convention), and mount
4309
- * the custom blinking caret (text-caret.ts) for the session. Afterwards every
4310
- * plain in-editor click re-places the caret from its coordinates on pointerup
4311
- * so repositioning never depends on native hit-testing (synthetic e2e clicks
4312
- * take the same path — untrusted events get no UA caret action at all).
4313
- * Shift-clicks and drag-selections keep native behavior; ⌘A stays native.
4314
- */
4315
- function useEditorCaret(
4316
- ref: RefObject<HTMLDivElement | null>,
4317
- caretPoint: { x: number; y: number } | null | undefined
4318
- ) {
4319
- const entryPointRef = useRef(caretPoint ?? null);
4320
- useEffect(() => {
4321
- const el = ref.current;
4322
- if (!el) return;
4323
- el.focus();
4324
- placeCaretAt(el, window, entryPointRef.current ?? undefined);
4325
- return mountCaret(el, window);
4326
- }, [ref]);
4327
- const downRef = useRef<{ x: number; y: number } | null>(null);
4328
- const onPointerDown = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
4329
- downRef.current = e.button === 0 && !e.shiftKey ? { x: e.clientX, y: e.clientY } : null;
4330
- }, []);
4331
- const onPointerUp = useCallback(
4332
- (e: ReactPointerEvent<HTMLDivElement>) => {
4333
- const d = downRef.current;
4334
- downRef.current = null;
4335
- if (!d || e.shiftKey) return;
4336
- // A real drag is a range-selection gesture — leave it to the engine.
4337
- if (Math.hypot(e.clientX - d.x, e.clientY - d.y) > 3) return;
4338
- const el = ref.current;
4339
- if (!el) return;
4340
- const sel = window.getSelection();
4341
- if (sel && !sel.isCollapsed) return; // double-click word-select etc.
4342
- placeCaretAt(el, window, { x: e.clientX, y: e.clientY }, false);
4343
- },
4344
- [ref]
4345
- );
4346
- return { onPointerDown, onPointerUp };
4347
- }
4348
-
4349
- /**
4350
- * issue-106 — the body an annotation editor hands React as its contentEditable
4351
- * children, snapshotted for the life of ONE edit session.
4352
- *
4353
- * These editors are uncontrolled: the user types straight into the DOM. If the
4354
- * rendered children value changes while the session is open, React writes the
4355
- * new string into the live node and everything typed since — line breaks
4356
- * included — is gone. That is exactly what a peer's commit arriving over
4357
- * shared-doc sync (DDR-064) used to do, and what the hazard note above
4358
- * `useEditorFormat` warns about for local mutations. Snapshotting per target id
4359
- * makes the children a constant for the session, so no store update can reach
4360
- * the DOM; the commit path reads `innerText` from the node itself, which is the
4361
- * user's real content either way. Re-keys when the editor is reused for a
4362
- * different stroke.
4363
- *
4364
- * `listType` is snapshotted through the same hook: the body is prefixed with
4365
- * list markers at OPEN time and stripped again at commit, so if a peer flipped
4366
- * the stroke's list style mid-session the strip would run a different rule than
4367
- * the prefix did — turning a bullet into a literal "• " in the stored text, or
4368
- * eating a leading "1. " the user actually typed.
4369
- */
4370
- function useSessionValue<T>(id: string, compute: () => T): T {
4371
- const ref = useRef<{ id: string; value: T } | null>(null);
4372
- if (ref.current === null || ref.current.id !== id) ref.current = { id, value: compute() };
4373
- return ref.current.value;
4374
- }
4375
-
4376
- function TextEditor({
4377
- anchorId,
4378
- host,
4379
- existing,
4380
- caretPoint,
4381
- onCommit,
4382
- onCancel,
4383
- }: {
4384
- anchorId: string;
4385
- host: AnchorHost | null;
4386
- existing: TextStroke | undefined;
4387
- caretPoint?: { x: number; y: number } | null;
4388
- onCommit: (anchorId: string, text: string, fmt?: EditorFmt) => void;
4389
- onCancel: () => void;
4390
- }) {
4391
- const ref = useRef<HTMLDivElement | null>(null);
4392
- // Show list markers WHILE editing so the read↔edit swap doesn't flicker
4393
- // (item 4c) — stripped back to raw text on commit.
4394
- const sessionListType = useSessionValue(anchorId, () => existing?.listType);
4395
- const initial = useSessionValue(anchorId, () =>
4396
- listPrefixedBody(existing?.text ?? '', sessionListType)
4397
- );
4398
- // Cmd/Ctrl+B/I/U formatting while editing (item 4d).
4399
- const {
4400
- fmtRef,
4401
- style: fmtStyle,
4402
- onFormatKey,
4403
- } = useEditorFormat({
4404
- bold: existing?.bold,
4405
- italic: existing?.italic,
4406
- underline: existing?.underline,
4407
- strike: existing?.strike,
4408
- fontSize: existing?.fontSize ?? DEFAULT_FONT_SIZE,
4409
- align: existing?.align ?? 'center',
4410
- });
4411
- // Both commit sites below (outside-click + Cmd/Ctrl+Enter) need the same
4412
- // sentinel-strip + marker-strip pipeline. Memoized so the outside-click
4413
- // effect below can depend on it directly instead of its own copy of
4414
- // existing?.listType (lint/correctness/useExhaustiveDependencies).
4415
- const toCommittedText = useCallback(
4416
- (raw: string) => stripEditorMarkers(stripJumpSentinel(raw), sessionListType),
4417
- [sessionListType]
4418
- );
4419
-
4420
- // Caret-at-click on entry + custom blinking caret + click re-placement
4421
- // (select-all only for keyboard entry — see useEditorCaret).
4422
- const caretHandlers = useEditorCaret(ref, caretPoint);
4423
-
4424
- // Commit on outside click; cancel-on-Esc handled in onKeyDown below.
4425
- useEffect(() => {
4426
- if (typeof document === 'undefined') return;
4427
- const onDown = (e: PointerEvent) => {
4428
- if (isMediaPlayerTarget(e)) return; // mediaref inline player owns this event
4429
- const el = ref.current;
4430
- if (!el) return;
4431
- if (el.contains(e.target as Node)) return;
4432
- // FigJam v3 — the edit-mode text toolbar drives THIS editor; clicking
4433
- // it must not commit-and-close the session.
4434
- if ((e.target as Element | null)?.closest?.('.dc-annot-ctx')) return;
4435
- onCommit(anchorId, toCommittedText(el.innerText || ''), fmtRef.current);
4436
- };
4437
- document.addEventListener('pointerdown', onDown, true);
4438
- return () => document.removeEventListener('pointerdown', onDown, true);
4439
- }, [anchorId, onCommit, fmtRef, toCommittedText]);
4440
-
4441
- if (!host) return null;
4442
- const bbox = strokeBBox(host);
4443
- if (!bbox) return null;
4444
- const fontSize = existing?.fontSize ?? DEFAULT_FONT_SIZE;
4445
- // Phase 24 — match the committed render's bold / strike / align (anchored
4446
- // default align = centre).
4447
- const align = existing?.align ?? 'center';
4448
- return (
4449
- // Plain HTML host in the world div (NOT foreignObject — see AnnotEditors'
4450
- // docblock): world coords map 1:1 to left/top, clicks hit-test correctly.
4451
- <div
4452
- data-annot-editor="1"
4453
- style={{
4454
- position: 'absolute',
4455
- left: bbox.x,
4456
- top: bbox.y,
4457
- width: Math.max(20, bbox.w),
4458
- height: Math.max(20, bbox.h),
4459
- zIndex: 5,
4460
- }}
4461
- >
4462
- <div
4463
- ref={ref}
4464
- className="dc-annot-editor"
4465
- contentEditable
4466
- suppressContentEditableWarning
4467
- aria-label="Edit annotation text"
4468
- style={{
4469
- width: '100%',
4470
- height: '100%',
4471
- // Column flex (NOT row) so contentEditable line breaks stack
4472
- // vertically; justify-center keeps the block vertically centred in
4473
- // the host. The pre-Task-5 row-flex laid lines out side-by-side
4474
- // (item 4a — the mangled multi-line look).
4475
- display: 'flex',
4476
- flexDirection: 'column',
4477
- justifyContent: 'center',
4478
- alignItems: align === 'left' ? 'flex-start' : align === 'right' ? 'flex-end' : 'center',
4479
- padding: '0 8px',
4480
- boxSizing: 'border-box',
4481
- textAlign: align,
4482
- whiteSpace: 'pre-wrap',
4483
- color: existing?.color ?? '#1a1a1a',
4484
- fontSize: `${fontSize}px`,
4485
- fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
4486
- ...fmtStyle,
4487
- lineHeight: 1.25,
4488
- outline: 'none',
4489
- background: 'transparent',
4490
- cursor: 'text',
4491
- ...CARET_FIX_STYLE,
4492
- }}
4493
- onPointerDown={caretHandlers.onPointerDown}
4494
- onPointerUp={caretHandlers.onPointerUp}
4495
- onKeyDown={(e) => {
4496
- if (onFormatKey(e)) return; // Cmd/Ctrl+B/I/U
4497
- if (e.key === 'Escape') {
4498
- e.preventDefault();
4499
- onCancel();
4500
- return;
4501
- }
4502
- // issue-106 — Shift+Enter ADDS a line. On keyboard entry the whole
4503
- // body is still select-all'd (the retype convention), so letting the
4504
- // break replace the selection wiped the text. Collapse that entry
4505
- // selection to its end first; a user's own partial selection is left
4506
- // alone. See collapseEntrySelectAll.
4507
- if (e.key === 'Enter' && e.shiftKey && ref.current) {
4508
- collapseEntrySelectAll(ref.current, window);
4509
- }
4510
- // Unified across every text surface: plain Enter commits,
4511
- // Shift+Enter inserts a newline (falls through untouched). ⌘/Ctrl
4512
- // +Enter also commits AND chains a connected sibling (quick-create).
4513
- if (e.key === 'Enter' && !e.shiftKey) {
4514
- e.preventDefault();
4515
- const el = ref.current;
4516
- onCommit(anchorId, toCommittedText(el?.innerText || ''), fmtRef.current);
4517
- if (e.metaKey || e.ctrlKey) {
4518
- document.dispatchEvent(
4519
- new CustomEvent('maude:chain-create', { detail: { id: anchorId } })
4520
- );
4521
- }
4522
- }
4523
- }}
4524
- >
4525
- {initial || JUMP_SENTINEL}
4526
- </div>
4527
- </div>
4528
- );
4529
- }
4530
-
4531
- // Phase 21 — sticky body editor, hosted as plain HTML at the card's bbox in
4532
- // the world div (word-wrap + zoom come from the div's own box + the world
4533
- // transform). Commit on blur, cancel on Esc; Enter commits, Shift+Enter
4534
- // inserts a newline.
4535
- function StickyEditor({
4536
- sticky,
4537
- caretPoint,
4538
- onCommit,
4539
- onCancel,
4540
- }: {
4541
- sticky: StickyStroke;
4542
- caretPoint?: { x: number; y: number } | null;
4543
- onCommit: (text: string, fmt?: EditorFmt, measuredH?: number) => void;
4544
- onCancel: () => void;
4545
- }) {
4546
- // A flex-centered contentEditable (NOT a textarea) so the edit view matches
4547
- // the committed `.dc-sticky-body` exactly — text stays centered, no jump on
4548
- // commit. Multi-line: Shift+Enter inserts a line break, plain Enter commits;
4549
- // Esc cancels; blur commits; Cmd/Ctrl+B/I/U format (unified with the others).
4550
- const ref = useRef<HTMLDivElement | null>(null);
4551
- const doneRef = useRef(false);
4552
- const sessionListType = useSessionValue(sticky.id, () => sticky.listType);
4553
- const sessionBody = useSessionValue(sticky.id, () => stickyBodyText(sticky));
4554
- const {
4555
- fmtRef,
4556
- style: fmtStyle,
4557
- onFormatKey,
4558
- } = useEditorFormat({
4559
- bold: sticky.bold,
4560
- italic: sticky.italic,
4561
- underline: sticky.underline,
4562
- strike: sticky.strike,
4563
- fontSize: sticky.fontSize,
4564
- align: sticky.align ?? 'left',
4565
- });
4566
- const commit = () => {
4567
- if (doneRef.current) return;
4568
- doneRef.current = true;
4569
- const el = ref.current;
4570
- // issue-106 — the editor IS the measurement: same class, same width, same
4571
- // font as the committed body, and laid out in world units (the pan/zoom
4572
- // transform on `.dc-world` doesn't change layout), so its scrollHeight is
4573
- // the height the card needs. No offscreen measuring rig required.
4574
- onCommit(
4575
- stripEditorMarkers(el?.innerText ?? '', sessionListType),
4576
- fmtRef.current,
4577
- el?.scrollHeight
4578
- );
4579
- };
4580
- // FigJam v3 — a toolbar click steals focus for a tick; don't treat it as
4581
- // "done editing" (the button's onMouseDown preventDefault usually stops the
4582
- // blur, this guards the browsers where it doesn't).
4583
- const onBlur = (e: { relatedTarget?: EventTarget | null }) => {
4584
- const to = e.relatedTarget as Element | null;
4585
- if (to?.closest?.('.dc-annot-ctx')) return;
4586
- commit();
4587
- };
4588
- // Caret-at-click on entry + custom blinking caret + click re-placement.
4589
- const caretHandlers = useEditorCaret(ref, caretPoint);
4590
- const x = Math.min(sticky.x, sticky.x + sticky.w);
4591
- const y = Math.min(sticky.y, sticky.y + sticky.h);
4592
- const w = Math.abs(sticky.w);
4593
- const h = Math.abs(sticky.h);
4594
- const r = sticky.cornerRadius ?? STICKY_CORNER_RADIUS;
4595
- return (
4596
- <div
4597
- data-annot-editor="1"
4598
- style={{
4599
- position: 'absolute',
4600
- left: x,
4601
- top: y,
4602
- width: w,
4603
- // issue-106 — NOT a fixed `height: h`. The card clips at `overflow:
4604
- // hidden`, so on a sticky that is already full the line Shift+Enter
4605
- // inserts landed outside the box: the keystroke worked, nothing moved
4606
- // on screen, and it read as "shift+enter does nothing". While the
4607
- // editor is open it grows downward instead, painting its own paper so
4608
- // the overflow still reads as the note (the SVG card behind is still
4609
- // the old size until the commit below persists the grown height).
4610
- minHeight: h,
4611
- zIndex: 5,
4612
- // `backgroundColor`, never the `background` shorthand: `color` comes
4613
- // verbatim from a synced stroke's `fill` (peer-controlled, and the
4614
- // sanitizer does not touch `fill`), and the shorthand would accept a
4615
- // `url(...)` — an outbound request from the studio origin the moment a
4616
- // note is opened. `background-color` cannot take one.
4617
- backgroundColor: sticky.color,
4618
- // Mirrors stickyCornerPath: TL/TR/BL rounded, BR sharp.
4619
- borderRadius: `${r}px ${r}px 0 ${r}px`,
4620
- }}
4621
- >
4622
- <div
4623
- ref={ref}
4624
- className="dc-annot-editor dc-sticky-body"
4625
- contentEditable
4626
- suppressContentEditableWarning
4627
- aria-label="Edit sticky note text"
4628
- style={{
4629
- ...stickyBodyStyle(sticky),
4630
- ...fmtStyle,
4631
- outline: 'none',
4632
- cursor: 'text',
4633
- ...CARET_FIX_STYLE,
4634
- // Beats `.dc-sticky-body { height: 100%; overflow: hidden }` for the
4635
- // duration of the edit — see the wrapper note above.
4636
- height: 'auto',
4637
- minHeight: h,
4638
- overflow: 'visible',
4639
- }}
4640
- onBlur={onBlur}
4641
- onPointerDown={caretHandlers.onPointerDown}
4642
- onPointerUp={caretHandlers.onPointerUp}
4643
- onKeyDown={(e) => {
4644
- if (onFormatKey(e)) return; // Cmd/Ctrl+B/I/U
4645
- if (e.key === 'Escape') {
4646
- e.preventDefault();
4647
- doneRef.current = true; // suppress the unmount blur-commit
4648
- onCancel();
4649
- return;
4650
- }
4651
- // issue-106 — see the same guard in TextEditor: Shift+Enter must add
4652
- // a line, never consume the entry select-all and delete the body.
4653
- if (e.key === 'Enter' && e.shiftKey && ref.current) {
4654
- collapseEntrySelectAll(ref.current, window);
4655
- }
4656
- // Unified: plain Enter commits, Shift+Enter inserts a newline.
4657
- // ⌘/Ctrl+Enter also commits AND chains the next sticky beside it.
4658
- if (e.key === 'Enter' && !e.shiftKey) {
4659
- e.preventDefault();
4660
- commit();
4661
- if (e.metaKey || e.ctrlKey) {
4662
- document.dispatchEvent(
4663
- new CustomEvent('maude:chain-create', { detail: { id: sticky.id } })
4664
- );
4665
- }
4666
- }
4667
- }}
4668
- >
4669
- {/* Show the list markers while editing (item 4c) so the read↔edit swap
4670
- doesn't flicker; stripped back to raw text on commit. */}
4671
- {sessionBody}
4672
- </div>
4673
- </div>
4674
- );
4675
- }
4676
-
4677
- // Phase 21 — standalone text editor. A single-line contentEditable box anchored
4678
- // at the world (x, y). Enter / blur / outside-click commit; Esc cancels.
4679
- function StandaloneTextEditor({
4680
- x,
4681
- y,
4682
- fontSize,
4683
- color,
4684
- initialText,
4685
- bold,
4686
- italic,
4687
- strike,
4688
- underline,
4689
- align,
4690
- listType,
4691
- singleLine,
4692
- boxStyle,
4693
- caretPoint,
4694
- onCommit,
4695
- onCancel,
4696
- }: {
4697
- x: number;
4698
- y: number;
4699
- fontSize: number;
4700
- color: string;
4701
- initialText: string;
4702
- bold?: boolean;
4703
- italic?: boolean;
4704
- strike?: boolean;
4705
- underline?: boolean;
4706
- align?: TextAlign;
4707
- listType?: ListType;
4708
- /** The click that opened the editor — caret lands there (Phase 3). */
4709
- caretPoint?: { x: number; y: number } | null;
4710
- /** A one-line field (e.g. a section title rename) — plain Enter commits
4711
- * instead of inserting a newline, matching a native text-input's Enter. */
4712
- singleLine?: boolean;
4713
- /** Extra style merged onto the editable box — e.g. a section rename wants
4714
- * the same chip background/padding/radius the read-only label chip has. */
4715
- boxStyle?: CSSProperties;
4716
- onCommit: (text: string, fmt?: EditorFmt) => void;
4717
- onCancel: () => void;
4718
- }) {
4719
- const ref = useRef<HTMLDivElement | null>(null);
4720
- // Cmd/Ctrl+B/I/U formatting while editing (item 4d).
4721
- const {
4722
- fmtRef,
4723
- style: fmtStyle,
4724
- onFormatKey,
4725
- } = useEditorFormat({
4726
- bold,
4727
- italic,
4728
- underline,
4729
- strike,
4730
- fontSize,
4731
- align: align ?? 'left',
4732
- });
4733
- // Single-fire commit guard — outside-click + blur can both fire in one tick;
4734
- // without this the text would commit twice (two undo records). Markers shown
4735
- // while editing (item 4c) are stripped back to raw text here on commit.
4736
- const doneRef = useRef(false);
4737
- const commitOnce = useCallback(
4738
- (text: string) => {
4739
- if (doneRef.current) return;
4740
- doneRef.current = true;
4741
- onCommit(stripEditorMarkers(text, listType), fmtRef.current);
4742
- },
4743
- [onCommit, listType, fmtRef]
4744
- );
4745
- // Caret-at-click on entry + custom blinking caret + click re-placement.
4746
- const caretHandlers = useEditorCaret(ref, caretPoint);
4747
- // Commit on outside click.
4748
- useEffect(() => {
4749
- if (typeof document === 'undefined') return;
4750
- const onDown = (e: PointerEvent) => {
4751
- if (isMediaPlayerTarget(e)) return; // mediaref inline player owns this event
4752
- const el = ref.current;
4753
- if (!el) return;
4754
- if (el.contains(e.target as Node)) return;
4755
- // FigJam v3 — clicks into the edit-mode text toolbar keep the session.
4756
- if ((e.target as Element | null)?.closest?.('.dc-annot-ctx')) return;
4757
- commitOnce(el.innerText || '');
4758
- };
4759
- document.addEventListener('pointerdown', onDown, true);
4760
- return () => document.removeEventListener('pointerdown', onDown, true);
4761
- }, [commitOnce]);
4762
- return (
4763
- // Generous box so multi-line text isn't clipped while typing (item 4a).
4764
- // The host passes pointer events through (empty area is not the editor —
4765
- // outside-click must still commit); only the editable itself is
4766
- // interactive, so clicks in it place the caret.
4767
- <div
4768
- data-annot-editor="1"
4769
- style={{
4770
- position: 'absolute',
4771
- left: x,
4772
- top: y,
4773
- width: 640,
4774
- height: 480,
4775
- zIndex: 5,
4776
- pointerEvents: 'none',
4777
- }}
4778
- >
4779
- <div
4780
- ref={ref}
4781
- className="dc-annot-editor"
4782
- contentEditable
4783
- suppressContentEditableWarning
4784
- aria-label="Edit text"
4785
- style={{
4786
- display: 'inline-block',
4787
- minWidth: '8px',
4788
- // The pass-through host (above) is inert — re-enable events HERE so
4789
- // in-editor clicks place the caret instead of falling through.
4790
- pointerEvents: 'auto',
4791
- // pre-wrap so Enter inserts a real newline (multi-line text), not a
4792
- // commit; long lines also wrap within the box.
4793
- whiteSpace: 'pre-wrap',
4794
- padding: '0 2px',
4795
- color,
4796
- fontSize: `${fontSize}px`,
4797
- fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
4798
- ...fmtStyle,
4799
- textAlign: align ?? 'left',
4800
- lineHeight: TEXT_LINE_HEIGHT,
4801
- outline: 'none',
4802
- background: 'transparent',
4803
- cursor: 'text',
4804
- ...CARET_FIX_STYLE,
4805
- ...boxStyle,
4806
- }}
4807
- onBlur={() => commitOnce(ref.current?.innerText || '')}
4808
- onPointerDown={caretHandlers.onPointerDown}
4809
- onPointerUp={caretHandlers.onPointerUp}
4810
- onKeyDown={(e) => {
4811
- if (onFormatKey(e)) return; // Cmd/Ctrl+B/I/U
4812
- if (e.key === 'Escape') {
4813
- e.preventDefault();
4814
- // Mark done so the unmount blur that follows doesn't commit.
4815
- doneRef.current = true;
4816
- onCancel();
4817
- return;
4818
- }
4819
- // issue-106 — same guard as the other two editors, for the multi-line
4820
- // case only (a singleLine field commits on Shift+Enter anyway).
4821
- if (e.key === 'Enter' && e.shiftKey && !singleLine && ref.current) {
4822
- collapseEntrySelectAll(ref.current, window);
4823
- }
4824
- // Unified: plain Enter commits, Shift+Enter inserts a newline. A
4825
- // singleLine field (section rename) is a title — Shift+Enter commits
4826
- // too rather than adding a newline the one-line chip can't show.
4827
- if (e.key === 'Enter' && (!e.shiftKey || singleLine)) {
4828
- e.preventDefault();
4829
- commitOnce(ref.current?.innerText || '');
4830
- }
4831
- }}
4832
- >
4833
- {listPrefixedBody(initialText, listType)}
4834
- </div>
4835
- </div>
4836
- );
4837
- }
4838
-
4839
4490
  /**
4840
4491
  * FigJam v3 — centered ghost "Add text" label on a hovered EMPTY rect/ellipse
4841
4492
  * (FigJam shows the same invitation). Pure chrome; double-click / Enter edits.
@@ -5232,51 +4883,6 @@ function AnnotGroupBbox({
5232
4883
  * read↔edit swap doesn't shift). Applies bold / strike / align atop the
5233
4884
  * `.dc-sticky-body` defaults (top-left).
5234
4885
  */
5235
- function stickyBodyStyle(s: StickyStroke): CSSProperties {
5236
- const align = s.align ?? 'left';
5237
- return {
5238
- fontSize: `${s.fontSize}px`,
5239
- fontWeight: s.bold ? 700 : undefined,
5240
- fontStyle: s.italic ? 'italic' : undefined,
5241
- textDecoration: textDecoCss(s.strike, s.underline),
5242
- textAlign: align,
5243
- justifyContent: align === 'left' ? 'flex-start' : align === 'right' ? 'flex-end' : 'center',
5244
- };
5245
- }
5246
-
5247
- /** Sticky body content: raw text for a plain card, else per-line with list
5248
- * markers prepended (item 4c — markers are render-only). */
5249
- function stickyBodyText(s: StickyStroke): string {
5250
- if (!s.listType) return s.text;
5251
- return splitTextLines(s.text)
5252
- .map((line, i) => listPrefixedLine(line, i, s.listType))
5253
- .join('\n');
5254
- }
5255
-
5256
- /**
5257
- * Render the inner content of a `<text>` stroke: a single string for single-
5258
- * line unstyled text (item 4a parity with the legacy form), else one `<tspan>`
5259
- * per line with list markers prepended (item 4c). `tx` is the per-line origin;
5260
- * `centered` lifts the block half its height for vertically-centred anchored
5261
- * text. Mirrors `textInnerSvg` so the live + persisted geometry agree.
5262
- */
5263
- function renderTextLines(
5264
- text: string,
5265
- fontSize: number,
5266
- tx: number,
5267
- centered: boolean,
5268
- list?: ListType
5269
- ) {
5270
- if (!list && !text.includes('\n')) return text;
5271
- const lines = splitTextLines(text);
5272
- return lines.map((line, i) => (
5273
- // biome-ignore lint/suspicious/noArrayIndexKey: lines are positional + immutable per render
5274
- <tspan key={i} x={tx} dy={textLineDy(i, fontSize, lines.length, centered)}>
5275
- {listPrefixedLine(line, i, list)}
5276
- </tspan>
5277
- ));
5278
- }
5279
-
5280
4886
  /**
5281
4887
  * Phase 24 — the translucent cursor-following ghost placeholder. Pure chrome:
5282
4888
  * `pointer-events:none`, never added to `strokes`, so it can't be selected,
@@ -5341,614 +4947,6 @@ function GhostPreview({ ghost }: { ghost: GhostDescriptor }) {
5341
4947
  return <polygon points={polygonPoints(ghost.shapeKind, x, y, sz, sz)} {...common} />;
5342
4948
  }
5343
4949
 
5344
- /**
5345
- * FigJam v3 — rotation wrapper. The base node renders axis-aligned geometry;
5346
- * a rotated stroke wraps it in a `rotate()` group around its bbox center
5347
- * (anchored text inherits its HOST's rotation so labels turn with the shape).
5348
- * Pointer events pass through the group, so hit-testing + the ctx-toolbar's
5349
- * getBoundingClientRect positioning keep working on the rotated form.
5350
- */
5351
- /** Section title chip — deliberately screen-size-constant. The body/border
5352
- * scale with the world like everything else, but a title that shrinks to
5353
- * unreadable at zoom-out defeats the point of a label (matches the `r={5/zoom}`
5354
- * counter-scale convention used for halos/connector-dots elsewhere in this file). */
5355
- function SectionLabelChip({
5356
- stroke,
5357
- x,
5358
- y,
5359
- hitMode,
5360
- }: {
5361
- stroke: SectionStroke;
5362
- x: number;
5363
- y: number;
5364
- hitMode: 'visiblePainted' | 'none';
5365
- }) {
5366
- // Counter-scaled chrome — must hold a constant screen size while the world
5367
- // scales, so it needs the live zoom, not the settle-cadence published one.
5368
- const zoom = useLiveViewport().zoom || 1;
5369
- const fontSize = SECTION_LABEL_FONT / zoom;
5370
- const chipH = SECTION_LABEL_H / zoom;
5371
- const gap = 4 / zoom;
5372
- const padX = 9 / zoom;
5373
- const chipW = Math.max(56 / zoom, stroke.label.length * fontSize * 0.62 + 18 / zoom);
5374
- // NOTE: this chip geometry (chipW/chipH/gap vs the region's y) is mirrored
5375
- // by findTextStrokeAt's section branch — the Text tool's click-through
5376
- // renames a section only from its label chip. Keep the two in sync.
5377
- return (
5378
- <g pointerEvents={hitMode}>
5379
- <rect
5380
- x={x}
5381
- y={y - chipH - gap}
5382
- width={chipW}
5383
- height={chipH}
5384
- rx={5 / zoom}
5385
- ry={5 / zoom}
5386
- fill={stroke.color}
5387
- fillOpacity={0.16}
5388
- />
5389
- <text
5390
- x={x + padX}
5391
- y={y - chipH / 2 - gap}
5392
- dominantBaseline="middle"
5393
- fontSize={fontSize}
5394
- fill={stroke.color}
5395
- style={{
5396
- fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
5397
- }}
5398
- >
5399
- {stroke.label}
5400
- </text>
5401
- </g>
5402
- );
5403
- }
5404
-
5405
- /** Section title RENAME field — same chip visuals + zoom-invariant sizing as
5406
- * SectionLabelChip (read state), so switching into edit mode doesn't swap
5407
- * the pill for a bare, ambient-zoomed sliver of text (it used to: the editor
5408
- * had no background and its font-size wasn't counter-scaled, so at any zoom
5409
- * below 1× the chip effectively vanished mid-rename). */
5410
- function SectionTitleEditor({
5411
- section,
5412
- caretPoint,
5413
- onCommit,
5414
- onCancel,
5415
- }: {
5416
- section: SectionStroke;
5417
- caretPoint?: { x: number; y: number } | null;
5418
- onCommit: (text: string, fmt?: EditorFmt) => void;
5419
- onCancel: () => void;
5420
- }) {
5421
- // Counter-scaled chrome — must hold a constant screen size while the world
5422
- // scales, so it needs the live zoom, not the settle-cadence published one.
5423
- const zoom = useLiveViewport().zoom || 1;
5424
- const fontSize = SECTION_LABEL_FONT / zoom;
5425
- const chipH = SECTION_LABEL_H / zoom;
5426
- const gap = 4 / zoom;
5427
- const padX = 9 / zoom;
5428
- const x = Math.min(section.x, section.x + section.w);
5429
- const y = Math.min(section.y, section.y + section.h);
5430
- return (
5431
- <StandaloneTextEditor
5432
- x={x}
5433
- y={y - chipH - gap}
5434
- fontSize={fontSize}
5435
- color={section.color}
5436
- initialText={section.label}
5437
- caretPoint={caretPoint}
5438
- singleLine
5439
- boxStyle={{
5440
- background: `color-mix(in oklab, ${section.color} 16%, transparent)`,
5441
- borderRadius: `${5 / zoom}px`,
5442
- padding: `0 ${padX}px`,
5443
- minHeight: `${chipH}px`,
5444
- lineHeight: `${chipH}px`,
5445
- whiteSpace: 'nowrap',
5446
- }}
5447
- onCommit={onCommit}
5448
- onCancel={onCancel}
5449
- />
5450
- );
5451
- }
5452
-
5453
- function StrokeNode(props: {
5454
- stroke: Stroke;
5455
- anchorsById: Map<string, AnchorHost>;
5456
- interactive: boolean;
5457
- editing?: boolean;
5458
- }) {
5459
- const { stroke, anchorsById } = props;
5460
- let rot = strokeRotation(stroke);
5461
- let pivot = rot !== 0 ? strokeCenter(stroke) : null;
5462
- if (stroke.tool === 'text' && stroke.anchorId != null && stroke.anchorId !== '') {
5463
- const host = anchorsById.get(stroke.anchorId);
5464
- rot = host ? strokeRotation(host) : 0;
5465
- pivot = rot !== 0 && host ? strokeCenter(host) : null;
5466
- }
5467
- const node = <StrokeNodeBase {...props} />;
5468
- if (rot === 0 || !pivot) return node;
5469
- return <g transform={`rotate(${rot} ${pivot[0]} ${pivot[1]})`}>{node}</g>;
5470
- }
5471
-
5472
- function StrokeNodeBase({
5473
- stroke,
5474
- anchorsById,
5475
- interactive,
5476
- editing = false,
5477
- }: {
5478
- stroke: Stroke;
5479
- anchorsById: Map<string, AnchorHost>;
5480
- interactive: boolean;
5481
- /** Hide the read-only body/text while its editor is up (sticky, section, or
5482
- * anchored shape text — whichever this stroke is). */
5483
- editing?: boolean;
5484
- }) {
5485
- // In Move mode, individual stroke nodes claim pointer events so we can
5486
- // hit-test them from the doc-level capture listener. In draw mode the
5487
- // overlay above handles input, so the strokes themselves stay inert.
5488
- const hitMode = interactive ? 'visiblePainted' : ('none' as const);
5489
- const strokeHit = interactive ? 'stroke' : ('none' as const);
5490
- if (stroke.tool === 'text') {
5491
- // Anchored text renders centered in its host; standalone (Phase 21) renders
5492
- // top-left-anchored at its own world (x, y). bold / italic / strike /
5493
- // underline applied to the rendered <text>; multi-line + list markers via
5494
- // renderTextLines (one <tspan> per line).
5495
- const textStyle = {
5496
- fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
5497
- fontWeight: stroke.bold ? 700 : undefined,
5498
- fontStyle: stroke.italic ? 'italic' : undefined,
5499
- textDecoration: textDecoCss(stroke.strike, stroke.underline),
5500
- } as const;
5501
- if (stroke.anchorId != null && stroke.anchorId !== '') {
5502
- const host = anchorsById.get(stroke.anchorId);
5503
- const bbox = host ? strokeBBox(host) : null;
5504
- if (!bbox) return null;
5505
- // Its editor (TextEditor) paints the same bbox while active — skip the
5506
- // read-only <text> so the two don't double-paint (Phase 1 jump-fix
5507
- // companion: the editor was already exempt via editingStickyId's
5508
- // sibling, this stroke type never was).
5509
- if (editing) return null;
5510
- const cy = bbox.y + bbox.h / 2;
5511
- const align = stroke.align ?? 'center';
5512
- const pad = 8;
5513
- const anchor = align === 'left' ? 'start' : align === 'right' ? 'end' : 'middle';
5514
- const tx =
5515
- align === 'left'
5516
- ? bbox.x + pad
5517
- : align === 'right'
5518
- ? bbox.x + bbox.w - pad
5519
- : bbox.x + bbox.w / 2;
5520
- return (
5521
- <text
5522
- data-id={stroke.id}
5523
- data-tool="text"
5524
- data-anchor-id={stroke.anchorId}
5525
- data-font-size={stroke.fontSize}
5526
- x={tx}
5527
- y={cy}
5528
- fill={stroke.color}
5529
- fontSize={stroke.fontSize}
5530
- textAnchor={anchor}
5531
- dominantBaseline="middle"
5532
- style={textStyle}
5533
- >
5534
- {renderTextLines(stroke.text, stroke.fontSize, tx, true, stroke.listType)}
5535
- </text>
5536
- );
5537
- }
5538
- // Its editor (StandaloneTextEditor) paints at the same x/y while active —
5539
- // skip the read-only <text> so the two don't double-paint (the "ghost").
5540
- if (editing) return null;
5541
- const align = stroke.align ?? 'left';
5542
- const anchor = align === 'left' ? 'start' : align === 'right' ? 'end' : 'middle';
5543
- const tx = stroke.x ?? 0;
5544
- return (
5545
- <text
5546
- data-id={stroke.id}
5547
- data-tool="text"
5548
- data-font-size={stroke.fontSize}
5549
- x={tx}
5550
- y={stroke.y ?? 0}
5551
- fill={stroke.color}
5552
- fontSize={stroke.fontSize}
5553
- textAnchor={anchor}
5554
- dominantBaseline="hanging"
5555
- pointerEvents={interactive ? 'visiblePainted' : 'none'}
5556
- style={textStyle}
5557
- >
5558
- {renderTextLines(stroke.text, stroke.fontSize, tx, false, stroke.listType)}
5559
- </text>
5560
- );
5561
- }
5562
- if (stroke.tool === 'sticky') {
5563
- const x = Math.min(stroke.x, stroke.x + stroke.w);
5564
- const y = Math.min(stroke.y, stroke.y + stroke.h);
5565
- const w = Math.abs(stroke.w);
5566
- const h = Math.abs(stroke.h);
5567
- const r = stroke.cornerRadius ?? STICKY_CORNER_RADIUS;
5568
- return (
5569
- <g data-id={stroke.id} data-tool="sticky" pointerEvents={hitMode}>
5570
- {/* Paper card: soft drop shadow + hairline edge so it reads as a
5571
- lifted sticky, not a flat colored box (FigJam-style). The body is a
5572
- path with a SHARP bottom-right corner (item 1) — TL/TR/BL rounded.
5573
- The persisted form stays a <rect> (DDR), so this is render-only. */}
5574
- <path
5575
- d={stickyCornerPath(x, y, w, h, r)}
5576
- fill={stroke.color}
5577
- stroke="rgba(0,0,0,0.05)"
5578
- strokeWidth={1}
5579
- vectorEffect="non-scaling-stroke"
5580
- filter="url(#dc-sticky-shadow)"
5581
- />
5582
- {editing ? null : (
5583
- <foreignObject x={x} y={y} width={w} height={h} pointerEvents="none">
5584
- <div
5585
- xmlns="http://www.w3.org/1999/xhtml"
5586
- className="dc-sticky-body"
5587
- style={stickyBodyStyle(stroke)}
5588
- >
5589
- {stickyBodyText(stroke)}
5590
- </div>
5591
- </foreignObject>
5592
- )}
5593
- {/* Phase 3 (whiteboard-improvements) — author badge, bottom-right
5594
- corner. A name label (not an avatar — a full name/nickname reads
5595
- faster than initials and doesn't need a legend to decode). Color
5596
- re-derives from the (sanitized) name via colorForName — NEVER a
5597
- stored/wire color — so it matches the author's live presence hue
5598
- (cursor/avatar use the same function). foreignObject width is a
5599
- fixed generous box right-anchored via flex, since a name's pixel
5600
- width isn't known without measuring the DOM. */}
5601
- {stroke.authorName && (
5602
- <foreignObject
5603
- x={x + w - 160}
5604
- y={y + h - 20}
5605
- width={160}
5606
- height={20}
5607
- pointerEvents="none"
5608
- >
5609
- <div
5610
- xmlns="http://www.w3.org/1999/xhtml"
5611
- style={{
5612
- display: 'flex',
5613
- justifyContent: 'flex-end',
5614
- alignItems: 'center',
5615
- height: '100%',
5616
- paddingRight: 4,
5617
- }}
5618
- >
5619
- <span
5620
- title={stroke.authorName}
5621
- style={{
5622
- maxWidth: '100%',
5623
- overflow: 'hidden',
5624
- textOverflow: 'ellipsis',
5625
- whiteSpace: 'nowrap',
5626
- fontSize: 9,
5627
- fontWeight: 600,
5628
- fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
5629
- color: colorForName(stroke.authorName),
5630
- background: 'rgba(255,255,255,0.78)',
5631
- padding: '1px 5px',
5632
- borderRadius: 8,
5633
- lineHeight: 1.4,
5634
- }}
5635
- >
5636
- {stroke.authorName}
5637
- </span>
5638
- </div>
5639
- </foreignObject>
5640
- )}
5641
- </g>
5642
- );
5643
- }
5644
- if (stroke.tool === 'image') {
5645
- const x = Math.min(stroke.x, stroke.x + stroke.w);
5646
- const y = Math.min(stroke.y, stroke.y + stroke.h);
5647
- const w = Math.abs(stroke.w);
5648
- const h = Math.abs(stroke.h);
5649
- return (
5650
- <image
5651
- data-id={stroke.id}
5652
- data-tool="image"
5653
- x={x}
5654
- y={y}
5655
- width={w}
5656
- height={h}
5657
- href={resolveAssetHref(stroke.href)}
5658
- preserveAspectRatio="xMidYMid meet"
5659
- aria-label={stroke.alt || undefined}
5660
- pointerEvents={hitMode}
5661
- />
5662
- );
5663
- }
5664
- if (stroke.tool === 'link') {
5665
- const x = Math.min(stroke.x, stroke.x + stroke.w);
5666
- const y = Math.min(stroke.y, stroke.y + stroke.h);
5667
- const w = Math.abs(stroke.w);
5668
- const h = Math.abs(stroke.h);
5669
- const lay = linkCardLayout(x, y, w, h);
5670
- const shownTitle = clampLinkTitle(stroke.title, lay.textMaxChars);
5671
- const textFont = {
5672
- fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
5673
- } as const;
5674
- return (
5675
- <g
5676
- data-id={stroke.id}
5677
- data-tool="link"
5678
- data-url={stroke.url}
5679
- data-title={stroke.title}
5680
- data-domain={stroke.domain}
5681
- pointerEvents={hitMode}
5682
- >
5683
- <rect
5684
- x={x}
5685
- y={y}
5686
- width={w}
5687
- height={h}
5688
- rx={8}
5689
- ry={8}
5690
- fill={LINK_CARD_FILL}
5691
- stroke={LINK_CARD_STROKE}
5692
- strokeWidth={1}
5693
- vectorEffect="non-scaling-stroke"
5694
- filter="url(#dc-sticky-shadow)"
5695
- />
5696
- <svg
5697
- x={lay.glyph.x}
5698
- y={lay.glyph.y}
5699
- width={lay.glyph.size}
5700
- height={lay.glyph.size}
5701
- viewBox="0 0 24 24"
5702
- fill="none"
5703
- stroke={LINK_GLYPH_STROKE}
5704
- strokeWidth={2}
5705
- strokeLinecap="round"
5706
- strokeLinejoin="round"
5707
- aria-hidden="true"
5708
- >
5709
- <path d={LINK_GLYPH_D1} />
5710
- <path d={LINK_GLYPH_D2} />
5711
- </svg>
5712
- <text
5713
- x={lay.textX}
5714
- y={lay.domain.y}
5715
- fontSize={lay.domain.fontSize}
5716
- fill={LINK_DOMAIN_FILL}
5717
- dominantBaseline="hanging"
5718
- style={textFont}
5719
- >
5720
- {stroke.domain}
5721
- </text>
5722
- <text
5723
- x={lay.textX}
5724
- y={lay.title.y}
5725
- fontSize={lay.title.fontSize}
5726
- fill={LINK_TITLE_FILL}
5727
- fontWeight={600}
5728
- dominantBaseline="hanging"
5729
- style={textFont}
5730
- >
5731
- {shownTitle}
5732
- </text>
5733
- </g>
5734
- );
5735
- }
5736
- if (stroke.tool === 'mediaref') {
5737
- // DDR-150 P4 + dogfood #8 — reference chip with a REAL inline player.
5738
- // LIVE-RENDER ONLY: the <foreignObject> + <video>/<audio> below never
5739
- // persist — the model serializer still writes the sanitizer-safe data-*
5740
- // card (foreignObject is stripped by sanitizeAnnotationSvg by design).
5741
- // The 26px header strip (badge + filename) is the select/drag handle; the
5742
- // player area is fenced off from the annotation handlers by the
5743
- // [data-mediaref-player] window-capture guard.
5744
- const x = Math.min(stroke.x, stroke.x + stroke.w);
5745
- const y = Math.min(stroke.y, stroke.y + stroke.h);
5746
- const w = Math.abs(stroke.w);
5747
- const h = Math.abs(stroke.h);
5748
- const HEADER = 26;
5749
- const isAudio = stroke.mediaKind === 'audio';
5750
- const mediaUrl = stroke.src ? resolveAssetHref(stroke.src) : '';
5751
- const shownTitle = clampLinkTitle(stroke.title, Math.max(8, Math.floor((w - 40) / 7)));
5752
- const textFont = {
5753
- fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
5754
- } as const;
5755
- return (
5756
- <g
5757
- data-id={stroke.id}
5758
- data-tool="mediaref"
5759
- data-src={stroke.src}
5760
- data-media-kind={stroke.mediaKind}
5761
- data-title={stroke.title}
5762
- pointerEvents={hitMode}
5763
- >
5764
- <rect
5765
- x={x}
5766
- y={y}
5767
- width={w}
5768
- height={h}
5769
- rx={8}
5770
- ry={8}
5771
- fill={LINK_CARD_FILL}
5772
- stroke={LINK_CARD_STROKE}
5773
- strokeWidth={1}
5774
- vectorEffect="non-scaling-stroke"
5775
- filter="url(#dc-sticky-shadow)"
5776
- />
5777
- <svg
5778
- x={x + 8}
5779
- y={y + 5}
5780
- width={16}
5781
- height={16}
5782
- viewBox="0 0 24 24"
5783
- fill={LINK_GLYPH_STROKE}
5784
- stroke="none"
5785
- aria-hidden="true"
5786
- >
5787
- <path d={isAudio ? MEDIAREF_AUDIO_GLYPH : MEDIAREF_VIDEO_GLYPH} />
5788
- </svg>
5789
- <text
5790
- x={x + 30}
5791
- y={y + 9}
5792
- fontSize={11}
5793
- fill={LINK_TITLE_FILL}
5794
- fontWeight={600}
5795
- dominantBaseline="hanging"
5796
- style={textFont}
5797
- >
5798
- {shownTitle}
5799
- </text>
5800
- {/* The inline player itself is an HTML overlay portaled beside this SVG
5801
- (MediaRefPlayers below) — NOT a foreignObject: Chromium hit-tests
5802
- foreignObject content under a CSS-transformed ancestor in the WRONG
5803
- coordinate space (the un-panned/un-zoomed one), so real clicks miss
5804
- the player at most zoom levels while elementFromPoint lies that
5805
- they'd land. Plain HTML in the transformed world hit-tests right. */}
5806
- {!mediaUrl ? (
5807
- <text
5808
- x={x + 30}
5809
- y={y + HEADER + 10}
5810
- fontSize={10}
5811
- fill={LINK_DOMAIN_FILL}
5812
- dominantBaseline="hanging"
5813
- style={textFont}
5814
- >
5815
- (missing media reference)
5816
- </text>
5817
- ) : null}
5818
- </g>
5819
- );
5820
- }
5821
- if (stroke.tool === 'section') {
5822
- const x = Math.min(stroke.x, stroke.x + stroke.w);
5823
- const y = Math.min(stroke.y, stroke.y + stroke.h);
5824
- const w = Math.abs(stroke.w);
5825
- const h = Math.abs(stroke.h);
5826
- return (
5827
- <g data-id={stroke.id} data-tool="section">
5828
- {/* Region body — pure backdrop, CLICK-THROUGH (FigJam: content on a
5829
- section selects normally; the section is grabbed by border/chip). */}
5830
- <rect
5831
- x={x}
5832
- y={y}
5833
- width={w}
5834
- height={h}
5835
- rx={SECTION_CORNER_RADIUS}
5836
- ry={SECTION_CORNER_RADIUS}
5837
- fill={stroke.color}
5838
- fillOpacity={0.07}
5839
- stroke={stroke.color}
5840
- strokeOpacity={0.45}
5841
- strokeWidth={1.5}
5842
- vectorEffect="non-scaling-stroke"
5843
- pointerEvents="none"
5844
- />
5845
- {/* Invisible border hit ring — the grabbable edge. */}
5846
- {interactive ? (
5847
- <rect
5848
- x={x}
5849
- y={y}
5850
- width={w}
5851
- height={h}
5852
- rx={SECTION_CORNER_RADIUS}
5853
- ry={SECTION_CORNER_RADIUS}
5854
- fill="none"
5855
- stroke="transparent"
5856
- strokeWidth={12}
5857
- vectorEffect="non-scaling-stroke"
5858
- pointerEvents="stroke"
5859
- />
5860
- ) : null}
5861
- {/* Label chip above the top-left corner — also a grab handle. Hidden
5862
- while the rename editor (StandaloneTextEditor) is up at the same
5863
- spot, else the old label reads through behind the new text. */}
5864
- {editing ? null : <SectionLabelChip stroke={stroke} x={x} y={y} hitMode={hitMode} />}
5865
- </g>
5866
- );
5867
- }
5868
- // Deliberately NO `vector-effect="non-scaling-stroke"` here: drawn ink is
5869
- // world-space content and must thicken/thin with the zoom, the way it does in
5870
- // Figma/FigJam. The attribute also rendered differently per engine — Blink
5871
- // honours it under the world's CSS `zoom` (ink stayed a fixed screen width at
5872
- // every zoom level), WebKit ignores it under `transform: scale`, so the same
5873
- // board drew differently in Chrome than in the desktop shell. Card hairlines
5874
- // and selection chrome below still opt in — those ARE fixed-px by intent.
5875
- const common = {
5876
- 'data-id': stroke.id,
5877
- 'data-tool': stroke.tool,
5878
- stroke: stroke.color,
5879
- strokeWidth: stroke.width,
5880
- strokeLinecap: 'round' as const,
5881
- strokeLinejoin: 'round' as const,
5882
- };
5883
- if (stroke.tool === 'pen') {
5884
- // Highlighter (item 8) — overlaps darken via multiply; the translucent hue
5885
- // lives in `stroke.color`, the wide nib in `stroke.width`.
5886
- return (
5887
- <path
5888
- {...common}
5889
- fill="none"
5890
- d={penPathD(stroke.points)}
5891
- style={stroke.highlighter ? { mixBlendMode: 'multiply' } : undefined}
5892
- pointerEvents={strokeHit}
5893
- />
5894
- );
5895
- }
5896
- if (stroke.tool === 'rect') {
5897
- const x = Math.min(stroke.x, stroke.x + stroke.w);
5898
- const y = Math.min(stroke.y, stroke.y + stroke.h);
5899
- const r = stroke.cornerRadius ?? 0;
5900
- return (
5901
- <rect
5902
- {...common}
5903
- fill={stroke.fill ?? 'none'}
5904
- x={x}
5905
- y={y}
5906
- width={Math.abs(stroke.w)}
5907
- height={Math.abs(stroke.h)}
5908
- rx={r}
5909
- ry={r}
5910
- strokeDasharray={stroke.dashed ? '6 4' : undefined}
5911
- pointerEvents={hitMode}
5912
- />
5913
- );
5914
- }
5915
- if (stroke.tool === 'ellipse') {
5916
- return (
5917
- <ellipse
5918
- {...common}
5919
- fill={stroke.fill ?? 'none'}
5920
- cx={stroke.cx}
5921
- cy={stroke.cy}
5922
- rx={Math.max(0, stroke.rx)}
5923
- ry={Math.max(0, stroke.ry)}
5924
- strokeDasharray={stroke.dashed ? '6 4' : undefined}
5925
- pointerEvents={hitMode}
5926
- />
5927
- );
5928
- }
5929
- if (stroke.tool === 'polygon') {
5930
- const nx = Math.min(stroke.x, stroke.x + stroke.w);
5931
- const ny = Math.min(stroke.y, stroke.y + stroke.h);
5932
- return (
5933
- <polygon
5934
- {...common}
5935
- data-shape={stroke.shape}
5936
- fill={stroke.fill ?? 'none'}
5937
- points={polygonPoints(stroke.shape, nx, ny, Math.abs(stroke.w), Math.abs(stroke.h))}
5938
- strokeDasharray={stroke.dashed ? '6 4' : undefined}
5939
- pointerEvents={hitMode}
5940
- />
5941
- );
5942
- }
5943
- // arrow — Phase 24 renders the SAME ordered primitives the serializer emits
5944
- // (canvas-arrowheads), so the on-canvas and persisted forms can never drift.
5945
- return (
5946
- <g {...common} fill="none" pointerEvents={hitMode}>
5947
- {arrowPrimitives(stroke).map((p, i) => renderArrowPrimitive(p, i))}
5948
- </g>
5949
- );
5950
- }
5951
-
5952
4950
  /** Map one arrow primitive to JSX (heads inherit stroke from the parent <g>). */
5953
4951
  function renderArrowPrimitive(p: SvgPrimitive, key: number): JSX.Element {
5954
4952
  switch (p.el) {