@1agh/maude 0.29.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/README.md +6 -6
  2. package/apps/studio/acp/bridge.ts +285 -0
  3. package/apps/studio/acp/env.ts +48 -0
  4. package/apps/studio/acp/index.ts +132 -0
  5. package/apps/studio/acp/probe.ts +112 -0
  6. package/apps/studio/acp/transcript.ts +149 -0
  7. package/apps/studio/ai-banner.tsx +0 -1
  8. package/apps/studio/annotations-align.ts +149 -0
  9. package/apps/studio/annotations-bindings.ts +197 -0
  10. package/apps/studio/annotations-context-toolbar.tsx +436 -186
  11. package/apps/studio/annotations-groups.ts +270 -0
  12. package/apps/studio/annotations-layer.tsx +1714 -1661
  13. package/apps/studio/annotations-model.ts +2077 -0
  14. package/apps/studio/annotations-snap.ts +125 -0
  15. package/apps/studio/api.ts +433 -182
  16. package/apps/studio/bin/_png-playwright.mjs +1 -1
  17. package/apps/studio/bin/annotate.mjs +732 -0
  18. package/apps/studio/bin/annotate.sh +17 -0
  19. package/apps/studio/bin/chat-open.sh +44 -0
  20. package/apps/studio/bin/read-annotations.mjs +152 -17
  21. package/apps/studio/build.ts +1 -1
  22. package/apps/studio/canvas-arrowheads.ts +78 -9
  23. package/apps/studio/canvas-cursors.ts +2 -0
  24. package/apps/studio/canvas-edit.ts +257 -7
  25. package/apps/studio/canvas-icons.tsx +105 -0
  26. package/apps/studio/canvas-lib.tsx +112 -19
  27. package/apps/studio/canvas-list-watch.ts +177 -0
  28. package/apps/studio/canvas-shell.tsx +326 -9
  29. package/apps/studio/client/app.jsx +3579 -250
  30. package/apps/studio/client/canvas-url.js +5 -0
  31. package/apps/studio/client/github.js +99 -0
  32. package/apps/studio/client/index.html +1 -1
  33. package/apps/studio/client/panels/ChatPanel.jsx +770 -0
  34. package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
  35. package/apps/studio/client/panels/CreateProject.jsx +334 -0
  36. package/apps/studio/client/panels/DiffView.jsx +590 -0
  37. package/apps/studio/client/panels/GitPanel.jsx +767 -0
  38. package/apps/studio/client/panels/IdentityBar.jsx +294 -0
  39. package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
  40. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
  41. package/apps/studio/client/panels/acp-runtime.js +286 -0
  42. package/apps/studio/client/panels/chat-markdown.jsx +138 -0
  43. package/apps/studio/client/panels/git-grouping.js +86 -0
  44. package/apps/studio/client/styles/0-reset.css +4 -0
  45. package/apps/studio/client/styles/3-shell-maude.css +1326 -14
  46. package/apps/studio/client/styles/4-components.css +39 -0
  47. package/apps/studio/client/styles/5-maude-overrides.css +41 -2
  48. package/apps/studio/client/styles/6-acp-chat.css +771 -0
  49. package/apps/studio/client/styles/_index.css +2 -0
  50. package/apps/studio/client/tour/collab-tour.js +61 -0
  51. package/apps/studio/client/tour/overlay.jsx +116 -18
  52. package/apps/studio/client/tour/usage-tour.js +23 -10
  53. package/apps/studio/client/whats-new.jsx +25 -10
  54. package/apps/studio/collab/registry.ts +13 -0
  55. package/apps/studio/collab/room.ts +36 -0
  56. package/apps/studio/commands/annotation-strokes-command.ts +1 -1
  57. package/apps/studio/commands/move-artboards-command.ts +1 -1
  58. package/apps/studio/comments-overlay.tsx +7 -5
  59. package/apps/studio/context-menu.tsx +4 -3
  60. package/apps/studio/contextual-toolbar.tsx +14 -0
  61. package/apps/studio/cursors-overlay.tsx +37 -21
  62. package/apps/studio/dist/client.bundle.js +31795 -1829
  63. package/apps/studio/dist/comment-mount.js +97 -9
  64. package/apps/studio/dist/styles.css +7938 -1599
  65. package/apps/studio/dom-selection.ts +115 -0
  66. package/apps/studio/export-dialog.tsx +3 -3
  67. package/apps/studio/git/endpoints.ts +338 -0
  68. package/apps/studio/git/service.ts +1334 -0
  69. package/apps/studio/git/watch.ts +97 -0
  70. package/apps/studio/github/endpoints.ts +358 -0
  71. package/apps/studio/github/service.ts +231 -0
  72. package/apps/studio/github/token.ts +53 -0
  73. package/apps/studio/history.ts +20 -3
  74. package/apps/studio/hmr-broadcast.ts +9 -2
  75. package/apps/studio/http.ts +508 -6
  76. package/apps/studio/input-router.tsx +7 -0
  77. package/apps/studio/inspect.ts +37 -8
  78. package/apps/studio/participants-chrome.tsx +70 -16
  79. package/apps/studio/paths.ts +12 -0
  80. package/apps/studio/scaffold-design.ts +57 -0
  81. package/apps/studio/server.ts +70 -4
  82. package/apps/studio/sync/agent.ts +311 -57
  83. package/apps/studio/sync/codec.ts +69 -0
  84. package/apps/studio/sync/cold-start.ts +198 -0
  85. package/apps/studio/sync/connection-state.ts +58 -2
  86. package/apps/studio/sync/hub-link.ts +137 -0
  87. package/apps/studio/sync/index.ts +563 -221
  88. package/apps/studio/sync/journal.ts +190 -0
  89. package/apps/studio/sync/migrate-seed.ts +202 -44
  90. package/apps/studio/sync/projection.ts +22 -3
  91. package/apps/studio/sync/status.ts +15 -4
  92. package/apps/studio/test/acp-bridge.test.ts +127 -0
  93. package/apps/studio/test/acp-env.test.ts +65 -0
  94. package/apps/studio/test/acp-origin-gate.test.ts +78 -0
  95. package/apps/studio/test/acp-transcript.test.ts +112 -0
  96. package/apps/studio/test/activity.test.ts +1 -7
  97. package/apps/studio/test/annotate-write.test.ts +184 -0
  98. package/apps/studio/test/annotations-align.test.ts +88 -0
  99. package/apps/studio/test/annotations-bindings.test.ts +124 -0
  100. package/apps/studio/test/annotations-groups.test.ts +231 -0
  101. package/apps/studio/test/annotations-snap.test.ts +79 -0
  102. package/apps/studio/test/canvas-create-api.test.ts +72 -0
  103. package/apps/studio/test/canvas-edit.test.ts +181 -1
  104. package/apps/studio/test/canvas-list-watch.test.ts +322 -0
  105. package/apps/studio/test/canvas-meta-api.test.ts +161 -27
  106. package/apps/studio/test/canvas-origin-gate.test.ts +43 -0
  107. package/apps/studio/test/chat-markdown.test.tsx +58 -0
  108. package/apps/studio/test/collab-bridge.test.ts +0 -2
  109. package/apps/studio/test/collab-room.test.ts +2 -7
  110. package/apps/studio/test/collab-session-survival.test.tsx +176 -0
  111. package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
  112. package/apps/studio/test/csrf-write-guard.test.ts +78 -0
  113. package/apps/studio/test/editing-presence.test.ts +103 -0
  114. package/apps/studio/test/figjam-v3-model.test.ts +342 -0
  115. package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
  116. package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
  117. package/apps/studio/test/git-api.test.ts +0 -0
  118. package/apps/studio/test/git-branches.test.ts +106 -0
  119. package/apps/studio/test/git-grouping.test.ts +106 -0
  120. package/apps/studio/test/git-watch.test.ts +97 -0
  121. package/apps/studio/test/github-api.test.ts +465 -0
  122. package/apps/studio/test/hub-link.test.ts +69 -0
  123. package/apps/studio/test/participants-chrome.test.ts +36 -1
  124. package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
  125. package/apps/studio/test/sync-agent.test.ts +272 -2
  126. package/apps/studio/test/sync-codec.test.ts +65 -0
  127. package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
  128. package/apps/studio/test/sync-cold-start.test.ts +304 -0
  129. package/apps/studio/test/sync-connection-state.test.ts +68 -0
  130. package/apps/studio/test/sync-hardening.test.ts +0 -8
  131. package/apps/studio/test/sync-hubs-config.test.ts +0 -1
  132. package/apps/studio/test/sync-incident-replay.test.ts +211 -0
  133. package/apps/studio/test/sync-journal.test.ts +176 -0
  134. package/apps/studio/test/sync-runtime.test.ts +360 -5
  135. package/apps/studio/test/sync-status.test.ts +66 -0
  136. package/apps/studio/test/tour-overlay.test.tsx +18 -0
  137. package/apps/studio/test/use-annotation-resize.test.ts +159 -0
  138. package/apps/studio/test/use-artboard-drag.test.ts +0 -1
  139. package/apps/studio/test/use-collab.test.ts +0 -0
  140. package/apps/studio/test/use-tool-mode.test.tsx +5 -2
  141. package/apps/studio/tool-palette.tsx +30 -11
  142. package/apps/studio/undo-hud.tsx +0 -1
  143. package/apps/studio/use-agent-presence.tsx +4 -2
  144. package/apps/studio/use-annotation-resize.tsx +484 -54
  145. package/apps/studio/use-annotation-selection.tsx +16 -4
  146. package/apps/studio/use-canvas-activity.tsx +2 -2
  147. package/apps/studio/use-chrome-visibility.tsx +66 -0
  148. package/apps/studio/use-collab.tsx +434 -202
  149. package/apps/studio/use-selection-set.tsx +9 -0
  150. package/apps/studio/use-tool-mode.tsx +5 -1
  151. package/apps/studio/whats-new.json +208 -10
  152. package/apps/studio/whats-new.schema.json +18 -2
  153. package/apps/studio/ws.ts +44 -1
  154. package/cli/commands/design-link.test.mjs +84 -0
  155. package/cli/commands/design.mjs +3 -1
  156. package/cli/commands/design.test.mjs +5 -1
  157. package/cli/lib/design-link.mjs +51 -1
  158. package/cli/lib/gitignore-block.mjs +16 -3
  159. package/cli/lib/gitignore-block.test.mjs +13 -1
  160. package/package.json +11 -9
  161. package/plugins/design/dependencies.json +17 -0
  162. package/plugins/design/templates/_shell.html +58 -12
@@ -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';
@@ -58,24 +161,26 @@ import {
58
161
  uploadAsset,
59
162
  useCanvasMediaDrop,
60
163
  } from './use-canvas-media-drop.tsx';
164
+ import { useChromeVisibility } from './use-chrome-visibility.tsx';
61
165
  import { useCollab } from './use-collab.tsx';
62
166
  import { useSelectionSetOptional } from './use-selection-set.tsx';
63
167
  import { type ShapeKind, useToolMode } from './use-tool-mode.tsx';
64
168
  import { useUndoSinks, useUndoStackOptional } from './use-undo-stack.tsx';
65
169
 
170
+ // FigJam v3 — the pure data model (Stroke types, palettes, serialize/parse,
171
+ // geometry) lives in annotations-model.ts: React-free, importable headlessly
172
+ // by bun tests and the `maude design annotate` write verb. The layer
173
+ // re-exports the whole model so every existing
174
+ // `from './annotations-layer.tsx'` import keeps working unchanged.
175
+ export * from './annotations-model.ts';
176
+
66
177
  // ─────────────────────────────────────────────────────────────────────────────
67
178
  // Types
68
179
 
69
- type WorldPoint = readonly [number, number];
70
-
71
180
  // Phase 24 — arrow style enums are OWNED by canvas-arrowheads.ts (so that
72
181
  // module imports nothing back from here — no cycle, see DDR-067) and re-exported
73
182
  // here for back-compat (context-toolbar etc. import them from this module).
74
183
  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
184
 
80
185
  /** Phase 24 — cursor-following ghost placeholder descriptor (pure chrome). */
81
186
  type GhostDescriptor =
@@ -83,199 +188,6 @@ type GhostDescriptor =
83
188
  | { kind: 'sticky'; x: number; y: number; color: string }
84
189
  | { kind: 'shape'; x: number; y: number; shapeKind: ShapeKind; color: string };
85
190
 
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
191
  /**
280
192
  * Phase 21 — what the inline editor is currently bound to. `anchored` edits
281
193
  * the text hosted by a rect/ellipse; `sticky` edits a card body; `standalone`
@@ -283,48 +195,14 @@ export type Stroke =
283
195
  * exists until real text is committed).
284
196
  */
285
197
  type EditingTarget =
286
- | { kind: 'anchored'; anchorId: string; host: RectStroke | EllipseStroke }
198
+ | { kind: 'anchored'; anchorId: string; host: AnchorHost }
287
199
  | { kind: 'sticky'; sticky: StickyStroke }
288
200
  | { kind: 'standalone'; text: TextStroke }
201
+ /** FigJam v3 — renaming a section's label chip. */
202
+ | { kind: 'section'; section: SectionStroke }
289
203
  | { kind: 'pending'; x: number; y: number }
290
204
  | null;
