@1agh/maude 0.29.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +1 -1
  2. package/apps/studio/ai-banner.tsx +0 -1
  3. package/apps/studio/annotations-align.ts +149 -0
  4. package/apps/studio/annotations-bindings.ts +197 -0
  5. package/apps/studio/annotations-context-toolbar.tsx +436 -186
  6. package/apps/studio/annotations-groups.ts +270 -0
  7. package/apps/studio/annotations-layer.tsx +1708 -1660
  8. package/apps/studio/annotations-model.ts +2077 -0
  9. package/apps/studio/annotations-snap.ts +125 -0
  10. package/apps/studio/api.ts +208 -116
  11. package/apps/studio/bin/_png-playwright.mjs +1 -1
  12. package/apps/studio/bin/annotate.mjs +732 -0
  13. package/apps/studio/bin/annotate.sh +17 -0
  14. package/apps/studio/bin/read-annotations.mjs +152 -17
  15. package/apps/studio/build.ts +1 -1
  16. package/apps/studio/canvas-arrowheads.ts +78 -9
  17. package/apps/studio/canvas-cursors.ts +2 -0
  18. package/apps/studio/canvas-edit.ts +257 -7
  19. package/apps/studio/canvas-icons.tsx +105 -0
  20. package/apps/studio/canvas-shell.tsx +305 -8
  21. package/apps/studio/client/app.jsx +2812 -238
  22. package/apps/studio/client/index.html +1 -1
  23. package/apps/studio/client/styles/3-shell-maude.css +713 -11
  24. package/apps/studio/client/styles/4-components.css +39 -0
  25. package/apps/studio/client/styles/5-maude-overrides.css +16 -2
  26. package/apps/studio/client/tour/overlay.jsx +105 -17
  27. package/apps/studio/client/tour/usage-tour.js +23 -10
  28. package/apps/studio/commands/annotation-strokes-command.ts +1 -1
  29. package/apps/studio/commands/move-artboards-command.ts +1 -1
  30. package/apps/studio/comments-overlay.tsx +7 -5
  31. package/apps/studio/context-menu.tsx +4 -3
  32. package/apps/studio/contextual-toolbar.tsx +14 -0
  33. package/apps/studio/cursors-overlay.tsx +20 -20
  34. package/apps/studio/dist/client.bundle.js +3322 -602
  35. package/apps/studio/dist/comment-mount.js +95 -9
  36. package/apps/studio/dist/styles.css +2490 -167
  37. package/apps/studio/dom-selection.ts +115 -0
  38. package/apps/studio/export-dialog.tsx +3 -3
  39. package/apps/studio/history.ts +20 -3
  40. package/apps/studio/http.ts +124 -5
  41. package/apps/studio/input-router.tsx +7 -0
  42. package/apps/studio/inspect.ts +37 -8
  43. package/apps/studio/participants-chrome.tsx +1 -7
  44. package/apps/studio/server.ts +5 -2
  45. package/apps/studio/sync/agent.ts +231 -57
  46. package/apps/studio/sync/codec.ts +45 -0
  47. package/apps/studio/sync/cold-start.ts +158 -0
  48. package/apps/studio/sync/connection-state.ts +58 -2
  49. package/apps/studio/sync/index.ts +563 -221
  50. package/apps/studio/sync/journal.ts +190 -0
  51. package/apps/studio/sync/migrate-seed.ts +202 -44
  52. package/apps/studio/sync/projection.ts +22 -3
  53. package/apps/studio/sync/status.ts +15 -4
  54. package/apps/studio/test/activity.test.ts +1 -7
  55. package/apps/studio/test/annotate-write.test.ts +184 -0
  56. package/apps/studio/test/annotations-align.test.ts +88 -0
  57. package/apps/studio/test/annotations-bindings.test.ts +124 -0
  58. package/apps/studio/test/annotations-groups.test.ts +231 -0
  59. package/apps/studio/test/annotations-snap.test.ts +79 -0
  60. package/apps/studio/test/canvas-edit.test.ts +181 -1
  61. package/apps/studio/test/canvas-origin-gate.test.ts +8 -0
  62. package/apps/studio/test/collab-bridge.test.ts +0 -2
  63. package/apps/studio/test/collab-room.test.ts +2 -7
  64. package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
  65. package/apps/studio/test/csrf-write-guard.test.ts +52 -0
  66. package/apps/studio/test/figjam-v3-model.test.ts +342 -0
  67. package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
  68. package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
  69. package/apps/studio/test/sync-agent.test.ts +272 -2
  70. package/apps/studio/test/sync-codec.test.ts +65 -0
  71. package/apps/studio/test/sync-cold-start.test.ts +244 -0
  72. package/apps/studio/test/sync-connection-state.test.ts +68 -0
  73. package/apps/studio/test/sync-hardening.test.ts +0 -8
  74. package/apps/studio/test/sync-hubs-config.test.ts +0 -1
  75. package/apps/studio/test/sync-incident-replay.test.ts +211 -0
  76. package/apps/studio/test/sync-journal.test.ts +176 -0
  77. package/apps/studio/test/sync-runtime.test.ts +360 -5
  78. package/apps/studio/test/sync-status.test.ts +66 -0
  79. package/apps/studio/test/use-annotation-resize.test.ts +159 -0
  80. package/apps/studio/test/use-artboard-drag.test.ts +0 -1
  81. package/apps/studio/test/use-collab.test.ts +0 -0
  82. package/apps/studio/test/use-tool-mode.test.tsx +5 -2
  83. package/apps/studio/tool-palette.tsx +12 -2
  84. package/apps/studio/undo-hud.tsx +0 -1
  85. package/apps/studio/use-agent-presence.tsx +4 -2
  86. package/apps/studio/use-annotation-resize.tsx +484 -54
  87. package/apps/studio/use-annotation-selection.tsx +16 -4
  88. package/apps/studio/use-canvas-activity.tsx +2 -2
  89. package/apps/studio/use-collab.tsx +20 -15
  90. package/apps/studio/use-selection-set.tsx +9 -0
  91. package/apps/studio/use-tool-mode.tsx +5 -1
  92. package/apps/studio/whats-new.json +135 -10
  93. package/apps/studio/whats-new.schema.json +18 -2
  94. package/cli/commands/design-link.test.mjs +84 -0
  95. package/cli/commands/design.mjs +2 -1
  96. package/cli/commands/design.test.mjs +5 -1
  97. package/cli/lib/design-link.mjs +51 -1
  98. package/package.json +8 -8
  99. package/plugins/design/templates/_shell.html +28 -4
@@ -34,18 +34,121 @@ import {
34
34
  useState,
35
35
  } from 'react';
36
36
  import { createPortal } from 'react-dom';
37
-
37
+ import {
38
+ type AlignEdge,
39
+ alignStrokes,
40
+ type DistributeAxis,
41
+ distributeStrokes,
42
+ } from './annotations-align.ts';
43
+ import {
44
+ anchorPoint,
45
+ BIND_THRESHOLD_PX,
46
+ bindCandidate,
47
+ isBindable,
48
+ recomputeBoundArrows,
49
+ } from './annotations-bindings.ts';
38
50
  import { AnnotationContextToolbar } from './annotations-context-toolbar.tsx';
39
51
  import {
40
- ARROW_HEADS,
41
- type ArrowHead,
42
- type ArrowLineType,
43
- arrowPrimitives,
44
- type SvgPrimitive,
45
- } from './canvas-arrowheads.ts';
52
+ duplicateStrokes,
53
+ expandIdsToGroups,
54
+ groupStrokes,
55
+ normalizeGroups,
56
+ outermostGroupOf,
57
+ reorderStrokes,
58
+ ungroupStrokes,
59
+ type ZOrderOp,
60
+ } from './annotations-groups.ts';
61
+ import {
62
+ type AnchorHost,
63
+ type ArrowStroke,
64
+ applyDrawModifiers,
65
+ clampLinkTitle,
66
+ DEFAULT_FONT_SIZE,
67
+ DEFAULT_HIGHLIGHTER_COLOR,
68
+ DEFAULT_HIGHLIGHTER_WIDTH,
69
+ DEFAULT_SECTION_COLOR,
70
+ DEFAULT_STICKY_COLOR,
71
+ type DrawMods,
72
+ defaultFillFor,
73
+ type EditorFmt,
74
+ FILL_PALETTE,
75
+ fmtEqual,
76
+ HALO_PAD_PX,
77
+ HIGHLIGHTER_PALETTE,
78
+ HIGHLIGHTER_WIDTHS,
79
+ IMAGE_MAX_DROP_SIDE,
80
+ IMAGE_MIN_SIZE,
81
+ type ImageStroke,
82
+ isStrokeMeaningful,
83
+ LINK_CARD_FILL,
84
+ LINK_CARD_STROKE,
85
+ LINK_DEFAULT_H,
86
+ LINK_DEFAULT_W,
87
+ LINK_DOMAIN_FILL,
88
+ LINK_GLYPH_D1,
89
+ LINK_GLYPH_D2,
90
+ LINK_GLYPH_STROKE,
91
+ LINK_TITLE_FILL,
92
+ type ListType,
93
+ linkCardLayout,
94
+ listPrefixedBody,
95
+ listPrefixedLine,
96
+ normalizeBox,
97
+ normalizeRect,
98
+ normalizeSticky,
99
+ normFmt,
100
+ penPathD,
101
+ polygonPoints,
102
+ resolveDefaultInk,
103
+ rid,
104
+ SECTION_CORNER_RADIUS,
105
+ SECTION_DEFAULT_H,
106
+ SECTION_DEFAULT_W,
107
+ SECTION_LABEL_FONT,
108
+ SECTION_LABEL_H,
109
+ SECTION_MIN_SIZE,
110
+ type SectionStroke,
111
+ SHAPE_DEFAULT_SIZE,
112
+ STICKY_CORNER_RADIUS,
113
+ STICKY_DEFAULT_H,
114
+ STICKY_DEFAULT_W,
115
+ STICKY_MIN_SIZE,
116
+ STICKY_PALETTE,
117
+ STROKE_PALETTE,
118
+ STROKE_WIDTH_THICK,
119
+ STROKE_WIDTH_THIN,
120
+ type StickyStroke,
121
+ type Stroke,
122
+ splitTextLines,
123
+ stickyCornerPath,
124
+ stripEditorMarkers,
125
+ strokeBBox,
126
+ strokeCenter,
127
+ strokeHitTest,
128
+ strokeRotation,
129
+ strokesShallowEqual,
130
+ strokesToSvg,
131
+ svgToStrokes,
132
+ TEXT_LINE_HEIGHT,
133
+ type TextAlign,
134
+ type TextStroke,
135
+ type Thickness,
136
+ textDecoCss,
137
+ textLineDy,
138
+ translateOne,
139
+ type WorldPoint,
140
+ } from './annotations-model.ts';
141
+ import {
142
+ computeSnap,
143
+ GRID_PITCH_PX,
144
+ SNAP_THRESHOLD_PX,
145
+ type SnapGuide,
146
+ } from './annotations-snap.ts';
147
+ import { arrowPrimitives, type SvgPrimitive } from './canvas-arrowheads.ts';
46
148
  import { IconLineThick, IconLineThin } from './canvas-icons.tsx';
47
149
  import { useViewportControllerContext, useWorldRefContext } from './canvas-lib.tsx';
48
150
  import { buildAnnotationStrokesRecord } from './commands/annotation-strokes-command.ts';
151
+ import { ensureMenuStyles as ensureCtxMenuStyles } from './context-menu.tsx';
49
152
  import { crossedDragThreshold, type Tool } from './input-router.tsx';
50
153
  import { AnnotationResizeOverlay } from './use-annotation-resize.tsx';
51
154
  import { useAnnotationSelectionOptional } from './use-annotation-selection.tsx';
@@ -63,19 +166,20 @@ import { useSelectionSetOptional } from './use-selection-set.tsx';
63
166
  import { type ShapeKind, useToolMode } from './use-tool-mode.tsx';
64
167
  import { useUndoSinks, useUndoStackOptional } from './use-undo-stack.tsx';
65
168
 
169
+ // FigJam v3 — the pure data model (Stroke types, palettes, serialize/parse,
170
+ // geometry) lives in annotations-model.ts: React-free, importable headlessly
171
+ // by bun tests and the `maude design annotate` write verb. The layer
172
+ // re-exports the whole model so every existing
173
+ // `from './annotations-layer.tsx'` import keeps working unchanged.
174
+ export * from './annotations-model.ts';
175
+
66
176
  // ─────────────────────────────────────────────────────────────────────────────
67
177
  // Types
68
178
 
69
- type WorldPoint = readonly [number, number];
70
-
71
179
  // Phase 24 — arrow style enums are OWNED by canvas-arrowheads.ts (so that
72
180
  // module imports nothing back from here — no cycle, see DDR-067) and re-exported
73
181
  // here for back-compat (context-toolbar etc. import them from this module).
74
182
  export type { ArrowHead, ArrowLineType } from './canvas-arrowheads.ts';
75
- /** Phase 24 — polygon shape primitives (diamond + the two triangle pointings). */
76
- export type PolygonShape = 'diamond' | 'triangle' | 'triangle-down';
77
- /** Phase 24 — horizontal alignment for text + sticky bodies. */
78
- export type TextAlign = 'left' | 'center' | 'right';
79
183
 
80
184
  /** Phase 24 — cursor-following ghost placeholder descriptor (pure chrome). */
81
185
  type GhostDescriptor =
@@ -83,199 +187,6 @@ type GhostDescriptor =
83
187
  | { kind: 'sticky'; x: number; y: number; color: string }
84
188
  | { kind: 'shape'; x: number; y: number; shapeKind: ShapeKind; color: string };
85
189
 
86
- export interface PenStroke {
87
- id: string;
88
- tool: 'pen';
89
- color: string;
90
- width: number;
91
- points: WorldPoint[];
92
- /**
93
- * Highlighter (item 8). A `highlighter:true` pen reuses ALL pen draw / erase /
94
- * hit-test / translate logic; it just renders wide + translucent with
95
- * `mix-blend-mode:multiply` (overlaps darken) and carries a translucent
96
- * marker colour. Absent / false = a normal solid pen (back-compat).
97
- */
98
- highlighter?: boolean;
99
- }
100
- export interface RectStroke {
101
- id: string;
102
- tool: 'rect';
103
- color: string;
104
- width: number;
105
- x: number;
106
- y: number;
107
- w: number;
108
- h: number;
109
- fill?: string | null;
110
- /** Phase 21 — corner radius (rx/ry). Absent / 0 = sharp 90° corners (back-compat). */
111
- cornerRadius?: number;
112
- /** Dashed outline (stroke-dasharray). Absent / false = solid (back-compat). */
113
- dashed?: boolean;
114
- }
115
- export interface EllipseStroke {
116
- id: string;
117
- tool: 'ellipse';
118
- color: string;
119
- width: number;
120
- cx: number;
121
- cy: number;
122
- rx: number;
123
- ry: number;
124
- fill?: string | null;
125
- /** Dashed outline (stroke-dasharray). Absent / false = solid (back-compat). */
126
- dashed?: boolean;
127
- }
128
- /**
129
- * Phase 24 — diamond / triangle / triangle-down primitives. Stored as a bbox
130
- * (x/y/w/h, exactly like a rect) + a `shape` discriminant; the actual SVG
131
- * points are derived from the bbox at serialize + render time. Brand-new on
132
- * disk (`<polygon data-tool="polygon" data-shape="…">`), so no back-compat
133
- * constraint — only idempotent round-trip.
134
- */
135
- export interface PolygonStroke {
136
- id: string;
137
- tool: 'polygon';
138
- shape: PolygonShape;
139
- color: string;
140
- width: number;
141
- x: number;
142
- y: number;
143
- w: number;
144
- h: number;
145
- fill?: string | null;
146
- /** Dashed outline (stroke-dasharray). Absent / false = solid. */
147
- dashed?: boolean;
148
- }
149
- export interface ArrowStroke {
150
- id: string;
151
- tool: 'arrow';
152
- color: string;
153
- width: number;
154
- x1: number;
155
- y1: number;
156
- x2: number;
157
- y2: number;
158
- /** Head on the (x1,y1) start. Absent = 'none' (back-compat). Phase 24 widened the enum. */
159
- startHead?: ArrowHead;
160
- /** Head on the (x2,y2) end. Absent = 'triangle' (back-compat). Phase 24 widened the enum. */
161
- endHead?: ArrowHead;
162
- /** Phase 21 — dashed shaft (stroke-dasharray). Absent / false = solid. */
163
- dashed?: boolean;
164
- /** Phase 24 — shaft routing. Absent = 'straight' (back-compat). */
165
- lineType?: ArrowLineType;
166
- }
167
- export interface TextStroke {
168
- id: string;
169
- tool: 'text';
170
- color: string;
171
- fontSize: number;
172
- text: string;
173
- /**
174
- * Host shape id for anchored text (double-click a rect/ellipse). Phase 21
175
- * relaxed this to optional: standalone text (the `text` tool) carries no
176
- * anchor and renders at its own world `(x, y)` instead.
177
- */
178
- anchorId?: string;
179
- /** Phase 21 — world coords for standalone (unanchored) text. */
180
- x?: number;
181
- y?: number;
182
- /** Phase 24 — bold weight. Absent / false = normal (back-compat). */
183
- bold?: boolean;
184
- /** Phase 24 — strikethrough. Absent / false = none (back-compat). */
185
- strike?: boolean;
186
- /** Italic style (item 4b). Absent / false = upright (back-compat). */
187
- italic?: boolean;
188
- /** Underline (item 4b). Combined with strike into one text-decoration. */
189
- underline?: boolean;
190
- /** List style (item 4c). Markers are render-only — never stored in `text`. */
191
- listType?: ListType;
192
- /**
193
- * Phase 24 — horizontal alignment. Absent default differs by kind: anchored
194
- * text = 'center' (legacy, byte-identical), standalone = 'left'.
195
- */
196
- align?: TextAlign;
197
- }
198
- /** Phase 21 — sticky note: a paper-tone card with its own word-wrapped text. */
199
- export interface StickyStroke {
200
- id: string;
201
- tool: 'sticky';
202
- color: string;
203
- x: number;
204
- y: number;
205
- w: number;
206
- h: number;
207
- text: string;
208
- fontSize: number;
209
- /** Corner radius; defaults to STICKY_CORNER_RADIUS (8 = soft). */
210
- cornerRadius?: number;
211
- /** Phase 24 — bold body weight. Absent / false = normal. */
212
- bold?: boolean;
213
- /** Phase 24 — strikethrough body. Absent / false = none. */
214
- strike?: boolean;
215
- /** Italic body (item 4b). Absent / false = upright. */
216
- italic?: boolean;
217
- /** Underline body (item 4b). Combined with strike into one text-decoration. */
218
- underline?: boolean;
219
- /** List style (item 4c). Markers are render-only — never stored in `text`. */
220
- listType?: ListType;
221
- /** Phase 24 — body alignment. Absent = 'left' (FigJam sticky default). */
222
- align?: TextAlign;
223
- }
224
- /**
225
- * Phase 23 — dropped / pasted raster image. Free-floating, rect-shaped, moves
226
- * and resizes like any annotation. `href` is ALWAYS a relative
227
- * `assets/<sha8>.<ext>` path (never a data: URL — keeps the persisted SVG under
228
- * its 1 MB cap and matches the sanitizer's `<image>` href allowlist). The live
229
- * canvas may briefly render an optimistic `blob:` href before the upload swaps
230
- * it to the content-addressed path; only the `assets/…` form is ever persisted.
231
- */
232
- export interface ImageStroke {
233
- id: string;
234
- tool: 'image';
235
- x: number;
236
- y: number;
237
- w: number;
238
- h: number;
239
- href: string;
240
- /**
241
- * Alt text. Persisted in `data-alt` and emitted as `aria-label` on the
242
- * `<image>`, so it travels with the exported / saved SVG (where AT reads it).
243
- * NOTE: in the LIVE canvas the whole annotation SVG root is `aria-hidden`
244
- * (editor chrome — AT shouldn't be flooded by decorative strokes), so the live
245
- * in-canvas `aria-label` is pruned; the alt's audience is the export. Absent ⇒ ''.
246
- */
247
- alt?: string;
248
- }
249
- /**
250
- * Phase 23 — pasted / dropped URL rendered as a client-only preview chip. NO
251
- * server fetch and NO external favicon (the dev-server stays zero-egress —
252
- * DDR-054/060). `title` comes from the clipboard/DnD `text/html` anchor text
253
- * when present, else the prettified URL; `domain` is `new URL(url).hostname`.
254
- * Persists as an allowlisted `<g>` (rect + vector glyph + two `<text>` runs) —
255
- * the click-to-open handler reads `data-url`, no `<a href>` is ever stored.
256
- */
257
- export interface LinkStroke {
258
- id: string;
259
- tool: 'link';
260
- x: number;
261
- y: number;
262
- w: number;
263
- h: number;
264
- url: string;
265
- title: string;
266
- domain: string;
267
- }
268
- export type Stroke =
269
- | PenStroke
270
- | RectStroke
271
- | EllipseStroke
272
- | PolygonStroke
273
- | ArrowStroke
274
- | TextStroke
275
- | StickyStroke
276
- | ImageStroke
277
- | LinkStroke;
278
-
279
190
  /**
280
191
  * Phase 21 — what the inline editor is currently bound to. `anchored` edits
281
192
  * the text hosted by a rect/ellipse; `sticky` edits a card body; `standalone`
@@ -283,48 +194,14 @@ export type Stroke =
283
194
  * exists until real text is committed).
284
195
  */
285
196
  type EditingTarget =
286
- | { kind: 'anchored'; anchorId: string; host: RectStroke | EllipseStroke }
197
+ | { kind: 'anchored'; anchorId: string; host: AnchorHost }
287
198
  | { kind: 'sticky'; sticky: StickyStroke }
288
199
  | { kind: 'standalone'; text: TextStroke }
200
+ /** FigJam v3 — renaming a section's label chip. */
201
+ | { kind: 'section'; section: SectionStroke }
289
202
  | { kind: 'pending'; x: number; y: number }
290
203
  | null;
291
204
 