291
205
 
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
206
  /**
329
207
  * The canvas-shell CHROME theme — `data-maude-theme` on `<html>`, default
330
208
  * 'dark'. This (NOT the DS `data-theme`, which is deliberately separate and
@@ -353,266 +231,6 @@ function useCanvasChromeTheme(): 'light' | 'dark' {
353
231
  return theme;
354
232
  }
355
233
 
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
234
  /**
617
235
  * Shared inline-formatting state for the three text editors (sticky / anchored /
618
236
  * standalone) — the unification surface (item 4d). Cmd/Ctrl + B / I / U toggle
@@ -625,1012 +243,86 @@ function fmtEqual(s: EditorFmt, fmt?: EditorFmt): boolean {
625
243
  function useEditorFormat(initial: EditorFmt): {
626
244
  fmtRef: { current: EditorFmt };
627
245
  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,
246
+ onFormatKey: (e: ReactKeyboardEvent) => boolean;
247
+ } {
248
+ const [bold, setBold] = useState(!!initial.bold);
249
+ const [italic, setItalic] = useState(!!initial.italic);
250
+ const [underline, setUnderline] = useState(!!initial.underline);
251
+ const [strike, setStrike] = useState(!!initial.strike);
252
+ // FigJam v3 edit-mode toolbar extensions: size + alignment preview live in
253
+ // the editor and commit with the text (normFmt carries them through).
254
+ const [fontSize, setFontSize] = useState<number | undefined>(initial.fontSize);
255
+ const [align, setAlign] = useState<TextAlign | undefined>(initial.align);
256
+ const fmtRef = useRef<EditorFmt>({ bold, italic, underline, strike, fontSize, align });
257
+ fmtRef.current = { bold, italic, underline, strike, fontSize, align };
258
+ const style: CSSProperties = {
259
+ fontWeight: bold ? 700 : undefined,
260
+ fontStyle: italic ? 'italic' : undefined,
261
+ textDecoration: textDecoCss(strike, underline),
262
+ ...(fontSize != null && fontSize !== initial.fontSize ? { fontSize: `${fontSize}px` } : {}),
263
+ ...(align && align !== initial.align ? { textAlign: align } : {}),
264
+ };
265
+ // FigJam v3 — the edit-mode context toolbar drives the editor through this
266
+ // event (mutating the STROKE mid-edit would re-render the contentEditable
267
+ // and clobber typed text). The editor echoes its state back so the toolbar's
268
+ // pressed-states track live.
269
+ useEffect(() => {
270
+ if (typeof document === 'undefined') return;
271
+ const onFmt = (e: Event) => {
272
+ const d = (e as CustomEvent<{ key?: string; value?: unknown }>).detail;
273
+ if (!d?.key) return;
274
+ if (d.key === 'bold') setBold((v) => !v);
275
+ else if (d.key === 'italic') setItalic((v) => !v);
276
+ else if (d.key === 'underline') setUnderline((v) => !v);
277
+ else if (d.key === 'strike') setStrike((v) => !v);
278
+ else if (d.key === 'fontSize' && typeof d.value === 'number') setFontSize(d.value);
279
+ else if (d.key === 'align' && typeof d.value === 'string') setAlign(d.value as TextAlign);
1510
280
  };
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;
281
+ document.addEventListener('maude:editor-format', onFmt);
282
+ return () => document.removeEventListener('maude:editor-format', onFmt);
283
+ }, []);
284
+ useEffect(() => {
285
+ if (typeof document === 'undefined') return;
286
+ const broadcast = () => {
287
+ document.dispatchEvent(
288
+ new CustomEvent('maude:editor-format-state', {
289
+ detail: { bold, italic, underline, strike, fontSize, align },
290
+ })
291
+ );
292
+ };
293
+ broadcast();
294
+ // The toolbar may mount AFTER the editor's first broadcast — it asks.
295
+ document.addEventListener('maude:editor-format-request', broadcast);
296
+ return () => document.removeEventListener('maude:editor-format-request', broadcast);
297
+ }, [bold, italic, underline, strike, fontSize, align]);
298
+ const onFormatKey = useCallback((e: ReactKeyboardEvent): boolean => {
299
+ if (!(e.metaKey || e.ctrlKey) || e.altKey || e.shiftKey) return false;
300
+ const k = e.key.toLowerCase();
301
+ if (k === 'b') {
302
+ e.preventDefault();
303
+ setBold((v) => !v);
304
+ return true;
1530
305
  }
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 };
306
+ if (k === 'i') {
307
+ e.preventDefault();
308
+ setItalic((v) => !v);
309
+ return true;
1534
310
  }
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;
311
+ if (k === 'u') {
312
+ e.preventDefault();
313
+ setUnderline((v) => !v);
314
+ return true;
1579
315
  }
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
- };
316
+ return false;
317
+ }, []);
318
+ return { fmtRef, style, onFormatKey };
1632
319
  }
1633
320
 
321
+ // Phase 24 — moved to canvas-arrowheads.ts (single source for shaft + heads).
322
+ // Re-exported so the existing test import (`from '../annotations-layer.tsx'`)
323
+ // and the byte-identical canary keep working.
324
+ export { arrowHeadPoints } from './canvas-arrowheads.ts';
325
+
1634
326
  function isEditable(t: EventTarget | null): boolean {
1635
327
  if (!t || !(t as HTMLElement).tagName) return false;
1636
328
  const el = t as HTMLElement;
@@ -1862,6 +554,10 @@ const ANNOT_CSS = `
1862
554
  See DDR-067. (No backticks in this comment: the whole block is a JS template
1863
555
  literal, so a backtick here would terminate the string.) */
1864
556
  .dc-annot-editor, .dc-annot-editor * { cursor: text !important; }
557
+ /* FigJam v3 — connection dots on a selected bindable shape. The important flag
558
+ beats use-tool-mode's blanket star-cursor rule (same fight as the editor +
559
+ resize handles — DDR-067). */
560
+ .dc-annot-conn-dot { cursor: crosshair !important; }
1865
561
  `.trim();
1866
562
 
1867
563
  function ensureAnnotStyles(): void {
@@ -1883,6 +579,36 @@ export interface StrokesStoreValue {
1883
579
  updateStroke: (id: string, patch: Partial<Stroke>) => void;
1884
580
  deleteStrokes: (ids: string[]) => void;
1885
581
  translateStrokes: (ids: string[], dx: number, dy: number) => void;
582
+ /**
583
+ * FigJam v3 — bulk mutation in ONE undo record (the per-stroke
584
+ * `updateStroke` loop the context toolbar used pre-v3 pushed N records for
585
+ * an N-stroke selection). `fn` returns the patch for a stroke or null to
586
+ * leave it untouched.
587
+ */
588
+ applyToStrokes: (
589
+ ids: readonly string[],
590
+ fn: (s: Stroke) => Partial<Stroke> | null,
591
+ label?: string
592
+ ) => void;
593
+ /** Group the (expanded) selection; returns the member ids to select, or null. */
594
+ groupSelection: (ids: readonly string[]) => string[] | null;
595
+ /** Dissolve the outermost group of every selected stroke. */
596
+ ungroupSelection: (ids: readonly string[]) => void;
597
+ /** Cmd+D / paste — clone with fresh ids; returns the clone ids to select. */
598
+ duplicateSelection: (ids: readonly string[], dx: number, dy: number) => string[];
599
+ /** Z-order — `]` `[` `Cmd+]` `Cmd+[`; group units move contiguously. */
600
+ reorderSelection: (ids: readonly string[], op: ZOrderOp) => void;
601
+ alignSelection: (ids: readonly string[], edge: AlignEdge) => void;
602
+ distributeSelection: (ids: readonly string[], axis: DistributeAxis) => void;
603
+ /**
604
+ * Wave H — transient gesture preview: applies the patch to local React
605
+ * state ONLY (no undo record, no persistence), exactly like the move-drag's
606
+ * per-tick path. Close the gesture with `commitGesture` on pointerup so the
607
+ * whole drag lands as ONE undo record (undo used to walk every resize px).
608
+ */
609
+ previewStroke: (id: string, patch: Partial<Stroke>) => void;
610
+ /** Wave H — single undo record from a preview gesture's start snapshot. */
611
+ commitGesture: (before: readonly Stroke[], label?: string) => void;
1886
612
  }
1887
613
 
1888
614
  const StrokesStoreContext = createContext<StrokesStoreValue | null>(null);
@@ -1891,37 +617,33 @@ export function useStrokesStore(): StrokesStoreValue | null {
1891
617
  return useContext(StrokesStoreContext);
1892
618
  }
1893
619
 
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
- }
620
+ // ─────────────────────────────────────────────────────────────────────────────
621
+ // FigJam v3 — one-time contextual hints (first-use discoverability). Behaviour-
622
+ // triggered micro-toasts, never a modal tour: each key fires at most once per
623
+ // browser profile (localStorage bitmap), reusing the existing canvas toast.
1909
624
 
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;
625
+ // Chrome elements never deselect. Includes the per-shape context toolbar,
626
+ // the main tool palette, the in-canvas draw chrome, the minimap, and the
627
+ // right-click menu. Clicks on these route to their own handlers.
628
+ const CHROME_SELECTOR =
629
+ '.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';
630
+
631
+ const HINTS_KEY = 'maude-annot-hints-v1';
632
+
633
+ function showOnceHint(key: string, msg: string): void {
634
+ if (typeof window === 'undefined') return;
635
+ try {
636
+ const seen = JSON.parse(window.localStorage.getItem(HINTS_KEY) || '{}') as Record<
637
+ string,
638
+ number
639
+ >;
640
+ if (seen[key]) return;
641
+ seen[key] = 1;
642
+ window.localStorage.setItem(HINTS_KEY, JSON.stringify(seen));
643
+ } catch {
644
+ return; // storage blocked — skip rather than re-toast forever
1923
645
  }
1924
- return true;
646
+ showCanvasToast(msg);
1925
647
  }
1926
648
 
1927
649
  // Annotations visibility now lives in use-annotations-visibility.tsx so the
@@ -1988,8 +710,22 @@ export function AnnotationsLayer() {
1988
710
  // shape/sticky/text tool is armed and nothing is being drawn yet. Pure chrome
1989
711
  // (low-opacity, pointer-events:none) — never selectable, hit-tested, or saved.
1990
712
  const [ghost, setGhost] = useState<{ x: number; y: number } | null>(null);
713
+ // FigJam v3 — smart-guide lines painted while a drag is snapping, and the
714
+ // id of the host a dragged arrow endpoint would bind to (accent halo).
715
+ const [snapGuides, setSnapGuides] = useState<SnapGuide[] | null>(null);
716
+ const [bindHintId, setBindHintId] = useState<string | null>(null);
717
+ // Cmd/Ctrl held — suppresses binding at arrow draw-end (FigJam: ⌘ keeps the
718
+ // endpoint free). Tracked here because endStroke (pointerup) carries no
719
+ // modifier state of its own.
720
+ const cmdHeldRef = useRef(false);
721
+ const vpRef = useRef(vp);
722
+ vpRef.current = vp;
1991
723
  const visibilityCtx = useAnnotationsVisibility();
1992
- const visible = visibilityCtx?.visible ?? true;
724
+ const chrome = useChromeVisibility();
725
+ // Presentation Mode hides annotations without mutating the user's own
726
+ // visibility toggle — render/input gate folds `present` in, the stored value
727
+ // (visibilityCtx.visible) is left untouched so exiting restores it.
728
+ const visible = (visibilityCtx?.visible ?? true) && !(chrome?.present ?? false);
1993
729
  const setVisible = useCallback(
1994
730
  (next: boolean | ((cur: boolean) => boolean)) => {
1995
731
  if (!visibilityCtx) return;
@@ -2026,6 +762,7 @@ export function AnnotationsLayer() {
2026
762
  tool === 'shape' ||
2027
763
  tool === 'arrow' ||
2028
764
  tool === 'sticky' ||
765
+ tool === 'section' ||
2029
766
  tool === 'text';
2030
767
  const isErase = tool === 'eraser';
2031
768
  const isActive = isDraw || isErase;
@@ -2182,30 +919,126 @@ export function AnnotationsLayer() {
2182
919
  const strokesStore = useMemo<StrokesStoreValue>(() => {
2183
920
  const updateStroke = (id: string, patch: Partial<Stroke>): void => {
2184
921
  const prev = strokesRef.current;
2185
- const next = prev.map((s) => (s.id === id ? ({ ...s, ...patch } as Stroke) : s));
922
+ // FigJam v3 bound arrow endpoints re-derive from their host after ANY
923
+ // geometry patch (resize ticks included), so connectors track live.
924
+ const next = recomputeBoundArrows(
925
+ prev.map((s) => (s.id === id ? ({ ...s, ...patch } as Stroke) : s))
926
+ );
2186
927
  commitStrokes(prev, next);
2187
928
  };
2188
929
  const deleteStrokes = (ids: string[]): void => {
2189
930
  const set = new Set(ids);
2190
931
  const prev = strokesRef.current;
2191
- const next = prev.filter(
932
+ const filtered = prev.filter(
2192
933
  (s) => !set.has(s.id) && !(s.tool === 'text' && s.anchorId != null && set.has(s.anchorId))
2193
934
  );
2194
- if (next.length === prev.length) return;
2195
- commitStrokes(prev, next);
935
+ if (filtered.length === prev.length) return;
936
+ // FigJam v3 — deleting a bind host strips the bind (endpoint frozen,
937
+ // arrow survives); singleton/empty groups dissolve (tldraw lifecycle).
938
+ commitStrokes(prev, recomputeBoundArrows(normalizeGroups(filtered)));
2196
939
  };
2197
940
  const translateStrokes = (ids: string[], dx: number, dy: number): void => {
2198
941
  const set = new Set(ids);
2199
942
  const prev = strokesRef.current;
2200
- const next = prev.map((s) => (set.has(s.id) ? translateOne(s, dx, dy) : s));
943
+ const next = recomputeBoundArrows(
944
+ prev.map((s) => (set.has(s.id) ? translateOne(s, dx, dy) : s))
945
+ );
2201
946
  commitStrokes(prev, next, `move ${ids.length} stroke${ids.length === 1 ? '' : 's'}`);
2202
947
  };
948
+ const applyToStrokes = (
949
+ ids: readonly string[],
950
+ fn: (s: Stroke) => Partial<Stroke> | null,
951
+ label?: string
952
+ ): void => {
953
+ const set = new Set(ids);
954
+ const prev = strokesRef.current;
955
+ let touched = 0;
956
+ const next = recomputeBoundArrows(
957
+ prev.map((s) => {
958
+ if (!set.has(s.id)) return s;
959
+ const patch = fn(s);
960
+ if (!patch) return s;
961
+ touched++;
962
+ return { ...s, ...patch } as Stroke;
963
+ })
964
+ );
965
+ if (touched === 0) return;
966
+ commitStrokes(prev, next, label ?? `edit ${touched} stroke${touched === 1 ? '' : 's'}`);
967
+ };
968
+ const groupSelection = (ids: readonly string[]): string[] | null => {
969
+ const prev = strokesRef.current;
970
+ const res = groupStrokes(prev, ids);
971
+ if (!res) return null;
972
+ commitStrokes(prev, res.strokes, `group ${res.memberIds.length} strokes`);
973
+ return res.memberIds;
974
+ };
975
+ const ungroupSelection = (ids: readonly string[]): void => {
976
+ const prev = strokesRef.current;
977
+ const next = ungroupStrokes(prev, ids);
978
+ if (strokesShallowEqual(prev, next)) return;
979
+ commitStrokes(prev, next, 'ungroup');
980
+ };
981
+ const duplicateSelection = (ids: readonly string[], dx: number, dy: number): string[] => {
982
+ const prev = strokesRef.current;
983
+ const res = duplicateStrokes(prev, ids, dx, dy);
984
+ if (res.strokes.length === prev.length) return [];
985
+ const added = res.strokes.length - prev.length;
986
+ commitStrokes(prev, res.strokes, `duplicate ${added} stroke${added === 1 ? '' : 's'}`);
987
+ return res.newIds;
988
+ };
989
+ const reorderSelection = (ids: readonly string[], op: ZOrderOp): void => {
990
+ const prev = strokesRef.current;
991
+ const next = reorderStrokes(prev, ids, op);
992
+ if (strokesShallowEqual(prev, next)) return;
993
+ commitStrokes(
994
+ prev,
995
+ next,
996
+ op === 'front' || op === 'forward' ? 'bring forward' : 'send backward'
997
+ );
998
+ };
999
+ const alignSelection = (ids: readonly string[], edge: AlignEdge): void => {
1000
+ const prev = strokesRef.current;
1001
+ const next = recomputeBoundArrows(alignStrokes(prev, ids, edge));
1002
+ if (strokesShallowEqual(prev, next)) return;
1003
+ commitStrokes(prev, next, `align ${edge}`);
1004
+ };
1005
+ const distributeSelection = (ids: readonly string[], axis: DistributeAxis): void => {
1006
+ const prev = strokesRef.current;
1007
+ const next = recomputeBoundArrows(distributeStrokes(prev, ids, axis));
1008
+ if (strokesShallowEqual(prev, next)) return;
1009
+ commitStrokes(prev, next, 'distribute');
1010
+ };
1011
+ // Wave H — transient per-tick path for handle drags (resize / rotate /
1012
+ // endpoint re-anchor). Local React state only — no undo push, no PUT —
1013
+ // mirroring the move-drag's onMove. `commitGesture` closes it as ONE
1014
+ // record (no-op when the gesture ended where it started).
1015
+ const previewStroke = (id: string, patch: Partial<Stroke>): void => {
1016
+ setStrokesState(
1017
+ recomputeBoundArrows(
1018
+ strokesRef.current.map((s) => (s.id === id ? ({ ...s, ...patch } as Stroke) : s))
1019
+ )
1020
+ );
1021
+ };
1022
+ const commitGesture = (before: readonly Stroke[], label?: string): void => {
1023
+ const cur = strokesRef.current;
1024
+ if (strokesShallowEqual(before, cur)) return;
1025
+ commitStrokes(before, cur, label);
1026
+ };
2203
1027
  return {
2204
1028
  strokes,
2205
1029
  setStrokes,
2206
1030
  updateStroke,
2207
1031
  deleteStrokes,
2208
1032
  translateStrokes,
1033
+ applyToStrokes,
1034
+ groupSelection,
1035
+ ungroupSelection,
1036
+ duplicateSelection,
1037
+ reorderSelection,
1038
+ alignSelection,
1039
+ distributeSelection,
1040
+ previewStroke,
1041
+ commitGesture,
2209
1042
  };
2210
1043
  }, [strokes, setStrokes, commitStrokes]);
2211
1044
 
@@ -2474,6 +1307,19 @@ export function AnnotationsLayer() {
2474
1307
  fontSize: DEFAULT_FONT_SIZE,
2475
1308
  cornerRadius: STICKY_CORNER_RADIUS,
2476
1309
  });
1310
+ } else if (tool === 'section') {
1311
+ // FigJam v3 — drag-create a labelled container; a bare tap drops the
1312
+ // default-sized region (endStroke applies the default).
1313
+ setDrawing({
1314
+ id,
1315
+ tool: 'section',
1316
+ x: wx,
1317
+ y: wy,
1318
+ w: 0,
1319
+ h: 0,
1320
+ label: 'Section',
1321
+ color: DEFAULT_SECTION_COLOR,
1322
+ });
2477
1323
  } else if (tool === 'text') {
2478
1324
  // Phase 21 — single click drops an editable caret at the click point.
2479
1325
  // No stroke is created until the user commits real text (mirrors the
@@ -2512,6 +1358,7 @@ export function AnnotationsLayer() {
2512
1358
  (e: ReactPointerEvent<HTMLDivElement>) => {
2513
1359
  if (!isActive || !visible) return;
2514
1360
  const [wx, wy] = screenToWorld(e.clientX, e.clientY);
1361
+ cmdHeldRef.current = e.metaKey || e.ctrlKey;
2515
1362
  if (isErase) {
2516
1363
  if ((e.buttons & 1) === 0) return;
2517
1364
  eraseAt(wx, wy);
@@ -2527,6 +1374,15 @@ export function AnnotationsLayer() {
2527
1374
  lastDrawPointRef.current = { x: wx, y: wy };
2528
1375
  const anchor = drawAnchorRef.current;
2529
1376
  const mods: DrawMods = { shift: e.shiftKey, alt: e.altKey };
1377
+ // FigJam v3 — while dragging an arrow, halo the host the dragged end
1378
+ // would magnetically attach to (⌘ suppresses binding entirely).
1379
+ if (drawingRef.current.tool === 'arrow') {
1380
+ const zoom = vpRef.current?.zoom || 1;
1381
+ const cand = cmdHeldRef.current
1382
+ ? null
1383
+ : bindCandidate(wx, wy, strokesRef.current, BIND_THRESHOLD_PX / zoom);
1384
+ setBindHintId(cand?.hostId ?? null);
1385
+ }
2530
1386
  setDrawing((cur) => {
2531
1387
  if (!cur) return cur;
2532
1388
  if (cur.tool === 'pen') {
@@ -2578,12 +1434,51 @@ export function AnnotationsLayer() {
2578
1434
  Math.abs(norm.w) < STICKY_MIN_SIZE || Math.abs(norm.h) < STICKY_MIN_SIZE
2579
1435
  ? { ...norm, w: STICKY_DEFAULT_W, h: STICKY_DEFAULT_H }
2580
1436
  : norm;
1437
+ } else if (cur.tool === 'section') {
1438
+ const norm = normalizeBox(cur);
1439
+ final =
1440
+ Math.abs(norm.w) < SECTION_MIN_SIZE || Math.abs(norm.h) < SECTION_MIN_SIZE
1441
+ ? { ...norm, w: SECTION_DEFAULT_W, h: SECTION_DEFAULT_H }
1442
+ : norm;
2581
1443
  }
2582
1444
  if (final && !isStrokeMeaningful(final)) final = null;
1445
+ // FigJam v3 — magnetic connector binding at draw-end. Each arrow endpoint
1446
+ // within the zoom-scaled threshold of a bindable host attaches to its
1447
+ // nearest side/center magnet and snaps onto it; the bind persists and the
1448
+ // endpoint re-derives from the host from then on. ⌘ held = stay free.
1449
+ if (final && final.tool === 'arrow' && !cmdHeldRef.current) {
1450
+ const zoom = vpRef.current?.zoom || 1;
1451
+ const threshold = BIND_THRESHOLD_PX / zoom;
1452
+ const others = strokesRef.current;
1453
+ const sb = bindCandidate(final.x1, final.y1, others, threshold);
1454
+ const eb = bindCandidate(final.x2, final.y2, others, threshold);
1455
+ if (sb) {
1456
+ const host = others.find((s) => s.id === sb.hostId);
1457
+ const pt = host ? anchorPoint(host, sb.nx, sb.ny) : null;
1458
+ if (pt) final = { ...final, startBind: sb, x1: pt[0], y1: pt[1] };
1459
+ }
1460
+ // A zero-length self-loop (both ends on the same magnet) is useless —
1461
+ // keep the end free when it would collapse onto the start bind.
1462
+ const sameMagnet = sb && eb && sb.hostId === eb.hostId && sb.nx === eb.nx && sb.ny === eb.ny;
1463
+ if (eb && !sameMagnet) {
1464
+ const host = others.find((s) => s.id === eb.hostId);
1465
+ const pt = host ? anchorPoint(host, eb.nx, eb.ny) : null;
1466
+ if (pt) final = { ...final, endBind: eb, x2: pt[0], y2: pt[1] };
1467
+ }
1468
+ if ((final as ArrowStroke).startBind || (final as ArrowStroke).endBind) {
1469
+ showOnceHint(
1470
+ 'bind',
1471
+ 'Arrow attached — it follows the shape now. Drag an endpoint to re-anchor, hold ⌘ to keep it free.'
1472
+ );
1473
+ }
1474
+ }
1475
+ setBindHintId(null);
2583
1476
  if (final) {
2584
1477
  const committed = final;
2585
1478
  const prev = strokesRef.current;
2586
- const next = [...prev, committed];
1479
+ // FigJam sections are CONTAINERS: they slot in at the BACK of the
1480
+ // z-order so content placed on them keeps rendering above.
1481
+ const next = committed.tool === 'section' ? [committed, ...prev] : [...prev, committed];
2587
1482
  commitStrokes(prev, next, `draw ${committed.tool}`);
2588
1483
  // T18 — auto-select the freshly drawn shape so the user can immediately
2589
1484
  // see + adjust it. annotSel is optional (some test harnesses mount
@@ -2591,7 +1486,10 @@ export function AnnotationsLayer() {
2591
1486
  if (annotSel) annotSel.replace(committed.id);
2592
1487
  // Phase 21 — a fresh sticky opens in edit mode (FigJam parity: drop a
2593
1488
  // note, type immediately). Only meaningful deviation from rect/ellipse.
2594
- if (committed.tool === 'sticky') setEditingId(committed.id);
1489
+ if (committed.tool === 'sticky') {
1490
+ setEditingId(committed.id);
1491
+ showOnceHint('chain', '⌘Enter commits and creates the next sticky beside it.');
1492
+ }
2595
1493
  }
2596
1494
  // T18 / T19 — flip the tool back to Move after every commit UNLESS sticky
2597
1495
  // mode is locked on this tool. Sticky lets the user draw many shapes in a
@@ -2666,9 +1564,9 @@ export function AnnotationsLayer() {
2666
1564
  }, [ghost, ghostCapable, drawing, tool, shapeKind, color, stickyColor]);
2667
1565
 
2668
1566
  const anchorsById = useMemo(() => {
2669
- const map = new Map<string, RectStroke | EllipseStroke>();
1567
+ const map = new Map<string, AnchorHost>();
2670
1568
  for (const s of strokes) {
2671
- if (s.tool === 'rect' || s.tool === 'ellipse') map.set(s.id, s);
1569
+ if (s.tool === 'rect' || s.tool === 'ellipse' || s.tool === 'polygon') map.set(s.id, s);
2672
1570
  }
2673
1571
  return map;
2674
1572
  }, [strokes]);
@@ -2700,6 +1598,11 @@ export function AnnotationsLayer() {
2700
1598
  startWY: number;
2701
1599
  movedIds: string[];
2702
1600
  snapshot: Stroke[];
1601
+ /** FigJam v3 — pre-Alt-duplicate baseline. Differs from `snapshot` only
1602
+ * during an Alt+drag duplicate; the undo record spans undoBase → final
1603
+ * so clone + move land as ONE step. */
1604
+ undoBase: Stroke[];
1605
+ altDup: boolean;
2703
1606
  } | null>(null);
2704
1607
 
2705
1608
  // Drag-select marquee state. World-coord rectangle (anchor + cursor); the
@@ -2732,19 +1635,14 @@ export function AnnotationsLayer() {
2732
1635
  t === 'text' ||
2733
1636
  t === 'sticky' ||
2734
1637
  t === 'image' ||
2735
- t === 'link')
1638
+ t === 'link' ||
1639
+ t === 'section')
2736
1640
  ) {
2737
1641
  return id;
2738
1642
  }
2739
1643
  return null;
2740
1644
  };
2741
1645
 
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
1646
  const onDown = (e: PointerEvent) => {
2749
1647
  if (e.button !== 0) return;
2750
1648
  if (e.metaKey || e.ctrlKey) return; // escape hatch into element-selection
@@ -2763,16 +1661,20 @@ export function AnnotationsLayer() {
2763
1661
  let ids: string[] | null = null;
2764
1662
  if (strokeId) {
2765
1663
  elementSel?.clear();
1664
+ // FigJam v3 — clicking a group member acts on the WHOLE outermost
1665
+ // group. Double-click deep-selects the member (see the dblclick
1666
+ // handler); an already-selected stroke keeps the current selection, so
1667
+ // a deep-selected member drags alone without re-expanding.
1668
+ const members = expandIdsToGroups([strokeId], strokesStoreRef.current.strokes);
2766
1669
  if (e.shiftKey) {
2767
- annotSel.add(strokeId);
2768
- ids = annotSel.contains(strokeId)
2769
- ? annotSel.selectedIds
2770
- : [...annotSel.selectedIds, strokeId];
1670
+ annotSel.add(members);
1671
+ const merged = new Set([...annotSel.selectedIds, ...members]);
1672
+ ids = [...merged];
2771
1673
  } else if (annotSel.contains(strokeId)) {
2772
1674
  ids = annotSel.selectedIds;
2773
1675
  } else {
2774
- annotSel.replace(strokeId);
2775
- ids = [strokeId];
1676
+ annotSel.replace(members);
1677
+ ids = members;
2776
1678
  }
2777
1679
  } else if (!e.shiftKey && annotSel.selectedIds.length > 0) {
2778
1680
  // Hull hit-test — union bbox of the currently-selected strokes.
@@ -2793,7 +1695,7 @@ export function AnnotationsLayer() {
2793
1695
  ids = annotSel.selectedIds;
2794
1696
  }
2795
1697
  }
2796
- if (ids && ids.length) {
1698
+ if (ids?.length) {
2797
1699
  e.preventDefault();
2798
1700
  e.stopImmediatePropagation();
2799
1701
  // Capture a snapshot of all strokes at drag start. Every pointermove
@@ -2801,27 +1703,118 @@ export function AnnotationsLayer() {
2801
1703
  // (NOT a delta-from-last-frame mutation), so dragging back to origin
2802
1704
  // restores positions exactly. Optimistic state-only updates during
2803
1705
  // the move; ONE undo record + ONE server PUT fires on pointerup.
2804
- const dragSnapshot = strokesRef.current.slice();
1706
+ const undoBase = strokesRef.current.slice();
1707
+ const preAltIds = ids;
1708
+ let dragSnapshot = undoBase;
1709
+ let altDup = false;
1710
+ // FigJam v3 — Alt+drag duplicates: clone the (expanded) selection up
1711
+ // front and drag the CLONES; a zero-movement release reverts so a bare
1712
+ // Alt+click can't silently mint copies. undoBase stays pre-clone, so
1713
+ // clone + move commit as one record.
1714
+ if (e.altKey) {
1715
+ const res = duplicateStrokes(undoBase, ids, 0, 0);
1716
+ if (res.newIds.length) {
1717
+ altDup = true;
1718
+ dragSnapshot = res.strokes;
1719
+ setStrokesState(res.strokes);
1720
+ annotSel.replace(res.newIds);
1721
+ ids = res.newIds;
1722
+ }
1723
+ }
2805
1724
  dragStateRef.current = {
2806
1725
  pointerId: e.pointerId,
2807
1726
  startWX: wx,
2808
1727
  startWY: wy,
2809
1728
  movedIds: ids,
2810
1729
  snapshot: dragSnapshot,
1730
+ undoBase,
1731
+ altDup,
2811
1732
  };
2812
1733
  const movedSet = new Set(ids);
1734
+ // FigJam v3 — dragging a SECTION carries everything sitting on it
1735
+ // (bbox-center containment, captured at gesture start).
1736
+ for (const s of dragSnapshot) {
1737
+ if (s.tool !== 'section' || !movedSet.has(s.id)) continue;
1738
+ const sx = Math.min(s.x, s.x + s.w);
1739
+ const sy = Math.min(s.y, s.y + s.h);
1740
+ const sx2 = sx + Math.abs(s.w);
1741
+ const sy2 = sy + Math.abs(s.h);
1742
+ for (const t of dragSnapshot) {
1743
+ if (movedSet.has(t.id) || t.tool === 'section') continue;
1744
+ const bb = strokeBBox(t);
1745
+ if (!bb) continue;
1746
+ const ccx = bb.x + bb.w / 2;
1747
+ const ccy = bb.y + bb.h / 2;
1748
+ if (ccx >= sx && ccx <= sx2 && ccy >= sy && ccy <= sy2) movedSet.add(t.id);
1749
+ }
1750
+ }
1751
+ // FigJam v3 — snap setup, computed ONCE per gesture: candidates are
1752
+ // the bboxes of every non-moved stroke plus the artboard rects (in
1753
+ // world coords); the moving hull is the union bbox of the dragged
1754
+ // strokes at drag start.
1755
+ const candidates: Array<{ x: number; y: number; w: number; h: number }> = [];
1756
+ for (const s of dragSnapshot) {
1757
+ if (movedSet.has(s.id)) continue;
1758
+ const bb = strokeBBox(s);
1759
+ if (bb && bb.w > 0 && bb.h > 0) candidates.push(bb);
1760
+ }
1761
+ for (const screenEl of Array.from(document.querySelectorAll('[data-dc-screen]'))) {
1762
+ const r = screenEl.getBoundingClientRect();
1763
+ if (r.width <= 0 || r.height <= 0) continue;
1764
+ const [ax, ay] = screenToWorld(r.left, r.top);
1765
+ const [bx, by] = screenToWorld(r.right, r.bottom);
1766
+ candidates.push({ x: ax, y: ay, w: bx - ax, h: by - ay });
1767
+ }
1768
+ let hull: { x: number; y: number; w: number; h: number } | null = null;
1769
+ for (const s of dragSnapshot) {
1770
+ if (!movedSet.has(s.id)) continue;
1771
+ const bb = strokeBBox(s);
1772
+ if (!bb) continue;
1773
+ if (!hull) {
1774
+ hull = { ...bb };
1775
+ } else {
1776
+ const hx = Math.min(hull.x, bb.x);
1777
+ const hy = Math.min(hull.y, bb.y);
1778
+ hull = {
1779
+ x: hx,
1780
+ y: hy,
1781
+ w: Math.max(hull.x + hull.w, bb.x + bb.w) - hx,
1782
+ h: Math.max(hull.y + hull.h, bb.y + bb.h) - hy,
1783
+ };
1784
+ }
1785
+ }
1786
+ const zoom = vpRef.current?.zoom || 1;
2813
1787
  const onMove = (mv: PointerEvent) => {
2814
1788
  const st = dragStateRef.current;
2815
1789
  if (!st || mv.pointerId !== st.pointerId) return;
2816
1790
  const [cwx, cwy] = screenToWorld(mv.clientX, mv.clientY);
2817
- const dx = cwx - st.startWX;
2818
- const dy = cwy - st.startWY;
1791
+ let dx = cwx - st.startWX;
1792
+ let dy = cwy - st.startWY;
1793
+ // FigJam v3 — edge/center snapping; ⌘ suppresses (Figma convention).
1794
+ // Axes with no smart-guide match fall back to the 24px dot grid
1795
+ // (GRID_PITCH_PX = the DS --canvas-grid pitch).
1796
+ if (hull && !(mv.metaKey || mv.ctrlKey)) {
1797
+ const snap = computeSnap(
1798
+ { x: hull.x + dx, y: hull.y + dy, w: hull.w, h: hull.h },
1799
+ candidates,
1800
+ SNAP_THRESHOLD_PX / zoom,
1801
+ { grid: GRID_PITCH_PX }
1802
+ );
1803
+ dx += snap.dx;
1804
+ dy += snap.dy;
1805
+ setSnapGuides(snap.guides.length ? snap.guides : null);
1806
+ } else {
1807
+ setSnapGuides(null);
1808
+ }
2819
1809
  // Drag-back-to-origin: restore exact references so the pointerup
2820
- // shallow-equality check skips committing a no-op record.
1810
+ // shallow-equality check skips committing a no-op record. Bound
1811
+ // arrows re-derive from their hosts so connectors track live.
2821
1812
  const next =
2822
1813
  dx === 0 && dy === 0
2823
1814
  ? st.snapshot
2824
- : st.snapshot.map((s) => (movedSet.has(s.id) ? translateOne(s, dx, dy) : s));
1815
+ : recomputeBoundArrows(
1816
+ st.snapshot.map((s) => (movedSet.has(s.id) ? translateOne(s, dx, dy) : s))
1817
+ );
2825
1818
  // Local React state only. No commitStrokes — no PUT, no undo push.
2826
1819
  setStrokesState(next);
2827
1820
  };
@@ -2829,17 +1822,27 @@ export function AnnotationsLayer() {
2829
1822
  const st = dragStateRef.current;
2830
1823
  if (!st || up.pointerId !== st.pointerId) return;
2831
1824
  dragStateRef.current = null;
1825
+ setSnapGuides(null);
2832
1826
  document.removeEventListener('pointermove', onMove, true);
2833
1827
  document.removeEventListener('pointerup', onUp, true);
2834
1828
  document.removeEventListener('pointercancel', onUp, true);
2835
1829
  // Commit the gesture as ONE record. Skip on zero-movement
2836
1830
  // (click without drag past threshold or drag back to origin).
2837
1831
  const final = strokesRef.current;
2838
- if (strokesShallowEqual(st.snapshot, final)) return;
1832
+ if (strokesShallowEqual(st.snapshot, final)) {
1833
+ if (st.altDup) {
1834
+ // Alt+click without a drag — revert the eager clones.
1835
+ setStrokesState(st.undoBase);
1836
+ annotSel.replace(preAltIds);
1837
+ }
1838
+ return;
1839
+ }
2839
1840
  commitStrokes(
2840
- st.snapshot,
1841
+ st.undoBase,
2841
1842
  final,
2842
- `move ${st.movedIds.length} stroke${st.movedIds.length === 1 ? '' : 's'}`
1843
+ `${st.altDup ? 'duplicate' : 'move'} ${st.movedIds.length} stroke${
1844
+ st.movedIds.length === 1 ? '' : 's'
1845
+ }`
2843
1846
  );
2844
1847
  };
2845
1848
  document.addEventListener('pointermove', onMove, true);
@@ -2852,8 +1855,10 @@ export function AnnotationsLayer() {
2852
1855
  // artboard the gesture belongs to artboard-drag / element-marquee — not
2853
1856
  // the annotation marquee (post-Wave-3 grievance G5). Checked AFTER the
2854
1857
  // 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;
1858
+ // the strokes sit over an artboard. FigJam v3 exception: SHIFT+drag is
1859
+ // the additive annotation marquee, so it rubber-bands annotations
1860
+ // sitting ON an artboard instead of falling through to artboard-drag.
1861
+ if (!strokeId && !e.shiftKey && target?.closest?.('[data-dc-screen]')) return;
2857
1862
 
2858
1863
  // Empty world — start a drag-select gesture. A bare click without
2859
1864
  // moving clears annotation selection (post-Wave-3 feedback: click-to-
@@ -2900,8 +1905,11 @@ export function AnnotationsLayer() {
2900
1905
  }
2901
1906
  // Marquee that captured no strokes — preserve existing selection.
2902
1907
  if (hits.length === 0) return;
2903
- if (addToSelection) annotSel.add(hits);
2904
- else annotSel.replace(hits);
1908
+ // FigJam v3 — a marquee touching any group member selects the whole
1909
+ // group (tldraw: the brush resolves to the outermost ancestor).
1910
+ const expanded = expandIdsToGroups(hits, strokesStoreRef.current.strokes);
1911
+ if (addToSelection) annotSel.add(expanded);
1912
+ else annotSel.replace(expanded);
2905
1913
  };
2906
1914
  document.addEventListener('pointermove', onMove, true);
2907
1915
  document.addEventListener('pointerup', onUp, true);
@@ -2935,7 +1943,16 @@ export function AnnotationsLayer() {
2935
1943
  const id = node.getAttribute('data-id');
2936
1944
  const t = node.getAttribute('data-tool');
2937
1945
  if (!id) return;
2938
- if (t === 'rect' || t === 'ellipse' || t === 'sticky') {
1946
+ // FigJam v3 double-click DEEP-SELECTS a group member (a single click
1947
+ // selects the whole outermost group; Esc clears back out). The editor
1948
+ // still opens below for text-bearing types — FigJam's enter-group-then-
1949
+ // edit flow in one gesture.
1950
+ const stroke = strokesRef.current.find((s) => s.id === id);
1951
+ if (stroke && outermostGroupOf(stroke) && annotSel) {
1952
+ e.preventDefault();
1953
+ annotSel.replace(id);
1954
+ }
1955
+ if (t === 'rect' || t === 'ellipse' || t === 'polygon' || t === 'sticky' || t === 'section') {
2939
1956
  e.preventDefault();
2940
1957
  setEditingId(id);
2941
1958
  return;
@@ -2947,7 +1964,7 @@ export function AnnotationsLayer() {
2947
1964
  };
2948
1965
  document.addEventListener('dblclick', onDbl, true);
2949
1966
  return () => document.removeEventListener('dblclick', onDbl, true);
2950
- }, [tool]);
1967
+ }, [tool, annotSel]);
2951
1968
 
2952
1969
  const commitText = useCallback(
2953
1970
  (anchorId: string, text: string, fmt?: EditorFmt) => {
@@ -3055,43 +2072,520 @@ export function AnnotationsLayer() {
3055
2072
  [commitStrokes, color, annotSel]
3056
2073
  );
3057
2074
 
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]);
2075
+ // Phase 21 — resolve what (if anything) is being edited, and route a single
2076
+ // commit call to the right writer. `editingId` doubles as the host id
2077
+ // (anchored) OR the sticky/standalone stroke id; `pendingText` is the
2078
+ // not-yet-born text caret.
2079
+ const editingTarget = useMemo<EditingTarget>(() => {
2080
+ if (pendingText) return { kind: 'pending', x: pendingText.x, y: pendingText.y };
2081
+ if (!editingId) return null;
2082
+ const host = anchorsById.get(editingId);
2083
+ if (host) return { kind: 'anchored', anchorId: editingId, host };
2084
+ const s = strokesById.get(editingId);
2085
+ if (s?.tool === 'sticky') return { kind: 'sticky', sticky: s };
2086
+ if (s?.tool === 'section') return { kind: 'section', section: s };
2087
+ if (s?.tool === 'text' && (s.anchorId == null || s.anchorId === ''))
2088
+ return { kind: 'standalone', text: s };
2089
+ return null;
2090
+ }, [pendingText, editingId, anchorsById, strokesById]);
2091
+
2092
+ const editingTargetRef = useRef(editingTarget);
2093
+ editingTargetRef.current = editingTarget;
2094
+
2095
+ const commitEditing = useCallback(
2096
+ (text: string, fmt?: EditorFmt) => {
2097
+ const target = editingTargetRef.current;
2098
+ setEditingId(null);
2099
+ setPendingText(null);
2100
+ if (!target) return;
2101
+ if (target.kind === 'anchored') commitText(target.anchorId, text, fmt);
2102
+ else if (target.kind === 'sticky') commitStickyText(target.sticky.id, text, fmt);
2103
+ else if (target.kind === 'standalone') commitStandaloneText(target.text.id, text, fmt);
2104
+ else if (target.kind === 'section') {
2105
+ const label = text.trim().replace(/\s*\n+\s*/g, ' ') || 'Section';
2106
+ if (label !== target.section.label) {
2107
+ strokesStoreRef.current.updateStroke(target.section.id, {
2108
+ label,
2109
+ } as Partial<Stroke>);
2110
+ }
2111
+ } else if (target.kind === 'pending') createStandaloneText(target.x, target.y, text, fmt);
2112
+ },
2113
+ [commitText, commitStickyText, commitStandaloneText, createStandaloneText]
2114
+ );
2115
+
2116
+ const cancelEditing = useCallback(() => {
2117
+ setEditingId(null);
2118
+ setPendingText(null);
2119
+ }, []);
2120
+
2121
+ /**
2122
+ * FigJam v3 — copy/cut the (expanded) selection to the OS clipboard as a
2123
+ * `{"maudeStrokes":1}` JSON text payload. Shared by ⌘C/⌘X and the
2124
+ * right-click menu. Returns true when something was copied.
2125
+ */
2126
+ const copySelection = useCallback(
2127
+ (cut: boolean): boolean => {
2128
+ if (!annotSel) return false;
2129
+ const sel = annotSel.selectedIds;
2130
+ if (sel.length === 0) return false;
2131
+ const store = strokesStoreRef.current;
2132
+ const expanded = new Set(expandIdsToGroups(sel, store.strokes));
2133
+ const payload = store.strokes.filter(
2134
+ (s) =>
2135
+ expanded.has(s.id) ||
2136
+ (s.tool === 'text' && s.anchorId != null && expanded.has(s.anchorId))
2137
+ );
2138
+ if (payload.length === 0) return false;
2139
+ try {
2140
+ void navigator.clipboard
2141
+ ?.writeText(JSON.stringify({ maudeStrokes: 1, strokes: payload }))
2142
+ .catch(() => {
2143
+ /* clipboard permission denied — copy is best-effort */
2144
+ });
2145
+ } catch {
2146
+ /* clipboard API absent — non-fatal */
2147
+ }
2148
+ if (cut) {
2149
+ store.deleteStrokes([...expanded]);
2150
+ annotSel.clear();
2151
+ }
2152
+ return true;
2153
+ },
2154
+ [annotSel]
2155
+ );
2156
+
2157
+ /**
2158
+ * FigJam v3 — paste a strokes JSON payload (⌘V or the right-click menu).
2159
+ * Round-trips through the serializer + parser so a malformed foreign payload
2160
+ * coerces to valid strokes or drops; clones get fresh ids + a +16/+16 offset.
2161
+ */
2162
+ const pasteStrokesText = useCallback(
2163
+ (txt: string): boolean => {
2164
+ if (!annotSel) return false;
2165
+ if (!txt.startsWith('{"maudeStrokes"')) return false;
2166
+ let parsed: { maudeStrokes?: number; strokes?: unknown } | null = null;
2167
+ try {
2168
+ parsed = JSON.parse(txt) as { maudeStrokes?: number; strokes?: unknown };
2169
+ } catch {
2170
+ return false;
2171
+ }
2172
+ if (parsed?.maudeStrokes !== 1 || !Array.isArray(parsed.strokes)) return false;
2173
+ let safe: Stroke[] = [];
2174
+ try {
2175
+ safe = svgToStrokes(strokesToSvg(parsed.strokes as Stroke[]));
2176
+ } catch {
2177
+ return false;
2178
+ }
2179
+ if (safe.length === 0) return false;
2180
+ const res = duplicateStrokes(
2181
+ safe,
2182
+ safe.map((s) => s.id),
2183
+ 16,
2184
+ 16
2185
+ );
2186
+ const clones = res.strokes.slice(safe.length);
2187
+ if (clones.length === 0) return false;
2188
+ const prev = strokesRef.current;
2189
+ // recompute keeps binds to hosts present in THIS canvas and strips the
2190
+ // cross-canvas danglers (endpoint frozen).
2191
+ const next = recomputeBoundArrows([...prev, ...clones]);
2192
+ commitStrokes(prev, next, `paste ${clones.length} stroke${clones.length === 1 ? '' : 's'}`);
2193
+ annotSel.replace(res.newIds);
2194
+ return true;
2195
+ },
2196
+ [annotSel, commitStrokes]
2197
+ );
2198
+
2199
+ /**
2200
+ * FigJam v3 — quick-create chain (⌘Enter): with a sticky or shape selected,
2201
+ * spawn a sibling of the same type/size/style to the right with its editor
2202
+ * active. Shapes ALSO get a bound connector source → sibling (FigJam quick-
2203
+ * create: shapes connect, stickies don't). Returns true when spawned.
2204
+ */
2205
+ const chainCreate = useCallback(
2206
+ (sourceId: string): boolean => {
2207
+ const prev = strokesRef.current;
2208
+ const src = prev.find((s) => s.id === sourceId);
2209
+ if (!src) return false;
2210
+ if (
2211
+ src.tool !== 'sticky' &&
2212
+ src.tool !== 'rect' &&
2213
+ src.tool !== 'ellipse' &&
2214
+ src.tool !== 'polygon'
2215
+ ) {
2216
+ return false;
2217
+ }
2218
+ const bb = strokeBBox(src);
2219
+ if (!bb) return false;
2220
+ const gap = src.tool === 'sticky' ? 40 : 64;
2221
+ const nid = rid();
2222
+ // The sibling copies style + size but starts loose (no group membership,
2223
+ // human provenance) and empty-bodied. Undefined-assignment (not rest-
2224
+ // destructuring) keeps the discriminated-union narrowing intact for the
2225
+ // branches below; the serializer treats undefined as absent.
2226
+ const bare = structuredClone(src);
2227
+ bare.groupIds = undefined;
2228
+ bare.author = undefined;
2229
+ let sibling: Stroke;
2230
+ if (bare.tool === 'ellipse') {
2231
+ sibling = { ...bare, id: nid, cx: bare.cx + bb.w + gap };
2232
+ } else if (bare.tool === 'sticky') {
2233
+ sibling = { ...bare, id: nid, x: bb.x + bb.w + gap, y: bb.y, text: '' };
2234
+ } else {
2235
+ sibling = { ...bare, id: nid, x: bb.x + bb.w + gap, y: bb.y };
2236
+ }
2237
+ let next: Stroke[] = [...prev, sibling];
2238
+ if (src.tool !== 'sticky') {
2239
+ const p1 = anchorPoint(src, 1, 0.5);
2240
+ const p2 = anchorPoint(sibling, 0, 0.5);
2241
+ if (p1 && p2) {
2242
+ next = [
2243
+ ...next,
2244
+ {
2245
+ id: rid(),
2246
+ tool: 'arrow',
2247
+ color: resolveDefaultInk(theme),
2248
+ width: STROKE_WIDTH_THIN,
2249
+ x1: p1[0],
2250
+ y1: p1[1],
2251
+ x2: p2[0],
2252
+ y2: p2[1],
2253
+ startBind: { hostId: src.id, nx: 1, ny: 0.5 },
2254
+ endBind: { hostId: nid, nx: 0, ny: 0.5 },
2255
+ },
2256
+ ];
2257
+ }
2258
+ }
2259
+ commitStrokes(prev, next, `quick-create ${sibling.tool}`);
2260
+ annotSel?.replace(nid);
2261
+ // Sticky → body editor; rect/ellipse/polygon → anchored-text editor
2262
+ // (Wave G widened anchorsById to every closed shape).
2263
+ if (
2264
+ sibling.tool === 'sticky' ||
2265
+ sibling.tool === 'rect' ||
2266
+ sibling.tool === 'ellipse' ||
2267
+ sibling.tool === 'polygon'
2268
+ ) {
2269
+ setEditingId(nid);
2270
+ }
2271
+ return true;
2272
+ },
2273
+ [commitStrokes, annotSel, theme]
2274
+ );
2275
+
2276
+ // FigJam v3 — ⌘Enter pressed INSIDE a sticky/anchored editor commits there
2277
+ // and asks the layer (via this event) to chain the next sibling. Deferred a
2278
+ // tick so the editor's commit lands in strokesRef first.
2279
+ useEffect(() => {
2280
+ if (typeof document === 'undefined') return;
2281
+ const onChain = (e: Event) => {
2282
+ const id = (e as CustomEvent<{ id?: string }>).detail?.id;
2283
+ if (!id) return;
2284
+ window.setTimeout(() => {
2285
+ chainCreate(id);
2286
+ }, 0);
2287
+ };
2288
+ document.addEventListener('maude:chain-create', onChain);
2289
+ return () => document.removeEventListener('maude:chain-create', onChain);
2290
+ }, [chainCreate]);
2291
+
2292
+ // FigJam v3 — the resize overlay (a sibling component that owns the arrow
2293
+ // endpoint handles) broadcasts the bind candidate while an endpoint drags;
2294
+ // the halo renders here because the SVG layer owns the world overlay.
2295
+ useEffect(() => {
2296
+ if (typeof document === 'undefined') return;
2297
+ const onHint = (e: Event) => {
2298
+ setBindHintId((e as CustomEvent<{ hostId?: string | null }>).detail?.hostId ?? null);
2299
+ };
2300
+ document.addEventListener('maude:bind-hint', onHint);
2301
+ return () => document.removeEventListener('maude:bind-hint', onHint);
2302
+ }, []);
2303
+
2304
+ // FigJam v3 — live size label + dimension-match halos while resizing (the
2305
+ // overlay broadcasts; the SVG layer paints).
2306
+ const [resizeInfo, setResizeInfo] = useState<{
2307
+ box: { x: number; y: number; w: number; h: number } | null;
2308
+ matchIds: string[];
2309
+ } | null>(null);
2310
+ useEffect(() => {
2311
+ if (typeof document === 'undefined') return;
2312
+ const onInfo = (e: Event) => {
2313
+ const detail = (
2314
+ e as CustomEvent<{
2315
+ box?: { x: number; y: number; w: number; h: number } | null;
2316
+ matchIds?: string[];
2317
+ }>
2318
+ ).detail;
2319
+ setResizeInfo(detail?.box ? { box: detail.box, matchIds: detail.matchIds ?? [] } : null);
2320
+ };
2321
+ document.addEventListener('maude:resize-info', onInfo);
2322
+ return () => document.removeEventListener('maude:resize-info', onInfo);
2323
+ }, []);
2324
+
2325
+ // FigJam v3 — manipulation shortcuts: ⌘G group / ⌘⇧G ungroup, ⌘D duplicate,
2326
+ // ] [ ⌘] ⌘[ z-order, ⌘C/⌘X copy/cut (selection → OS clipboard as a JSON
2327
+ // text payload), ⌘Enter quick-create chain. Document capture, mirroring the
2328
+ // nudge handler below; the input-router never claims these combos.
2329
+ useEffect(() => {
2330
+ if (typeof document === 'undefined') return;
2331
+ if (!annotSel) return;
2332
+ const onKey = (e: KeyboardEvent) => {
2333
+ if (isEditable(e.target)) return;
2334
+ const store = strokesStoreRef.current;
2335
+ const sel = annotSel.selectedIds;
2336
+ const cmd = e.metaKey || e.ctrlKey;
2337
+ const k = e.key.toLowerCase();
2338
+ if (cmd && !e.altKey && k === 'g') {
2339
+ // ⌘G claims the browser's find-next ONLY when a selection exists.
2340
+ if (sel.length === 0) return;
2341
+ e.preventDefault();
2342
+ e.stopImmediatePropagation();
2343
+ if (e.shiftKey) {
2344
+ store.ungroupSelection(sel);
2345
+ } else {
2346
+ const members = store.groupSelection(sel);
2347
+ if (members) annotSel.replace(members);
2348
+ }
2349
+ return;
2350
+ }
2351
+ if (cmd && !e.shiftKey && !e.altKey && k === 'd') {
2352
+ if (sel.length === 0) return; // browser bookmark stays available
2353
+ e.preventDefault();
2354
+ e.stopImmediatePropagation();
2355
+ const ids = store.duplicateSelection(sel, 16, 16);
2356
+ if (ids.length) annotSel.replace(ids);
2357
+ return;
2358
+ }
2359
+ if ((e.key === ']' || e.key === '[') && !e.altKey && !e.shiftKey) {
2360
+ if (sel.length === 0) return;
2361
+ e.preventDefault();
2362
+ const op: ZOrderOp =
2363
+ e.key === ']' ? (cmd ? 'forward' : 'front') : cmd ? 'backward' : 'back';
2364
+ store.reorderSelection(sel, op);
2365
+ return;
2366
+ }
2367
+ if (cmd && !e.shiftKey && !e.altKey && (k === 'c' || k === 'x')) {
2368
+ if (sel.length === 0) return; // let the native copy run
2369
+ if (copySelection(k === 'x')) e.preventDefault();
2370
+ return;
2371
+ }
2372
+ if (cmd && e.key === 'Enter' && !e.shiftKey && !e.altKey) {
2373
+ if (sel.length !== 1) return;
2374
+ const only = sel[0];
2375
+ if (only && chainCreate(only)) e.preventDefault();
2376
+ return;
2377
+ }
2378
+ // FigJam v3 — plain Enter on a single text-capable stroke opens its
2379
+ // editor (FigJam: select a shape, press Enter, start typing).
2380
+ if (e.key === 'Enter' && !cmd && !e.shiftKey && !e.altKey) {
2381
+ if (sel.length !== 1) return;
2382
+ const only = strokesRef.current.find((x) => x.id === sel[0]);
2383
+ if (!only) return;
2384
+ if (
2385
+ only.tool === 'rect' ||
2386
+ only.tool === 'ellipse' ||
2387
+ only.tool === 'polygon' ||
2388
+ only.tool === 'sticky' ||
2389
+ (only.tool === 'text' && (only.anchorId == null || only.anchorId === ''))
2390
+ ) {
2391
+ e.preventDefault();
2392
+ setEditingId(only.id);
2393
+ }
2394
+ }
2395
+ };
2396
+ document.addEventListener('keydown', onKey, true);
2397
+ return () => document.removeEventListener('keydown', onKey, true);
2398
+ }, [annotSel, chainCreate, copySelection]);
2399
+
2400
+ // FigJam v3 — paste strokes. ⌘C serialized the selection as a JSON text
2401
+ // payload; this CAPTURE-phase listener claims it before the media-intake
2402
+ // hook (bubble phase) so a strokes payload never falls through to the URL/
2403
+ // link branch.
2404
+ useEffect(() => {
2405
+ if (typeof document === 'undefined') return;
2406
+ if (!annotSel) return;
2407
+ const onPaste = (e: ClipboardEvent) => {
2408
+ if (isEditable(e.target)) return;
2409
+ const txt = e.clipboardData?.getData('text/plain') ?? '';
2410
+ if (!txt.startsWith('{"maudeStrokes"')) return;
2411
+ e.preventDefault();
2412
+ e.stopImmediatePropagation();
2413
+ pasteStrokesText(txt);
2414
+ };
2415
+ document.addEventListener('paste', onPaste, true);
2416
+ return () => document.removeEventListener('paste', onPaste, true);
2417
+ }, [annotSel, pasteStrokesText]);
3073
2418
 
3074
- const editingTargetRef = useRef(editingTarget);
3075
- editingTargetRef.current = editingTarget;
2419
+ // FigJam v3 — hover "Add text" affordance: an empty rect/ellipse hovered in
2420
+ // move mode shows a ghost label; double-click (existing) or Enter edits.
2421
+ const [addTextHintId, setAddTextHintId] = useState<string | null>(null);
2422
+ useEffect(() => {
2423
+ if (typeof document === 'undefined') return;
2424
+ if (tool !== 'move') {
2425
+ setAddTextHintId(null);
2426
+ return;
2427
+ }
2428
+ const onMove = (e: PointerEvent) => {
2429
+ const node = (e.target as Element | null)?.closest?.('[data-id][data-tool]');
2430
+ const t = node?.getAttribute('data-tool');
2431
+ const id = node?.getAttribute('data-id') ?? null;
2432
+ if (!id || (t !== 'rect' && t !== 'ellipse' && t !== 'polygon')) {
2433
+ setAddTextHintId(null);
2434
+ return;
2435
+ }
2436
+ const hasText = strokesRef.current.some(
2437
+ (x) => x.tool === 'text' && x.anchorId === id && x.text.length > 0
2438
+ );
2439
+ setAddTextHintId(hasText ? null : id);
2440
+ };
2441
+ document.addEventListener('pointermove', onMove, { passive: true });
2442
+ return () => document.removeEventListener('pointermove', onMove);
2443
+ }, [tool]);
3076
2444
 
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
- );
2445
+ // FigJam v3 — connector draft: dragging from a connection dot (the side
2446
+ // magnets shown on a selected bindable shape) draws a BOUND curved
2447
+ // connector; releasing over another bindable shape binds the far end too
2448
+ // (⌘ keeps it free). The draft renders through the same arrow primitives.
2449
+ const [connDraft, setConnDraft] = useState<{
2450
+ x1: number;
2451
+ y1: number;
2452
+ x2: number;
2453
+ y2: number;
2454
+ startBind: { hostId: string; nx: number; ny: number };
2455
+ endBind?: { hostId: string; nx: number; ny: number };
2456
+ } | null>(null);
2457
+ const connDraftRef = useRef(connDraft);
2458
+ connDraftRef.current = connDraft;
2459
+ useEffect(() => {
2460
+ if (typeof document === 'undefined') return;
2461
+ if (tool !== 'move') return;
2462
+ const onDown = (e: PointerEvent) => {
2463
+ if (e.button !== 0) return;
2464
+ const dot = (e.target as Element | null)?.closest?.('.dc-annot-conn-dot');
2465
+ if (!dot) return;
2466
+ const hostId = dot.getAttribute('data-host') ?? '';
2467
+ const nx = Number.parseFloat(dot.getAttribute('data-nx') ?? '');
2468
+ const ny = Number.parseFloat(dot.getAttribute('data-ny') ?? '');
2469
+ const host = strokesRef.current.find((s) => s.id === hostId);
2470
+ if (!host || !Number.isFinite(nx) || !Number.isFinite(ny)) return;
2471
+ const pt = anchorPoint(host, nx, ny);
2472
+ if (!pt) return;
2473
+ e.preventDefault();
2474
+ e.stopImmediatePropagation();
2475
+ const pointerId = e.pointerId;
2476
+ const startBind = { hostId, nx, ny };
2477
+ setConnDraft({ x1: pt[0], y1: pt[1], x2: pt[0], y2: pt[1], startBind });
2478
+ const onMove = (mv: PointerEvent) => {
2479
+ if (mv.pointerId !== pointerId) return;
2480
+ const [wx, wy] = screenToWorld(mv.clientX, mv.clientY);
2481
+ const zoom = vpRef.current?.zoom || 1;
2482
+ const cand =
2483
+ mv.metaKey || mv.ctrlKey
2484
+ ? null
2485
+ : bindCandidate(
2486
+ wx,
2487
+ wy,
2488
+ strokesRef.current,
2489
+ BIND_THRESHOLD_PX / zoom,
2490
+ new Set([hostId])
2491
+ );
2492
+ setBindHintId(cand?.hostId ?? null);
2493
+ if (cand) {
2494
+ const target = strokesRef.current.find((s) => s.id === cand.hostId);
2495
+ const tp = target ? anchorPoint(target, cand.nx, cand.ny) : null;
2496
+ if (tp) {
2497
+ setConnDraft({ x1: pt[0], y1: pt[1], x2: tp[0], y2: tp[1], startBind, endBind: cand });
2498
+ return;
2499
+ }
2500
+ }
2501
+ setConnDraft({ x1: pt[0], y1: pt[1], x2: wx, y2: wy, startBind });
2502
+ };
2503
+ const onUp = (up: PointerEvent) => {
2504
+ if (up.pointerId !== pointerId) return;
2505
+ document.removeEventListener('pointermove', onMove, true);
2506
+ document.removeEventListener('pointerup', onUp, true);
2507
+ document.removeEventListener('pointercancel', onUp, true);
2508
+ const draft = connDraftRef.current;
2509
+ setConnDraft(null);
2510
+ setBindHintId(null);
2511
+ if (!draft) return;
2512
+ // A bare tap on the dot creates nothing.
2513
+ if (Math.hypot(draft.x2 - draft.x1, draft.y2 - draft.y1) < 8) return;
2514
+ const arrow: ArrowStroke = {
2515
+ id: rid(),
2516
+ tool: 'arrow',
2517
+ color: resolveDefaultInk(theme),
2518
+ width: STROKE_WIDTH_THIN,
2519
+ x1: draft.x1,
2520
+ y1: draft.y1,
2521
+ x2: draft.x2,
2522
+ y2: draft.y2,
2523
+ lineType: 'curved',
2524
+ startBind: draft.startBind,
2525
+ ...(draft.endBind ? { endBind: draft.endBind } : {}),
2526
+ };
2527
+ const prev = strokesRef.current;
2528
+ commitStrokes(prev, [...prev, arrow], 'draw connector');
2529
+ annotSel?.replace(arrow.id);
2530
+ if (arrow.endBind) {
2531
+ showOnceHint(
2532
+ 'bind',
2533
+ 'Arrow attached — it follows the shape now. Drag an endpoint to re-anchor, hold ⌘ to keep it free.'
2534
+ );
2535
+ }
2536
+ };
2537
+ document.addEventListener('pointermove', onMove, true);
2538
+ document.addEventListener('pointerup', onUp, true);
2539
+ document.addEventListener('pointercancel', onUp, true);
2540
+ };
2541
+ document.addEventListener('pointerdown', onDown, true);
2542
+ return () => document.removeEventListener('pointerdown', onDown, true);
2543
+ }, [tool, screenToWorld, commitStrokes, annotSel, theme]);
3090
2544
 
3091
- const cancelEditing = useCallback(() => {
3092
- setEditingId(null);
3093
- setPendingText(null);
3094
- }, []);
2545
+ // FigJam v3 — right-click on a stroke SELECTS it (keeping a multi-selection
2546
+ // the press lands inside) and opens the annotation context menu (z-order,
2547
+ // group, copy/paste, delete). Capture phase on document so it claims the
2548
+ // event before the input-router's host-level contextmenu handler.
2549
+ const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null);
2550
+ useEffect(() => {
2551
+ if (typeof document === 'undefined') return;
2552
+ if (tool !== 'move') return;
2553
+ if (!annotSel) return;
2554
+ const strokeAt = (target: Element | null): string | null => {
2555
+ if (target?.closest?.(CHROME_SELECTOR)) return null;
2556
+ const node = target?.closest?.('[data-id][data-tool]');
2557
+ const id = node?.getAttribute('data-id');
2558
+ if (!id || !strokesRef.current.some((s) => s.id === id)) return null;
2559
+ return id;
2560
+ };
2561
+ const onCtx = (e: MouseEvent) => {
2562
+ const id = strokeAt(e.target as Element | null);
2563
+ if (!id) return;
2564
+ e.preventDefault();
2565
+ e.stopImmediatePropagation();
2566
+ if (!annotSel.contains(id)) {
2567
+ annotSel.replace(expandIdsToGroups([id], strokesRef.current));
2568
+ }
2569
+ setCtxMenu({ x: e.clientX, y: e.clientY });
2570
+ };
2571
+ // Wave G — the input-router ALSO opens the shell canvas menu from a
2572
+ // right-button POINTERDOWN (classify maps button 2 → 'context-menu'), so
2573
+ // claiming only the contextmenu event left BOTH menus open. This document-
2574
+ // capture listener fires before the router's host-capture one and stops
2575
+ // propagation WITHOUT preventDefault, so the native contextmenu event
2576
+ // (which opens OUR menu above) still follows.
2577
+ const onDown = (e: PointerEvent) => {
2578
+ if (e.button !== 2) return;
2579
+ if (!strokeAt(e.target as Element | null)) return;
2580
+ e.stopImmediatePropagation();
2581
+ };
2582
+ document.addEventListener('contextmenu', onCtx, true);
2583
+ document.addEventListener('pointerdown', onDown, true);
2584
+ return () => {
2585
+ document.removeEventListener('contextmenu', onCtx, true);
2586
+ document.removeEventListener('pointerdown', onDown, true);
2587
+ };
2588
+ }, [tool, annotSel]);
3095
2589
 
3096
2590
  // Keyboard: arrow nudge + Backspace/Delete remove selected strokes.
3097
2591
  useEffect(() => {
@@ -3132,6 +2626,62 @@ export function AnnotationsLayer() {
3132
2626
  return () => document.removeEventListener('keydown', onKey, true);
3133
2627
  }, [annotSel, strokesStore]);
3134
2628
 
2629
+ /** FigJam v3 — context-menu action dispatcher (shares the shortcut paths). */
2630
+ const onMenuAction = useCallback(
2631
+ (action: string) => {
2632
+ const store = strokesStoreRef.current;
2633
+ if (!annotSel) return;
2634
+ const sel = annotSel.selectedIds;
2635
+ if (action === 'copy') copySelection(false);
2636
+ else if (action === 'cut') copySelection(true);
2637
+ else if (action === 'paste') {
2638
+ try {
2639
+ void navigator.clipboard
2640
+ ?.readText()
2641
+ .then((t) => {
2642
+ pasteStrokesText(t);
2643
+ })
2644
+ .catch(() => {
2645
+ /* clipboard read blocked — paste is best-effort from the menu */
2646
+ });
2647
+ } catch {
2648
+ /* clipboard API absent */
2649
+ }
2650
+ } else if (action === 'duplicate') {
2651
+ const ids = store.duplicateSelection(sel, 16, 16);
2652
+ if (ids.length) annotSel.replace(ids);
2653
+ } else if (action === 'delete') {
2654
+ store.deleteStrokes(sel);
2655
+ annotSel.clear();
2656
+ } else if (
2657
+ action === 'front' ||
2658
+ action === 'forward' ||
2659
+ action === 'backward' ||
2660
+ action === 'back'
2661
+ ) {
2662
+ store.reorderSelection(sel, action);
2663
+ } else if (action === 'group') {
2664
+ const members = store.groupSelection(sel);
2665
+ if (members) annotSel.replace(members);
2666
+ } else if (action === 'ungroup') {
2667
+ store.ungroupSelection(sel);
2668
+ }
2669
+ },
2670
+ [annotSel, copySelection, pasteStrokesText]
2671
+ );
2672
+
2673
+ // FigJam v3 — first time a multi-selection lands, surface the group /
2674
+ // duplicate affordances once (behaviour-triggered, never a tour).
2675
+ const selCount = annotSel?.selectedIds.length ?? 0;
2676
+ useEffect(() => {
2677
+ if (selCount >= 2) {
2678
+ showOnceHint(
2679
+ 'multi',
2680
+ '⌘G groups the selection · drag inside the box moves everything · ⌘D duplicates.'
2681
+ );
2682
+ }
2683
+ }, [selCount]);
2684
+
3135
2685
  // Selected stroke halos — bboxes in world coords, vector-effect non-scaling-stroke.
3136
2686
  const selectedStrokes = useMemo(() => {
3137
2687
  if (!annotSel || annotSel.selectedIds.length === 0) return [] as Stroke[];
@@ -3145,54 +2695,76 @@ export function AnnotationsLayer() {
3145
2695
 
3146
2696
  return (
3147
2697
  <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)}
2698
+ <AnnotationsInput
2699
+ isActive={isActive}
2700
+ visible={visible}
2701
+ cursor={tools.find((t) => t.id === tool)?.cursor ?? 'crosshair'}
2702
+ beginStroke={beginStroke}
2703
+ moveStroke={moveStroke}
2704
+ endStroke={endStroke}
2705
+ onLeave={() => setGhost(null)}
2706
+ />
2707
+ {visible ? (
2708
+ <AnnotationsSvg
2709
+ worldRef={worldRef}
2710
+ strokes={renderStrokes}
2711
+ anchorsById={anchorsById}
2712
+ selectMode={tool === 'move'}
2713
+ selectedStrokes={selectedStrokes}
2714
+ marquee={marquee}
2715
+ snapGuides={snapGuides}
2716
+ bindHintId={bindHintId}
2717
+ resizeInfo={resizeInfo}
2718
+ connDraft={connDraft}
2719
+ addTextHintId={editingTarget ? null : addTextHintId}
2720
+ ghost={ghostPreview}
2721
+ editingTarget={editingTarget}
2722
+ inkColor={color}
2723
+ onCommitEdit={commitEditing}
2724
+ onCancelEdit={cancelEditing}
3157
2725
  />
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
- </>
2726
+ ) : null}
2727
+ <AnnotationContextToolbar
2728
+ editingId={
2729
+ editingTarget?.kind === 'anchored'
2730
+ ? editingTarget.anchorId
2731
+ : editingTarget?.kind === 'sticky'
2732
+ ? editingTarget.sticky.id
2733
+ : editingTarget?.kind === 'standalone'
2734
+ ? editingTarget.text.id
2735
+ : null
2736
+ }
2737
+ />
2738
+ {ctxMenu && annotSel ? (
2739
+ <AnnotationContextMenu
2740
+ pos={ctxMenu}
2741
+ selCount={annotSel.selectedIds.length}
2742
+ canUngroup={selectedStrokes.some((s) => (s.groupIds?.length ?? 0) > 0)}
2743
+ onAction={onMenuAction}
2744
+ onClose={() => setCtxMenu(null)}
2745
+ />
2746
+ ) : null}
2747
+ {visible && tool === 'move' ? <AnnotationResizeOverlay store={strokesStore} /> : null}
2748
+ {isActive ? (
2749
+ <AnnotationsChrome
2750
+ tool={tool}
2751
+ theme={theme}
2752
+ color={color}
2753
+ setColor={setColor}
2754
+ stickyColor={stickyColor}
2755
+ setStickyColor={setStickyColor}
2756
+ highlighterColor={highlighterColor}
2757
+ setHighlighterColor={setHighlighterColor}
2758
+ highlighterWidth={highlighterWidth}
2759
+ setHighlighterWidth={setHighlighterWidth}
2760
+ supportsFill={supportsFill}
2761
+ fill={fill}
2762
+ setFill={setFill}
2763
+ supportsThickness={supportsThickness}
2764
+ thickness={thickness}
2765
+ setThickness={setThickness}
2766
+ />
2767
+ ) : null}
3196
2768
  </StrokesStoreContext.Provider>
3197
2769
  );
3198
2770
  }
@@ -3286,6 +2858,11 @@ function AnnotationsSvg({
3286
2858
  selectMode,
3287
2859
  selectedStrokes,
3288
2860
  marquee,
2861
+ snapGuides,
2862
+ bindHintId,
2863
+ resizeInfo,
2864
+ connDraft,
2865
+ addTextHintId,
3289
2866
  ghost,
3290
2867
  editingTarget,
3291
2868
  inkColor,
@@ -3294,10 +2871,30 @@ function AnnotationsSvg({
3294
2871
  }: {
3295
2872
  worldRef: ReturnType<typeof useWorldRefContext>;
3296
2873
  strokes: readonly Stroke[];
3297
- anchorsById: Map<string, RectStroke | EllipseStroke>;
2874
+ anchorsById: Map<string, AnchorHost>;
3298
2875
  selectMode: boolean;
3299
2876
  selectedStrokes: readonly Stroke[];
3300
2877
  marquee: { ax: number; ay: number; bx: number; by: number } | null;
2878
+ /** FigJam v3 — smart-guide lines painted while a drag is snapping. */
2879
+ snapGuides: SnapGuide[] | null;
2880
+ /** FigJam v3 — host a dragged arrow endpoint would bind to (accent halo). */
2881
+ bindHintId: string | null;
2882
+ /** FigJam v3 — live size label + dimension-match halos while resizing. */
2883
+ resizeInfo: {
2884
+ box: { x: number; y: number; w: number; h: number } | null;
2885
+ matchIds: string[];
2886
+ } | null;
2887
+ /** FigJam v3 — in-flight connector drawn from a connection dot. */
2888
+ connDraft: {
2889
+ x1: number;
2890
+ y1: number;
2891
+ x2: number;
2892
+ y2: number;
2893
+ startBind: { hostId: string; nx: number; ny: number };
2894
+ endBind?: { hostId: string; nx: number; ny: number };
2895
+ } | null;
2896
+ /** FigJam v3 — hovered empty shape that shows the "Add text" ghost label. */
2897
+ addTextHintId: string | null;
3301
2898
  ghost: GhostDescriptor | null;
3302
2899
  editingTarget: EditingTarget;
3303
2900
  /** Live default ink (theme-aware) for a not-yet-born pending text caret. */
@@ -3358,6 +2955,94 @@ function AnnotationsSvg({
3358
2955
  vectorEffect="non-scaling-stroke"
3359
2956
  />
3360
2957
  ) : null}
2958
+ {/* FigJam v3 — smart guides: solid 1px accent lines at the snapped edge/
2959
+ center, painted only while a drag is actively snapping. */}
2960
+ {snapGuides?.map((g, i) => (
2961
+ <line
2962
+ // biome-ignore lint/suspicious/noArrayIndexKey: guides are positional + rebuilt per move tick
2963
+ key={`guide-${i}`}
2964
+ x1={g.axis === 'x' ? g.at : g.from}
2965
+ y1={g.axis === 'x' ? g.from : g.at}
2966
+ x2={g.axis === 'x' ? g.at : g.to}
2967
+ y2={g.axis === 'x' ? g.to : g.at}
2968
+ stroke="var(--maude-hud-accent, #d63b1f)"
2969
+ strokeWidth={1}
2970
+ vectorEffect="non-scaling-stroke"
2971
+ pointerEvents="none"
2972
+ />
2973
+ ))}
2974
+ {/* FigJam v3 — bind hint: halo the host a dragged arrow endpoint would
2975
+ magnetically attach to. */}
2976
+ <BindHintHalo strokes={strokes} bindHintId={bindHintId} />
2977
+ {/* FigJam v3 — connection dots on a single selected bindable shape;
2978
+ dragging one draws a bound connector (rendered below as a draft). */}
2979
+ {selectMode && !connDraft && selectedStrokes.length === 1 && selectedStrokes[0] ? (
2980
+ <ConnectorDots stroke={selectedStrokes[0]} />
2981
+ ) : null}
2982
+ {connDraft ? (
2983
+ <g
2984
+ stroke={inkColor}
2985
+ strokeWidth={2.5}
2986
+ strokeLinecap="round"
2987
+ strokeLinejoin="round"
2988
+ vectorEffect="non-scaling-stroke"
2989
+ fill="none"
2990
+ pointerEvents="none"
2991
+ >
2992
+ {arrowPrimitives({
2993
+ x1: connDraft.x1,
2994
+ y1: connDraft.y1,
2995
+ x2: connDraft.x2,
2996
+ y2: connDraft.y2,
2997
+ width: 2.5,
2998
+ color: inkColor,
2999
+ lineType: 'curved',
3000
+ startBind: connDraft.startBind,
3001
+ ...(connDraft.endBind ? { endBind: connDraft.endBind } : {}),
3002
+ }).map((prim, i) => renderArrowPrimitive(prim, i))}
3003
+ </g>
3004
+ ) : null}
3005
+ {/* FigJam v3 — hover affordance: an empty shape invites text. */}
3006
+ <AddTextHint strokes={strokes} hintId={addTextHintId} />
3007
+ {/* FigJam v3 — resize chrome: live W × H label at the box corner plus a
3008
+ dashed halo on any neighbour whose dimension the resize just matched
3009
+ (the "same size as that one" quota). */}
3010
+ {resizeInfo?.box ? (
3011
+ <g pointerEvents="none">
3012
+ {resizeInfo.matchIds.map((id) => {
3013
+ const m = strokes.find((s) => s.id === id);
3014
+ const bb = m ? strokeBBox(m) : null;
3015
+ if (!bb) return null;
3016
+ return (
3017
+ <rect
3018
+ key={`dim-${id}`}
3019
+ x={bb.x - 2}
3020
+ y={bb.y - 2}
3021
+ width={bb.w + 4}
3022
+ height={bb.h + 4}
3023
+ fill="none"
3024
+ stroke="var(--maude-hud-accent, #d63b1f)"
3025
+ strokeWidth={1.5}
3026
+ strokeDasharray="5 3"
3027
+ vectorEffect="non-scaling-stroke"
3028
+ rx={2}
3029
+ />
3030
+ );
3031
+ })}
3032
+ <text
3033
+ x={resizeInfo.box.x + resizeInfo.box.w / 2}
3034
+ y={resizeInfo.box.y + resizeInfo.box.h + 18}
3035
+ textAnchor="middle"
3036
+ fontSize={11}
3037
+ fill="var(--maude-hud-accent, #d63b1f)"
3038
+ style={{
3039
+ fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
3040
+ }}
3041
+ >
3042
+ {`${Math.round(resizeInfo.box.w)} × ${Math.round(resizeInfo.box.h)}`}
3043
+ </text>
3044
+ </g>
3045
+ ) : null}
3361
3046
  {ghost ? <GhostPreview ghost={ghost} /> : null}
3362
3047
  {editingTarget?.kind === 'anchored' ? (
3363
3048
  <TextEditor
@@ -3403,6 +3088,23 @@ function AnnotationsSvg({
3403
3088
  onCancel={onCancelEdit}
3404
3089
  />
3405
3090
  ) : null}
3091
+ {editingTarget?.kind === 'section' ? (
3092
+ <StandaloneTextEditor
3093
+ x={
3094
+ Math.min(editingTarget.section.x, editingTarget.section.x + editingTarget.section.w) + 2
3095
+ }
3096
+ y={
3097
+ Math.min(editingTarget.section.y, editingTarget.section.y + editingTarget.section.h) -
3098
+ SECTION_LABEL_H -
3099
+ 2
3100
+ }
3101
+ fontSize={SECTION_LABEL_FONT}
3102
+ color={editingTarget.section.color}
3103
+ initialText={editingTarget.section.label}
3104
+ onCommit={onCommitEdit}
3105
+ onCancel={onCancelEdit}
3106
+ />
3107
+ ) : null}
3406
3108
  </svg>,
3407
3109
  target
3408
3110
  );
@@ -3416,7 +3118,7 @@ function TextEditor({
3416
3118
  onCancel,
3417
3119
  }: {
3418
3120
  anchorId: string;
3419
- host: RectStroke | EllipseStroke | null;
3121
+ host: AnchorHost | null;
3420
3122
  existing: TextStroke | undefined;
3421
3123
  onCommit: (anchorId: string, text: string, fmt?: EditorFmt) => void;
3422
3124
  onCancel: () => void;
@@ -3437,6 +3139,8 @@ function TextEditor({
3437
3139
  italic: existing?.italic,
3438
3140
  underline: existing?.underline,
3439
3141
  strike: existing?.strike,
3142
+ fontSize: existing?.fontSize ?? DEFAULT_FONT_SIZE,
3143
+ align: existing?.align ?? 'center',
3440
3144
  });
3441
3145
 
3442
3146
  useEffect(() => {
@@ -3464,6 +3168,9 @@ function TextEditor({
3464
3168
  const el = ref.current;
3465
3169
  if (!el) return;
3466
3170
  if (el.contains(e.target as Node)) return;
3171
+ // FigJam v3 — the edit-mode text toolbar drives THIS editor; clicking
3172
+ // it must not commit-and-close the session.
3173
+ if ((e.target as Element | null)?.closest?.('.dc-annot-ctx')) return;
3467
3174
  onCommit(
3468
3175
  anchorId,
3469
3176
  stripEditorMarkers(el.innerText || '', existing?.listType),
@@ -3528,6 +3235,11 @@ function TextEditor({
3528
3235
  stripEditorMarkers(el?.innerText || '', existing?.listType),
3529
3236
  fmtRef.current
3530
3237
  );
3238
+ // FigJam v3 — ⌘Enter chains: commit, then spawn a CONNECTED
3239
+ // sibling shape (quick-create) from the host.
3240
+ document.dispatchEvent(
3241
+ new CustomEvent('maude:chain-create', { detail: { id: anchorId } })
3242
+ );
3531
3243
  }
3532
3244
  }}
3533
3245
  >
@@ -3564,12 +3276,22 @@ function StickyEditor({
3564
3276
  italic: sticky.italic,
3565
3277
  underline: sticky.underline,
3566
3278
  strike: sticky.strike,
3279
+ fontSize: sticky.fontSize,
3280
+ align: sticky.align ?? 'left',
3567
3281
  });
3568
3282
  const commit = () => {
3569
3283
  if (doneRef.current) return;
3570
3284
  doneRef.current = true;
3571
3285
  onCommit(stripEditorMarkers(ref.current?.innerText ?? '', sticky.listType), fmtRef.current);
3572
3286
  };
3287
+ // FigJam v3 — a toolbar click steals focus for a tick; don't treat it as
3288
+ // "done editing" (the button's onMouseDown preventDefault usually stops the
3289
+ // blur, this guards the browsers where it doesn't).
3290
+ const onBlur = (e: { relatedTarget?: EventTarget | null }) => {
3291
+ const to = e.relatedTarget as Element | null;
3292
+ if (to?.closest?.('.dc-annot-ctx')) return;
3293
+ commit();
3294
+ };
3573
3295
  useEffect(() => {
3574
3296
  const el = ref.current;
3575
3297
  if (!el) return;
@@ -3600,7 +3322,7 @@ function StickyEditor({
3600
3322
  suppressContentEditableWarning
3601
3323
  aria-label="Edit sticky note text"
3602
3324
  style={{ ...stickyBodyStyle(sticky), ...fmtStyle, outline: 'none', cursor: 'text' }}
3603
- onBlur={commit}
3325
+ onBlur={onBlur}
3604
3326
  onKeyDown={(e) => {
3605
3327
  if (onFormatKey(e)) return; // Cmd/Ctrl+B/I/U
3606
3328
  if (e.key === 'Escape') {
@@ -3612,6 +3334,11 @@ function StickyEditor({
3612
3334
  if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
3613
3335
  e.preventDefault();
3614
3336
  commit();
3337
+ // FigJam v3 — ⌘Enter chains: commit this body, then ask the layer
3338
+ // to spawn + edit the next sticky beside it.
3339
+ document.dispatchEvent(
3340
+ new CustomEvent('maude:chain-create', { detail: { id: sticky.id } })
3341
+ );
3615
3342
  }
3616
3343
  }}
3617
3344
  >
@@ -3665,6 +3392,8 @@ function StandaloneTextEditor({
3665
3392
  italic,
3666
3393
  underline,
3667
3394
  strike,
3395
+ fontSize,
3396
+ align: align ?? 'left',
3668
3397
  });
3669
3398
  // Single-fire commit guard — outside-click + blur can both fire in one tick;
3670
3399
  // without this the text would commit twice (two undo records). Markers shown
@@ -3701,6 +3430,8 @@ function StandaloneTextEditor({
3701
3430
  const el = ref.current;
3702
3431
  if (!el) return;
3703
3432
  if (el.contains(e.target as Node)) return;
3433
+ // FigJam v3 — clicks into the edit-mode text toolbar keep the session.
3434
+ if ((e.target as Element | null)?.closest?.('.dc-annot-ctx')) return;
3704
3435
  commitOnce(el.innerText || '');
3705
3436
  };
3706
3437
  document.addEventListener('pointerdown', onDown, true);
@@ -3758,15 +3489,233 @@ function StandaloneTextEditor({
3758
3489
  );
3759
3490
  }
3760
3491
 
3492
+ /**
3493
+ * FigJam v3 — centered ghost "Add text" label on a hovered EMPTY rect/ellipse
3494
+ * (FigJam shows the same invitation). Pure chrome; double-click / Enter edits.
3495
+ */
3496
+ function AddTextHint({ strokes, hintId }: { strokes: readonly Stroke[]; hintId: string | null }) {
3497
+ if (!hintId) return null;
3498
+ const host = strokes.find((s) => s.id === hintId);
3499
+ if (!host || (host.tool !== 'rect' && host.tool !== 'ellipse' && host.tool !== 'polygon'))
3500
+ return null;
3501
+ const bb = strokeBBox(host);
3502
+ if (!bb || bb.w < 48 || bb.h < 28) return null;
3503
+ const rot = strokeRotation(host);
3504
+ const label = (
3505
+ <text
3506
+ x={bb.x + bb.w / 2}
3507
+ y={bb.y + bb.h / 2}
3508
+ textAnchor="middle"
3509
+ dominantBaseline="middle"
3510
+ fontSize={13}
3511
+ fill={host.color}
3512
+ opacity={0.45}
3513
+ pointerEvents="none"
3514
+ style={{ fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)' }}
3515
+ >
3516
+ Add text
3517
+ </text>
3518
+ );
3519
+ if (rot === 0) return label;
3520
+ return (
3521
+ <g transform={`rotate(${rot} ${bb.x + bb.w / 2} ${bb.y + bb.h / 2})`} pointerEvents="none">
3522
+ {label}
3523
+ </g>
3524
+ );
3525
+ }
3526
+
3527
+ /**
3528
+ * FigJam v3 — connection dots: the four side magnets of a selected bindable
3529
+ * shape, screen-constant size, accent-ringed. Dragging one starts a bound
3530
+ * connector (the layer owns the gesture; dots are in CHROME_SELECTOR so the
3531
+ * marquee/drag handler yields).
3532
+ */
3533
+ function ConnectorDots({ stroke }: { stroke: Stroke }) {
3534
+ const controller = useViewportControllerContext();
3535
+ const zoom = controller?.viewport?.zoom || 1;
3536
+ if (!isBindable(stroke)) return null;
3537
+ const center = strokeCenter(stroke);
3538
+ if (!center) return null;
3539
+ const magnets: Array<[number, number]> = [
3540
+ [0.5, 0],
3541
+ [1, 0.5],
3542
+ [0.5, 1],
3543
+ [0, 0.5],
3544
+ ];
3545
+ // FigJam parity — the dots float a step OUTSIDE the edge (along the outward
3546
+ // normal), which also keeps them clear of the mid-edge RESIZE handles that
3547
+ // sit exactly on the edge midpoints (DOM, higher layer — they'd swallow the
3548
+ // drag otherwise). Deriving the normal from center→anchor keeps rotated
3549
+ // shapes correct for free.
3550
+ const offset = 16 / zoom;
3551
+ return (
3552
+ <g>
3553
+ {magnets.map(([nx, ny]) => {
3554
+ const pt = anchorPoint(stroke, nx, ny);
3555
+ if (!pt) return null;
3556
+ const dx = pt[0] - center[0];
3557
+ const dy = pt[1] - center[1];
3558
+ const len = Math.hypot(dx, dy) || 1;
3559
+ return (
3560
+ <circle
3561
+ key={`${nx}-${ny}`}
3562
+ className="dc-annot-conn-dot"
3563
+ data-host={stroke.id}
3564
+ data-nx={nx}
3565
+ data-ny={ny}
3566
+ cx={pt[0] + (dx / len) * offset}
3567
+ cy={pt[1] + (dy / len) * offset}
3568
+ r={5 / zoom}
3569
+ fill="var(--maude-hud-accent, #d63b1f)"
3570
+ stroke="var(--maude-chrome-bg-0, #ffffff)"
3571
+ strokeWidth={1.5 / zoom}
3572
+ pointerEvents="all"
3573
+ />
3574
+ );
3575
+ })}
3576
+ </g>
3577
+ );
3578
+ }
3579
+
3580
+ /**
3581
+ * FigJam v3 — right-click context menu for annotation strokes. Reuses the
3582
+ * `.dc-context-menu` visual language (stylesheet injected by context-menu.tsx)
3583
+ * so the annotation menu and the canvas menu read as one product surface.
3584
+ */
3585
+ function AnnotationContextMenu({
3586
+ pos,
3587
+ selCount,
3588
+ canUngroup,
3589
+ onAction,
3590
+ onClose,
3591
+ }: {
3592
+ pos: { x: number; y: number };
3593
+ selCount: number;
3594
+ canUngroup: boolean;
3595
+ onAction: (action: string) => void;
3596
+ onClose: () => void;
3597
+ }) {
3598
+ ensureCtxMenuStyles();
3599
+ const ref = useRef<HTMLDivElement | null>(null);
3600
+ const [at, setAt] = useState<{ x: number; y: number }>(pos);
3601
+ useEffect(() => {
3602
+ const el = ref.current;
3603
+ if (!el || typeof window === 'undefined') return;
3604
+ const r = el.getBoundingClientRect();
3605
+ let nx = pos.x;
3606
+ let ny = pos.y;
3607
+ if (nx + r.width > window.innerWidth - 8) nx = Math.max(8, window.innerWidth - r.width - 8);
3608
+ if (ny + r.height > window.innerHeight - 8) ny = Math.max(8, window.innerHeight - r.height - 8);
3609
+ if (nx !== at.x || ny !== at.y) setAt({ x: nx, y: ny });
3610
+ el.querySelector<HTMLButtonElement>('button.dc-menu-item:not([disabled])')?.focus();
3611
+ const onDown = (e: PointerEvent) => {
3612
+ if (!el.contains(e.target as Node)) onClose();
3613
+ };
3614
+ const onKey = (e: KeyboardEvent) => {
3615
+ if (e.key === 'Escape') {
3616
+ e.preventDefault();
3617
+ onClose();
3618
+ }
3619
+ };
3620
+ document.addEventListener('pointerdown', onDown, true);
3621
+ document.addEventListener('keydown', onKey, true);
3622
+ return () => {
3623
+ document.removeEventListener('pointerdown', onDown, true);
3624
+ document.removeEventListener('keydown', onKey, true);
3625
+ };
3626
+ }, [pos, onClose, at.x, at.y]);
3627
+ const item = (
3628
+ id: string,
3629
+ label: string,
3630
+ shortcut?: string,
3631
+ opts?: { destructive?: boolean; disabled?: boolean }
3632
+ ) => (
3633
+ <button
3634
+ type="button"
3635
+ role="menuitem"
3636
+ disabled={opts?.disabled}
3637
+ className={`dc-menu-item${opts?.destructive ? ' is-destructive' : ''}`}
3638
+ onClick={() => {
3639
+ if (opts?.disabled) return;
3640
+ onAction(id);
3641
+ onClose();
3642
+ }}
3643
+ >
3644
+ <span>{label}</span>
3645
+ {shortcut ? <span className="dc-menu-shortcut">{shortcut}</span> : null}
3646
+ </button>
3647
+ );
3648
+ return (
3649
+ <div
3650
+ ref={ref}
3651
+ className="dc-context-menu"
3652
+ role="menu"
3653
+ aria-label="Annotation actions"
3654
+ style={{ left: at.x, top: at.y }}
3655
+ >
3656
+ {item('copy', 'Copy', '⌘C')}
3657
+ {item('cut', 'Cut', '⌘X')}
3658
+ {item('paste', 'Paste', '⌘V')}
3659
+ {item('duplicate', 'Duplicate', '⌘D')}
3660
+ <div className="dc-menu-sep" aria-hidden="true" />
3661
+ {item('front', 'Bring to front', ']')}
3662
+ {item('forward', 'Bring forward', '⌘]')}
3663
+ {item('backward', 'Send backward', '⌘[')}
3664
+ {item('back', 'Send to back', '[')}
3665
+ <div className="dc-menu-sep" aria-hidden="true" />
3666
+ {item('group', 'Group selection', '⌘G', { disabled: selCount < 2 })}
3667
+ {canUngroup ? item('ungroup', 'Ungroup', '⌘⇧G') : null}
3668
+ <div className="dc-menu-sep" aria-hidden="true" />
3669
+ {item('delete', 'Delete', '⌫', { destructive: true })}
3670
+ </div>
3671
+ );
3672
+ }
3673
+
3674
+ /**
3675
+ * FigJam v3 — accent halo on the host a dragged arrow endpoint would bind to.
3676
+ * Pure chrome (pointer-events:none); renders nothing when no candidate.
3677
+ */
3678
+ function BindHintHalo({
3679
+ strokes,
3680
+ bindHintId,
3681
+ }: {
3682
+ strokes: readonly Stroke[];
3683
+ bindHintId: string | null;
3684
+ }) {
3685
+ if (!bindHintId) return null;
3686
+ const host = strokes.find((s) => s.id === bindHintId);
3687
+ if (!host) return null;
3688
+ const bbox = strokeBBox(host);
3689
+ if (!bbox) return null;
3690
+ const pad = 3;
3691
+ return (
3692
+ <rect
3693
+ x={bbox.x - pad}
3694
+ y={bbox.y - pad}
3695
+ width={bbox.w + pad * 2}
3696
+ height={bbox.h + pad * 2}
3697
+ fill="none"
3698
+ stroke="var(--maude-hud-accent, #d63b1f)"
3699
+ strokeWidth={2}
3700
+ strokeOpacity={0.8}
3701
+ vectorEffect="non-scaling-stroke"
3702
+ pointerEvents="none"
3703
+ rx={3}
3704
+ />
3705
+ );
3706
+ }
3707
+
3761
3708
  function SelectionHalo({
3762
3709
  stroke,
3763
3710
  anchorsById,
3764
3711
  multi,
3765
3712
  }: {
3766
3713
  stroke: Stroke;
3767
- anchorsById: Map<string, RectStroke | EllipseStroke>;
3714
+ anchorsById: Map<string, AnchorHost>;
3768
3715
  multi: boolean;
3769
3716
  }) {
3717
+ const controller = useViewportControllerContext();
3718
+ const zoom = controller?.viewport?.zoom || 1;
3770
3719
  const bbox = strokeBBox(stroke, anchorsById);
3771
3720
  if (!bbox) return null;
3772
3721
  // T17 + post-Wave-2 fix — annotation halo idioms:
@@ -3781,8 +3730,10 @@ function SelectionHalo({
3781
3730
  // bbox above carries the container affordance).
3782
3731
  // Marquee STAYS dashed (drawn elsewhere) — dashed is reserved for the
3783
3732
  // ambient group-container + active-gesture idioms per DDR-046 rev 2.
3784
- const pad = 4;
3785
- return (
3733
+ // Wave H — screen-constant breathing room (matches the resize handles,
3734
+ // which sit on the same padded frame — HALO_PAD_PX single source).
3735
+ const pad = HALO_PAD_PX / zoom;
3736
+ const halo = (
3786
3737
  <rect
3787
3738
  x={bbox.x - pad}
3788
3739
  y={bbox.y - pad}
@@ -3796,6 +3747,10 @@ function SelectionHalo({
3796
3747
  rx={2}
3797
3748
  />
3798
3749
  );
3750
+ // FigJam v3 — the halo turns with a rotated stroke.
3751
+ const rot = strokeRotation(stroke);
3752
+ if (rot === 0) return halo;
3753
+ return <g transform={`rotate(${rot} ${bbox.x + bbox.w / 2} ${bbox.y + bbox.h / 2})`}>{halo}</g>;
3799
3754
  }
3800
3755
 
3801
3756
  // T17 — group bbox dashed rect for multi-stroke annotation selection. Mirrors
@@ -3805,8 +3760,10 @@ function AnnotGroupBbox({
3805
3760
  anchorsById,
3806
3761
  }: {
3807
3762
  selectedStrokes: readonly Stroke[];
3808
- anchorsById: Map<string, RectStroke | EllipseStroke>;
3763
+ anchorsById: Map<string, AnchorHost>;
3809
3764
  }) {
3765
+ const controller = useViewportControllerContext();
3766
+ const zoom = controller?.viewport?.zoom || 1;
3810
3767
  if (selectedStrokes.length < 2) return null;
3811
3768
  let xMin = Number.POSITIVE_INFINITY;
3812
3769
  let yMin = Number.POSITIVE_INFINITY;
@@ -3823,7 +3780,8 @@ function AnnotGroupBbox({
3823
3780
  if (b.y + b.h > yMax) yMax = b.y + b.h;
3824
3781
  }
3825
3782
  if (!any) return null;
3826
- const pad = 6;
3783
+ // Wave H — screen-constant pad, one step wider than the single halo.
3784
+ const pad = (HALO_PAD_PX + 2) / zoom;
3827
3785
  const x = xMin - pad;
3828
3786
  const y = yMin - pad;
3829
3787
  const w = xMax - xMin + pad * 2;
@@ -3985,14 +3943,40 @@ function GhostPreview({ ghost }: { ghost: GhostDescriptor }) {
3985
3943
  return <polygon points={polygonPoints(ghost.shapeKind, x, y, sz, sz)} {...common} />;
3986
3944
  }
3987
3945
 
3988
- function StrokeNode({
3946
+ /**
3947
+ * FigJam v3 — rotation wrapper. The base node renders axis-aligned geometry;
3948
+ * a rotated stroke wraps it in a `rotate()` group around its bbox center
3949
+ * (anchored text inherits its HOST's rotation so labels turn with the shape).
3950
+ * Pointer events pass through the group, so hit-testing + the ctx-toolbar's
3951
+ * getBoundingClientRect positioning keep working on the rotated form.
3952
+ */
3953
+ function StrokeNode(props: {
3954
+ stroke: Stroke;
3955
+ anchorsById: Map<string, AnchorHost>;
3956
+ interactive: boolean;
3957
+ editing?: boolean;
3958
+ }) {
3959
+ const { stroke, anchorsById } = props;
3960
+ let rot = strokeRotation(stroke);
3961
+ let pivot = rot !== 0 ? strokeCenter(stroke) : null;
3962
+ if (stroke.tool === 'text' && stroke.anchorId != null && stroke.anchorId !== '') {
3963
+ const host = anchorsById.get(stroke.anchorId);
3964
+ rot = host ? strokeRotation(host) : 0;
3965
+ pivot = rot !== 0 && host ? strokeCenter(host) : null;
3966
+ }
3967
+ const node = <StrokeNodeBase {...props} />;
3968
+ if (rot === 0 || !pivot) return node;
3969
+ return <g transform={`rotate(${rot} ${pivot[0]} ${pivot[1]})`}>{node}</g>;
3970
+ }
3971
+
3972
+ function StrokeNodeBase({
3989
3973
  stroke,
3990
3974
  anchorsById,
3991
3975
  interactive,
3992
3976
  editing = false,
3993
3977
  }: {
3994
3978
  stroke: Stroke;
3995
- anchorsById: Map<string, RectStroke | EllipseStroke>;
3979
+ anchorsById: Map<string, AnchorHost>;
3996
3980
  interactive: boolean;
3997
3981
  /** Phase 21 — sticky-only: hide the read-only body while its editor is up. */
3998
3982
  editing?: boolean;
@@ -4192,6 +4176,75 @@ function StrokeNode({
4192
4176
  </g>
4193
4177
  );
4194
4178
  }
4179
+ if (stroke.tool === 'section') {
4180
+ const x = Math.min(stroke.x, stroke.x + stroke.w);
4181
+ const y = Math.min(stroke.y, stroke.y + stroke.h);
4182
+ const w = Math.abs(stroke.w);
4183
+ const h = Math.abs(stroke.h);
4184
+ const chipW = Math.max(56, stroke.label.length * SECTION_LABEL_FONT * 0.62 + 18);
4185
+ return (
4186
+ <g data-id={stroke.id} data-tool="section">
4187
+ {/* Region body — pure backdrop, CLICK-THROUGH (FigJam: content on a
4188
+ section selects normally; the section is grabbed by border/chip). */}
4189
+ <rect
4190
+ x={x}
4191
+ y={y}
4192
+ width={w}
4193
+ height={h}
4194
+ rx={SECTION_CORNER_RADIUS}
4195
+ ry={SECTION_CORNER_RADIUS}
4196
+ fill={stroke.color}
4197
+ fillOpacity={0.07}
4198
+ stroke={stroke.color}
4199
+ strokeOpacity={0.45}
4200
+ strokeWidth={1.5}
4201
+ vectorEffect="non-scaling-stroke"
4202
+ pointerEvents="none"
4203
+ />
4204
+ {/* Invisible border hit ring — the grabbable edge. */}
4205
+ {interactive ? (
4206
+ <rect
4207
+ x={x}
4208
+ y={y}
4209
+ width={w}
4210
+ height={h}
4211
+ rx={SECTION_CORNER_RADIUS}
4212
+ ry={SECTION_CORNER_RADIUS}
4213
+ fill="none"
4214
+ stroke="transparent"
4215
+ strokeWidth={12}
4216
+ vectorEffect="non-scaling-stroke"
4217
+ pointerEvents="stroke"
4218
+ />
4219
+ ) : null}
4220
+ {/* Label chip above the top-left corner — also a grab handle. */}
4221
+ <g pointerEvents={hitMode}>
4222
+ <rect
4223
+ x={x}
4224
+ y={y - SECTION_LABEL_H - 4}
4225
+ width={chipW}
4226
+ height={SECTION_LABEL_H}
4227
+ rx={5}
4228
+ ry={5}
4229
+ fill={stroke.color}
4230
+ fillOpacity={0.16}
4231
+ />
4232
+ <text
4233
+ x={x + 9}
4234
+ y={y - SECTION_LABEL_H / 2 - 4}
4235
+ dominantBaseline="middle"
4236
+ fontSize={SECTION_LABEL_FONT}
4237
+ fill={stroke.color}
4238
+ style={{
4239
+ fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
4240
+ }}
4241
+ >
4242
+ {stroke.label}
4243
+ </text>
4244
+ </g>
4245
+ </g>
4246
+ );
4247
+ }
4195
4248
  const common = {
4196
4249
  'data-id': stroke.id,
4197
4250
  'data-tool': stroke.tool,