292
- // Phase 21 colour system — a single coherent hue family used everywhere.
293
- // FigJam model: stroke (saturated ink) is INDEPENDENT of fill, and fills are
294
- // light TINTS of the same hue (index-paired with STROKE_PALETTE). Stickies use
295
- // their own lightened paper set (STICKY_PALETTE). Exported so the draw-time
296
- // chrome AND the per-selection context toolbar share ONE palette instead of
297
- // drifting apart.
298
- export const STROKE_PALETTE = [
299
- '#e5484d', // red (default — markup ink)
300
- '#f2762a', // orange
301
- '#e0a500', // amber
302
- '#30a46c', // green
303
- '#3b82f6', // blue
304
- '#8b5cf6', // purple
305
- '#e93d82', // pink
306
- '#7c7c7c', // gray
307
- '#1f1f1f', // ink
308
- ] as const;
309
- type PaletteColor = (typeof STROKE_PALETTE)[number];
310
- // Phase 24 — default markup ink is BLACK (the `#1f1f1f` ink swatch, slot 8) for
311
- // EVERY ink tool (pen / shape / arrow / text). It's a palette member so the
312
- // draw chrome + per-selection toolbar highlight it as the active swatch; the
313
- // other hues stay one click away. (Stickies keep their warm-paper default —
314
- // DEFAULT_STICKY_COLOR — they're paper, not ink.)
315
- const DEFAULT_COLOR: PaletteColor = STROKE_PALETTE[8];
316
- // Annotation polish — the LIVE default ink follows the canvas theme so a
317
- // freshly-armed pen/shape/arrow/text reads true on dark canvases (the
318
- // `#1f1f1f` ink is near-invisible on a dark mock). Light → the `#1f1f1f`
319
- // ink slot; dark → a light ink that reads on dark. This is the live draw
320
- // default ONLY — `DEFAULT_COLOR` stays the parse fallback (round-trip
321
- // determinism + back-compat), and stored strokes keep their literal hex
322
- // (FigJam parity — no retroactive recolour).
323
- const DEFAULT_INK_DARK = '#ededed';
324
- export function resolveDefaultInk(theme: string): string {
325
- return theme === 'dark' ? DEFAULT_INK_DARK : DEFAULT_COLOR;
326
- }
327
-
328
205
  /**
329
206
  * The canvas-shell CHROME theme — `data-maude-theme` on `<html>`, default
330
207
  * 'dark'. This (NOT the DS `data-theme`, which is deliberately separate and
@@ -353,266 +230,6 @@ function useCanvasChromeTheme(): 'light' | 'dark' {
353
230
  return theme;
354
231
  }
355
232
 
356
- // Light tints, index-paired to STROKE_PALETTE — picking "blue fill" gives a
357
- // pale blue wash under a saturated stroke, exactly like FigJam shapes.
358
- export const FILL_PALETTE = [
359
- '#fbe0e1', // red tint
360
- '#fce6d6', // orange tint
361
- '#fbeec2', // amber tint
362
- '#d9f1e2', // green tint
363
- '#e0ebfd', // blue tint
364
- '#ebe3fc', // purple tint
365
- '#fbdfeb', // pink tint
366
- '#ededed', // gray tint
367
- '#e7e7e7', // ink tint
368
- ] as const;
369
-
370
- // Neutral fill wash for the ink slot (no paired hue) — light vs dark canvas.
371
- const NEUTRAL_FILL_LIGHT = FILL_PALETTE[8]; // '#e7e7e7'
372
- const NEUTRAL_FILL_DARK = '#2a2a2a';
373
- /**
374
- * Annotation polish (item 2) — the LIVE default fill for a freshly-armed Shape
375
- * tool. A coloured ink maps to its index-paired light tint (FigJam: a saturated
376
- * outline over a pale wash of the same hue); the ink slot / themed-dark ink /
377
- * any unknown hex maps to a neutral wash. "No fill" stays one click away (the
378
- * chrome's None swatch) and, once picked, sticks (fillTouchedRef). Stored
379
- * shapes keep their literal fill — only NEW shapes pick up this default.
380
- */
381
- export function defaultFillFor(color: string, theme: string): string {
382
- const idx = STROKE_PALETTE.indexOf(color as PaletteColor);
383
- // Coloured ink (slots 0–7) → its paired tint; ink slot (8) / unknown → neutral.
384
- if (idx >= 0 && idx < FILL_PALETTE.length - 1) return FILL_PALETTE[idx];
385
- return theme === 'dark' ? NEUTRAL_FILL_DARK : NEUTRAL_FILL_LIGHT;
386
- }
387
-
388
- const STROKE_WIDTH_THIN = 3;
389
- const STROKE_WIDTH_THICK = 6;
390
- type Thickness = typeof STROKE_WIDTH_THIN | typeof STROKE_WIDTH_THICK;
391
-
392
- const FONT_SIZE_MEDIUM = 14;
393
- const DEFAULT_FONT_SIZE = FONT_SIZE_MEDIUM;
394
-
395
- // Phase 24 — sticky-note paper tints. A muted/desaturated FigJam-style set
396
- // (Image #2): a warm paper yellow default, then white/grey + soft pastels.
397
- // Wholly separate from the stroke ink PALETTE and the translucent FILL_PALETTE
398
- // so stickies read as "paper", not "ink". Slot 0 (yellow) is the default.
399
- // Existing stickies keep their stored hex; only NEW stickies pick up the new
400
- // default tint.
401
- export const STICKY_PALETTE = [
402
- '#fce8a6', // muted yellow (default — warm paper)
403
- '#ffffff', // white
404
- '#e6e4e0', // light grey
405
- '#f7c5c0', // salmon
406
- '#f8d2a6', // peach
407
- '#bfe3c0', // mint
408
- '#a9dbdb', // aqua
409
- '#bcd2f0', // light blue
410
- '#cfc4ec', // lavender
411
- '#f3c4dd', // light pink
412
- ] as const;
413
- const DEFAULT_STICKY_COLOR = STICKY_PALETTE[0];
414
- const STICKY_CORNER_RADIUS = 8;
415
-
416
- // Annotation polish (item 8) — highlighter marker hues. Translucent 8-digit hex
417
- // (RRGGBBAA, ~50% alpha) so overlaps darken under `mix-blend-mode:multiply`.
418
- // Yellow is the default; green / pink / blue follow. Wholly separate from the
419
- // ink PALETTE — the highlighter draws a soft wash, not a saturated line.
420
- export const HIGHLIGHTER_PALETTE = [
421
- '#ffe24d80', // yellow (default)
422
- '#7ce8a080', // green
423
- '#ff9ed180', // pink
424
- '#7ec5ff80', // blue
425
- ] as const;
426
- const DEFAULT_HIGHLIGHTER_COLOR = HIGHLIGHTER_PALETTE[0];
427
- // Highlighter marker nib widths (item 8) — three sizes (thin / medium / thick),
428
- // all wider than the pen. Default medium.
429
- const HIGHLIGHTER_WIDTHS = [10, 18, 28] as const;
430
- const DEFAULT_HIGHLIGHTER_WIDTH = HIGHLIGHTER_WIDTHS[1];
431
- // Phase 24 — stickies are 1:1; the default tap size is a square.
432
- const STICKY_DEFAULT_W = 200;
433
- const STICKY_DEFAULT_H = 200;
434
- const STICKY_MIN_SIZE = 40;
435
- // Phase 24 — a bare tap with the Shape tool drops a default-sized shape at the
436
- // tap point (FigJam parity: click commits, drag sizes). Square aspect.
437
- const SHAPE_DEFAULT_SIZE = 120;
438
-
439
- // Phase 23 — image + link media strokes.
440
- /** Below this side an image stroke is discarded as an accidental micro-drop. */
441
- const IMAGE_MIN_SIZE = 16;
442
- /** Longest side a freshly dropped/pasted image is scaled down to (world px). */
443
- export const IMAGE_MAX_DROP_SIDE = 480;
444
- const LINK_DEFAULT_W = 260;
445
- const LINK_DEFAULT_H = 76;
446
- const LINK_CARD_FILL = '#ffffff';
447
- const LINK_CARD_STROKE = '#d4d4d8';
448
- const LINK_DOMAIN_FILL = '#71717a';
449
- const LINK_TITLE_FILL = '#18181b';
450
- const LINK_GLYPH_STROKE = '#52525b';
451
- // Lucide "link" icon (24×24 viewBox) — two interlocked loops. ONE source for the
452
- // serialized nested-<svg> glyph AND the StrokeNode render so re-serialize stays
453
- // byte-stable. The parser ignores the glyph entirely (it reads data-* + the
454
- // <rect> geometry), so render/serialize only need to agree visually.
455
- const LINK_GLYPH_D1 = 'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71';
456
- const LINK_GLYPH_D2 = 'M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71';
457
-
458
- /** Card text positions, derived purely from the bbox (idempotent round-trip). */
459
- function linkCardLayout(x: number, y: number, w: number, h: number) {
460
- const textX = x + 48;
461
- return {
462
- glyph: { x: x + 16, y: y + h / 2 - 10, size: 20 },
463
- textX,
464
- domain: { y: y + h / 2 - 14, fontSize: 11 },
465
- title: { y: y + h / 2, fontSize: 13 },
466
- textMaxChars: Math.max(8, Math.floor((w - 60) / 7)),
467
- };
468
- }
469
-
470
- /** Clamp a link title to the card's character budget (pure → byte-stable). */
471
- function clampLinkTitle(title: string, maxChars: number): string {
472
- return title.length > maxChars ? `${title.slice(0, Math.max(1, maxChars - 1))}…` : title;
473
- }
474
-
475
- // ─────────────────────────────────────────────────────────────────────────────
476
- // Pure helpers — exported for unit tests.
477
-
478
- export function rid(): string {
479
- return `s_${Math.random().toString(36).slice(2, 10)}`;
480
- }
481
-
482
- function esc(s: string): string {
483
- return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
484
- }
485
-
486
- /**
487
- * Phase 23 — attribute-safe escape: `esc()` plus `>`. The legacy text/sticky
488
- * paths only put user text in element CONTENT (where a bare `>` is harmless),
489
- * so `esc()` never escaped it; but the media strokes carry user text (pasted
490
- * link title/url, image alt) inside ATTRIBUTES (data-title / data-url / data-alt
491
- * / href). A bare `>` there would prematurely close the tag and confuse the
492
- * `[^>]*>` element scan in `sanitizeAnnotationSvg`. Use this for every media
493
- * attribute value; element CONTENT keeps plain `esc()`.
494
- */
495
- function escAttr(s: string): string {
496
- return esc(s).replace(/>/g, '&gt;');
497
- }
498
-
499
- export function penPathD(points: readonly WorldPoint[]): string {
500
- if (points.length === 0) return '';
501
- const [first, ...rest] = points as readonly WorldPoint[];
502
- if (!first) return '';
503
- let d = `M${first[0]} ${first[1]}`;
504
- for (const p of rest) d += ` L${p[0]} ${p[1]}`;
505
- return d;
506
- }
507
-
508
- // ── Multi-line text (item 4a) ────────────────────────────────────────────────
509
- // SVG <text> ignores `\n`, so multi-line annotation text must render as one
510
- // <tspan> per line. The serialized + live forms share this geometry; single-
511
- // line text keeps the legacy single-run form (no tspan) so the canary holds.
512
-
513
- /** Line-height multiplier for multi-line annotation text. */
514
- const TEXT_LINE_HEIGHT = 1.25;
515
-
516
- /** Split a text body into its display lines. */
517
- export function splitTextLines(text: string): string[] {
518
- return text.split('\n');
519
- }
520
-
521
- /** Annotation polish (item 4c) — list style for text + sticky bodies. */
522
- export type ListType = 'bullet' | 'number';
523
-
524
- /**
525
- * Render-time list marker prefix for one line. Markers are PRESENTATION ONLY —
526
- * never stored in `text` (DDR) — so the stored string stays clean and
527
- * contentEditable editing is sane. Bullet → `• `; number → `${i + 1}. `.
528
- */
529
- function listPrefixedLine(line: string, index: number, list?: ListType): string {
530
- if (!list) return line;
531
- return list === 'bullet' ? `• ${line}` : `${index + 1}. ${line}`;
532
- }
533
-
534
- /** Inverse of {@link listPrefixedLine} — strip a render-time marker on parse. */
535
- function stripListPrefix(line: string, index: number, list?: ListType): string {
536
- if (!list) return line;
537
- const marker = list === 'bullet' ? '• ' : `${index + 1}. `;
538
- return line.startsWith(marker) ? line.slice(marker.length) : line;
539
- }
540
-
541
- /** Prefix every line of a body with its list marker (for the editor display). */
542
- export function listPrefixedBody(text: string, list?: ListType): string {
543
- if (!list) return text;
544
- return splitTextLines(text)
545
- .map((line, i) => listPrefixedLine(line, i, list))
546
- .join('\n');
547
- }
548
-
549
- /**
550
- * Strip list markers off editor `innerText` on commit (item 4c). Generic — a
551
- * `•` bullet OR any leading `N. ` number is removed once per line, regardless of
552
- * the index the user actually typed, so re-numbering while editing round-trips
553
- * cleanly (the stored text stays marker-free; the read view re-derives markers).
554
- */
555
- export function stripEditorMarkers(text: string, list?: ListType): string {
556
- if (!list) return text;
557
- const re = list === 'bullet' ? /^• / : /^\d+\.\s/;
558
- return splitTextLines(text)
559
- .map((line) => line.replace(re, ''))
560
- .join('\n');
561
- }
562
-
563
- /**
564
- * Combined `text-decoration` SVG attribute for strike + underline (item 4b).
565
- * Strike-only stays `text-decoration="line-through"` (byte-identical to the
566
- * legacy Phase-24 form); both → `line-through underline`; neither → empty.
567
- */
568
- function textDecoAttr(strike?: boolean, underline?: boolean): string {
569
- const vals: string[] = [];
570
- if (strike) vals.push('line-through');
571
- if (underline) vals.push('underline');
572
- return vals.length ? ` text-decoration="${vals.join(' ')}"` : '';
573
- }
574
-
575
- /** CSS `text-decoration` value for the live render (strike + underline). */
576
- function textDecoCss(strike?: boolean, underline?: boolean): string | undefined {
577
- const vals: string[] = [];
578
- if (strike) vals.push('line-through');
579
- if (underline) vals.push('underline');
580
- return vals.length ? vals.join(' ') : undefined;
581
- }
582
-
583
- /**
584
- * Inline text formatting carried by an editor through commit (item 4b/4d
585
- * unification) — so Cmd+B / Cmd+I / Cmd+U toggled WHILE editing land on the
586
- * stroke. `strike` rides along unchanged (no shortcut; toolbar-only).
587
- */
588
- export interface EditorFmt {
589
- bold?: boolean;
590
- italic?: boolean;
591
- underline?: boolean;
592
- strike?: boolean;
593
- }
594
- /** Normalize an EditorFmt → only-true keys kept; false becomes undefined so the
595
- * serialize-only-when-set invariant + byte-identical canary hold. */
596
- function normFmt(fmt?: EditorFmt): EditorFmt {
597
- return {
598
- bold: fmt?.bold || undefined,
599
- italic: fmt?.italic || undefined,
600
- underline: fmt?.underline || undefined,
601
- strike: fmt?.strike || undefined,
602
- };
603
- }
604
- /** True when a stroke's existing formatting already matches `fmt` (so a pure
605
- * identity edit can short-circuit without a redundant undo record). */
606
- function fmtEqual(s: EditorFmt, fmt?: EditorFmt): boolean {
607
- if (!fmt) return true;
608
- return (
609
- !!s.bold === !!fmt.bold &&
610
- !!s.italic === !!fmt.italic &&
611
- !!s.underline === !!fmt.underline &&
612
- !!s.strike === !!fmt.strike
613
- );
614
- }
615
-
616
233
  /**
617
234
  * Shared inline-formatting state for the three text editors (sticky / anchored /
618
235
  * standalone) — the unification surface (item 4d). Cmd/Ctrl + B / I / U toggle
@@ -625,1012 +242,86 @@ function fmtEqual(s: EditorFmt, fmt?: EditorFmt): boolean {
625
242
  function useEditorFormat(initial: EditorFmt): {
626
243
  fmtRef: { current: EditorFmt };
627
244
  style: CSSProperties;
628
- onFormatKey: (e: ReactKeyboardEvent) => boolean;
629
- } {
630
- const [bold, setBold] = useState(!!initial.bold);
631
- const [italic, setItalic] = useState(!!initial.italic);
632
- const [underline, setUnderline] = useState(!!initial.underline);
633
- const strike = !!initial.strike;
634
- const fmtRef = useRef<EditorFmt>({ bold, italic, underline, strike });
635
- fmtRef.current = { bold, italic, underline, strike };
636
- const style: CSSProperties = {
637
- fontWeight: bold ? 700 : undefined,
638
- fontStyle: italic ? 'italic' : undefined,
639
- textDecoration: textDecoCss(strike, underline),
640
- };
641
- const onFormatKey = useCallback((e: ReactKeyboardEvent): boolean => {
642
- if (!(e.metaKey || e.ctrlKey) || e.altKey || e.shiftKey) return false;
643
- const k = e.key.toLowerCase();
644
- if (k === 'b') {
645
- e.preventDefault();
646
- setBold((v) => !v);
647
- return true;
648
- }
649
- if (k === 'i') {
650
- e.preventDefault();
651
- setItalic((v) => !v);
652
- return true;
653
- }
654
- if (k === 'u') {
655
- e.preventDefault();
656
- setUnderline((v) => !v);
657
- return true;
658
- }
659
- return false;
660
- }, []);
661
- return { fmtRef, style, onFormatKey };
662
- }
663
-
664
- /**
665
- * Per-line baseline offset (`dy`). Line 0 sits at the anchor when top-anchored
666
- * (hanging) or is lifted half the block height when vertically centred
667
- * (anchored-in-host); every later line advances one line-height.
668
- */
669
- function textLineDy(i: number, fontSize: number, lineCount: number, centered: boolean): number {
670
- const lh = fontSize * TEXT_LINE_HEIGHT;
671
- if (i > 0) return lh;
672
- return centered ? (-(lineCount - 1) / 2) * lh : 0;
673
- }
674
-
675
- /**
676
- * Inner content for a serialized `<text>` stroke: the legacy single esc'd run
677
- * when there's no newline (byte-identical, canary-safe), else one `<tspan>` per
678
- * line. `x` is set on each tspan for standalone text (resets the line origin);
679
- * anchored text omits it (the persisted form carries no absolute position —
680
- * geometry is resolved against the host at render time). A `list` prefix
681
- * (bullet / number) is prepended per line at render time only (DDR — never
682
- * stored in `text`).
683
- */
684
- function textInnerSvg(
685
- text: string,
686
- fontSize: number,
687
- centered: boolean,
688
- x: number | undefined,
689
- list?: ListType
690
- ): string {
691
- if (!list && !text.includes('\n')) return esc(listPrefixedLine(text, 0, list));
692
- const lines = splitTextLines(text);
693
- const xAttr = x != null ? ` x="${x}"` : '';
694
- return lines
695
- .map(
696
- (line, i) =>
697
- `<tspan${xAttr} dy="${textLineDy(i, fontSize, lines.length, centered)}">${esc(
698
- listPrefixedLine(line, i, list)
699
- )}</tspan>`
700
- )
701
- .join('');
702
- }
703
-
704
- // Phase 24 — moved to canvas-arrowheads.ts (single source for shaft + heads).
705
- // Re-exported so the existing test import (`from '../annotations-layer.tsx'`)
706
- // and the byte-identical canary keep working.
707
- export { arrowHeadPoints } from './canvas-arrowheads.ts';
708
-
709
- /**
710
- * Phase 24 — polygon vertices derived from the bbox. `diamond` = the four
711
- * edge-midpoints; `triangle` = apex-up; `triangle-down` = apex-down. Every
712
- * shape's vertices span the FULL bbox, so a parse-back via the points' min/max
713
- * recovers x/y/w/h exactly (idempotent round-trip).
714
- */
715
- export function polygonVertices(
716
- shape: PolygonShape,
717
- x: number,
718
- y: number,
719
- w: number,
720
- h: number
721
- ): Array<[number, number]> {
722
- if (shape === 'diamond') {
723
- return [
724
- [x + w / 2, y],
725
- [x + w, y + h / 2],
726
- [x + w / 2, y + h],
727
- [x, y + h / 2],
728
- ];
729
- }
730
- if (shape === 'triangle') {
731
- return [
732
- [x + w / 2, y],
733
- [x + w, y + h],
734
- [x, y + h],
735
- ];
736
- }
737
- // triangle-down — apex at the bottom.
738
- return [
739
- [x, y],
740
- [x + w, y],
741
- [x + w / 2, y + h],
742
- ];
743
- }
744
-
745
- /** Vertices as an SVG `points` string. */
746
- export function polygonPoints(
747
- shape: PolygonShape,
748
- x: number,
749
- y: number,
750
- w: number,
751
- h: number
752
- ): string {
753
- return polygonVertices(shape, x, y, w, h)
754
- .map(([px, py]) => `${px},${py}`)
755
- .join(' ');
756
- }
757
-
758
- /**
759
- * Annotation polish (item 1) — a rounded-rect `d` with TL/TR/BL rounded at `r`
760
- * and the **bottom-right corner SHARP** (the FigJam sticky-note silhouette). The
761
- * radius is clamped to half the smaller side so it never self-overlaps. Used by
762
- * `StrokeNode`'s LIVE sticky render only; the persisted form (`strokeToSvgEl`)
763
- * stays a plain `<rect>` (DDR — zero canary / sanitizer / parse impact).
764
- */
765
- export function stickyCornerPath(x: number, y: number, w: number, h: number, r: number): string {
766
- const rr = Math.max(0, Math.min(r, w / 2, h / 2));
767
- return [
768
- `M${x + rr} ${y}`,
769
- `L${x + w - rr} ${y}`,
770
- `Q${x + w} ${y} ${x + w} ${y + rr}`,
771
- `L${x + w} ${y + h}`, // sharp bottom-right
772
- `L${x + rr} ${y + h}`,
773
- `Q${x} ${y + h} ${x} ${y + h - rr}`,
774
- `L${x} ${y + rr}`,
775
- `Q${x} ${y} ${x + rr} ${y}`,
776
- 'Z',
777
- ].join(' ');
778
- }
779
-
780
- /** Even-odd ray-cast point-in-polygon test. */
781
- function pointInPolygon(px: number, py: number, pts: ReadonlyArray<[number, number]>): boolean {
782
- let inside = false;
783
- for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
784
- const a = pts[i];
785
- const b = pts[j];
786
- if (!a || !b) continue;
787
- const [xi, yi] = a;
788
- const [xj, yj] = b;
789
- if (yi > py !== yj > py && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi) {
790
- inside = !inside;
791
- }
792
- }
793
- return inside;
794
- }
795
-
796
- /** Parse a polygon `points` string back into its bounding box. */
797
- function polygonBBox(points: string): { x: number; y: number; w: number; h: number } | null {
798
- let xMin = Number.POSITIVE_INFINITY;
799
- let yMin = Number.POSITIVE_INFINITY;
800
- let xMax = Number.NEGATIVE_INFINITY;
801
- let yMax = Number.NEGATIVE_INFINITY;
802
- for (const pair of points.trim().split(/\s+/)) {
803
- const [px, py] = pair.split(',').map((n) => Number.parseFloat(n));
804
- if (px == null || py == null || Number.isNaN(px) || Number.isNaN(py)) continue;
805
- if (px < xMin) xMin = px;
806
- if (px > xMax) xMax = px;
807
- if (py < yMin) yMin = py;
808
- if (py > yMax) yMax = py;
809
- }
810
- if (!Number.isFinite(xMin)) return null;
811
- return { x: xMin, y: yMin, w: xMax - xMin, h: yMax - yMin };
812
- }
813
-
814
- function strokeToSvgEl(s: Stroke): string {
815
- if (s.tool === 'text') {
816
- // Phase 21 — anchored text keeps the byte-identical Phase 5.1 form;
817
- // standalone text (no anchorId) writes its own world x/y and omits
818
- // data-anchor-id (so the parser routes it back to the standalone branch).
819
- // bold/italic/strike/underline/align/list serialize ONLY for non-default
820
- // values, so a legacy text node stays byte-identical (every added fragment
821
- // is empty). Multi-line text emits one <tspan> per line (item 4a); a
822
- // single-line unstyled run stays the legacy single esc'd text.
823
- const weight = s.bold ? ' font-weight="700"' : '';
824
- const italic = s.italic ? ' font-style="italic"' : '';
825
- const deco = textDecoAttr(s.strike, s.underline);
826
- const listAttr = s.listType ? ` data-list="${s.listType}"` : '';
827
- if (s.anchorId != null && s.anchorId !== '') {
828
- const align = s.align ?? 'center'; // anchored default = centre (legacy)
829
- const anchor = align === 'left' ? 'start' : align === 'right' ? 'end' : 'middle';
830
- const alignAttr = align !== 'center' ? ` data-align="${align}"` : '';
831
- return `<text data-id="${esc(s.id)}" data-tool="text" data-anchor-id="${esc(
832
- s.anchorId
833
- )}" data-font-size="${s.fontSize}" fill="${esc(
834
- s.color
835
- )}"${weight}${italic}${deco}${listAttr} text-anchor="${anchor}" dominant-baseline="middle"${alignAttr}>${textInnerSvg(
836
- s.text,
837
- s.fontSize,
838
- true,
839
- undefined,
840
- s.listType
841
- )}</text>`;
842
- }
843
- const tx = s.x ?? 0;
844
- const ty = s.y ?? 0;
845
- const align = s.align ?? 'left'; // standalone default = left
846
- const anchor = align === 'left' ? 'start' : align === 'right' ? 'end' : 'middle';
847
- const alignAttr = align !== 'left' ? ` data-align="${align}"` : '';
848
- return `<text data-id="${esc(s.id)}" data-tool="text" x="${tx}" y="${ty}" data-font-size="${
849
- s.fontSize
850
- }" fill="${esc(
851
- s.color
852
- )}"${weight}${italic}${deco}${listAttr} text-anchor="${anchor}" dominant-baseline="hanging"${alignAttr}>${textInnerSvg(
853
- s.text,
854
- s.fontSize,
855
- false,
856
- tx,
857
- s.listType
858
- )}</text>`;
859
- }
860
- if (s.tool === 'sticky') {
861
- // Phase 21 — sticky body lives in an allowlisted <text> child so it
862
- // survives sanitizeAnnotationSvg (which strips <foreignObject>, DDR-060
863
- // F1). The live canvas re-renders this stroke with a foreignObject so the
864
- // text word-wraps; the persisted <text> is the inert, sanitizer-safe form.
865
- const r = s.cornerRadius ?? STICKY_CORNER_RADIUS;
866
- const w = Math.max(0, s.w);
867
- const h = Math.max(0, s.h);
868
- // bold/italic/strike/underline/align/list on the <g> data-attrs, emitted
869
- // ONLY for non-default values (sticky default align = left) so Phase-21
870
- // stickies serialize byte-identically. The body <text> stays raw text —
871
- // list markers are render-only (item 4c), never persisted.
872
- const align = s.align ?? 'left';
873
- const styleAttrs =
874
- (s.bold ? ' data-bold="1"' : '') +
875
- (s.italic ? ' data-italic="1"' : '') +
876
- (s.strike ? ' data-strike="1"' : '') +
877
- (s.underline ? ' data-underline="1"' : '') +
878
- (align !== 'left' ? ` data-align="${align}"` : '') +
879
- (s.listType ? ` data-list="${s.listType}"` : '');
880
- return `<g data-id="${esc(s.id)}" data-tool="sticky" data-r="${r}" data-fs="${
881
- s.fontSize
882
- }" fill="${esc(s.color)}"${styleAttrs}><rect x="${s.x}" y="${
883
- s.y
884
- }" width="${w}" height="${h}" rx="${r}" ry="${r}"/><text data-sticky-body="1" x="${
885
- s.x + 12
886
- }" y="${s.y + 12}" font-size="${
887
- s.fontSize
888
- }" fill="#1a1a1a" dominant-baseline="hanging">${esc(s.text)}</text></g>`;
889
- }
890
- if (s.tool === 'image') {
891
- // Phase 23 — `href` is ALWAYS a relative assets/<sha8>.<ext> path (asserted
892
- // on create + re-validated by the sanitizer's <image> href allowlist). Alt
893
- // text persists in `data-alt` + is emitted as `aria-label` for the exported
894
- // SVG (the live annotation root is aria-hidden — see ImageStroke.alt).
895
- const nx = Math.min(s.x, s.x + s.w);
896
- const ny = Math.min(s.y, s.y + s.h);
897
- const nw = Math.abs(s.w);
898
- const nh = Math.abs(s.h);
899
- const altAttr = s.alt ? ` data-alt="${escAttr(s.alt)}"` : '';
900
- return `<image data-id="${esc(s.id)}" data-tool="image" x="${nx}" y="${ny}" width="${nw}" height="${nh}" href="${escAttr(
901
- s.href
902
- )}" preserveAspectRatio="xMidYMid meet"${altAttr}/>`;
903
- }
904
- if (s.tool === 'link') {
905
- // Phase 23 — client-only preview chip. data-url/title/domain are the
906
- // round-trip source of truth; the inner rect/glyph/text are the inert,
907
- // sanitizer-safe visual (no <a href> persisted — click-to-open reads
908
- // data-url client-side and validates http(s) before window.open).
909
- const nx = Math.min(s.x, s.x + s.w);
910
- const ny = Math.min(s.y, s.y + s.h);
911
- const nw = Math.abs(s.w);
912
- const nh = Math.abs(s.h);
913
- const lay = linkCardLayout(nx, ny, nw, nh);
914
- const shownTitle = clampLinkTitle(s.title, lay.textMaxChars);
915
- return (
916
- `<g data-id="${esc(s.id)}" data-tool="link" data-url="${escAttr(s.url)}" data-title="${escAttr(
917
- s.title
918
- )}" data-domain="${escAttr(s.domain)}">` +
919
- `<rect x="${nx}" y="${ny}" width="${nw}" height="${nh}" rx="8" ry="8" fill="${LINK_CARD_FILL}" stroke="${LINK_CARD_STROKE}" stroke-width="1"/>` +
920
- `<svg x="${lay.glyph.x}" y="${lay.glyph.y}" width="${lay.glyph.size}" height="${lay.glyph.size}" viewBox="0 0 24 24" fill="none" stroke="${LINK_GLYPH_STROKE}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="${LINK_GLYPH_D1}"/><path d="${LINK_GLYPH_D2}"/></svg>` +
921
- `<text x="${lay.textX}" y="${lay.domain.y}" font-size="${lay.domain.fontSize}" fill="${LINK_DOMAIN_FILL}" dominant-baseline="hanging">${esc(
922
- s.domain
923
- )}</text>` +
924
- `<text x="${lay.textX}" y="${lay.title.y}" font-size="${lay.title.fontSize}" fill="${LINK_TITLE_FILL}" font-weight="600" dominant-baseline="hanging">${esc(
925
- shownTitle
926
- )}</text>` +
927
- `</g>`
928
- );
929
- }
930
- const common = `data-id="${esc(s.id)}" data-tool="${s.tool}" stroke="${esc(s.color)}" stroke-width="${s.width}" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke"`;
931
- if (s.tool === 'pen') {
932
- // Highlighter (item 8) — data-highlighter ONLY when true so a normal pen
933
- // stays byte-identical (canary).
934
- const hl = s.highlighter ? ' data-highlighter="1"' : '';
935
- return `<path ${common} fill="none" d="${penPathD(s.points)}"${hl} pointer-events="stroke"/>`;
936
- }
937
- if (s.tool === 'rect') {
938
- const fill = s.fill ? esc(s.fill) : 'none';
939
- // Phase 21 — corner radius: append rx/ry/data-r ONLY when > 0 so legacy
940
- // sharp-corner rects serialize byte-identically (Task 10 canary).
941
- const r = s.cornerRadius ?? 0;
942
- const round = r > 0 ? ` rx="${r}" ry="${r}" data-r="${r}"` : '';
943
- // Dashed outline (item 7) — emitted ONLY when true (mirror polygon/arrow)
944
- // so a legacy solid rect stays byte-identical.
945
- const dash = s.dashed ? ' stroke-dasharray="6 4"' : '';
946
- const dashAttr = s.dashed ? ' data-dash="1"' : '';
947
- return `<rect ${common} fill="${fill}" x="${s.x}" y="${s.y}" width="${Math.max(
948
- 0,
949
- s.w
950
- )}" height="${Math.max(0, s.h)}"${round}${dash}${dashAttr}/>`;
951
- }
952
- if (s.tool === 'ellipse') {
953
- const fill = s.fill ? esc(s.fill) : 'none';
954
- const dash = s.dashed ? ' stroke-dasharray="6 4"' : '';
955
- const dashAttr = s.dashed ? ' data-dash="1"' : '';
956
- return `<ellipse ${common} fill="${fill}" cx="${s.cx}" cy="${s.cy}" rx="${Math.max(
957
- 0,
958
- s.rx
959
- )}" ry="${Math.max(0, s.ry)}"${dash}${dashAttr}/>`;
960
- }
961
- if (s.tool === 'polygon') {
962
- // Phase 24 — bbox-derived points + data-shape. Normalize the bbox so a
963
- // negative-extent (mid-flip) stroke serializes idempotently.
964
- const nx = Math.min(s.x, s.x + s.w);
965
- const ny = Math.min(s.y, s.y + s.h);
966
- const nw = Math.abs(s.w);
967
- const nh = Math.abs(s.h);
968
- const fill = s.fill ? esc(s.fill) : 'none';
969
- const dash = s.dashed ? ' stroke-dasharray="6 4"' : '';
970
- const dashAttr = s.dashed ? ' data-dash="1"' : '';
971
- return `<polygon ${common} fill="${fill}" data-shape="${s.shape}" points="${polygonPoints(
972
- s.shape,
973
- nx,
974
- ny,
975
- nw,
976
- nh
977
- )}"${dash}${dashAttr}/>`;
978
- }
979
- // arrow — Phase 24 reduces to ordered SVG primitives (canvas-arrowheads), the
980
- // same primitives StrokeNode renders. Defaults (startHead 'none', endHead
981
- // 'triangle', lineType 'straight', solid) reduce to exactly
982
- // [<line>, <polyline fill=color>] → the byte-identical Phase 5.1 form. data-*
983
- // attrs appear only for non-default values.
984
- const startHead = s.startHead ?? 'none';
985
- const endHead = s.endHead ?? 'triangle';
986
- const lineType = s.lineType ?? 'straight';
987
- const dashed = s.dashed ?? false;
988
- // esc() every interpolated value (defence-in-depth, Phase 24 security review
989
- // DDR-067) — heads are clamped on parse, but a value reaching serialize must
990
- // never be able to break out of the attribute.
991
- const dataAttrs =
992
- (startHead !== 'none' ? ` data-start-head="${esc(startHead)}"` : '') +
993
- (endHead !== 'triangle' ? ` data-end-head="${esc(endHead)}"` : '') +
994
- (lineType !== 'straight' ? ` data-line-type="${esc(lineType)}"` : '') +
995
- (dashed ? ' data-dash="1"' : '');
996
- const body = arrowPrimitives(s).map(svgPrimitiveToString).join('');
997
- return `<g ${common} fill="none"${dataAttrs}>${body}</g>`;
998
- }
999
-
1000
- /** Format one arrow SVG primitive for the persisted string (byte-identical to
1001
- * the Phase-5.1 `<line>`/`<polyline>` forms for the legacy default arrow). */
1002
- function svgPrimitiveToString(p: SvgPrimitive): string {
1003
- const dash = 'dash' in p && p.dash ? ' stroke-dasharray="6 4"' : '';
1004
- switch (p.el) {
1005
- case 'line':
1006
- return `<line x1="${p.x1}" y1="${p.y1}" x2="${p.x2}" y2="${p.y2}"${dash}/>`;
1007
- case 'path':
1008
- return `<path d="${p.d}"${dash}/>`;
1009
- case 'polyline':
1010
- return `<polyline points="${p.points}" fill="${esc(p.fill)}"/>`;
1011
- case 'polygon':
1012
- return `<polygon points="${p.points}" fill="${esc(p.fill)}"/>`;
1013
- case 'circle':
1014
- return `<circle cx="${p.cx}" cy="${p.cy}" r="${p.r}" fill="${esc(p.fill)}"/>`;
1015
- }
1016
- }
1017
-
1018
- export function strokesToSvg(strokes: readonly Stroke[]): string {
1019
- const header = '<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1">';
1020
- if (strokes.length === 0) return `${header}</svg>`;
1021
- const body = strokes.map(strokeToSvgEl).join('');
1022
- return `${header}${body}</svg>`;
1023
- }
1024
-
1025
- function parsePathD(d: string): WorldPoint[] {
1026
- const out: WorldPoint[] = [];
1027
- const re = /[ML]\s*(-?\d+(?:\.\d+)?)\s*[\s,]\s*(-?\d+(?:\.\d+)?)/g;
1028
- let m: RegExpExecArray | null;
1029
- // biome-ignore lint/suspicious/noAssignInExpressions: idiomatic regex loop
1030
- while ((m = re.exec(d)) !== null) {
1031
- const [, x = '0', y = '0'] = m;
1032
- out.push([Number.parseFloat(x), Number.parseFloat(y)]);
1033
- }
1034
- return out;
1035
- }
1036
-
1037
- function parseFill(raw: string | null): string | null {
1038
- if (!raw) return null;
1039
- const v = raw.trim().toLowerCase();
1040
- if (!v || v === 'none' || v === 'transparent') return null;
1041
- return raw;
1042
- }
1043
-
1044
- /**
1045
- * Phase 24 — recover an arrow's two endpoints from its shaft. A straight arrow
1046
- * persists a `<line>`; a curved/elbow arrow persists a `<path>` whose first and
1047
- * last coordinate pairs are the endpoints (the bow control / elbow corner sit
1048
- * between them, so first-pair = start, last-pair = end recovers the ends
1049
- * exactly → idempotent re-serialize).
1050
- */
1051
- function arrowEndpoints(el: Element): { x1: number; y1: number; x2: number; y2: number } | null {
1052
- const line = el.querySelector('line');
1053
- if (line) {
1054
- return {
1055
- x1: Number.parseFloat(line.getAttribute('x1') || '0'),
1056
- y1: Number.parseFloat(line.getAttribute('y1') || '0'),
1057
- x2: Number.parseFloat(line.getAttribute('x2') || '0'),
1058
- y2: Number.parseFloat(line.getAttribute('y2') || '0'),
1059
- };
1060
- }
1061
- const path = el.querySelector('path');
1062
- if (path) {
1063
- const nums = (path.getAttribute('d') || '').match(/-?\d+(?:\.\d+)?/g);
1064
- if (nums && nums.length >= 4) {
1065
- return {
1066
- x1: Number.parseFloat(nums[0] as string),
1067
- y1: Number.parseFloat(nums[1] as string),
1068
- x2: Number.parseFloat(nums[nums.length - 2] as string),
1069
- y2: Number.parseFloat(nums[nums.length - 1] as string),
1070
- };
1071
- }
1072
- }
1073
- return null;
1074
- }
1075
-
1076
- export function svgToStrokes(svgText: string): Stroke[] {
1077
- const text = (svgText ?? '').trim();
1078
- if (!text) return [];
1079
- if (typeof DOMParser === 'undefined') return [];
1080
- try {
1081
- const doc = new DOMParser().parseFromString(text, 'image/svg+xml');
1082
- if (doc.querySelector('parsererror')) return [];
1083
- const out: Stroke[] = [];
1084
- for (const el of Array.from(doc.querySelectorAll('[data-tool]'))) {
1085
- const tool = el.getAttribute('data-tool');
1086
- const id = el.getAttribute('data-id') || rid();
1087
- const color = el.getAttribute('stroke') || el.getAttribute('fill') || DEFAULT_COLOR;
1088
- const width = Number.parseFloat(el.getAttribute('stroke-width') || '2') || 2;
1089
- if (tool === 'pen') {
1090
- const d = el.getAttribute('d') || '';
1091
- const points = parsePathD(d);
1092
- if (points.length) {
1093
- const pen: PenStroke = { id, tool: 'pen', color, width, points };
1094
- // Highlighter flag; absent ⇒ undefined so a normal pen round-trips.
1095
- if (el.getAttribute('data-highlighter') === '1') pen.highlighter = true;
1096
- out.push(pen);
1097
- }
1098
- continue;
1099
- }
1100
- if (tool === 'sticky') {
1101
- // Phase 21 — sticky reads geometry off its <rect> child, paper tint
1102
- // off the group fill, body text off the inner <text>.
1103
- const rectEl = el.querySelector('rect');
1104
- const x = Number.parseFloat(rectEl?.getAttribute('x') || '0');
1105
- const y = Number.parseFloat(rectEl?.getAttribute('y') || '0');
1106
- const w = Number.parseFloat(rectEl?.getAttribute('width') || '0');
1107
- const h = Number.parseFloat(rectEl?.getAttribute('height') || '0');
1108
- const cornerRadius =
1109
- Number.parseFloat(el.getAttribute('data-r') || String(STICKY_CORNER_RADIUS)) || 0;
1110
- const fontSize =
1111
- Number.parseFloat(el.getAttribute('data-fs') || String(DEFAULT_FONT_SIZE)) ||
1112
- DEFAULT_FONT_SIZE;
1113
- const stickyColor = el.getAttribute('fill') || DEFAULT_STICKY_COLOR;
1114
- const body = el.querySelector('text');
1115
- const sticky: StickyStroke = {
1116
- id,
1117
- tool: 'sticky',
1118
- color: stickyColor,
1119
- x,
1120
- y,
1121
- w,
1122
- h,
1123
- text: body?.textContent ?? '',
1124
- fontSize,
1125
- cornerRadius,
1126
- };
1127
- // Style attrs; absent ⇒ defaults (normal / left), left unset.
1128
- if (el.getAttribute('data-bold') === '1') sticky.bold = true;
1129
- if (el.getAttribute('data-italic') === '1') sticky.italic = true;
1130
- if (el.getAttribute('data-strike') === '1') sticky.strike = true;
1131
- if (el.getAttribute('data-underline') === '1') sticky.underline = true;
1132
- const sticAlign = el.getAttribute('data-align');
1133
- if (sticAlign === 'left' || sticAlign === 'center' || sticAlign === 'right') {
1134
- sticky.align = sticAlign;
1135
- }
1136
- const sticList = el.getAttribute('data-list');
1137
- if (sticList === 'bullet' || sticList === 'number') sticky.listType = sticList;
1138
- out.push(sticky);
1139
- continue;
1140
- }
1141
- if (tool === 'rect') {
1142
- const x = Number.parseFloat(el.getAttribute('x') || '0');
1143
- const y = Number.parseFloat(el.getAttribute('y') || '0');
1144
- const w = Number.parseFloat(el.getAttribute('width') || '0');
1145
- const h = Number.parseFloat(el.getAttribute('height') || '0');
1146
- const fill = parseFill(el.getAttribute('fill'));
1147
- // Phase 21 — corner radius; absent ⇒ 0 (sharp, back-compat).
1148
- const cornerRadius = Number.parseFloat(el.getAttribute('data-r') || '0') || 0;
1149
- const rect: RectStroke = { id, tool: 'rect', color, width, x, y, w, h, fill, cornerRadius };
1150
- // Dashed (item 7); absent ⇒ undefined so a solid rect round-trips.
1151
- const rectDash = el.getAttribute('data-dash');
1152
- if (rectDash === '1' || rectDash === 'true') rect.dashed = true;
1153
- out.push(rect);
1154
- continue;
1155
- }
1156
- if (tool === 'ellipse') {
1157
- const cx = Number.parseFloat(el.getAttribute('cx') || '0');
1158
- const cy = Number.parseFloat(el.getAttribute('cy') || '0');
1159
- const rx = Number.parseFloat(el.getAttribute('rx') || '0');
1160
- const ry = Number.parseFloat(el.getAttribute('ry') || '0');
1161
- const fill = parseFill(el.getAttribute('fill'));
1162
- const ell: EllipseStroke = { id, tool: 'ellipse', color, width, cx, cy, rx, ry, fill };
1163
- const ellDash = el.getAttribute('data-dash');
1164
- if (ellDash === '1' || ellDash === 'true') ell.dashed = true;
1165
- out.push(ell);
1166
- continue;
1167
- }
1168
- if (tool === 'polygon') {
1169
- // Phase 24 — recover the bbox from the points; shape from data-shape.
1170
- const shapeRaw = el.getAttribute('data-shape');
1171
- const shape: PolygonShape =
1172
- shapeRaw === 'triangle' || shapeRaw === 'triangle-down' ? shapeRaw : 'diamond';
1173
- const bb = polygonBBox(el.getAttribute('points') || '');
1174
- if (bb) {
1175
- const fill = parseFill(el.getAttribute('fill'));
1176
- const poly: PolygonStroke = {
1177
- id,
1178
- tool: 'polygon',
1179
- shape,
1180
- color,
1181
- width,
1182
- x: bb.x,
1183
- y: bb.y,
1184
- w: bb.w,
1185
- h: bb.h,
1186
- fill,
1187
- };
1188
- const dashRaw = el.getAttribute('data-dash');
1189
- if (dashRaw === '1' || dashRaw === 'true') poly.dashed = true;
1190
- out.push(poly);
1191
- }
1192
- continue;
1193
- }
1194
- if (tool === 'arrow') {
1195
- // Phase 24 — shaft is a <line> (straight) OR a <path> (curved/elbow).
1196
- // Recover the two endpoints from whichever is present.
1197
- const ends = arrowEndpoints(el);
1198
- if (ends) {
1199
- const arrow: ArrowStroke = {
1200
- id,
1201
- tool: 'arrow',
1202
- color,
1203
- width,
1204
- x1: ends.x1,
1205
- y1: ends.y1,
1206
- x2: ends.x2,
1207
- y2: ends.y2,
1208
- };
1209
- // Heads + dash + line-type. The serializer writes a data-* attribute
1210
- // only for a NON-default value, so a legacy arrow carries none of
1211
- // these and stays { startHead/endHead/dashed/lineType: undefined } →
1212
- // defaults on re-serialize (byte-identical, canary). Phase 24 widened
1213
- // the head enum, so read the literal value rather than match a single
1214
- // string.
1215
- // Clamp to the known head vocabulary — an out-of-vocab / poisoned
1216
- // value (hub-pushed SVG) is rejected, never cast through unchecked
1217
- // (Phase 24 security review, DDR-067).
1218
- const sh = el.getAttribute('data-start-head');
1219
- if (sh && ARROW_HEADS.has(sh)) arrow.startHead = sh as ArrowHead;
1220
- const eh = el.getAttribute('data-end-head');
1221
- if (eh && ARROW_HEADS.has(eh)) arrow.endHead = eh as ArrowHead;
1222
- const lt = el.getAttribute('data-line-type');
1223
- if (lt === 'curved' || lt === 'elbow' || lt === 'straight') arrow.lineType = lt;
1224
- const dashRaw = el.getAttribute('data-dash');
1225
- if (dashRaw === '1' || dashRaw === 'true') arrow.dashed = true;
1226
- out.push(arrow);
1227
- }
1228
- continue;
1229
- }
1230
- if (tool === 'image') {
1231
- // Phase 23 — geometry off the element; href is whatever survived the
1232
- // sanitizer (a valid assets/<sha8>.<ext> path, or '' if it was stripped
1233
- // — an external/data:/`..` href is dropped server-side, so a poisoned
1234
- // SVG round-trips to an inert empty-href stroke that fetches nothing).
1235
- const x = Number.parseFloat(el.getAttribute('x') || '0');
1236
- const y = Number.parseFloat(el.getAttribute('y') || '0');
1237
- const w = Number.parseFloat(el.getAttribute('width') || '0');
1238
- const h = Number.parseFloat(el.getAttribute('height') || '0');
1239
- const href = el.getAttribute('href') || el.getAttribute('xlink:href') || '';
1240
- const img: ImageStroke = { id, tool: 'image', x, y, w, h, href };
1241
- const alt = el.getAttribute('data-alt');
1242
- if (alt) img.alt = alt;
1243
- out.push(img);
1244
- continue;
1245
- }
1246
- if (tool === 'link') {
1247
- // Phase 23 — data-* are the source of truth; geometry off the <rect>
1248
- // child (mirrors sticky). Defensive: missing title ⇒ domain.
1249
- const rectEl = el.querySelector('rect');
1250
- const x = Number.parseFloat(rectEl?.getAttribute('x') || '0');
1251
- const y = Number.parseFloat(rectEl?.getAttribute('y') || '0');
1252
- const w = Number.parseFloat(rectEl?.getAttribute('width') || String(LINK_DEFAULT_W));
1253
- const h = Number.parseFloat(rectEl?.getAttribute('height') || String(LINK_DEFAULT_H));
1254
- const url = el.getAttribute('data-url') || '';
1255
- const domain = el.getAttribute('data-domain') || '';
1256
- const title = el.getAttribute('data-title') || domain || url;
1257
- out.push({ id, tool: 'link', x, y, w, h, url, title, domain });
1258
- continue;
1259
- }
1260
- if (tool === 'text') {
1261
- const rawAnchor = el.getAttribute('data-anchor-id');
1262
- const fontSize =
1263
- Number.parseFloat(el.getAttribute('data-font-size') || String(DEFAULT_FONT_SIZE)) ||
1264
- DEFAULT_FONT_SIZE;
1265
- const inkColor = el.getAttribute('fill') || color;
1266
- // List style (item 4c) — read FIRST so per-line markers can be stripped
1267
- // off the parsed text (markers are render-only; never stored).
1268
- const listRaw = el.getAttribute('data-list');
1269
- const listType: ListType | undefined =
1270
- listRaw === 'bullet' || listRaw === 'number' ? listRaw : undefined;
1271
- // Multi-line text (item 4a) — one <tspan> per line. Recover `\n` by
1272
- // joining tspan text content (markers stripped); a legacy single-run
1273
- // <text> has no tspans → read its trimmed textContent.
1274
- const tspans = el.querySelectorAll('tspan');
1275
- const body =
1276
- tspans.length > 0
1277
- ? Array.from(tspans)
1278
- .map((t, i) => stripListPrefix(t.textContent ?? '', i, listType))
1279
- .join('\n')
1280
- : stripListPrefix((el.textContent || '').trim(), 0, listType);
1281
- // bold / italic / strike / underline / align. `data-align` is the
1282
- // round-trip source of truth (text-anchor is derived from it). Absent ⇒
1283
- // default (normal / per-kind align), left unset so legacy nodes
1284
- // round-trip.
1285
- const isBold = el.getAttribute('font-weight') === '700';
1286
- const isItalic = el.getAttribute('font-style') === 'italic';
1287
- const decoAttr = el.getAttribute('text-decoration') || '';
1288
- const isStrike = decoAttr.includes('line-through');
1289
- const isUnderline = decoAttr.includes('underline');
1290
- const da = el.getAttribute('data-align');
1291
- const align: TextAlign | undefined =
1292
- da === 'left' || da === 'center' || da === 'right' ? da : undefined;
1293
- // Phase 21 — standalone text (no data-anchor-id) carries world x/y
1294
- // instead of a host id.
1295
- if (!rawAnchor) {
1296
- const t: TextStroke = {
1297
- id,
1298
- tool: 'text',
1299
- color: inkColor,
1300
- fontSize,
1301
- text: body,
1302
- x: Number.parseFloat(el.getAttribute('x') || '0'),
1303
- y: Number.parseFloat(el.getAttribute('y') || '0'),
1304
- };
1305
- if (isBold) t.bold = true;
1306
- if (isItalic) t.italic = true;
1307
- if (isStrike) t.strike = true;
1308
- if (isUnderline) t.underline = true;
1309
- if (listType) t.listType = listType;
1310
- if (align) t.align = align;
1311
- out.push(t);
1312
- continue;
1313
- }
1314
- const t: TextStroke = {
1315
- id,
1316
- tool: 'text',
1317
- color: inkColor,
1318
- fontSize,
1319
- text: body,
1320
- anchorId: rawAnchor,
1321
- };
1322
- if (isBold) t.bold = true;
1323
- if (isItalic) t.italic = true;
1324
- if (isStrike) t.strike = true;
1325
- if (isUnderline) t.underline = true;
1326
- if (listType) t.listType = listType;
1327
- if (align) t.align = align;
1328
- out.push(t);
1329
- }
1330
- }
1331
- return out;
1332
- } catch {
1333
- return [];
1334
- }
1335
- }
1336
-
1337
- function pointSegmentDist(
1338
- px: number,
1339
- py: number,
1340
- ax: number,
1341
- ay: number,
1342
- bx: number,
1343
- by: number
1344
- ): number {
1345
- const dx = bx - ax;
1346
- const dy = by - ay;
1347
- const len2 = dx * dx + dy * dy;
1348
- if (len2 === 0) return Math.hypot(px - ax, py - ay);
1349
- let t = ((px - ax) * dx + (py - ay) * dy) / len2;
1350
- t = Math.max(0, Math.min(1, t));
1351
- return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
1352
- }
1353
-
1354
- export function strokeHitTest(s: Stroke, wx: number, wy: number, tol: number): boolean {
1355
- if (s.tool === 'text') {
1356
- // Anchored text isn't independently hit-testable (it inherits its host).
1357
- // Standalone text (Phase 21) uses its synthetic bbox so the eraser can
1358
- // reach it.
1359
- if (s.anchorId != null && s.anchorId !== '') return false;
1360
- const bb = strokeBBox(s);
1361
- if (!bb) return false;
1362
- return (
1363
- wx >= bb.x - tol && wx <= bb.x + bb.w + tol && wy >= bb.y - tol && wy <= bb.y + bb.h + tol
1364
- );
1365
- }
1366
- const t = Math.max(tol, 'width' in s ? s.width : 2);
1367
- if (s.tool === 'sticky' || s.tool === 'image' || s.tool === 'link') {
1368
- // Sticky / image / link are solid cards — filled-rect hit anywhere inside.
1369
- const xMin = Math.min(s.x, s.x + s.w);
1370
- const xMax = Math.max(s.x, s.x + s.w);
1371
- const yMin = Math.min(s.y, s.y + s.h);
1372
- const yMax = Math.max(s.y, s.y + s.h);
1373
- return wx >= xMin - t && wx <= xMax + t && wy >= yMin - t && wy <= yMax + t;
1374
- }
1375
- if (s.tool === 'pen') {
1376
- if (s.points.length === 1) {
1377
- const p = s.points[0] as WorldPoint;
1378
- return Math.hypot(wx - p[0], wy - p[1]) <= t;
1379
- }
1380
- for (let i = 1; i < s.points.length; i++) {
1381
- const a = s.points[i - 1] as WorldPoint;
1382
- const b = s.points[i] as WorldPoint;
1383
- if (pointSegmentDist(wx, wy, a[0], a[1], b[0], b[1]) <= t) return true;
1384
- }
1385
- return false;
1386
- }
1387
- if (s.tool === 'arrow') {
1388
- return pointSegmentDist(wx, wy, s.x1, s.y1, s.x2, s.y2) <= t;
1389
- }
1390
- if (s.tool === 'ellipse') {
1391
- // Inside-ellipse hit when filled; on the perimeter otherwise.
1392
- if (s.rx <= 0 || s.ry <= 0) return false;
1393
- const nx = (wx - s.cx) / s.rx;
1394
- const ny = (wy - s.cy) / s.ry;
1395
- const d = nx * nx + ny * ny;
1396
- if (s.fill) return d <= 1.0 + t / Math.max(s.rx, s.ry);
1397
- // Stroke-only: hit if normalized distance is within a band around 1.
1398
- const band = t / Math.max(s.rx, s.ry);
1399
- const dist = Math.abs(Math.sqrt(d) - 1);
1400
- return dist <= band;
1401
- }
1402
- if (s.tool === 'polygon') {
1403
- const nx = Math.min(s.x, s.x + s.w);
1404
- const ny = Math.min(s.y, s.y + s.h);
1405
- const pts = polygonVertices(s.shape, nx, ny, Math.abs(s.w), Math.abs(s.h));
1406
- // Filled → inside-hit; always allow an edge-proximity hit (covers the
1407
- // stroke-only outline + a tolerance band on a filled shape).
1408
- if (s.fill && pointInPolygon(wx, wy, pts)) return true;
1409
- for (let i = 0; i < pts.length; i++) {
1410
- const a = pts[i] as [number, number];
1411
- const b = pts[(i + 1) % pts.length] as [number, number];
1412
- if (pointSegmentDist(wx, wy, a[0], a[1], b[0], b[1]) <= t) return true;
1413
- }
1414
- return false;
1415
- }
1416
- // rect — inside when filled, edge-only otherwise.
1417
- const x = s.x;
1418
- const y = s.y;
1419
- const x2 = x + s.w;
1420
- const y2 = y + s.h;
1421
- const xMin = Math.min(x, x2);
1422
- const xMax = Math.max(x, x2);
1423
- const yMin = Math.min(y, y2);
1424
- const yMax = Math.max(y, y2);
1425
- if (s.fill) {
1426
- return wx >= xMin - t && wx <= xMax + t && wy >= yMin - t && wy <= yMax + t;
1427
- }
1428
- if (wx < xMin - t || wx > xMax + t) return false;
1429
- if (wy < yMin - t || wy > yMax + t) return false;
1430
- const onLeft = Math.abs(wx - x) <= t;
1431
- const onRight = Math.abs(wx - x2) <= t;
1432
- const onTop = Math.abs(wy - y) <= t;
1433
- const onBottom = Math.abs(wy - y2) <= t;
1434
- return onLeft || onRight || onTop || onBottom;
1435
- }
1436
-
1437
- /** Flip a negative-extent box so x/y is the top-left and w/h are positive. */
1438
- function normalizeBox<T extends { x: number; y: number; w: number; h: number }>(r: T): T {
1439
- if (r.w >= 0 && r.h >= 0) return r;
1440
- return {
1441
- ...r,
1442
- x: Math.min(r.x, r.x + r.w),
1443
- y: Math.min(r.y, r.y + r.h),
1444
- w: Math.abs(r.w),
1445
- h: Math.abs(r.h),
1446
- };
1447
- }
1448
-
1449
- /**
1450
- * Phase 24 — draw-time resize modifiers (FigJam parity, mirror of the
1451
- * `use-annotation-resize.tsx` set so create + resize feel identical):
1452
- * • `shift` — lock to 1:1 (square / circle); the larger drag axis sets the
1453
- * side, each axis keeps its own sign so the drag direction holds.
1454
- * • `alt` — grow from the pointer-down point as CENTER (symmetric).
1455
- * With neither held the box is `{ x: down, w: cursor − down }` — byte-identical
1456
- * to the previous corner-drag math.
1457
- */
1458
- export interface DrawMods {
1459
- shift: boolean;
1460
- alt: boolean;
1461
- }
1462
-
1463
- /** Constrain a draw drag (`ax,ay` = pointer-down anchor; `wx,wy` = cursor). */
1464
- export function constrainDrawBox(
1465
- ax: number,
1466
- ay: number,
1467
- wx: number,
1468
- wy: number,
1469
- mods: DrawMods
1470
- ): { x: number; y: number; w: number; h: number } {
1471
- let dx = wx - ax;
1472
- let dy = wy - ay;
1473
- if (mods.shift) {
1474
- const side = Math.max(Math.abs(dx), Math.abs(dy));
1475
- dx = (dx < 0 ? -1 : 1) * side;
1476
- dy = (dy < 0 ? -1 : 1) * side;
1477
- }
1478
- if (mods.alt) {
1479
- // Anchor is the center → span ±|d| on each axis around it.
1480
- return { x: ax - dx, y: ay - dy, w: 2 * dx, h: 2 * dy };
1481
- }
1482
- return { x: ax, y: ay, w: dx, h: dy };
1483
- }
1484
-
1485
- /**
1486
- * Apply the draw-time modifiers to the in-progress stroke. Shared by the
1487
- * pointer-move handler and the live keydown/keyup re-apply, so holding Shift /
1488
- * Alt updates the draft even without moving the cursor. `anchor` is the
1489
- * pointer-down point; pen / text carry no box so they pass through unchanged.
1490
- */
1491
- export function applyDrawModifiers(
1492
- cur: Stroke,
1493
- anchor: { x: number; y: number },
1494
- wx: number,
1495
- wy: number,
1496
- mods: DrawMods
1497
- ): Stroke {
1498
- if (cur.tool === 'rect' || cur.tool === 'polygon') {
1499
- const b = constrainDrawBox(anchor.x, anchor.y, wx, wy, mods);
1500
- return { ...cur, x: b.x, y: b.y, w: b.w, h: b.h };
1501
- }
1502
- if (cur.tool === 'ellipse') {
1503
- const b = constrainDrawBox(anchor.x, anchor.y, wx, wy, mods);
1504
- return {
1505
- ...cur,
1506
- cx: b.x + b.w / 2,
1507
- cy: b.y + b.h / 2,
1508
- rx: Math.abs(b.w) / 2,
1509
- ry: Math.abs(b.h) / 2,
245
+ onFormatKey: (e: ReactKeyboardEvent) => boolean;
246
+ } {
247
+ const [bold, setBold] = useState(!!initial.bold);
248
+ const [italic, setItalic] = useState(!!initial.italic);
249
+ const [underline, setUnderline] = useState(!!initial.underline);
250
+ const [strike, setStrike] = useState(!!initial.strike);
251
+ // FigJam v3 edit-mode toolbar extensions: size + alignment preview live in
252
+ // the editor and commit with the text (normFmt carries them through).
253
+ const [fontSize, setFontSize] = useState<number | undefined>(initial.fontSize);
254
+ const [align, setAlign] = useState<TextAlign | undefined>(initial.align);
255
+ const fmtRef = useRef<EditorFmt>({ bold, italic, underline, strike, fontSize, align });
256
+ fmtRef.current = { bold, italic, underline, strike, fontSize, align };
257
+ const style: CSSProperties = {
258
+ fontWeight: bold ? 700 : undefined,
259
+ fontStyle: italic ? 'italic' : undefined,
260
+ textDecoration: textDecoCss(strike, underline),
261
+ ...(fontSize != null && fontSize !== initial.fontSize ? { fontSize: `${fontSize}px` } : {}),
262
+ ...(align && align !== initial.align ? { textAlign: align } : {}),
263
+ };
264
+ // FigJam v3 — the edit-mode context toolbar drives the editor through this
265
+ // event (mutating the STROKE mid-edit would re-render the contentEditable
266
+ // and clobber typed text). The editor echoes its state back so the toolbar's
267
+ // pressed-states track live.
268
+ useEffect(() => {
269
+ if (typeof document === 'undefined') return;
270
+ const onFmt = (e: Event) => {
271
+ const d = (e as CustomEvent<{ key?: string; value?: unknown }>).detail;
272
+ if (!d?.key) return;
273
+ if (d.key === 'bold') setBold((v) => !v);
274
+ else if (d.key === 'italic') setItalic((v) => !v);
275
+ else if (d.key === 'underline') setUnderline((v) => !v);
276
+ else if (d.key === 'strike') setStrike((v) => !v);
277
+ else if (d.key === 'fontSize' && typeof d.value === 'number') setFontSize(d.value);
278
+ else if (d.key === 'align' && typeof d.value === 'string') setAlign(d.value as TextAlign);
1510
279
  };
1511
- }
1512
- if (cur.tool === 'sticky') {
1513
- // Stickies are always 1:1 — force the square constraint; Alt still centers.
1514
- const b = constrainDrawBox(anchor.x, anchor.y, wx, wy, { shift: true, alt: mods.alt });
1515
- return { ...cur, x: b.x, y: b.y, w: b.w, h: b.h };
1516
- }
1517
- if (cur.tool === 'arrow') {
1518
- let x2 = wx;
1519
- let y2 = wy;
1520
- if (mods.shift) {
1521
- // Snap the shaft to the nearest 45° around the anchor (its midpoint
1522
- // under Alt), keeping the cursor's distance.
1523
- const dx = wx - anchor.x;
1524
- const dy = wy - anchor.y;
1525
- const dist = Math.hypot(dx, dy);
1526
- const step = Math.PI / 4;
1527
- const ang = Math.round(Math.atan2(dy, dx) / step) * step;
1528
- x2 = anchor.x + Math.cos(ang) * dist;
1529
- y2 = anchor.y + Math.sin(ang) * dist;
280
+ document.addEventListener('maude:editor-format', onFmt);
281
+ return () => document.removeEventListener('maude:editor-format', onFmt);
282
+ }, []);
283
+ useEffect(() => {
284
+ if (typeof document === 'undefined') return;
285
+ const broadcast = () => {
286
+ document.dispatchEvent(
287
+ new CustomEvent('maude:editor-format-state', {
288
+ detail: { bold, italic, underline, strike, fontSize, align },
289
+ })
290
+ );
291
+ };
292
+ broadcast();
293
+ // The toolbar may mount AFTER the editor's first broadcast — it asks.
294
+ document.addEventListener('maude:editor-format-request', broadcast);
295
+ return () => document.removeEventListener('maude:editor-format-request', broadcast);
296
+ }, [bold, italic, underline, strike, fontSize, align]);
297
+ const onFormatKey = useCallback((e: ReactKeyboardEvent): boolean => {
298
+ if (!(e.metaKey || e.ctrlKey) || e.altKey || e.shiftKey) return false;
299
+ const k = e.key.toLowerCase();
300
+ if (k === 'b') {
301
+ e.preventDefault();
302
+ setBold((v) => !v);
303
+ return true;
1530
304
  }
1531
- if (mods.alt) {
1532
- // Anchor is the midpoint → the start end mirrors the dragged end.
1533
- return { ...cur, x1: 2 * anchor.x - x2, y1: 2 * anchor.y - y2, x2, y2 };
305
+ if (k === 'i') {
306
+ e.preventDefault();
307
+ setItalic((v) => !v);
308
+ return true;
1534
309
  }
1535
- return { ...cur, x1: anchor.x, y1: anchor.y, x2, y2 };
1536
- }
1537
- return cur;
1538
- }
1539
-
1540
- function normalizeRect(r: RectStroke): RectStroke {
1541
- return normalizeBox(r);
1542
- }
1543
-
1544
- // Phase 21 — sticky shares rect's drag-to-create flip (x = min, w = abs(w)).
1545
- function normalizeSticky(s: StickyStroke): StickyStroke {
1546
- return normalizeBox(s);
1547
- }
1548
-
1549
- function isStrokeMeaningful(s: Stroke): boolean {
1550
- if (s.tool === 'pen') return s.points.length >= 2;
1551
- if (s.tool === 'rect') return Math.abs(s.w) >= 4 && Math.abs(s.h) >= 4;
1552
- if (s.tool === 'polygon') return Math.abs(s.w) >= 4 && Math.abs(s.h) >= 4;
1553
- if (s.tool === 'ellipse') return s.rx >= 2 && s.ry >= 2;
1554
- if (s.tool === 'text') return s.text.trim().length > 0;
1555
- // Sticky below a readable floor is discarded like a 2×2 rect.
1556
- if (s.tool === 'sticky')
1557
- return Math.abs(s.w) >= STICKY_MIN_SIZE && Math.abs(s.h) >= STICKY_MIN_SIZE;
1558
- // Phase 23 — an image needs real extent; a link needs a non-empty URL.
1559
- if (s.tool === 'image') return Math.abs(s.w) >= IMAGE_MIN_SIZE && Math.abs(s.h) >= IMAGE_MIN_SIZE;
1560
- if (s.tool === 'link') return s.url.trim().length > 0;
1561
- return Math.hypot(s.x2 - s.x1, s.y2 - s.y1) >= 4;
1562
- }
1563
-
1564
- export function strokeBBox(
1565
- s: Stroke,
1566
- anchors?: Map<string, RectStroke | EllipseStroke>
1567
- ): { x: number; y: number; w: number; h: number } | null {
1568
- if (s.tool === 'pen') {
1569
- if (!s.points.length) return null;
1570
- let xMin = Number.POSITIVE_INFINITY;
1571
- let xMax = Number.NEGATIVE_INFINITY;
1572
- let yMin = Number.POSITIVE_INFINITY;
1573
- let yMax = Number.NEGATIVE_INFINITY;
1574
- for (const [px, py] of s.points) {
1575
- if (px < xMin) xMin = px;
1576
- if (px > xMax) xMax = px;
1577
- if (py < yMin) yMin = py;
1578
- if (py > yMax) yMax = py;
310
+ if (k === 'u') {
311
+ e.preventDefault();
312
+ setUnderline((v) => !v);
313
+ return true;
1579
314
  }
1580
- return { x: xMin, y: yMin, w: xMax - xMin, h: yMax - yMin };
1581
- }
1582
- if (s.tool === 'rect' || s.tool === 'polygon') {
1583
- return {
1584
- x: Math.min(s.x, s.x + s.w),
1585
- y: Math.min(s.y, s.y + s.h),
1586
- w: Math.abs(s.w),
1587
- h: Math.abs(s.h),
1588
- };
1589
- }
1590
- if (s.tool === 'ellipse') {
1591
- return { x: s.cx - s.rx, y: s.cy - s.ry, w: s.rx * 2, h: s.ry * 2 };
1592
- }
1593
- if (s.tool === 'arrow') {
1594
- return {
1595
- x: Math.min(s.x1, s.x2),
1596
- y: Math.min(s.y1, s.y2),
1597
- w: Math.abs(s.x2 - s.x1),
1598
- h: Math.abs(s.y2 - s.y1),
1599
- };
1600
- }
1601
- if (s.tool === 'sticky' || s.tool === 'image' || s.tool === 'link') {
1602
- // Phase 23 — image + link are rect-shaped media, same bbox as a sticky card.
1603
- return {
1604
- x: Math.min(s.x, s.x + s.w),
1605
- y: Math.min(s.y, s.y + s.h),
1606
- w: Math.abs(s.w),
1607
- h: Math.abs(s.h),
1608
- };
1609
- }
1610
- // text — anchored inherits its host's bbox; standalone (Phase 21) gets a
1611
- // synthetic bbox from its world (x, y) so it's selectable and the context
1612
- // toolbar can position against it.
1613
- if (s.anchorId != null && s.anchorId !== '') {
1614
- const host = anchors?.get(s.anchorId);
1615
- return host ? strokeBBox(host) : null;
1616
- }
1617
- const tx = s.x ?? 0;
1618
- const ty = s.y ?? 0;
1619
- // Multi-line text (item 4a) — the bbox spans the longest line (width) and all
1620
- // lines (height) so the selection halo / hit-test / eraser cover the whole
1621
- // block, not just line one. List markers widen each line by 2–3 chars.
1622
- const lines = splitTextLines(s.text);
1623
- const markerPad = s.listType ? 3 : 0;
1624
- const longest = lines.reduce((m, l) => Math.max(m, l.length + markerPad), 0);
1625
- return {
1626
- x: tx,
1627
- y: ty,
1628
- w: Math.max(8, longest * s.fontSize * 0.55),
1629
- // Single-line keeps the legacy 1.2 height; multi-line grows by line count.
1630
- h: lines.length <= 1 ? s.fontSize * 1.2 : lines.length * s.fontSize * TEXT_LINE_HEIGHT,
1631
- };
315
+ return false;
316
+ }, []);
317
+ return { fmtRef, style, onFormatKey };
1632
318
  }
1633
319
 
320
+ // Phase 24 — moved to canvas-arrowheads.ts (single source for shaft + heads).
321
+ // Re-exported so the existing test import (`from '../annotations-layer.tsx'`)
322
+ // and the byte-identical canary keep working.
323
+ export { arrowHeadPoints } from './canvas-arrowheads.ts';
324
+
1634
325
  function isEditable(t: EventTarget | null): boolean {
1635
326
  if (!t || !(t as HTMLElement).tagName) return false;
1636
327
  const el = t as HTMLElement;
@@ -1862,6 +553,10 @@ const ANNOT_CSS = `
1862
553
  See DDR-067. (No backticks in this comment: the whole block is a JS template
1863
554
  literal, so a backtick here would terminate the string.) */
1864
555
  .dc-annot-editor, .dc-annot-editor * { cursor: text !important; }
556
+ /* FigJam v3 — connection dots on a selected bindable shape. The important flag
557
+ beats use-tool-mode's blanket star-cursor rule (same fight as the editor +
558
+ resize handles — DDR-067). */
559
+ .dc-annot-conn-dot { cursor: crosshair !important; }
1865
560
  `.trim();
1866
561
 
1867
562
  function ensureAnnotStyles(): void {
@@ -1883,6 +578,36 @@ export interface StrokesStoreValue {
1883
578
  updateStroke: (id: string, patch: Partial<Stroke>) => void;
1884
579
  deleteStrokes: (ids: string[]) => void;
1885
580
  translateStrokes: (ids: string[], dx: number, dy: number) => void;
581
+ /**
582
+ * FigJam v3 — bulk mutation in ONE undo record (the per-stroke
583
+ * `updateStroke` loop the context toolbar used pre-v3 pushed N records for
584
+ * an N-stroke selection). `fn` returns the patch for a stroke or null to
585
+ * leave it untouched.
586
+ */
587
+ applyToStrokes: (
588
+ ids: readonly string[],
589
+ fn: (s: Stroke) => Partial<Stroke> | null,
590
+ label?: string
591
+ ) => void;
592
+ /** Group the (expanded) selection; returns the member ids to select, or null. */
593
+ groupSelection: (ids: readonly string[]) => string[] | null;
594
+ /** Dissolve the outermost group of every selected stroke. */
595
+ ungroupSelection: (ids: readonly string[]) => void;
596
+ /** Cmd+D / paste — clone with fresh ids; returns the clone ids to select. */
597
+ duplicateSelection: (ids: readonly string[], dx: number, dy: number) => string[];
598
+ /** Z-order — `]` `[` `Cmd+]` `Cmd+[`; group units move contiguously. */
599
+ reorderSelection: (ids: readonly string[], op: ZOrderOp) => void;
600
+ alignSelection: (ids: readonly string[], edge: AlignEdge) => void;
601
+ distributeSelection: (ids: readonly string[], axis: DistributeAxis) => void;
602
+ /**
603
+ * Wave H — transient gesture preview: applies the patch to local React
604
+ * state ONLY (no undo record, no persistence), exactly like the move-drag's
605
+ * per-tick path. Close the gesture with `commitGesture` on pointerup so the
606
+ * whole drag lands as ONE undo record (undo used to walk every resize px).
607
+ */
608
+ previewStroke: (id: string, patch: Partial<Stroke>) => void;
609
+ /** Wave H — single undo record from a preview gesture's start snapshot. */
610
+ commitGesture: (before: readonly Stroke[], label?: string) => void;
1886
611
  }
1887
612
 
1888
613
  const StrokesStoreContext = createContext<StrokesStoreValue | null>(null);
@@ -1891,37 +616,33 @@ export function useStrokesStore(): StrokesStoreValue | null {
1891
616
  return useContext(StrokesStoreContext);
1892
617
  }
1893
618
 
1894
- function translateOne(s: Stroke, dx: number, dy: number): Stroke {
1895
- if (s.tool === 'pen') {
1896
- return { ...s, points: s.points.map(([x, y]) => [x + dx, y + dy] as WorldPoint) };
1897
- }
1898
- if (s.tool === 'rect' || s.tool === 'polygon') return { ...s, x: s.x + dx, y: s.y + dy };
1899
- if (s.tool === 'ellipse') return { ...s, cx: s.cx + dx, cy: s.cy + dy };
1900
- if (s.tool === 'arrow')
1901
- return { ...s, x1: s.x1 + dx, y1: s.y1 + dy, x2: s.x2 + dx, y2: s.y2 + dy };
1902
- if (s.tool === 'sticky' || s.tool === 'image' || s.tool === 'link')
1903
- return { ...s, x: s.x + dx, y: s.y + dy };
1904
- // text — anchored inherits its host's bbox (moves with the host); standalone
1905
- // (Phase 21) carries its own world (x, y) and translates directly.
1906
- if (s.anchorId != null && s.anchorId !== '') return s;
1907
- return { ...s, x: (s.x ?? 0) + dx, y: (s.y ?? 0) + dy };
1908
- }
619
+ // ─────────────────────────────────────────────────────────────────────────────
620
+ // FigJam v3 — one-time contextual hints (first-use discoverability). Behaviour-
621
+ // triggered micro-toasts, never a modal tour: each key fires at most once per
622
+ // browser profile (localStorage bitmap), reusing the existing canvas toast.
1909
623
 
1910
- /**
1911
- * Reference-equal stroke comparison true when the two arrays carry the same
1912
- * stroke object references in the same order. Used by the annotation drag
1913
- * onPointerUp to skip pushing an undo record when the gesture didn't actually
1914
- * move anything (zero movement OR snapshot mapped through a no-op translate
1915
- * back to the original references — `translateOne` short-circuits when dx=dy=0
1916
- * because new objects are still created, so we compare references defensively).
1917
- */
1918
- export function strokesShallowEqual(a: readonly Stroke[], b: readonly Stroke[]): boolean {
1919
- if (a === b) return true;
1920
- if (a.length !== b.length) return false;
1921
- for (let i = 0; i < a.length; i++) {
1922
- if (a[i] !== b[i]) return false;
624
+ // Chrome elements never deselect. Includes the per-shape context toolbar,
625
+ // the main tool palette, the in-canvas draw chrome, the minimap, and the
626
+ // right-click menu. Clicks on these route to their own handlers.
627
+ const CHROME_SELECTOR =
628
+ '.dc-annot-conn-dot, .dc-annot-ctx, .dc-tool-palette, .dc-annot-chrome, .dc-mm, .dc-context-menu, .dc-tp-popover, .dc-multi-artboard-tb, .dc-elem-ctx-tb, .dc-cv-eq-spacing-layer, .cm-composer, .cm-thread, .cm-mention-popup, .cm-pin, .dc-annot-resize-handle, .dc-annot-rotate-zone, .dc-annot-editor';
629
+
630
+ const HINTS_KEY = 'maude-annot-hints-v1';
631
+
632
+ function showOnceHint(key: string, msg: string): void {
633
+ if (typeof window === 'undefined') return;
634
+ try {
635
+ const seen = JSON.parse(window.localStorage.getItem(HINTS_KEY) || '{}') as Record<
636
+ string,
637
+ number
638
+ >;
639
+ if (seen[key]) return;
640
+ seen[key] = 1;
641
+ window.localStorage.setItem(HINTS_KEY, JSON.stringify(seen));
642
+ } catch {
643
+ return; // storage blocked — skip rather than re-toast forever
1923
644
  }
1924
- return true;
645
+ showCanvasToast(msg);
1925
646
  }
1926
647
 
1927
648
  // Annotations visibility now lives in use-annotations-visibility.tsx so the
@@ -1988,6 +709,16 @@ export function AnnotationsLayer() {
1988
709
  // shape/sticky/text tool is armed and nothing is being drawn yet. Pure chrome
1989
710
  // (low-opacity, pointer-events:none) — never selectable, hit-tested, or saved.
1990
711
  const [ghost, setGhost] = useState<{ x: number; y: number } | null>(null);
712
+ // FigJam v3 — smart-guide lines painted while a drag is snapping, and the
713
+ // id of the host a dragged arrow endpoint would bind to (accent halo).
714
+ const [snapGuides, setSnapGuides] = useState<SnapGuide[] | null>(null);
715
+ const [bindHintId, setBindHintId] = useState<string | null>(null);
716
+ // Cmd/Ctrl held — suppresses binding at arrow draw-end (FigJam: ⌘ keeps the
717
+ // endpoint free). Tracked here because endStroke (pointerup) carries no
718
+ // modifier state of its own.
719
+ const cmdHeldRef = useRef(false);
720
+ const vpRef = useRef(vp);
721
+ vpRef.current = vp;
1991
722
  const visibilityCtx = useAnnotationsVisibility();
1992
723
  const visible = visibilityCtx?.visible ?? true;
1993
724
  const setVisible = useCallback(
@@ -2026,6 +757,7 @@ export function AnnotationsLayer() {
2026
757
  tool === 'shape' ||
2027
758
  tool === 'arrow' ||
2028
759
  tool === 'sticky' ||
760
+ tool === 'section' ||
2029
761
  tool === 'text';
2030
762
  const isErase = tool === 'eraser';
2031
763
  const isActive = isDraw || isErase;
@@ -2182,30 +914,126 @@ export function AnnotationsLayer() {
2182
914
  const strokesStore = useMemo<StrokesStoreValue>(() => {
2183
915
  const updateStroke = (id: string, patch: Partial<Stroke>): void => {
2184
916
  const prev = strokesRef.current;
2185
- const next = prev.map((s) => (s.id === id ? ({ ...s, ...patch } as Stroke) : s));
917
+ // FigJam v3 bound arrow endpoints re-derive from their host after ANY
918
+ // geometry patch (resize ticks included), so connectors track live.
919
+ const next = recomputeBoundArrows(
920
+ prev.map((s) => (s.id === id ? ({ ...s, ...patch } as Stroke) : s))
921
+ );
2186
922
  commitStrokes(prev, next);
2187
923
  };
2188
924
  const deleteStrokes = (ids: string[]): void => {
2189
925
  const set = new Set(ids);
2190
926
  const prev = strokesRef.current;
2191
- const next = prev.filter(
927
+ const filtered = prev.filter(
2192
928
  (s) => !set.has(s.id) && !(s.tool === 'text' && s.anchorId != null && set.has(s.anchorId))
2193
929
  );
2194
- if (next.length === prev.length) return;
2195
- commitStrokes(prev, next);
930
+ if (filtered.length === prev.length) return;
931
+ // FigJam v3 — deleting a bind host strips the bind (endpoint frozen,
932
+ // arrow survives); singleton/empty groups dissolve (tldraw lifecycle).
933
+ commitStrokes(prev, recomputeBoundArrows(normalizeGroups(filtered)));
2196
934
  };
2197
935
  const translateStrokes = (ids: string[], dx: number, dy: number): void => {
2198
936
  const set = new Set(ids);
2199
937
  const prev = strokesRef.current;
2200
- const next = prev.map((s) => (set.has(s.id) ? translateOne(s, dx, dy) : s));
938
+ const next = recomputeBoundArrows(
939
+ prev.map((s) => (set.has(s.id) ? translateOne(s, dx, dy) : s))
940
+ );
2201
941
  commitStrokes(prev, next, `move ${ids.length} stroke${ids.length === 1 ? '' : 's'}`);
2202
942
  };
943
+ const applyToStrokes = (
944
+ ids: readonly string[],
945
+ fn: (s: Stroke) => Partial<Stroke> | null,
946
+ label?: string
947
+ ): void => {
948
+ const set = new Set(ids);
949
+ const prev = strokesRef.current;
950
+ let touched = 0;
951
+ const next = recomputeBoundArrows(
952
+ prev.map((s) => {
953
+ if (!set.has(s.id)) return s;
954
+ const patch = fn(s);
955
+ if (!patch) return s;
956
+ touched++;
957
+ return { ...s, ...patch } as Stroke;
958
+ })
959
+ );
960
+ if (touched === 0) return;
961
+ commitStrokes(prev, next, label ?? `edit ${touched} stroke${touched === 1 ? '' : 's'}`);
962
+ };
963
+ const groupSelection = (ids: readonly string[]): string[] | null => {
964
+ const prev = strokesRef.current;
965
+ const res = groupStrokes(prev, ids);
966
+ if (!res) return null;
967
+ commitStrokes(prev, res.strokes, `group ${res.memberIds.length} strokes`);
968
+ return res.memberIds;
969
+ };
970
+ const ungroupSelection = (ids: readonly string[]): void => {
971
+ const prev = strokesRef.current;
972
+ const next = ungroupStrokes(prev, ids);
973
+ if (strokesShallowEqual(prev, next)) return;
974
+ commitStrokes(prev, next, 'ungroup');
975
+ };
976
+ const duplicateSelection = (ids: readonly string[], dx: number, dy: number): string[] => {
977
+ const prev = strokesRef.current;
978
+ const res = duplicateStrokes(prev, ids, dx, dy);
979
+ if (res.strokes.length === prev.length) return [];
980
+ const added = res.strokes.length - prev.length;
981
+ commitStrokes(prev, res.strokes, `duplicate ${added} stroke${added === 1 ? '' : 's'}`);
982
+ return res.newIds;
983
+ };
984
+ const reorderSelection = (ids: readonly string[], op: ZOrderOp): void => {
985
+ const prev = strokesRef.current;
986
+ const next = reorderStrokes(prev, ids, op);
987
+ if (strokesShallowEqual(prev, next)) return;
988
+ commitStrokes(
989
+ prev,
990
+ next,
991
+ op === 'front' || op === 'forward' ? 'bring forward' : 'send backward'
992
+ );
993
+ };
994
+ const alignSelection = (ids: readonly string[], edge: AlignEdge): void => {
995
+ const prev = strokesRef.current;
996
+ const next = recomputeBoundArrows(alignStrokes(prev, ids, edge));
997
+ if (strokesShallowEqual(prev, next)) return;
998
+ commitStrokes(prev, next, `align ${edge}`);
999
+ };
1000
+ const distributeSelection = (ids: readonly string[], axis: DistributeAxis): void => {
1001
+ const prev = strokesRef.current;
1002
+ const next = recomputeBoundArrows(distributeStrokes(prev, ids, axis));
1003
+ if (strokesShallowEqual(prev, next)) return;
1004
+ commitStrokes(prev, next, 'distribute');
1005
+ };
1006
+ // Wave H — transient per-tick path for handle drags (resize / rotate /
1007
+ // endpoint re-anchor). Local React state only — no undo push, no PUT —
1008
+ // mirroring the move-drag's onMove. `commitGesture` closes it as ONE
1009
+ // record (no-op when the gesture ended where it started).
1010
+ const previewStroke = (id: string, patch: Partial<Stroke>): void => {
1011
+ setStrokesState(
1012
+ recomputeBoundArrows(
1013
+ strokesRef.current.map((s) => (s.id === id ? ({ ...s, ...patch } as Stroke) : s))
1014
+ )
1015
+ );
1016
+ };
1017
+ const commitGesture = (before: readonly Stroke[], label?: string): void => {
1018
+ const cur = strokesRef.current;
1019
+ if (strokesShallowEqual(before, cur)) return;
1020
+ commitStrokes(before, cur, label);
1021
+ };
2203
1022
  return {
2204
1023
  strokes,
2205
1024
  setStrokes,
2206
1025
  updateStroke,
2207
1026
  deleteStrokes,
2208
1027
  translateStrokes,
1028
+ applyToStrokes,
1029
+ groupSelection,
1030
+ ungroupSelection,
1031
+ duplicateSelection,
1032
+ reorderSelection,
1033
+ alignSelection,
1034
+ distributeSelection,
1035
+ previewStroke,
1036
+ commitGesture,
2209
1037
  };
2210
1038
  }, [strokes, setStrokes, commitStrokes]);
2211
1039
 
@@ -2474,6 +1302,19 @@ export function AnnotationsLayer() {
2474
1302
  fontSize: DEFAULT_FONT_SIZE,
2475
1303
  cornerRadius: STICKY_CORNER_RADIUS,
2476
1304
  });
1305
+ } else if (tool === 'section') {
1306
+ // FigJam v3 — drag-create a labelled container; a bare tap drops the
1307
+ // default-sized region (endStroke applies the default).
1308
+ setDrawing({
1309
+ id,
1310
+ tool: 'section',
1311
+ x: wx,
1312
+ y: wy,
1313
+ w: 0,
1314
+ h: 0,
1315
+ label: 'Section',
1316
+ color: DEFAULT_SECTION_COLOR,
1317
+ });
2477
1318
  } else if (tool === 'text') {
2478
1319
  // Phase 21 — single click drops an editable caret at the click point.
2479
1320
  // No stroke is created until the user commits real text (mirrors the
@@ -2512,6 +1353,7 @@ export function AnnotationsLayer() {
2512
1353
  (e: ReactPointerEvent<HTMLDivElement>) => {
2513
1354
  if (!isActive || !visible) return;
2514
1355
  const [wx, wy] = screenToWorld(e.clientX, e.clientY);
1356
+ cmdHeldRef.current = e.metaKey || e.ctrlKey;
2515
1357
  if (isErase) {
2516
1358
  if ((e.buttons & 1) === 0) return;
2517
1359
  eraseAt(wx, wy);
@@ -2527,6 +1369,15 @@ export function AnnotationsLayer() {
2527
1369
  lastDrawPointRef.current = { x: wx, y: wy };
2528
1370
  const anchor = drawAnchorRef.current;
2529
1371
  const mods: DrawMods = { shift: e.shiftKey, alt: e.altKey };
1372
+ // FigJam v3 — while dragging an arrow, halo the host the dragged end
1373
+ // would magnetically attach to (⌘ suppresses binding entirely).
1374
+ if (drawingRef.current.tool === 'arrow') {
1375
+ const zoom = vpRef.current?.zoom || 1;
1376
+ const cand = cmdHeldRef.current
1377
+ ? null
1378
+ : bindCandidate(wx, wy, strokesRef.current, BIND_THRESHOLD_PX / zoom);
1379
+ setBindHintId(cand?.hostId ?? null);
1380
+ }
2530
1381
  setDrawing((cur) => {
2531
1382
  if (!cur) return cur;
2532
1383
  if (cur.tool === 'pen') {
@@ -2578,12 +1429,51 @@ export function AnnotationsLayer() {
2578
1429
  Math.abs(norm.w) < STICKY_MIN_SIZE || Math.abs(norm.h) < STICKY_MIN_SIZE
2579
1430
  ? { ...norm, w: STICKY_DEFAULT_W, h: STICKY_DEFAULT_H }
2580
1431
  : norm;
1432
+ } else if (cur.tool === 'section') {
1433
+ const norm = normalizeBox(cur);
1434
+ final =
1435
+ Math.abs(norm.w) < SECTION_MIN_SIZE || Math.abs(norm.h) < SECTION_MIN_SIZE
1436
+ ? { ...norm, w: SECTION_DEFAULT_W, h: SECTION_DEFAULT_H }
1437
+ : norm;
2581
1438
  }
2582
1439
  if (final && !isStrokeMeaningful(final)) final = null;
1440
+ // FigJam v3 — magnetic connector binding at draw-end. Each arrow endpoint
1441
+ // within the zoom-scaled threshold of a bindable host attaches to its
1442
+ // nearest side/center magnet and snaps onto it; the bind persists and the
1443
+ // endpoint re-derives from the host from then on. ⌘ held = stay free.
1444
+ if (final && final.tool === 'arrow' && !cmdHeldRef.current) {
1445
+ const zoom = vpRef.current?.zoom || 1;
1446
+ const threshold = BIND_THRESHOLD_PX / zoom;
1447
+ const others = strokesRef.current;
1448
+ const sb = bindCandidate(final.x1, final.y1, others, threshold);
1449
+ const eb = bindCandidate(final.x2, final.y2, others, threshold);
1450
+ if (sb) {
1451
+ const host = others.find((s) => s.id === sb.hostId);
1452
+ const pt = host ? anchorPoint(host, sb.nx, sb.ny) : null;
1453
+ if (pt) final = { ...final, startBind: sb, x1: pt[0], y1: pt[1] };
1454
+ }
1455
+ // A zero-length self-loop (both ends on the same magnet) is useless —
1456
+ // keep the end free when it would collapse onto the start bind.
1457
+ const sameMagnet = sb && eb && sb.hostId === eb.hostId && sb.nx === eb.nx && sb.ny === eb.ny;
1458
+ if (eb && !sameMagnet) {
1459
+ const host = others.find((s) => s.id === eb.hostId);
1460
+ const pt = host ? anchorPoint(host, eb.nx, eb.ny) : null;
1461
+ if (pt) final = { ...final, endBind: eb, x2: pt[0], y2: pt[1] };
1462
+ }
1463
+ if ((final as ArrowStroke).startBind || (final as ArrowStroke).endBind) {
1464
+ showOnceHint(
1465
+ 'bind',
1466
+ 'Arrow attached — it follows the shape now. Drag an endpoint to re-anchor, hold ⌘ to keep it free.'
1467
+ );
1468
+ }
1469
+ }
1470
+ setBindHintId(null);
2583
1471
  if (final) {
2584
1472
  const committed = final;
2585
1473
  const prev = strokesRef.current;
2586
- const next = [...prev, committed];
1474
+ // FigJam sections are CONTAINERS: they slot in at the BACK of the
1475
+ // z-order so content placed on them keeps rendering above.
1476
+ const next = committed.tool === 'section' ? [committed, ...prev] : [...prev, committed];
2587
1477
  commitStrokes(prev, next, `draw ${committed.tool}`);
2588
1478
  // T18 — auto-select the freshly drawn shape so the user can immediately
2589
1479
  // see + adjust it. annotSel is optional (some test harnesses mount
@@ -2591,7 +1481,10 @@ export function AnnotationsLayer() {
2591
1481
  if (annotSel) annotSel.replace(committed.id);
2592
1482
  // Phase 21 — a fresh sticky opens in edit mode (FigJam parity: drop a
2593
1483
  // note, type immediately). Only meaningful deviation from rect/ellipse.
2594
- if (committed.tool === 'sticky') setEditingId(committed.id);
1484
+ if (committed.tool === 'sticky') {
1485
+ setEditingId(committed.id);
1486
+ showOnceHint('chain', '⌘Enter commits and creates the next sticky beside it.');
1487
+ }
2595
1488
  }
2596
1489
  // T18 / T19 — flip the tool back to Move after every commit UNLESS sticky
2597
1490
  // mode is locked on this tool. Sticky lets the user draw many shapes in a
@@ -2666,9 +1559,9 @@ export function AnnotationsLayer() {
2666
1559
  }, [ghost, ghostCapable, drawing, tool, shapeKind, color, stickyColor]);
2667
1560
 
2668
1561
  const anchorsById = useMemo(() => {
2669
- const map = new Map<string, RectStroke | EllipseStroke>();
1562
+ const map = new Map<string, AnchorHost>();
2670
1563
  for (const s of strokes) {
2671
- if (s.tool === 'rect' || s.tool === 'ellipse') map.set(s.id, s);
1564
+ if (s.tool === 'rect' || s.tool === 'ellipse' || s.tool === 'polygon') map.set(s.id, s);
2672
1565
  }
2673
1566
  return map;
2674
1567
  }, [strokes]);
@@ -2700,6 +1593,11 @@ export function AnnotationsLayer() {
2700
1593
  startWY: number;
2701
1594
  movedIds: string[];
2702
1595
  snapshot: Stroke[];
1596
+ /** FigJam v3 — pre-Alt-duplicate baseline. Differs from `snapshot` only
1597
+ * during an Alt+drag duplicate; the undo record spans undoBase → final
1598
+ * so clone + move land as ONE step. */
1599
+ undoBase: Stroke[];
1600
+ altDup: boolean;
2703
1601
  } | null>(null);
2704
1602
 
2705
1603
  // Drag-select marquee state. World-coord rectangle (anchor + cursor); the
@@ -2732,19 +1630,14 @@ export function AnnotationsLayer() {
2732
1630
  t === 'text' ||
2733
1631
  t === 'sticky' ||
2734
1632
  t === 'image' ||
2735
- t === 'link')
1633
+ t === 'link' ||
1634
+ t === 'section')
2736
1635
  ) {
2737
1636
  return id;
2738
1637
  }
2739
1638
  return null;
2740
1639
  };
2741
1640
 
2742
- // Chrome elements never deselect. Includes the per-shape context toolbar,
2743
- // the main tool palette, the in-canvas draw chrome, the minimap, and the
2744
- // right-click menu. Clicks on these route to their own handlers.
2745
- const CHROME_SELECTOR =
2746
- '.dc-annot-ctx, .dc-tool-palette, .dc-annot-chrome, .dc-mm, .dc-context-menu, .dc-tp-popover, .dc-multi-artboard-tb, .dc-elem-ctx-tb, .dc-cv-eq-spacing-layer, .cm-composer, .cm-thread, .cm-mention-popup, .cm-pin, .dc-annot-resize-handle, .dc-annot-editor';
2747
-
2748
1641
  const onDown = (e: PointerEvent) => {
2749
1642
  if (e.button !== 0) return;
2750
1643
  if (e.metaKey || e.ctrlKey) return; // escape hatch into element-selection
@@ -2763,16 +1656,20 @@ export function AnnotationsLayer() {
2763
1656
  let ids: string[] | null = null;
2764
1657
  if (strokeId) {
2765
1658
  elementSel?.clear();
1659
+ // FigJam v3 — clicking a group member acts on the WHOLE outermost
1660
+ // group. Double-click deep-selects the member (see the dblclick
1661
+ // handler); an already-selected stroke keeps the current selection, so
1662
+ // a deep-selected member drags alone without re-expanding.
1663
+ const members = expandIdsToGroups([strokeId], strokesStoreRef.current.strokes);
2766
1664
  if (e.shiftKey) {
2767
- annotSel.add(strokeId);
2768
- ids = annotSel.contains(strokeId)
2769
- ? annotSel.selectedIds
2770
- : [...annotSel.selectedIds, strokeId];
1665
+ annotSel.add(members);
1666
+ const merged = new Set([...annotSel.selectedIds, ...members]);
1667
+ ids = [...merged];
2771
1668
  } else if (annotSel.contains(strokeId)) {
2772
1669
  ids = annotSel.selectedIds;
2773
1670
  } else {
2774
- annotSel.replace(strokeId);
2775
- ids = [strokeId];
1671
+ annotSel.replace(members);
1672
+ ids = members;
2776
1673
  }
2777
1674
  } else if (!e.shiftKey && annotSel.selectedIds.length > 0) {
2778
1675
  // Hull hit-test — union bbox of the currently-selected strokes.
@@ -2793,7 +1690,7 @@ export function AnnotationsLayer() {
2793
1690
  ids = annotSel.selectedIds;
2794
1691
  }
2795
1692
  }
2796
- if (ids && ids.length) {
1693
+ if (ids?.length) {
2797
1694
  e.preventDefault();
2798
1695
  e.stopImmediatePropagation();
2799
1696
  // Capture a snapshot of all strokes at drag start. Every pointermove
@@ -2801,27 +1698,118 @@ export function AnnotationsLayer() {
2801
1698
  // (NOT a delta-from-last-frame mutation), so dragging back to origin
2802
1699
  // restores positions exactly. Optimistic state-only updates during
2803
1700
  // the move; ONE undo record + ONE server PUT fires on pointerup.
2804
- const dragSnapshot = strokesRef.current.slice();
1701
+ const undoBase = strokesRef.current.slice();
1702
+ const preAltIds = ids;
1703
+ let dragSnapshot = undoBase;
1704
+ let altDup = false;
1705
+ // FigJam v3 — Alt+drag duplicates: clone the (expanded) selection up
1706
+ // front and drag the CLONES; a zero-movement release reverts so a bare
1707
+ // Alt+click can't silently mint copies. undoBase stays pre-clone, so
1708
+ // clone + move commit as one record.
1709
+ if (e.altKey) {
1710
+ const res = duplicateStrokes(undoBase, ids, 0, 0);
1711
+ if (res.newIds.length) {
1712
+ altDup = true;
1713
+ dragSnapshot = res.strokes;
1714
+ setStrokesState(res.strokes);
1715
+ annotSel.replace(res.newIds);
1716
+ ids = res.newIds;
1717
+ }
1718
+ }
2805
1719
  dragStateRef.current = {
2806
1720
  pointerId: e.pointerId,
2807
1721
  startWX: wx,
2808
1722
  startWY: wy,
2809
1723
  movedIds: ids,
2810
1724
  snapshot: dragSnapshot,
1725
+ undoBase,
1726
+ altDup,
2811
1727
  };
2812
1728
  const movedSet = new Set(ids);
1729
+ // FigJam v3 — dragging a SECTION carries everything sitting on it
1730
+ // (bbox-center containment, captured at gesture start).
1731
+ for (const s of dragSnapshot) {
1732
+ if (s.tool !== 'section' || !movedSet.has(s.id)) continue;
1733
+ const sx = Math.min(s.x, s.x + s.w);
1734
+ const sy = Math.min(s.y, s.y + s.h);
1735
+ const sx2 = sx + Math.abs(s.w);
1736
+ const sy2 = sy + Math.abs(s.h);
1737
+ for (const t of dragSnapshot) {
1738
+ if (movedSet.has(t.id) || t.tool === 'section') continue;
1739
+ const bb = strokeBBox(t);
1740
+ if (!bb) continue;
1741
+ const ccx = bb.x + bb.w / 2;
1742
+ const ccy = bb.y + bb.h / 2;
1743
+ if (ccx >= sx && ccx <= sx2 && ccy >= sy && ccy <= sy2) movedSet.add(t.id);
1744
+ }
1745
+ }
1746
+ // FigJam v3 — snap setup, computed ONCE per gesture: candidates are
1747
+ // the bboxes of every non-moved stroke plus the artboard rects (in
1748
+ // world coords); the moving hull is the union bbox of the dragged
1749
+ // strokes at drag start.
1750
+ const candidates: Array<{ x: number; y: number; w: number; h: number }> = [];
1751
+ for (const s of dragSnapshot) {
1752
+ if (movedSet.has(s.id)) continue;
1753
+ const bb = strokeBBox(s);
1754
+ if (bb && bb.w > 0 && bb.h > 0) candidates.push(bb);
1755
+ }
1756
+ for (const screenEl of Array.from(document.querySelectorAll('[data-dc-screen]'))) {
1757
+ const r = screenEl.getBoundingClientRect();
1758
+ if (r.width <= 0 || r.height <= 0) continue;
1759
+ const [ax, ay] = screenToWorld(r.left, r.top);
1760
+ const [bx, by] = screenToWorld(r.right, r.bottom);
1761
+ candidates.push({ x: ax, y: ay, w: bx - ax, h: by - ay });
1762
+ }
1763
+ let hull: { x: number; y: number; w: number; h: number } | null = null;
1764
+ for (const s of dragSnapshot) {
1765
+ if (!movedSet.has(s.id)) continue;
1766
+ const bb = strokeBBox(s);
1767
+ if (!bb) continue;
1768
+ if (!hull) {
1769
+ hull = { ...bb };
1770
+ } else {
1771
+ const hx = Math.min(hull.x, bb.x);
1772
+ const hy = Math.min(hull.y, bb.y);
1773
+ hull = {
1774
+ x: hx,
1775
+ y: hy,
1776
+ w: Math.max(hull.x + hull.w, bb.x + bb.w) - hx,
1777
+ h: Math.max(hull.y + hull.h, bb.y + bb.h) - hy,
1778
+ };
1779
+ }
1780
+ }
1781
+ const zoom = vpRef.current?.zoom || 1;
2813
1782
  const onMove = (mv: PointerEvent) => {
2814
1783
  const st = dragStateRef.current;
2815
1784
  if (!st || mv.pointerId !== st.pointerId) return;
2816
1785
  const [cwx, cwy] = screenToWorld(mv.clientX, mv.clientY);
2817
- const dx = cwx - st.startWX;
2818
- const dy = cwy - st.startWY;
1786
+ let dx = cwx - st.startWX;
1787
+ let dy = cwy - st.startWY;
1788
+ // FigJam v3 — edge/center snapping; ⌘ suppresses (Figma convention).
1789
+ // Axes with no smart-guide match fall back to the 24px dot grid
1790
+ // (GRID_PITCH_PX = the DS --canvas-grid pitch).
1791
+ if (hull && !(mv.metaKey || mv.ctrlKey)) {
1792
+ const snap = computeSnap(
1793
+ { x: hull.x + dx, y: hull.y + dy, w: hull.w, h: hull.h },
1794
+ candidates,
1795
+ SNAP_THRESHOLD_PX / zoom,
1796
+ { grid: GRID_PITCH_PX }
1797
+ );
1798
+ dx += snap.dx;
1799
+ dy += snap.dy;
1800
+ setSnapGuides(snap.guides.length ? snap.guides : null);
1801
+ } else {
1802
+ setSnapGuides(null);
1803
+ }
2819
1804
  // Drag-back-to-origin: restore exact references so the pointerup
2820
- // shallow-equality check skips committing a no-op record.
1805
+ // shallow-equality check skips committing a no-op record. Bound
1806
+ // arrows re-derive from their hosts so connectors track live.
2821
1807
  const next =
2822
1808
  dx === 0 && dy === 0
2823
1809
  ? st.snapshot
2824
- : st.snapshot.map((s) => (movedSet.has(s.id) ? translateOne(s, dx, dy) : s));
1810
+ : recomputeBoundArrows(
1811
+ st.snapshot.map((s) => (movedSet.has(s.id) ? translateOne(s, dx, dy) : s))
1812
+ );
2825
1813
  // Local React state only. No commitStrokes — no PUT, no undo push.
2826
1814
  setStrokesState(next);
2827
1815
  };
@@ -2829,17 +1817,27 @@ export function AnnotationsLayer() {
2829
1817
  const st = dragStateRef.current;
2830
1818
  if (!st || up.pointerId !== st.pointerId) return;
2831
1819
  dragStateRef.current = null;
1820
+ setSnapGuides(null);
2832
1821
  document.removeEventListener('pointermove', onMove, true);
2833
1822
  document.removeEventListener('pointerup', onUp, true);
2834
1823
  document.removeEventListener('pointercancel', onUp, true);
2835
1824
  // Commit the gesture as ONE record. Skip on zero-movement
2836
1825
  // (click without drag past threshold or drag back to origin).
2837
1826
  const final = strokesRef.current;
2838
- if (strokesShallowEqual(st.snapshot, final)) return;
1827
+ if (strokesShallowEqual(st.snapshot, final)) {
1828
+ if (st.altDup) {
1829
+ // Alt+click without a drag — revert the eager clones.
1830
+ setStrokesState(st.undoBase);
1831
+ annotSel.replace(preAltIds);
1832
+ }
1833
+ return;
1834
+ }
2839
1835
  commitStrokes(
2840
- st.snapshot,
1836
+ st.undoBase,
2841
1837
  final,
2842
- `move ${st.movedIds.length} stroke${st.movedIds.length === 1 ? '' : 's'}`
1838
+ `${st.altDup ? 'duplicate' : 'move'} ${st.movedIds.length} stroke${
1839
+ st.movedIds.length === 1 ? '' : 's'
1840
+ }`
2843
1841
  );
2844
1842
  };
2845
1843
  document.addEventListener('pointermove', onMove, true);
@@ -2852,8 +1850,10 @@ export function AnnotationsLayer() {
2852
1850
  // artboard the gesture belongs to artboard-drag / element-marquee — not
2853
1851
  // the annotation marquee (post-Wave-3 grievance G5). Checked AFTER the
2854
1852
  // group-drag decision so a multi-selection hull-drag still wins even when
2855
- // the strokes sit over an artboard.
2856
- if (!strokeId && target?.closest?.('[data-dc-screen]')) return;
1853
+ // the strokes sit over an artboard. FigJam v3 exception: SHIFT+drag is
1854
+ // the additive annotation marquee, so it rubber-bands annotations
1855
+ // sitting ON an artboard instead of falling through to artboard-drag.
1856
+ if (!strokeId && !e.shiftKey && target?.closest?.('[data-dc-screen]')) return;
2857
1857
 
2858
1858
  // Empty world — start a drag-select gesture. A bare click without
2859
1859
  // moving clears annotation selection (post-Wave-3 feedback: click-to-
@@ -2900,8 +1900,11 @@ export function AnnotationsLayer() {
2900
1900
  }
2901
1901
  // Marquee that captured no strokes — preserve existing selection.
2902
1902
  if (hits.length === 0) return;
2903
- if (addToSelection) annotSel.add(hits);
2904
- else annotSel.replace(hits);
1903
+ // FigJam v3 — a marquee touching any group member selects the whole
1904
+ // group (tldraw: the brush resolves to the outermost ancestor).
1905
+ const expanded = expandIdsToGroups(hits, strokesStoreRef.current.strokes);
1906
+ if (addToSelection) annotSel.add(expanded);
1907
+ else annotSel.replace(expanded);
2905
1908
  };
2906
1909
  document.addEventListener('pointermove', onMove, true);
2907
1910
  document.addEventListener('pointerup', onUp, true);
@@ -2935,7 +1938,16 @@ export function AnnotationsLayer() {
2935
1938
  const id = node.getAttribute('data-id');
2936
1939
  const t = node.getAttribute('data-tool');
2937
1940
  if (!id) return;
2938
- if (t === 'rect' || t === 'ellipse' || t === 'sticky') {
1941
+ // FigJam v3 double-click DEEP-SELECTS a group member (a single click
1942
+ // selects the whole outermost group; Esc clears back out). The editor
1943
+ // still opens below for text-bearing types — FigJam's enter-group-then-
1944
+ // edit flow in one gesture.
1945
+ const stroke = strokesRef.current.find((s) => s.id === id);
1946
+ if (stroke && outermostGroupOf(stroke) && annotSel) {
1947
+ e.preventDefault();
1948
+ annotSel.replace(id);
1949
+ }
1950
+ if (t === 'rect' || t === 'ellipse' || t === 'polygon' || t === 'sticky' || t === 'section') {
2939
1951
  e.preventDefault();
2940
1952
  setEditingId(id);
2941
1953
  return;
@@ -2947,7 +1959,7 @@ export function AnnotationsLayer() {
2947
1959
  };
2948
1960
  document.addEventListener('dblclick', onDbl, true);
2949
1961
  return () => document.removeEventListener('dblclick', onDbl, true);
2950
- }, [tool]);
1962
+ }, [tool, annotSel]);
2951
1963
 
2952
1964
  const commitText = useCallback(
2953
1965
  (anchorId: string, text: string, fmt?: EditorFmt) => {
@@ -3055,43 +2067,520 @@ export function AnnotationsLayer() {
3055
2067
  [commitStrokes, color, annotSel]
3056
2068
  );
3057
2069
 
3058
- // Phase 21 — resolve what (if anything) is being edited, and route a single
3059
- // commit call to the right writer. `editingId` doubles as the host id
3060
- // (anchored) OR the sticky/standalone stroke id; `pendingText` is the
3061
- // not-yet-born text caret.
3062
- const editingTarget = useMemo<EditingTarget>(() => {
3063
- if (pendingText) return { kind: 'pending', x: pendingText.x, y: pendingText.y };
3064
- if (!editingId) return null;
3065
- const host = anchorsById.get(editingId);
3066
- if (host) return { kind: 'anchored', anchorId: editingId, host };
3067
- const s = strokesById.get(editingId);
3068
- if (s?.tool === 'sticky') return { kind: 'sticky', sticky: s };
3069
- if (s?.tool === 'text' && (s.anchorId == null || s.anchorId === ''))
3070
- return { kind: 'standalone', text: s };
3071
- return null;
3072
- }, [pendingText, editingId, anchorsById, strokesById]);
2070
+ // Phase 21 — resolve what (if anything) is being edited, and route a single
2071
+ // commit call to the right writer. `editingId` doubles as the host id
2072
+ // (anchored) OR the sticky/standalone stroke id; `pendingText` is the
2073
+ // not-yet-born text caret.
2074
+ const editingTarget = useMemo<EditingTarget>(() => {
2075
+ if (pendingText) return { kind: 'pending', x: pendingText.x, y: pendingText.y };
2076
+ if (!editingId) return null;
2077
+ const host = anchorsById.get(editingId);
2078
+ if (host) return { kind: 'anchored', anchorId: editingId, host };
2079
+ const s = strokesById.get(editingId);
2080
+ if (s?.tool === 'sticky') return { kind: 'sticky', sticky: s };
2081
+ if (s?.tool === 'section') return { kind: 'section', section: s };
2082
+ if (s?.tool === 'text' && (s.anchorId == null || s.anchorId === ''))
2083
+ return { kind: 'standalone', text: s };
2084
+ return null;
2085
+ }, [pendingText, editingId, anchorsById, strokesById]);
2086
+
2087
+ const editingTargetRef = useRef(editingTarget);
2088
+ editingTargetRef.current = editingTarget;
2089
+
2090
+ const commitEditing = useCallback(
2091
+ (text: string, fmt?: EditorFmt) => {
2092
+ const target = editingTargetRef.current;
2093
+ setEditingId(null);
2094
+ setPendingText(null);
2095
+ if (!target) return;
2096
+ if (target.kind === 'anchored') commitText(target.anchorId, text, fmt);
2097
+ else if (target.kind === 'sticky') commitStickyText(target.sticky.id, text, fmt);
2098
+ else if (target.kind === 'standalone') commitStandaloneText(target.text.id, text, fmt);
2099
+ else if (target.kind === 'section') {
2100
+ const label = text.trim().replace(/\s*\n+\s*/g, ' ') || 'Section';
2101
+ if (label !== target.section.label) {
2102
+ strokesStoreRef.current.updateStroke(target.section.id, {
2103
+ label,
2104
+ } as Partial<Stroke>);
2105
+ }
2106
+ } else if (target.kind === 'pending') createStandaloneText(target.x, target.y, text, fmt);
2107
+ },
2108
+ [commitText, commitStickyText, commitStandaloneText, createStandaloneText]
2109
+ );
2110
+
2111
+ const cancelEditing = useCallback(() => {
2112
+ setEditingId(null);
2113
+ setPendingText(null);
2114
+ }, []);
2115
+
2116
+ /**
2117
+ * FigJam v3 — copy/cut the (expanded) selection to the OS clipboard as a
2118
+ * `{"maudeStrokes":1}` JSON text payload. Shared by ⌘C/⌘X and the
2119
+ * right-click menu. Returns true when something was copied.
2120
+ */
2121
+ const copySelection = useCallback(
2122
+ (cut: boolean): boolean => {
2123
+ if (!annotSel) return false;
2124
+ const sel = annotSel.selectedIds;
2125
+ if (sel.length === 0) return false;
2126
+ const store = strokesStoreRef.current;
2127
+ const expanded = new Set(expandIdsToGroups(sel, store.strokes));
2128
+ const payload = store.strokes.filter(
2129
+ (s) =>
2130
+ expanded.has(s.id) ||
2131
+ (s.tool === 'text' && s.anchorId != null && expanded.has(s.anchorId))
2132
+ );
2133
+ if (payload.length === 0) return false;
2134
+ try {
2135
+ void navigator.clipboard
2136
+ ?.writeText(JSON.stringify({ maudeStrokes: 1, strokes: payload }))
2137
+ .catch(() => {
2138
+ /* clipboard permission denied — copy is best-effort */
2139
+ });
2140
+ } catch {
2141
+ /* clipboard API absent — non-fatal */
2142
+ }
2143
+ if (cut) {
2144
+ store.deleteStrokes([...expanded]);
2145
+ annotSel.clear();
2146
+ }
2147
+ return true;
2148
+ },
2149
+ [annotSel]
2150
+ );
2151
+
2152
+ /**
2153
+ * FigJam v3 — paste a strokes JSON payload (⌘V or the right-click menu).
2154
+ * Round-trips through the serializer + parser so a malformed foreign payload
2155
+ * coerces to valid strokes or drops; clones get fresh ids + a +16/+16 offset.
2156
+ */
2157
+ const pasteStrokesText = useCallback(
2158
+ (txt: string): boolean => {
2159
+ if (!annotSel) return false;
2160
+ if (!txt.startsWith('{"maudeStrokes"')) return false;
2161
+ let parsed: { maudeStrokes?: number; strokes?: unknown } | null = null;
2162
+ try {
2163
+ parsed = JSON.parse(txt) as { maudeStrokes?: number; strokes?: unknown };
2164
+ } catch {
2165
+ return false;
2166
+ }
2167
+ if (parsed?.maudeStrokes !== 1 || !Array.isArray(parsed.strokes)) return false;
2168
+ let safe: Stroke[] = [];
2169
+ try {
2170
+ safe = svgToStrokes(strokesToSvg(parsed.strokes as Stroke[]));
2171
+ } catch {
2172
+ return false;
2173
+ }
2174
+ if (safe.length === 0) return false;
2175
+ const res = duplicateStrokes(
2176
+ safe,
2177
+ safe.map((s) => s.id),
2178
+ 16,
2179
+ 16
2180
+ );
2181
+ const clones = res.strokes.slice(safe.length);
2182
+ if (clones.length === 0) return false;
2183
+ const prev = strokesRef.current;
2184
+ // recompute keeps binds to hosts present in THIS canvas and strips the
2185
+ // cross-canvas danglers (endpoint frozen).
2186
+ const next = recomputeBoundArrows([...prev, ...clones]);
2187
+ commitStrokes(prev, next, `paste ${clones.length} stroke${clones.length === 1 ? '' : 's'}`);
2188
+ annotSel.replace(res.newIds);
2189
+ return true;
2190
+ },
2191
+ [annotSel, commitStrokes]
2192
+ );
2193
+
2194
+ /**
2195
+ * FigJam v3 — quick-create chain (⌘Enter): with a sticky or shape selected,
2196
+ * spawn a sibling of the same type/size/style to the right with its editor
2197
+ * active. Shapes ALSO get a bound connector source → sibling (FigJam quick-
2198
+ * create: shapes connect, stickies don't). Returns true when spawned.
2199
+ */
2200
+ const chainCreate = useCallback(
2201
+ (sourceId: string): boolean => {
2202
+ const prev = strokesRef.current;
2203
+ const src = prev.find((s) => s.id === sourceId);
2204
+ if (!src) return false;
2205
+ if (
2206
+ src.tool !== 'sticky' &&
2207
+ src.tool !== 'rect' &&
2208
+ src.tool !== 'ellipse' &&
2209
+ src.tool !== 'polygon'
2210
+ ) {
2211
+ return false;
2212
+ }
2213
+ const bb = strokeBBox(src);
2214
+ if (!bb) return false;
2215
+ const gap = src.tool === 'sticky' ? 40 : 64;
2216
+ const nid = rid();
2217
+ // The sibling copies style + size but starts loose (no group membership,
2218
+ // human provenance) and empty-bodied. Undefined-assignment (not rest-
2219
+ // destructuring) keeps the discriminated-union narrowing intact for the
2220
+ // branches below; the serializer treats undefined as absent.
2221
+ const bare = structuredClone(src);
2222
+ bare.groupIds = undefined;
2223
+ bare.author = undefined;
2224
+ let sibling: Stroke;
2225
+ if (bare.tool === 'ellipse') {
2226
+ sibling = { ...bare, id: nid, cx: bare.cx + bb.w + gap };
2227
+ } else if (bare.tool === 'sticky') {
2228
+ sibling = { ...bare, id: nid, x: bb.x + bb.w + gap, y: bb.y, text: '' };
2229
+ } else {
2230
+ sibling = { ...bare, id: nid, x: bb.x + bb.w + gap, y: bb.y };
2231
+ }
2232
+ let next: Stroke[] = [...prev, sibling];
2233
+ if (src.tool !== 'sticky') {
2234
+ const p1 = anchorPoint(src, 1, 0.5);
2235
+ const p2 = anchorPoint(sibling, 0, 0.5);
2236
+ if (p1 && p2) {
2237
+ next = [
2238
+ ...next,
2239
+ {
2240
+ id: rid(),
2241
+ tool: 'arrow',
2242
+ color: resolveDefaultInk(theme),
2243
+ width: STROKE_WIDTH_THIN,
2244
+ x1: p1[0],
2245
+ y1: p1[1],
2246
+ x2: p2[0],
2247
+ y2: p2[1],
2248
+ startBind: { hostId: src.id, nx: 1, ny: 0.5 },
2249
+ endBind: { hostId: nid, nx: 0, ny: 0.5 },
2250
+ },
2251
+ ];
2252
+ }
2253
+ }
2254
+ commitStrokes(prev, next, `quick-create ${sibling.tool}`);
2255
+ annotSel?.replace(nid);
2256
+ // Sticky → body editor; rect/ellipse/polygon → anchored-text editor
2257
+ // (Wave G widened anchorsById to every closed shape).
2258
+ if (
2259
+ sibling.tool === 'sticky' ||
2260
+ sibling.tool === 'rect' ||
2261
+ sibling.tool === 'ellipse' ||
2262
+ sibling.tool === 'polygon'
2263
+ ) {
2264
+ setEditingId(nid);
2265
+ }
2266
+ return true;
2267
+ },
2268
+ [commitStrokes, annotSel, theme]
2269
+ );
2270
+
2271
+ // FigJam v3 — ⌘Enter pressed INSIDE a sticky/anchored editor commits there
2272
+ // and asks the layer (via this event) to chain the next sibling. Deferred a
2273
+ // tick so the editor's commit lands in strokesRef first.
2274
+ useEffect(() => {
2275
+ if (typeof document === 'undefined') return;
2276
+ const onChain = (e: Event) => {
2277
+ const id = (e as CustomEvent<{ id?: string }>).detail?.id;
2278
+ if (!id) return;
2279
+ window.setTimeout(() => {
2280
+ chainCreate(id);
2281
+ }, 0);
2282
+ };
2283
+ document.addEventListener('maude:chain-create', onChain);
2284
+ return () => document.removeEventListener('maude:chain-create', onChain);
2285
+ }, [chainCreate]);
2286
+
2287
+ // FigJam v3 — the resize overlay (a sibling component that owns the arrow
2288
+ // endpoint handles) broadcasts the bind candidate while an endpoint drags;
2289
+ // the halo renders here because the SVG layer owns the world overlay.
2290
+ useEffect(() => {
2291
+ if (typeof document === 'undefined') return;
2292
+ const onHint = (e: Event) => {
2293
+ setBindHintId((e as CustomEvent<{ hostId?: string | null }>).detail?.hostId ?? null);
2294
+ };
2295
+ document.addEventListener('maude:bind-hint', onHint);
2296
+ return () => document.removeEventListener('maude:bind-hint', onHint);
2297
+ }, []);
2298
+
2299
+ // FigJam v3 — live size label + dimension-match halos while resizing (the
2300
+ // overlay broadcasts; the SVG layer paints).
2301
+ const [resizeInfo, setResizeInfo] = useState<{
2302
+ box: { x: number; y: number; w: number; h: number } | null;
2303
+ matchIds: string[];
2304
+ } | null>(null);
2305
+ useEffect(() => {
2306
+ if (typeof document === 'undefined') return;
2307
+ const onInfo = (e: Event) => {
2308
+ const detail = (
2309
+ e as CustomEvent<{
2310
+ box?: { x: number; y: number; w: number; h: number } | null;
2311
+ matchIds?: string[];
2312
+ }>
2313
+ ).detail;
2314
+ setResizeInfo(detail?.box ? { box: detail.box, matchIds: detail.matchIds ?? [] } : null);
2315
+ };
2316
+ document.addEventListener('maude:resize-info', onInfo);
2317
+ return () => document.removeEventListener('maude:resize-info', onInfo);
2318
+ }, []);
2319
+
2320
+ // FigJam v3 — manipulation shortcuts: ⌘G group / ⌘⇧G ungroup, ⌘D duplicate,
2321
+ // ] [ ⌘] ⌘[ z-order, ⌘C/⌘X copy/cut (selection → OS clipboard as a JSON
2322
+ // text payload), ⌘Enter quick-create chain. Document capture, mirroring the
2323
+ // nudge handler below; the input-router never claims these combos.
2324
+ useEffect(() => {
2325
+ if (typeof document === 'undefined') return;
2326
+ if (!annotSel) return;
2327
+ const onKey = (e: KeyboardEvent) => {
2328
+ if (isEditable(e.target)) return;
2329
+ const store = strokesStoreRef.current;
2330
+ const sel = annotSel.selectedIds;
2331
+ const cmd = e.metaKey || e.ctrlKey;
2332
+ const k = e.key.toLowerCase();
2333
+ if (cmd && !e.altKey && k === 'g') {
2334
+ // ⌘G claims the browser's find-next ONLY when a selection exists.
2335
+ if (sel.length === 0) return;
2336
+ e.preventDefault();
2337
+ e.stopImmediatePropagation();
2338
+ if (e.shiftKey) {
2339
+ store.ungroupSelection(sel);
2340
+ } else {
2341
+ const members = store.groupSelection(sel);
2342
+ if (members) annotSel.replace(members);
2343
+ }
2344
+ return;
2345
+ }
2346
+ if (cmd && !e.shiftKey && !e.altKey && k === 'd') {
2347
+ if (sel.length === 0) return; // browser bookmark stays available
2348
+ e.preventDefault();
2349
+ e.stopImmediatePropagation();
2350
+ const ids = store.duplicateSelection(sel, 16, 16);
2351
+ if (ids.length) annotSel.replace(ids);
2352
+ return;
2353
+ }
2354
+ if ((e.key === ']' || e.key === '[') && !e.altKey && !e.shiftKey) {
2355
+ if (sel.length === 0) return;
2356
+ e.preventDefault();
2357
+ const op: ZOrderOp =
2358
+ e.key === ']' ? (cmd ? 'forward' : 'front') : cmd ? 'backward' : 'back';
2359
+ store.reorderSelection(sel, op);
2360
+ return;
2361
+ }
2362
+ if (cmd && !e.shiftKey && !e.altKey && (k === 'c' || k === 'x')) {
2363
+ if (sel.length === 0) return; // let the native copy run
2364
+ if (copySelection(k === 'x')) e.preventDefault();
2365
+ return;
2366
+ }
2367
+ if (cmd && e.key === 'Enter' && !e.shiftKey && !e.altKey) {
2368
+ if (sel.length !== 1) return;
2369
+ const only = sel[0];
2370
+ if (only && chainCreate(only)) e.preventDefault();
2371
+ return;
2372
+ }
2373
+ // FigJam v3 — plain Enter on a single text-capable stroke opens its
2374
+ // editor (FigJam: select a shape, press Enter, start typing).
2375
+ if (e.key === 'Enter' && !cmd && !e.shiftKey && !e.altKey) {
2376
+ if (sel.length !== 1) return;
2377
+ const only = strokesRef.current.find((x) => x.id === sel[0]);
2378
+ if (!only) return;
2379
+ if (
2380
+ only.tool === 'rect' ||
2381
+ only.tool === 'ellipse' ||
2382
+ only.tool === 'polygon' ||
2383
+ only.tool === 'sticky' ||
2384
+ (only.tool === 'text' && (only.anchorId == null || only.anchorId === ''))
2385
+ ) {
2386
+ e.preventDefault();
2387
+ setEditingId(only.id);
2388
+ }
2389
+ }
2390
+ };
2391
+ document.addEventListener('keydown', onKey, true);
2392
+ return () => document.removeEventListener('keydown', onKey, true);
2393
+ }, [annotSel, chainCreate, copySelection]);
2394
+
2395
+ // FigJam v3 — paste strokes. ⌘C serialized the selection as a JSON text
2396
+ // payload; this CAPTURE-phase listener claims it before the media-intake
2397
+ // hook (bubble phase) so a strokes payload never falls through to the URL/
2398
+ // link branch.
2399
+ useEffect(() => {
2400
+ if (typeof document === 'undefined') return;
2401
+ if (!annotSel) return;
2402
+ const onPaste = (e: ClipboardEvent) => {
2403
+ if (isEditable(e.target)) return;
2404
+ const txt = e.clipboardData?.getData('text/plain') ?? '';
2405
+ if (!txt.startsWith('{"maudeStrokes"')) return;
2406
+ e.preventDefault();
2407
+ e.stopImmediatePropagation();
2408
+ pasteStrokesText(txt);
2409
+ };
2410
+ document.addEventListener('paste', onPaste, true);
2411
+ return () => document.removeEventListener('paste', onPaste, true);
2412
+ }, [annotSel, pasteStrokesText]);
3073
2413
 
3074
- const editingTargetRef = useRef(editingTarget);
3075
- editingTargetRef.current = editingTarget;
2414
+ // FigJam v3 — hover "Add text" affordance: an empty rect/ellipse hovered in
2415
+ // move mode shows a ghost label; double-click (existing) or Enter edits.
2416
+ const [addTextHintId, setAddTextHintId] = useState<string | null>(null);
2417
+ useEffect(() => {
2418
+ if (typeof document === 'undefined') return;
2419
+ if (tool !== 'move') {
2420
+ setAddTextHintId(null);
2421
+ return;
2422
+ }
2423
+ const onMove = (e: PointerEvent) => {
2424
+ const node = (e.target as Element | null)?.closest?.('[data-id][data-tool]');
2425
+ const t = node?.getAttribute('data-tool');
2426
+ const id = node?.getAttribute('data-id') ?? null;
2427
+ if (!id || (t !== 'rect' && t !== 'ellipse' && t !== 'polygon')) {
2428
+ setAddTextHintId(null);
2429
+ return;
2430
+ }
2431
+ const hasText = strokesRef.current.some(
2432
+ (x) => x.tool === 'text' && x.anchorId === id && x.text.length > 0
2433
+ );
2434
+ setAddTextHintId(hasText ? null : id);
2435
+ };
2436
+ document.addEventListener('pointermove', onMove, { passive: true });
2437
+ return () => document.removeEventListener('pointermove', onMove);
2438
+ }, [tool]);
3076
2439
 
3077
- const commitEditing = useCallback(
3078
- (text: string, fmt?: EditorFmt) => {
3079
- const target = editingTargetRef.current;
3080
- setEditingId(null);
3081
- setPendingText(null);
3082
- if (!target) return;
3083
- if (target.kind === 'anchored') commitText(target.anchorId, text, fmt);
3084
- else if (target.kind === 'sticky') commitStickyText(target.sticky.id, text, fmt);
3085
- else if (target.kind === 'standalone') commitStandaloneText(target.text.id, text, fmt);
3086
- else if (target.kind === 'pending') createStandaloneText(target.x, target.y, text, fmt);
3087
- },
3088
- [commitText, commitStickyText, commitStandaloneText, createStandaloneText]
3089
- );
2440
+ // FigJam v3 — connector draft: dragging from a connection dot (the side
2441
+ // magnets shown on a selected bindable shape) draws a BOUND curved
2442
+ // connector; releasing over another bindable shape binds the far end too
2443
+ // (⌘ keeps it free). The draft renders through the same arrow primitives.
2444
+ const [connDraft, setConnDraft] = useState<{
2445
+ x1: number;
2446
+ y1: number;
2447
+ x2: number;
2448
+ y2: number;
2449
+ startBind: { hostId: string; nx: number; ny: number };
2450
+ endBind?: { hostId: string; nx: number; ny: number };
2451
+ } | null>(null);
2452
+ const connDraftRef = useRef(connDraft);
2453
+ connDraftRef.current = connDraft;
2454
+ useEffect(() => {
2455
+ if (typeof document === 'undefined') return;
2456
+ if (tool !== 'move') return;
2457
+ const onDown = (e: PointerEvent) => {
2458
+ if (e.button !== 0) return;
2459
+ const dot = (e.target as Element | null)?.closest?.('.dc-annot-conn-dot');
2460
+ if (!dot) return;
2461
+ const hostId = dot.getAttribute('data-host') ?? '';
2462
+ const nx = Number.parseFloat(dot.getAttribute('data-nx') ?? '');
2463
+ const ny = Number.parseFloat(dot.getAttribute('data-ny') ?? '');
2464
+ const host = strokesRef.current.find((s) => s.id === hostId);
2465
+ if (!host || !Number.isFinite(nx) || !Number.isFinite(ny)) return;
2466
+ const pt = anchorPoint(host, nx, ny);
2467
+ if (!pt) return;
2468
+ e.preventDefault();
2469
+ e.stopImmediatePropagation();
2470
+ const pointerId = e.pointerId;
2471
+ const startBind = { hostId, nx, ny };
2472
+ setConnDraft({ x1: pt[0], y1: pt[1], x2: pt[0], y2: pt[1], startBind });
2473
+ const onMove = (mv: PointerEvent) => {
2474
+ if (mv.pointerId !== pointerId) return;
2475
+ const [wx, wy] = screenToWorld(mv.clientX, mv.clientY);
2476
+ const zoom = vpRef.current?.zoom || 1;
2477
+ const cand =
2478
+ mv.metaKey || mv.ctrlKey
2479
+ ? null
2480
+ : bindCandidate(
2481
+ wx,
2482
+ wy,
2483
+ strokesRef.current,
2484
+ BIND_THRESHOLD_PX / zoom,
2485
+ new Set([hostId])
2486
+ );
2487
+ setBindHintId(cand?.hostId ?? null);
2488
+ if (cand) {
2489
+ const target = strokesRef.current.find((s) => s.id === cand.hostId);
2490
+ const tp = target ? anchorPoint(target, cand.nx, cand.ny) : null;
2491
+ if (tp) {
2492
+ setConnDraft({ x1: pt[0], y1: pt[1], x2: tp[0], y2: tp[1], startBind, endBind: cand });
2493
+ return;
2494
+ }
2495
+ }
2496
+ setConnDraft({ x1: pt[0], y1: pt[1], x2: wx, y2: wy, startBind });
2497
+ };
2498
+ const onUp = (up: PointerEvent) => {
2499
+ if (up.pointerId !== pointerId) return;
2500
+ document.removeEventListener('pointermove', onMove, true);
2501
+ document.removeEventListener('pointerup', onUp, true);
2502
+ document.removeEventListener('pointercancel', onUp, true);
2503
+ const draft = connDraftRef.current;
2504
+ setConnDraft(null);
2505
+ setBindHintId(null);
2506
+ if (!draft) return;
2507
+ // A bare tap on the dot creates nothing.
2508
+ if (Math.hypot(draft.x2 - draft.x1, draft.y2 - draft.y1) < 8) return;
2509
+ const arrow: ArrowStroke = {
2510
+ id: rid(),
2511
+ tool: 'arrow',
2512
+ color: resolveDefaultInk(theme),
2513
+ width: STROKE_WIDTH_THIN,
2514
+ x1: draft.x1,
2515
+ y1: draft.y1,
2516
+ x2: draft.x2,
2517
+ y2: draft.y2,
2518
+ lineType: 'curved',
2519
+ startBind: draft.startBind,
2520
+ ...(draft.endBind ? { endBind: draft.endBind } : {}),
2521
+ };
2522
+ const prev = strokesRef.current;
2523
+ commitStrokes(prev, [...prev, arrow], 'draw connector');
2524
+ annotSel?.replace(arrow.id);
2525
+ if (arrow.endBind) {
2526
+ showOnceHint(
2527
+ 'bind',
2528
+ 'Arrow attached — it follows the shape now. Drag an endpoint to re-anchor, hold ⌘ to keep it free.'
2529
+ );
2530
+ }
2531
+ };
2532
+ document.addEventListener('pointermove', onMove, true);
2533
+ document.addEventListener('pointerup', onUp, true);
2534
+ document.addEventListener('pointercancel', onUp, true);
2535
+ };
2536
+ document.addEventListener('pointerdown', onDown, true);
2537
+ return () => document.removeEventListener('pointerdown', onDown, true);
2538
+ }, [tool, screenToWorld, commitStrokes, annotSel, theme]);
3090
2539
 
3091
- const cancelEditing = useCallback(() => {
3092
- setEditingId(null);
3093
- setPendingText(null);
3094
- }, []);
2540
+ // FigJam v3 — right-click on a stroke SELECTS it (keeping a multi-selection
2541
+ // the press lands inside) and opens the annotation context menu (z-order,
2542
+ // group, copy/paste, delete). Capture phase on document so it claims the
2543
+ // event before the input-router's host-level contextmenu handler.
2544
+ const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null);
2545
+ useEffect(() => {
2546
+ if (typeof document === 'undefined') return;
2547
+ if (tool !== 'move') return;
2548
+ if (!annotSel) return;
2549
+ const strokeAt = (target: Element | null): string | null => {
2550
+ if (target?.closest?.(CHROME_SELECTOR)) return null;
2551
+ const node = target?.closest?.('[data-id][data-tool]');
2552
+ const id = node?.getAttribute('data-id');
2553
+ if (!id || !strokesRef.current.some((s) => s.id === id)) return null;
2554
+ return id;
2555
+ };
2556
+ const onCtx = (e: MouseEvent) => {
2557
+ const id = strokeAt(e.target as Element | null);
2558
+ if (!id) return;
2559
+ e.preventDefault();
2560
+ e.stopImmediatePropagation();
2561
+ if (!annotSel.contains(id)) {
2562
+ annotSel.replace(expandIdsToGroups([id], strokesRef.current));
2563
+ }
2564
+ setCtxMenu({ x: e.clientX, y: e.clientY });
2565
+ };
2566
+ // Wave G — the input-router ALSO opens the shell canvas menu from a
2567
+ // right-button POINTERDOWN (classify maps button 2 → 'context-menu'), so
2568
+ // claiming only the contextmenu event left BOTH menus open. This document-
2569
+ // capture listener fires before the router's host-capture one and stops
2570
+ // propagation WITHOUT preventDefault, so the native contextmenu event
2571
+ // (which opens OUR menu above) still follows.
2572
+ const onDown = (e: PointerEvent) => {
2573
+ if (e.button !== 2) return;
2574
+ if (!strokeAt(e.target as Element | null)) return;
2575
+ e.stopImmediatePropagation();
2576
+ };
2577
+ document.addEventListener('contextmenu', onCtx, true);
2578
+ document.addEventListener('pointerdown', onDown, true);
2579
+ return () => {
2580
+ document.removeEventListener('contextmenu', onCtx, true);
2581
+ document.removeEventListener('pointerdown', onDown, true);
2582
+ };
2583
+ }, [tool, annotSel]);
3095
2584
 
3096
2585
  // Keyboard: arrow nudge + Backspace/Delete remove selected strokes.
3097
2586
  useEffect(() => {
@@ -3132,6 +2621,62 @@ export function AnnotationsLayer() {
3132
2621
  return () => document.removeEventListener('keydown', onKey, true);
3133
2622
  }, [annotSel, strokesStore]);
3134
2623
 
2624
+ /** FigJam v3 — context-menu action dispatcher (shares the shortcut paths). */
2625
+ const onMenuAction = useCallback(
2626
+ (action: string) => {
2627
+ const store = strokesStoreRef.current;
2628
+ if (!annotSel) return;
2629
+ const sel = annotSel.selectedIds;
2630
+ if (action === 'copy') copySelection(false);
2631
+ else if (action === 'cut') copySelection(true);
2632
+ else if (action === 'paste') {
2633
+ try {
2634
+ void navigator.clipboard
2635
+ ?.readText()
2636
+ .then((t) => {
2637
+ pasteStrokesText(t);
2638
+ })
2639
+ .catch(() => {
2640
+ /* clipboard read blocked — paste is best-effort from the menu */
2641
+ });
2642
+ } catch {
2643
+ /* clipboard API absent */
2644
+ }
2645
+ } else if (action === 'duplicate') {
2646
+ const ids = store.duplicateSelection(sel, 16, 16);
2647
+ if (ids.length) annotSel.replace(ids);
2648
+ } else if (action === 'delete') {
2649
+ store.deleteStrokes(sel);
2650
+ annotSel.clear();
2651
+ } else if (
2652
+ action === 'front' ||
2653
+ action === 'forward' ||
2654
+ action === 'backward' ||
2655
+ action === 'back'
2656
+ ) {
2657
+ store.reorderSelection(sel, action);
2658
+ } else if (action === 'group') {
2659
+ const members = store.groupSelection(sel);
2660
+ if (members) annotSel.replace(members);
2661
+ } else if (action === 'ungroup') {
2662
+ store.ungroupSelection(sel);
2663
+ }
2664
+ },
2665
+ [annotSel, copySelection, pasteStrokesText]
2666
+ );
2667
+
2668
+ // FigJam v3 — first time a multi-selection lands, surface the group /
2669
+ // duplicate affordances once (behaviour-triggered, never a tour).
2670
+ const selCount = annotSel?.selectedIds.length ?? 0;
2671
+ useEffect(() => {
2672
+ if (selCount >= 2) {
2673
+ showOnceHint(
2674
+ 'multi',
2675
+ '⌘G groups the selection · drag inside the box moves everything · ⌘D duplicates.'
2676
+ );
2677
+ }
2678
+ }, [selCount]);
2679
+
3135
2680
  // Selected stroke halos — bboxes in world coords, vector-effect non-scaling-stroke.
3136
2681
  const selectedStrokes = useMemo(() => {
3137
2682
  if (!annotSel || annotSel.selectedIds.length === 0) return [] as Stroke[];
@@ -3145,54 +2690,76 @@ export function AnnotationsLayer() {
3145
2690
 
3146
2691
  return (
3147
2692
  <StrokesStoreContext.Provider value={strokesStore}>
3148
- <>
3149
- <AnnotationsInput
3150
- isActive={isActive}
3151
- visible={visible}
3152
- cursor={tools.find((t) => t.id === tool)?.cursor ?? 'crosshair'}
3153
- beginStroke={beginStroke}
3154
- moveStroke={moveStroke}
3155
- endStroke={endStroke}
3156
- onLeave={() => setGhost(null)}
2693
+ <AnnotationsInput
2694
+ isActive={isActive}
2695
+ visible={visible}
2696
+ cursor={tools.find((t) => t.id === tool)?.cursor ?? 'crosshair'}
2697
+ beginStroke={beginStroke}
2698
+ moveStroke={moveStroke}
2699
+ endStroke={endStroke}
2700
+ onLeave={() => setGhost(null)}
2701
+ />
2702
+ {visible ? (
2703
+ <AnnotationsSvg
2704
+ worldRef={worldRef}
2705
+ strokes={renderStrokes}
2706
+ anchorsById={anchorsById}
2707
+ selectMode={tool === 'move'}
2708
+ selectedStrokes={selectedStrokes}
2709
+ marquee={marquee}
2710
+ snapGuides={snapGuides}
2711
+ bindHintId={bindHintId}
2712
+ resizeInfo={resizeInfo}
2713
+ connDraft={connDraft}
2714
+ addTextHintId={editingTarget ? null : addTextHintId}
2715
+ ghost={ghostPreview}
2716
+ editingTarget={editingTarget}
2717
+ inkColor={color}
2718
+ onCommitEdit={commitEditing}
2719
+ onCancelEdit={cancelEditing}
3157
2720
  />
3158
- {visible ? (
3159
- <AnnotationsSvg
3160
- worldRef={worldRef}
3161
- strokes={renderStrokes}
3162
- anchorsById={anchorsById}
3163
- selectMode={tool === 'move'}
3164
- selectedStrokes={selectedStrokes}
3165
- marquee={marquee}
3166
- ghost={ghostPreview}
3167
- editingTarget={editingTarget}
3168
- inkColor={color}
3169
- onCommitEdit={commitEditing}
3170
- onCancelEdit={cancelEditing}
3171
- />
3172
- ) : null}
3173
- <AnnotationContextToolbar />
3174
- {visible && tool === 'move' ? <AnnotationResizeOverlay store={strokesStore} /> : null}
3175
- {isActive ? (
3176
- <AnnotationsChrome
3177
- tool={tool}
3178
- theme={theme}
3179
- color={color}
3180
- setColor={setColor}
3181
- stickyColor={stickyColor}
3182
- setStickyColor={setStickyColor}
3183
- highlighterColor={highlighterColor}
3184
- setHighlighterColor={setHighlighterColor}
3185
- highlighterWidth={highlighterWidth}
3186
- setHighlighterWidth={setHighlighterWidth}
3187
- supportsFill={supportsFill}
3188
- fill={fill}
3189
- setFill={setFill}
3190
- supportsThickness={supportsThickness}
3191
- thickness={thickness}
3192
- setThickness={setThickness}
3193
- />
3194
- ) : null}
3195
- </>
2721
+ ) : null}
2722
+ <AnnotationContextToolbar
2723
+ editingId={
2724
+ editingTarget?.kind === 'anchored'
2725
+ ? editingTarget.anchorId
2726
+ : editingTarget?.kind === 'sticky'
2727
+ ? editingTarget.sticky.id
2728
+ : editingTarget?.kind === 'standalone'
2729
+ ? editingTarget.text.id
2730
+ : null
2731
+ }
2732
+ />
2733
+ {ctxMenu && annotSel ? (
2734
+ <AnnotationContextMenu
2735
+ pos={ctxMenu}
2736
+ selCount={annotSel.selectedIds.length}
2737
+ canUngroup={selectedStrokes.some((s) => (s.groupIds?.length ?? 0) > 0)}
2738
+ onAction={onMenuAction}
2739
+ onClose={() => setCtxMenu(null)}
2740
+ />
2741
+ ) : null}
2742
+ {visible && tool === 'move' ? <AnnotationResizeOverlay store={strokesStore} /> : null}
2743
+ {isActive ? (
2744
+ <AnnotationsChrome
2745
+ tool={tool}
2746
+ theme={theme}
2747
+ color={color}
2748
+ setColor={setColor}
2749
+ stickyColor={stickyColor}
2750
+ setStickyColor={setStickyColor}
2751
+ highlighterColor={highlighterColor}
2752
+ setHighlighterColor={setHighlighterColor}
2753
+ highlighterWidth={highlighterWidth}
2754
+ setHighlighterWidth={setHighlighterWidth}
2755
+ supportsFill={supportsFill}
2756
+ fill={fill}
2757
+ setFill={setFill}
2758
+ supportsThickness={supportsThickness}
2759
+ thickness={thickness}
2760
+ setThickness={setThickness}
2761
+ />
2762
+ ) : null}
3196
2763
  </StrokesStoreContext.Provider>
3197
2764
  );
3198
2765
  }
@@ -3286,6 +2853,11 @@ function AnnotationsSvg({
3286
2853
  selectMode,
3287
2854
  selectedStrokes,
3288
2855
  marquee,
2856
+ snapGuides,
2857
+ bindHintId,
2858
+ resizeInfo,
2859
+ connDraft,
2860
+ addTextHintId,
3289
2861
  ghost,
3290
2862
  editingTarget,
3291
2863
  inkColor,
@@ -3294,10 +2866,30 @@ function AnnotationsSvg({
3294
2866
  }: {
3295
2867
  worldRef: ReturnType<typeof useWorldRefContext>;
3296
2868
  strokes: readonly Stroke[];
3297
- anchorsById: Map<string, RectStroke | EllipseStroke>;
2869
+ anchorsById: Map<string, AnchorHost>;
3298
2870
  selectMode: boolean;
3299
2871
  selectedStrokes: readonly Stroke[];
3300
2872
  marquee: { ax: number; ay: number; bx: number; by: number } | null;
2873
+ /** FigJam v3 — smart-guide lines painted while a drag is snapping. */
2874
+ snapGuides: SnapGuide[] | null;
2875
+ /** FigJam v3 — host a dragged arrow endpoint would bind to (accent halo). */
2876
+ bindHintId: string | null;
2877
+ /** FigJam v3 — live size label + dimension-match halos while resizing. */
2878
+ resizeInfo: {
2879
+ box: { x: number; y: number; w: number; h: number } | null;
2880
+ matchIds: string[];
2881
+ } | null;
2882
+ /** FigJam v3 — in-flight connector drawn from a connection dot. */
2883
+ connDraft: {
2884
+ x1: number;
2885
+ y1: number;
2886
+ x2: number;
2887
+ y2: number;
2888
+ startBind: { hostId: string; nx: number; ny: number };
2889
+ endBind?: { hostId: string; nx: number; ny: number };
2890
+ } | null;
2891
+ /** FigJam v3 — hovered empty shape that shows the "Add text" ghost label. */
2892
+ addTextHintId: string | null;
3301
2893
  ghost: GhostDescriptor | null;
3302
2894
  editingTarget: EditingTarget;
3303
2895
  /** Live default ink (theme-aware) for a not-yet-born pending text caret. */
@@ -3358,6 +2950,94 @@ function AnnotationsSvg({
3358
2950
  vectorEffect="non-scaling-stroke"
3359
2951
  />
3360
2952
  ) : null}
2953
+ {/* FigJam v3 — smart guides: solid 1px accent lines at the snapped edge/
2954
+ center, painted only while a drag is actively snapping. */}
2955
+ {snapGuides?.map((g, i) => (
2956
+ <line
2957
+ // biome-ignore lint/suspicious/noArrayIndexKey: guides are positional + rebuilt per move tick
2958
+ key={`guide-${i}`}
2959
+ x1={g.axis === 'x' ? g.at : g.from}
2960
+ y1={g.axis === 'x' ? g.from : g.at}
2961
+ x2={g.axis === 'x' ? g.at : g.to}
2962
+ y2={g.axis === 'x' ? g.to : g.at}
2963
+ stroke="var(--maude-hud-accent, #d63b1f)"
2964
+ strokeWidth={1}
2965
+ vectorEffect="non-scaling-stroke"
2966
+ pointerEvents="none"
2967
+ />
2968
+ ))}
2969
+ {/* FigJam v3 — bind hint: halo the host a dragged arrow endpoint would
2970
+ magnetically attach to. */}
2971
+ <BindHintHalo strokes={strokes} bindHintId={bindHintId} />
2972
+ {/* FigJam v3 — connection dots on a single selected bindable shape;
2973
+ dragging one draws a bound connector (rendered below as a draft). */}
2974
+ {selectMode && !connDraft && selectedStrokes.length === 1 && selectedStrokes[0] ? (
2975
+ <ConnectorDots stroke={selectedStrokes[0]} />
2976
+ ) : null}
2977
+ {connDraft ? (
2978
+ <g
2979
+ stroke={inkColor}
2980
+ strokeWidth={2.5}
2981
+ strokeLinecap="round"
2982
+ strokeLinejoin="round"
2983
+ vectorEffect="non-scaling-stroke"
2984
+ fill="none"
2985
+ pointerEvents="none"
2986
+ >
2987
+ {arrowPrimitives({
2988
+ x1: connDraft.x1,
2989
+ y1: connDraft.y1,
2990
+ x2: connDraft.x2,
2991
+ y2: connDraft.y2,
2992
+ width: 2.5,
2993
+ color: inkColor,
2994
+ lineType: 'curved',
2995
+ startBind: connDraft.startBind,
2996
+ ...(connDraft.endBind ? { endBind: connDraft.endBind } : {}),
2997
+ }).map((prim, i) => renderArrowPrimitive(prim, i))}
2998
+ </g>
2999
+ ) : null}
3000
+ {/* FigJam v3 — hover affordance: an empty shape invites text. */}
3001
+ <AddTextHint strokes={strokes} hintId={addTextHintId} />
3002
+ {/* FigJam v3 — resize chrome: live W × H label at the box corner plus a
3003
+ dashed halo on any neighbour whose dimension the resize just matched
3004
+ (the "same size as that one" quota). */}
3005
+ {resizeInfo?.box ? (
3006
+ <g pointerEvents="none">
3007
+ {resizeInfo.matchIds.map((id) => {
3008
+ const m = strokes.find((s) => s.id === id);
3009
+ const bb = m ? strokeBBox(m) : null;
3010
+ if (!bb) return null;
3011
+ return (
3012
+ <rect
3013
+ key={`dim-${id}`}
3014
+ x={bb.x - 2}
3015
+ y={bb.y - 2}
3016
+ width={bb.w + 4}
3017
+ height={bb.h + 4}
3018
+ fill="none"
3019
+ stroke="var(--maude-hud-accent, #d63b1f)"
3020
+ strokeWidth={1.5}
3021
+ strokeDasharray="5 3"
3022
+ vectorEffect="non-scaling-stroke"
3023
+ rx={2}
3024
+ />
3025
+ );
3026
+ })}
3027
+ <text
3028
+ x={resizeInfo.box.x + resizeInfo.box.w / 2}
3029
+ y={resizeInfo.box.y + resizeInfo.box.h + 18}
3030
+ textAnchor="middle"
3031
+ fontSize={11}
3032
+ fill="var(--maude-hud-accent, #d63b1f)"
3033
+ style={{
3034
+ fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
3035
+ }}
3036
+ >
3037
+ {`${Math.round(resizeInfo.box.w)} × ${Math.round(resizeInfo.box.h)}`}
3038
+ </text>
3039
+ </g>
3040
+ ) : null}
3361
3041
  {ghost ? <GhostPreview ghost={ghost} /> : null}
3362
3042
  {editingTarget?.kind === 'anchored' ? (
3363
3043
  <TextEditor
@@ -3403,6 +3083,23 @@ function AnnotationsSvg({
3403
3083
  onCancel={onCancelEdit}
3404
3084
  />
3405
3085
  ) : null}
3086
+ {editingTarget?.kind === 'section' ? (
3087
+ <StandaloneTextEditor
3088
+ x={
3089
+ Math.min(editingTarget.section.x, editingTarget.section.x + editingTarget.section.w) + 2
3090
+ }
3091
+ y={
3092
+ Math.min(editingTarget.section.y, editingTarget.section.y + editingTarget.section.h) -
3093
+ SECTION_LABEL_H -
3094
+ 2
3095
+ }
3096
+ fontSize={SECTION_LABEL_FONT}
3097
+ color={editingTarget.section.color}
3098
+ initialText={editingTarget.section.label}
3099
+ onCommit={onCommitEdit}
3100
+ onCancel={onCancelEdit}
3101
+ />
3102
+ ) : null}
3406
3103
  </svg>,
3407
3104
  target
3408
3105
  );
@@ -3416,7 +3113,7 @@ function TextEditor({
3416
3113
  onCancel,
3417
3114
  }: {
3418
3115
  anchorId: string;
3419
- host: RectStroke | EllipseStroke | null;
3116
+ host: AnchorHost | null;
3420
3117
  existing: TextStroke | undefined;
3421
3118
  onCommit: (anchorId: string, text: string, fmt?: EditorFmt) => void;
3422
3119
  onCancel: () => void;
@@ -3437,6 +3134,8 @@ function TextEditor({
3437
3134
  italic: existing?.italic,
3438
3135
  underline: existing?.underline,
3439
3136
  strike: existing?.strike,
3137
+ fontSize: existing?.fontSize ?? DEFAULT_FONT_SIZE,
3138
+ align: existing?.align ?? 'center',
3440
3139
  });
3441
3140
 
3442
3141
  useEffect(() => {
@@ -3464,6 +3163,9 @@ function TextEditor({
3464
3163
  const el = ref.current;
3465
3164
  if (!el) return;
3466
3165
  if (el.contains(e.target as Node)) return;
3166
+ // FigJam v3 — the edit-mode text toolbar drives THIS editor; clicking
3167
+ // it must not commit-and-close the session.
3168
+ if ((e.target as Element | null)?.closest?.('.dc-annot-ctx')) return;
3467
3169
  onCommit(
3468
3170
  anchorId,
3469
3171
  stripEditorMarkers(el.innerText || '', existing?.listType),
@@ -3528,6 +3230,11 @@ function TextEditor({
3528
3230
  stripEditorMarkers(el?.innerText || '', existing?.listType),
3529
3231
  fmtRef.current
3530
3232
  );
3233
+ // FigJam v3 — ⌘Enter chains: commit, then spawn a CONNECTED
3234
+ // sibling shape (quick-create) from the host.
3235
+ document.dispatchEvent(
3236
+ new CustomEvent('maude:chain-create', { detail: { id: anchorId } })
3237
+ );
3531
3238
  }
3532
3239
  }}
3533
3240
  >
@@ -3564,12 +3271,22 @@ function StickyEditor({
3564
3271
  italic: sticky.italic,
3565
3272
  underline: sticky.underline,
3566
3273
  strike: sticky.strike,
3274
+ fontSize: sticky.fontSize,
3275
+ align: sticky.align ?? 'left',
3567
3276
  });
3568
3277
  const commit = () => {
3569
3278
  if (doneRef.current) return;
3570
3279
  doneRef.current = true;
3571
3280
  onCommit(stripEditorMarkers(ref.current?.innerText ?? '', sticky.listType), fmtRef.current);
3572
3281
  };
3282
+ // FigJam v3 — a toolbar click steals focus for a tick; don't treat it as
3283
+ // "done editing" (the button's onMouseDown preventDefault usually stops the
3284
+ // blur, this guards the browsers where it doesn't).
3285
+ const onBlur = (e: { relatedTarget?: EventTarget | null }) => {
3286
+ const to = e.relatedTarget as Element | null;
3287
+ if (to?.closest?.('.dc-annot-ctx')) return;
3288
+ commit();
3289
+ };
3573
3290
  useEffect(() => {
3574
3291
  const el = ref.current;
3575
3292
  if (!el) return;
@@ -3600,7 +3317,7 @@ function StickyEditor({
3600
3317
  suppressContentEditableWarning
3601
3318
  aria-label="Edit sticky note text"
3602
3319
  style={{ ...stickyBodyStyle(sticky), ...fmtStyle, outline: 'none', cursor: 'text' }}
3603
- onBlur={commit}
3320
+ onBlur={onBlur}
3604
3321
  onKeyDown={(e) => {
3605
3322
  if (onFormatKey(e)) return; // Cmd/Ctrl+B/I/U
3606
3323
  if (e.key === 'Escape') {
@@ -3612,6 +3329,11 @@ function StickyEditor({
3612
3329
  if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
3613
3330
  e.preventDefault();
3614
3331
  commit();
3332
+ // FigJam v3 — ⌘Enter chains: commit this body, then ask the layer
3333
+ // to spawn + edit the next sticky beside it.
3334
+ document.dispatchEvent(
3335
+ new CustomEvent('maude:chain-create', { detail: { id: sticky.id } })
3336
+ );
3615
3337
  }
3616
3338
  }}
3617
3339
  >
@@ -3665,6 +3387,8 @@ function StandaloneTextEditor({
3665
3387
  italic,
3666
3388
  underline,
3667
3389
  strike,
3390
+ fontSize,
3391
+ align: align ?? 'left',
3668
3392
  });
3669
3393
  // Single-fire commit guard — outside-click + blur can both fire in one tick;
3670
3394
  // without this the text would commit twice (two undo records). Markers shown
@@ -3701,6 +3425,8 @@ function StandaloneTextEditor({
3701
3425
  const el = ref.current;
3702
3426
  if (!el) return;
3703
3427
  if (el.contains(e.target as Node)) return;
3428
+ // FigJam v3 — clicks into the edit-mode text toolbar keep the session.
3429
+ if ((e.target as Element | null)?.closest?.('.dc-annot-ctx')) return;
3704
3430
  commitOnce(el.innerText || '');
3705
3431
  };
3706
3432
  document.addEventListener('pointerdown', onDown, true);
@@ -3758,15 +3484,233 @@ function StandaloneTextEditor({
3758
3484
  );
3759
3485
  }
3760
3486
 
3487
+ /**
3488
+ * FigJam v3 — centered ghost "Add text" label on a hovered EMPTY rect/ellipse
3489
+ * (FigJam shows the same invitation). Pure chrome; double-click / Enter edits.
3490
+ */
3491
+ function AddTextHint({ strokes, hintId }: { strokes: readonly Stroke[]; hintId: string | null }) {
3492
+ if (!hintId) return null;
3493
+ const host = strokes.find((s) => s.id === hintId);
3494
+ if (!host || (host.tool !== 'rect' && host.tool !== 'ellipse' && host.tool !== 'polygon'))
3495
+ return null;
3496
+ const bb = strokeBBox(host);
3497
+ if (!bb || bb.w < 48 || bb.h < 28) return null;
3498
+ const rot = strokeRotation(host);
3499
+ const label = (
3500
+ <text
3501
+ x={bb.x + bb.w / 2}
3502
+ y={bb.y + bb.h / 2}
3503
+ textAnchor="middle"
3504
+ dominantBaseline="middle"
3505
+ fontSize={13}
3506
+ fill={host.color}
3507
+ opacity={0.45}
3508
+ pointerEvents="none"
3509
+ style={{ fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)' }}
3510
+ >
3511
+ Add text
3512
+ </text>
3513
+ );
3514
+ if (rot === 0) return label;
3515
+ return (
3516
+ <g transform={`rotate(${rot} ${bb.x + bb.w / 2} ${bb.y + bb.h / 2})`} pointerEvents="none">
3517
+ {label}
3518
+ </g>
3519
+ );
3520
+ }
3521
+
3522
+ /**
3523
+ * FigJam v3 — connection dots: the four side magnets of a selected bindable
3524
+ * shape, screen-constant size, accent-ringed. Dragging one starts a bound
3525
+ * connector (the layer owns the gesture; dots are in CHROME_SELECTOR so the
3526
+ * marquee/drag handler yields).
3527
+ */
3528
+ function ConnectorDots({ stroke }: { stroke: Stroke }) {
3529
+ const controller = useViewportControllerContext();
3530
+ const zoom = controller?.viewport?.zoom || 1;
3531
+ if (!isBindable(stroke)) return null;
3532
+ const center = strokeCenter(stroke);
3533
+ if (!center) return null;
3534
+ const magnets: Array<[number, number]> = [
3535
+ [0.5, 0],
3536
+ [1, 0.5],
3537
+ [0.5, 1],
3538
+ [0, 0.5],
3539
+ ];
3540
+ // FigJam parity — the dots float a step OUTSIDE the edge (along the outward
3541
+ // normal), which also keeps them clear of the mid-edge RESIZE handles that
3542
+ // sit exactly on the edge midpoints (DOM, higher layer — they'd swallow the
3543
+ // drag otherwise). Deriving the normal from center→anchor keeps rotated
3544
+ // shapes correct for free.
3545
+ const offset = 16 / zoom;
3546
+ return (
3547
+ <g>
3548
+ {magnets.map(([nx, ny]) => {
3549
+ const pt = anchorPoint(stroke, nx, ny);
3550
+ if (!pt) return null;
3551
+ const dx = pt[0] - center[0];
3552
+ const dy = pt[1] - center[1];
3553
+ const len = Math.hypot(dx, dy) || 1;
3554
+ return (
3555
+ <circle
3556
+ key={`${nx}-${ny}`}
3557
+ className="dc-annot-conn-dot"
3558
+ data-host={stroke.id}
3559
+ data-nx={nx}
3560
+ data-ny={ny}
3561
+ cx={pt[0] + (dx / len) * offset}
3562
+ cy={pt[1] + (dy / len) * offset}
3563
+ r={5 / zoom}
3564
+ fill="var(--maude-hud-accent, #d63b1f)"
3565
+ stroke="var(--maude-chrome-bg-0, #ffffff)"
3566
+ strokeWidth={1.5 / zoom}
3567
+ pointerEvents="all"
3568
+ />
3569
+ );
3570
+ })}
3571
+ </g>
3572
+ );
3573
+ }
3574
+
3575
+ /**
3576
+ * FigJam v3 — right-click context menu for annotation strokes. Reuses the
3577
+ * `.dc-context-menu` visual language (stylesheet injected by context-menu.tsx)
3578
+ * so the annotation menu and the canvas menu read as one product surface.
3579
+ */
3580
+ function AnnotationContextMenu({
3581
+ pos,
3582
+ selCount,
3583
+ canUngroup,
3584
+ onAction,
3585
+ onClose,
3586
+ }: {
3587
+ pos: { x: number; y: number };
3588
+ selCount: number;
3589
+ canUngroup: boolean;
3590
+ onAction: (action: string) => void;
3591
+ onClose: () => void;
3592
+ }) {
3593
+ ensureCtxMenuStyles();
3594
+ const ref = useRef<HTMLDivElement | null>(null);
3595
+ const [at, setAt] = useState<{ x: number; y: number }>(pos);
3596
+ useEffect(() => {
3597
+ const el = ref.current;
3598
+ if (!el || typeof window === 'undefined') return;
3599
+ const r = el.getBoundingClientRect();
3600
+ let nx = pos.x;
3601
+ let ny = pos.y;
3602
+ if (nx + r.width > window.innerWidth - 8) nx = Math.max(8, window.innerWidth - r.width - 8);
3603
+ if (ny + r.height > window.innerHeight - 8) ny = Math.max(8, window.innerHeight - r.height - 8);
3604
+ if (nx !== at.x || ny !== at.y) setAt({ x: nx, y: ny });
3605
+ el.querySelector<HTMLButtonElement>('button.dc-menu-item:not([disabled])')?.focus();
3606
+ const onDown = (e: PointerEvent) => {
3607
+ if (!el.contains(e.target as Node)) onClose();
3608
+ };
3609
+ const onKey = (e: KeyboardEvent) => {
3610
+ if (e.key === 'Escape') {
3611
+ e.preventDefault();
3612
+ onClose();
3613
+ }
3614
+ };
3615
+ document.addEventListener('pointerdown', onDown, true);
3616
+ document.addEventListener('keydown', onKey, true);
3617
+ return () => {
3618
+ document.removeEventListener('pointerdown', onDown, true);
3619
+ document.removeEventListener('keydown', onKey, true);
3620
+ };
3621
+ }, [pos, onClose, at.x, at.y]);
3622
+ const item = (
3623
+ id: string,
3624
+ label: string,
3625
+ shortcut?: string,
3626
+ opts?: { destructive?: boolean; disabled?: boolean }
3627
+ ) => (
3628
+ <button
3629
+ type="button"
3630
+ role="menuitem"
3631
+ disabled={opts?.disabled}
3632
+ className={`dc-menu-item${opts?.destructive ? ' is-destructive' : ''}`}
3633
+ onClick={() => {
3634
+ if (opts?.disabled) return;
3635
+ onAction(id);
3636
+ onClose();
3637
+ }}
3638
+ >
3639
+ <span>{label}</span>
3640
+ {shortcut ? <span className="dc-menu-shortcut">{shortcut}</span> : null}
3641
+ </button>
3642
+ );
3643
+ return (
3644
+ <div
3645
+ ref={ref}
3646
+ className="dc-context-menu"
3647
+ role="menu"
3648
+ aria-label="Annotation actions"
3649
+ style={{ left: at.x, top: at.y }}
3650
+ >
3651
+ {item('copy', 'Copy', '⌘C')}
3652
+ {item('cut', 'Cut', '⌘X')}
3653
+ {item('paste', 'Paste', '⌘V')}
3654
+ {item('duplicate', 'Duplicate', '⌘D')}
3655
+ <div className="dc-menu-sep" aria-hidden="true" />
3656
+ {item('front', 'Bring to front', ']')}
3657
+ {item('forward', 'Bring forward', '⌘]')}
3658
+ {item('backward', 'Send backward', '⌘[')}
3659
+ {item('back', 'Send to back', '[')}
3660
+ <div className="dc-menu-sep" aria-hidden="true" />
3661
+ {item('group', 'Group selection', '⌘G', { disabled: selCount < 2 })}
3662
+ {canUngroup ? item('ungroup', 'Ungroup', '⌘⇧G') : null}
3663
+ <div className="dc-menu-sep" aria-hidden="true" />
3664
+ {item('delete', 'Delete', '⌫', { destructive: true })}
3665
+ </div>
3666
+ );
3667
+ }
3668
+
3669
+ /**
3670
+ * FigJam v3 — accent halo on the host a dragged arrow endpoint would bind to.
3671
+ * Pure chrome (pointer-events:none); renders nothing when no candidate.
3672
+ */
3673
+ function BindHintHalo({
3674
+ strokes,
3675
+ bindHintId,
3676
+ }: {
3677
+ strokes: readonly Stroke[];
3678
+ bindHintId: string | null;
3679
+ }) {
3680
+ if (!bindHintId) return null;
3681
+ const host = strokes.find((s) => s.id === bindHintId);
3682
+ if (!host) return null;
3683
+ const bbox = strokeBBox(host);
3684
+ if (!bbox) return null;
3685
+ const pad = 3;
3686
+ return (
3687
+ <rect
3688
+ x={bbox.x - pad}
3689
+ y={bbox.y - pad}
3690
+ width={bbox.w + pad * 2}
3691
+ height={bbox.h + pad * 2}
3692
+ fill="none"
3693
+ stroke="var(--maude-hud-accent, #d63b1f)"
3694
+ strokeWidth={2}
3695
+ strokeOpacity={0.8}
3696
+ vectorEffect="non-scaling-stroke"
3697
+ pointerEvents="none"
3698
+ rx={3}
3699
+ />
3700
+ );
3701
+ }
3702
+
3761
3703
  function SelectionHalo({
3762
3704
  stroke,
3763
3705
  anchorsById,
3764
3706
  multi,
3765
3707
  }: {
3766
3708
  stroke: Stroke;
3767
- anchorsById: Map<string, RectStroke | EllipseStroke>;
3709
+ anchorsById: Map<string, AnchorHost>;
3768
3710
  multi: boolean;
3769
3711
  }) {
3712
+ const controller = useViewportControllerContext();
3713
+ const zoom = controller?.viewport?.zoom || 1;
3770
3714
  const bbox = strokeBBox(stroke, anchorsById);
3771
3715
  if (!bbox) return null;
3772
3716
  // T17 + post-Wave-2 fix — annotation halo idioms:
@@ -3781,8 +3725,10 @@ function SelectionHalo({
3781
3725
  // bbox above carries the container affordance).
3782
3726
  // Marquee STAYS dashed (drawn elsewhere) — dashed is reserved for the
3783
3727
  // ambient group-container + active-gesture idioms per DDR-046 rev 2.
3784
- const pad = 4;
3785
- return (
3728
+ // Wave H — screen-constant breathing room (matches the resize handles,
3729
+ // which sit on the same padded frame — HALO_PAD_PX single source).
3730
+ const pad = HALO_PAD_PX / zoom;
3731
+ const halo = (
3786
3732
  <rect
3787
3733
  x={bbox.x - pad}
3788
3734
  y={bbox.y - pad}
@@ -3796,6 +3742,10 @@ function SelectionHalo({
3796
3742
  rx={2}
3797
3743
  />
3798
3744
  );
3745
+ // FigJam v3 — the halo turns with a rotated stroke.
3746
+ const rot = strokeRotation(stroke);
3747
+ if (rot === 0) return halo;
3748
+ return <g transform={`rotate(${rot} ${bbox.x + bbox.w / 2} ${bbox.y + bbox.h / 2})`}>{halo}</g>;
3799
3749
  }
3800
3750
 
3801
3751
  // T17 — group bbox dashed rect for multi-stroke annotation selection. Mirrors
@@ -3805,8 +3755,10 @@ function AnnotGroupBbox({
3805
3755
  anchorsById,
3806
3756
  }: {
3807
3757
  selectedStrokes: readonly Stroke[];
3808
- anchorsById: Map<string, RectStroke | EllipseStroke>;
3758
+ anchorsById: Map<string, AnchorHost>;
3809
3759
  }) {
3760
+ const controller = useViewportControllerContext();
3761
+ const zoom = controller?.viewport?.zoom || 1;
3810
3762
  if (selectedStrokes.length < 2) return null;
3811
3763
  let xMin = Number.POSITIVE_INFINITY;
3812
3764
  let yMin = Number.POSITIVE_INFINITY;
@@ -3823,7 +3775,8 @@ function AnnotGroupBbox({
3823
3775
  if (b.y + b.h > yMax) yMax = b.y + b.h;
3824
3776
  }
3825
3777
  if (!any) return null;
3826
- const pad = 6;
3778
+ // Wave H — screen-constant pad, one step wider than the single halo.
3779
+ const pad = (HALO_PAD_PX + 2) / zoom;
3827
3780
  const x = xMin - pad;
3828
3781
  const y = yMin - pad;
3829
3782
  const w = xMax - xMin + pad * 2;
@@ -3985,14 +3938,40 @@ function GhostPreview({ ghost }: { ghost: GhostDescriptor }) {
3985
3938
  return <polygon points={polygonPoints(ghost.shapeKind, x, y, sz, sz)} {...common} />;
3986
3939
  }
3987
3940
 
3988
- function StrokeNode({
3941
+ /**
3942
+ * FigJam v3 — rotation wrapper. The base node renders axis-aligned geometry;
3943
+ * a rotated stroke wraps it in a `rotate()` group around its bbox center
3944
+ * (anchored text inherits its HOST's rotation so labels turn with the shape).
3945
+ * Pointer events pass through the group, so hit-testing + the ctx-toolbar's
3946
+ * getBoundingClientRect positioning keep working on the rotated form.
3947
+ */
3948
+ function StrokeNode(props: {
3949
+ stroke: Stroke;
3950
+ anchorsById: Map<string, AnchorHost>;
3951
+ interactive: boolean;
3952
+ editing?: boolean;
3953
+ }) {
3954
+ const { stroke, anchorsById } = props;
3955
+ let rot = strokeRotation(stroke);
3956
+ let pivot = rot !== 0 ? strokeCenter(stroke) : null;
3957
+ if (stroke.tool === 'text' && stroke.anchorId != null && stroke.anchorId !== '') {
3958
+ const host = anchorsById.get(stroke.anchorId);
3959
+ rot = host ? strokeRotation(host) : 0;
3960
+ pivot = rot !== 0 && host ? strokeCenter(host) : null;
3961
+ }
3962
+ const node = <StrokeNodeBase {...props} />;
3963
+ if (rot === 0 || !pivot) return node;
3964
+ return <g transform={`rotate(${rot} ${pivot[0]} ${pivot[1]})`}>{node}</g>;
3965
+ }
3966
+
3967
+ function StrokeNodeBase({
3989
3968
  stroke,
3990
3969
  anchorsById,
3991
3970
  interactive,
3992
3971
  editing = false,
3993
3972
  }: {
3994
3973
  stroke: Stroke;
3995
- anchorsById: Map<string, RectStroke | EllipseStroke>;
3974
+ anchorsById: Map<string, AnchorHost>;
3996
3975
  interactive: boolean;
3997
3976
  /** Phase 21 — sticky-only: hide the read-only body while its editor is up. */
3998
3977
  editing?: boolean;
@@ -4192,6 +4171,75 @@ function StrokeNode({
4192
4171
  </g>
4193
4172
  );
4194
4173
  }
4174
+ if (stroke.tool === 'section') {
4175
+ const x = Math.min(stroke.x, stroke.x + stroke.w);
4176
+ const y = Math.min(stroke.y, stroke.y + stroke.h);
4177
+ const w = Math.abs(stroke.w);
4178
+ const h = Math.abs(stroke.h);
4179
+ const chipW = Math.max(56, stroke.label.length * SECTION_LABEL_FONT * 0.62 + 18);
4180
+ return (
4181
+ <g data-id={stroke.id} data-tool="section">
4182
+ {/* Region body — pure backdrop, CLICK-THROUGH (FigJam: content on a
4183
+ section selects normally; the section is grabbed by border/chip). */}
4184
+ <rect
4185
+ x={x}
4186
+ y={y}
4187
+ width={w}
4188
+ height={h}
4189
+ rx={SECTION_CORNER_RADIUS}
4190
+ ry={SECTION_CORNER_RADIUS}
4191
+ fill={stroke.color}
4192
+ fillOpacity={0.07}
4193
+ stroke={stroke.color}
4194
+ strokeOpacity={0.45}
4195
+ strokeWidth={1.5}
4196
+ vectorEffect="non-scaling-stroke"
4197
+ pointerEvents="none"
4198
+ />
4199
+ {/* Invisible border hit ring — the grabbable edge. */}
4200
+ {interactive ? (
4201
+ <rect
4202
+ x={x}
4203
+ y={y}
4204
+ width={w}
4205
+ height={h}
4206
+ rx={SECTION_CORNER_RADIUS}
4207
+ ry={SECTION_CORNER_RADIUS}
4208
+ fill="none"
4209
+ stroke="transparent"
4210
+ strokeWidth={12}
4211
+ vectorEffect="non-scaling-stroke"
4212
+ pointerEvents="stroke"
4213
+ />
4214
+ ) : null}
4215
+ {/* Label chip above the top-left corner — also a grab handle. */}
4216
+ <g pointerEvents={hitMode}>
4217
+ <rect
4218
+ x={x}
4219
+ y={y - SECTION_LABEL_H - 4}
4220
+ width={chipW}
4221
+ height={SECTION_LABEL_H}
4222
+ rx={5}
4223
+ ry={5}
4224
+ fill={stroke.color}
4225
+ fillOpacity={0.16}
4226
+ />
4227
+ <text
4228
+ x={x + 9}
4229
+ y={y - SECTION_LABEL_H / 2 - 4}
4230
+ dominantBaseline="middle"
4231
+ fontSize={SECTION_LABEL_FONT}
4232
+ fill={stroke.color}
4233
+ style={{
4234
+ fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
4235
+ }}
4236
+ >
4237
+ {stroke.label}
4238
+ </text>
4239
+ </g>
4240
+ </g>
4241
+ );
4242
+ }
4195
4243
  const common = {
4196
4244
  'data-id': stroke.id,
4197
4245
  'data-tool': stroke.tool,