@1agh/maude 0.42.0 → 0.43.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 (236) hide show
  1. package/apps/studio/.ai/cache/_stats.json +7 -0
  2. package/apps/studio/acp/bootstrap-brief.ts +12 -2
  3. package/apps/studio/annotations-layer.tsx +1055 -278
  4. package/apps/studio/annotations-model.ts +58 -0
  5. package/apps/studio/api.ts +173 -22
  6. package/apps/studio/bin/_ingest-footage.mjs +386 -0
  7. package/apps/studio/bin/_probe-footage-playwright.mjs +269 -0
  8. package/apps/studio/bin/_video-playwright.mjs +141 -71
  9. package/apps/studio/bin/ingest-footage.sh +27 -0
  10. package/apps/studio/bin/photo-adjust.sh +163 -0
  11. package/apps/studio/bin/photo-bg-remove.sh +160 -0
  12. package/apps/studio/bin/probe-footage.sh +28 -0
  13. package/apps/studio/bin/read-annotations.mjs +125 -2
  14. package/apps/studio/canvas-edit.ts +459 -10
  15. package/apps/studio/canvas-lib.tsx +523 -5
  16. package/apps/studio/canvas-pipeline.ts +65 -7
  17. package/apps/studio/canvas-shell.tsx +156 -16
  18. package/apps/studio/client/app.jsx +451 -34
  19. package/apps/studio/client/export-center.jsx +35 -1
  20. package/apps/studio/client/github.js +4 -0
  21. package/apps/studio/client/panels/GitPanel.jsx +8 -0
  22. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +133 -21
  23. package/apps/studio/client/panels/StickerPicker.jsx +160 -0
  24. package/apps/studio/client/panels/TimelinePanel.jsx +15 -2
  25. package/apps/studio/client/photo-knobs.jsx +437 -0
  26. package/apps/studio/client/styles/3-shell-maude.css +42 -0
  27. package/apps/studio/commands/edit-source-command.ts +31 -4
  28. package/apps/studio/context-menu.tsx +31 -6
  29. package/apps/studio/dist/client.bundle.js +5561 -19
  30. package/apps/studio/dist/comment-mount.js +2 -2
  31. package/apps/studio/dist/runtime/.min-sizes.json +1 -0
  32. package/apps/studio/dist/runtime/@imgly_background-removal.js +5543 -0
  33. package/apps/studio/dist/runtime/pixi-js.js +519 -519
  34. package/apps/studio/dist/styles.css +1 -1
  35. package/apps/studio/dom-selection.ts +25 -0
  36. package/apps/studio/exporters/jobs.ts +47 -5
  37. package/apps/studio/exporters/video.ts +24 -4
  38. package/apps/studio/footage/schema.test.ts +195 -0
  39. package/apps/studio/footage/schema.ts +484 -0
  40. package/apps/studio/footage-store.ts +235 -0
  41. package/apps/studio/fs-watch.ts +10 -0
  42. package/apps/studio/git/endpoints.ts +68 -7
  43. package/apps/studio/git/service.ts +196 -20
  44. package/apps/studio/github/service.ts +67 -0
  45. package/apps/studio/handoff.ts +3 -2
  46. package/apps/studio/http.ts +173 -14
  47. package/apps/studio/input-router.tsx +10 -0
  48. package/apps/studio/inspect.ts +53 -1
  49. package/apps/studio/paths.ts +10 -0
  50. package/apps/studio/photo/filters.ts +285 -0
  51. package/apps/studio/photo/pipeline.ts +607 -0
  52. package/apps/studio/photo/schema.ts +367 -0
  53. package/apps/studio/photo-store.ts +154 -0
  54. package/apps/studio/runtime-bundle.ts +29 -6
  55. package/apps/studio/server.ts +5 -1
  56. package/apps/studio/stickers/figjam-doodle/manifest.json +104 -0
  57. package/apps/studio/stickers/figjam-doodle/slice1.png +0 -0
  58. package/apps/studio/stickers/figjam-doodle/slice10.png +0 -0
  59. package/apps/studio/stickers/figjam-doodle/slice11.png +0 -0
  60. package/apps/studio/stickers/figjam-doodle/slice12.png +0 -0
  61. package/apps/studio/stickers/figjam-doodle/slice13.png +0 -0
  62. package/apps/studio/stickers/figjam-doodle/slice14.png +0 -0
  63. package/apps/studio/stickers/figjam-doodle/slice15.png +0 -0
  64. package/apps/studio/stickers/figjam-doodle/slice16.png +0 -0
  65. package/apps/studio/stickers/figjam-doodle/slice17.png +0 -0
  66. package/apps/studio/stickers/figjam-doodle/slice18.png +0 -0
  67. package/apps/studio/stickers/figjam-doodle/slice19.png +0 -0
  68. package/apps/studio/stickers/figjam-doodle/slice2.png +0 -0
  69. package/apps/studio/stickers/figjam-doodle/slice20.png +0 -0
  70. package/apps/studio/stickers/figjam-doodle/slice21.png +0 -0
  71. package/apps/studio/stickers/figjam-doodle/slice22.png +0 -0
  72. package/apps/studio/stickers/figjam-doodle/slice23.png +0 -0
  73. package/apps/studio/stickers/figjam-doodle/slice24.png +0 -0
  74. package/apps/studio/stickers/figjam-doodle/slice3.png +0 -0
  75. package/apps/studio/stickers/figjam-doodle/slice4.png +0 -0
  76. package/apps/studio/stickers/figjam-doodle/slice5.png +0 -0
  77. package/apps/studio/stickers/figjam-doodle/slice6.png +0 -0
  78. package/apps/studio/stickers/figjam-doodle/slice7.png +0 -0
  79. package/apps/studio/stickers/figjam-doodle/slice8.png +0 -0
  80. package/apps/studio/stickers/figjam-doodle/slice9.png +0 -0
  81. package/apps/studio/stickers/life-style/best.png +0 -0
  82. package/apps/studio/stickers/life-style/come-on.png +0 -0
  83. package/apps/studio/stickers/life-style/cut.png +0 -0
  84. package/apps/studio/stickers/life-style/detox.png +0 -0
  85. package/apps/studio/stickers/life-style/just.png +0 -0
  86. package/apps/studio/stickers/life-style/manifest.json +68 -0
  87. package/apps/studio/stickers/life-style/mirror.png +0 -0
  88. package/apps/studio/stickers/life-style/nice.png +0 -0
  89. package/apps/studio/stickers/life-style/objects.png +0 -0
  90. package/apps/studio/stickers/life-style/ok.png +0 -0
  91. package/apps/studio/stickers/life-style/queen.png +0 -0
  92. package/apps/studio/stickers/life-style/star.png +0 -0
  93. package/apps/studio/stickers/life-style/sun.png +0 -0
  94. package/apps/studio/stickers/life-style/water.png +0 -0
  95. package/apps/studio/stickers/life-style/yeah.png +0 -0
  96. package/apps/studio/stickers/life-style/you-can.png +0 -0
  97. package/apps/studio/stickers/opposing-thoughts/check-the-deets.png +0 -0
  98. package/apps/studio/stickers/opposing-thoughts/cut-it.png +0 -0
  99. package/apps/studio/stickers/opposing-thoughts/dope.png +0 -0
  100. package/apps/studio/stickers/opposing-thoughts/fresh.png +0 -0
  101. package/apps/studio/stickers/opposing-thoughts/hot.png +0 -0
  102. package/apps/studio/stickers/opposing-thoughts/idea.png +0 -0
  103. package/apps/studio/stickers/opposing-thoughts/keep-exploring.png +0 -0
  104. package/apps/studio/stickers/opposing-thoughts/killed-it.png +0 -0
  105. package/apps/studio/stickers/opposing-thoughts/love-it.png +0 -0
  106. package/apps/studio/stickers/opposing-thoughts/manifest.json +88 -0
  107. package/apps/studio/stickers/opposing-thoughts/not-sure.png +0 -0
  108. package/apps/studio/stickers/opposing-thoughts/ok.png +0 -0
  109. package/apps/studio/stickers/opposing-thoughts/pure-gold.png +0 -0
  110. package/apps/studio/stickers/opposing-thoughts/save-for-later.png +0 -0
  111. package/apps/studio/stickers/opposing-thoughts/steal-this.png +0 -0
  112. package/apps/studio/stickers/opposing-thoughts/take-a-peek.png +0 -0
  113. package/apps/studio/stickers/opposing-thoughts/this-or-that.png +0 -0
  114. package/apps/studio/stickers/opposing-thoughts/thoughts.png +0 -0
  115. package/apps/studio/stickers/opposing-thoughts/vibes.png +0 -0
  116. package/apps/studio/stickers/opposing-thoughts/winner.png +0 -0
  117. package/apps/studio/stickers/opposing-thoughts/wip.png +0 -0
  118. package/apps/studio/stickers/project-status/group-100.png +0 -0
  119. package/apps/studio/stickers/project-status/group-101.png +0 -0
  120. package/apps/studio/stickers/project-status/group-102.png +0 -0
  121. package/apps/studio/stickers/project-status/group-103.png +0 -0
  122. package/apps/studio/stickers/project-status/group-104.png +0 -0
  123. package/apps/studio/stickers/project-status/group-105.png +0 -0
  124. package/apps/studio/stickers/project-status/group-106.png +0 -0
  125. package/apps/studio/stickers/project-status/group-107.png +0 -0
  126. package/apps/studio/stickers/project-status/group-108.png +0 -0
  127. package/apps/studio/stickers/project-status/group-109.png +0 -0
  128. package/apps/studio/stickers/project-status/group-110.png +0 -0
  129. package/apps/studio/stickers/project-status/group-111.png +0 -0
  130. package/apps/studio/stickers/project-status/group-112.png +0 -0
  131. package/apps/studio/stickers/project-status/group-113.png +0 -0
  132. package/apps/studio/stickers/project-status/group-114.png +0 -0
  133. package/apps/studio/stickers/project-status/group-115.png +0 -0
  134. package/apps/studio/stickers/project-status/group-117.png +0 -0
  135. package/apps/studio/stickers/project-status/group-118.png +0 -0
  136. package/apps/studio/stickers/project-status/group-119.png +0 -0
  137. package/apps/studio/stickers/project-status/group-120.png +0 -0
  138. package/apps/studio/stickers/project-status/group-121.png +0 -0
  139. package/apps/studio/stickers/project-status/group-122.png +0 -0
  140. package/apps/studio/stickers/project-status/group-41.png +0 -0
  141. package/apps/studio/stickers/project-status/group-42.png +0 -0
  142. package/apps/studio/stickers/project-status/group-43.png +0 -0
  143. package/apps/studio/stickers/project-status/group-44.png +0 -0
  144. package/apps/studio/stickers/project-status/group-45.png +0 -0
  145. package/apps/studio/stickers/project-status/group-46.png +0 -0
  146. package/apps/studio/stickers/project-status/group-47.png +0 -0
  147. package/apps/studio/stickers/project-status/group-48.png +0 -0
  148. package/apps/studio/stickers/project-status/group-49.png +0 -0
  149. package/apps/studio/stickers/project-status/group-50.png +0 -0
  150. package/apps/studio/stickers/project-status/group-51.png +0 -0
  151. package/apps/studio/stickers/project-status/group-52.png +0 -0
  152. package/apps/studio/stickers/project-status/group-53.png +0 -0
  153. package/apps/studio/stickers/project-status/group-54.png +0 -0
  154. package/apps/studio/stickers/project-status/group-55.png +0 -0
  155. package/apps/studio/stickers/project-status/group-56.png +0 -0
  156. package/apps/studio/stickers/project-status/group-57.png +0 -0
  157. package/apps/studio/stickers/project-status/group-58.png +0 -0
  158. package/apps/studio/stickers/project-status/group-59.png +0 -0
  159. package/apps/studio/stickers/project-status/group-60.png +0 -0
  160. package/apps/studio/stickers/project-status/group-61.png +0 -0
  161. package/apps/studio/stickers/project-status/group-62.png +0 -0
  162. package/apps/studio/stickers/project-status/group-63.png +0 -0
  163. package/apps/studio/stickers/project-status/group-64.png +0 -0
  164. package/apps/studio/stickers/project-status/group-65.png +0 -0
  165. package/apps/studio/stickers/project-status/group-66.png +0 -0
  166. package/apps/studio/stickers/project-status/group-67.png +0 -0
  167. package/apps/studio/stickers/project-status/group-68.png +0 -0
  168. package/apps/studio/stickers/project-status/group-69.png +0 -0
  169. package/apps/studio/stickers/project-status/group-70.png +0 -0
  170. package/apps/studio/stickers/project-status/group-71.png +0 -0
  171. package/apps/studio/stickers/project-status/group-72.png +0 -0
  172. package/apps/studio/stickers/project-status/group-73.png +0 -0
  173. package/apps/studio/stickers/project-status/group-74.png +0 -0
  174. package/apps/studio/stickers/project-status/group-75.png +0 -0
  175. package/apps/studio/stickers/project-status/group-76.png +0 -0
  176. package/apps/studio/stickers/project-status/group-77.png +0 -0
  177. package/apps/studio/stickers/project-status/group-78.png +0 -0
  178. package/apps/studio/stickers/project-status/group-79.png +0 -0
  179. package/apps/studio/stickers/project-status/group-80.png +0 -0
  180. package/apps/studio/stickers/project-status/group-81.png +0 -0
  181. package/apps/studio/stickers/project-status/group-82.png +0 -0
  182. package/apps/studio/stickers/project-status/group-83.png +0 -0
  183. package/apps/studio/stickers/project-status/group-84.png +0 -0
  184. package/apps/studio/stickers/project-status/group-85.png +0 -0
  185. package/apps/studio/stickers/project-status/group-86.png +0 -0
  186. package/apps/studio/stickers/project-status/group-87.png +0 -0
  187. package/apps/studio/stickers/project-status/group-88.png +0 -0
  188. package/apps/studio/stickers/project-status/group-89.png +0 -0
  189. package/apps/studio/stickers/project-status/group-90.png +0 -0
  190. package/apps/studio/stickers/project-status/group-91.png +0 -0
  191. package/apps/studio/stickers/project-status/group-92.png +0 -0
  192. package/apps/studio/stickers/project-status/group-93.png +0 -0
  193. package/apps/studio/stickers/project-status/group-94.png +0 -0
  194. package/apps/studio/stickers/project-status/group-96.png +0 -0
  195. package/apps/studio/stickers/project-status/group-97.png +0 -0
  196. package/apps/studio/stickers/project-status/group-98.png +0 -0
  197. package/apps/studio/stickers/project-status/group-99.png +0 -0
  198. package/apps/studio/stickers/project-status/manifest.json +328 -0
  199. package/apps/studio/test/acp-bootstrap-brief.test.ts +11 -0
  200. package/apps/studio/test/acp-commands.test.ts +2 -1
  201. package/apps/studio/test/asset-api.test.ts +3 -3
  202. package/apps/studio/test/canvas-edit.test.ts +17 -6
  203. package/apps/studio/test/canvas-origin-gate.test.ts +13 -0
  204. package/apps/studio/test/dynamic-text-edit.test.ts +162 -0
  205. package/apps/studio/test/edit-source-command.test.ts +23 -0
  206. package/apps/studio/test/element-structural-edit.test.ts +46 -0
  207. package/apps/studio/test/footage-store.test.ts +100 -0
  208. package/apps/studio/test/git-branches.test.ts +97 -0
  209. package/apps/studio/test/github-api.test.ts +56 -1
  210. package/apps/studio/test/input-router.test.ts +33 -0
  211. package/apps/studio/test/inspect-script-syntax.test.ts +53 -0
  212. package/apps/studio/test/photo-canvas-bundle.test.ts +61 -0
  213. package/apps/studio/test/photo-edit-api.test.ts +201 -0
  214. package/apps/studio/test/photo-filters.test.ts +149 -0
  215. package/apps/studio/test/photo-pipeline.test.ts +106 -0
  216. package/apps/studio/test/photo-taxonomy.test.ts +96 -0
  217. package/apps/studio/test/read-annotations.test.ts +164 -0
  218. package/apps/studio/test/shell-importmap.test.ts +49 -0
  219. package/apps/studio/test/text-editability-stamp.test.ts +131 -0
  220. package/apps/studio/test/timeline-parse.test.ts +57 -0
  221. package/apps/studio/test/tool-palette-insert-anchor.test.ts +5 -3
  222. package/apps/studio/test/undo-stack.test.ts +33 -0
  223. package/apps/studio/test/video-asset.test.ts +5 -5
  224. package/apps/studio/test/video-render-bridge.test.ts +23 -1
  225. package/apps/studio/text-caret.ts +239 -0
  226. package/apps/studio/tool-palette.tsx +43 -20
  227. package/apps/studio/undo-stack.ts +94 -2
  228. package/apps/studio/use-annotation-resize.tsx +4 -4
  229. package/apps/studio/use-canvas-media-drop.tsx +38 -1
  230. package/apps/studio/use-selection-set.tsx +21 -0
  231. package/apps/studio/video-comp.tsx +48 -7
  232. package/apps/studio/whats-new.json +53 -0
  233. package/cli/commands/design.mjs +34 -3
  234. package/cli/lib/gitignore-block.mjs +1 -0
  235. package/package.json +10 -10
  236. package/plugins/design/templates/_shell.html +1 -0
@@ -26,6 +26,7 @@ import {
26
26
  createContext,
27
27
  type KeyboardEvent as ReactKeyboardEvent,
28
28
  type PointerEvent as ReactPointerEvent,
29
+ type RefObject,
29
30
  useCallback,
30
31
  useContext,
31
32
  useEffect,
@@ -115,6 +116,7 @@ import {
115
116
  SECTION_MIN_SIZE,
116
117
  type SectionStroke,
117
118
  SHAPE_DEFAULT_SIZE,
119
+ STICKER_DROP_SIZE,
118
120
  STICKY_CORNER_RADIUS,
119
121
  STICKY_DEFAULT_H,
120
122
  STICKY_DEFAULT_W,
@@ -156,7 +158,15 @@ import { useViewportControllerContext, useWorldRefContext } from './canvas-lib.t
156
158
  import { buildAnnotationStrokesRecord } from './commands/annotation-strokes-command.ts';
157
159
  import { ensureMenuStyles as ensureCtxMenuStyles } from './context-menu.tsx';
158
160
  import { crossedDragThreshold, type Tool } from './input-router.tsx';
159
- import { AnnotationResizeOverlay } from './use-annotation-resize.tsx';
161
+ import { mountCaret, placeCaretAt } from './text-caret.ts';
162
+ import {
163
+ AnnotationResizeOverlay,
164
+ bboxResize,
165
+ type Corner,
166
+ padDX,
167
+ padDY,
168
+ type ResizeMods,
169
+ } from './use-annotation-resize.tsx';
160
170
  import { useAnnotationSelectionOptional } from './use-annotation-selection.tsx';
161
171
  import { useAnnotationsVisibility } from './use-annotations-visibility.tsx';
162
172
  import {
@@ -168,7 +178,7 @@ import {
168
178
  useCanvasMediaDrop,
169
179
  } from './use-canvas-media-drop.tsx';
170
180
  import { useChromeVisibility } from './use-chrome-visibility.tsx';
171
- import { useCollab } from './use-collab.tsx';
181
+ import { colorForName, useCollab } from './use-collab.tsx';
172
182
  import { useSelectionSetOptional } from './use-selection-set.tsx';
173
183
  import { type ShapeKind, useToolMode } from './use-tool-mode.tsx';
174
184
  import { useUndoSinks, useUndoStackOptional } from './use-undo-stack.tsx';
@@ -399,6 +409,70 @@ function isMediaPlayerTarget(e: Event): boolean {
399
409
  return !!(t && typeof t.closest === 'function' && t.closest('[data-mediaref-player]'));
400
410
  }
401
411
 
412
+ /**
413
+ * Dogfood fix — the Text tool's click-through target for ARTBOARD text. This
414
+ * layer's own input-capture div sits above the artboard content (z-index), so
415
+ * `elementsFromPoint` (the full z-stack at that point, topmost first) is
416
+ * needed to see past it. Mirrors canvas-shell.tsx's own `isLeafText` check
417
+ * exactly (all children are text nodes) so "would this be a leaf-text edit
418
+ * target" agrees between the Text-tool click-through and the native
419
+ * double-click path it delegates to.
420
+ */
421
+ function findEditableElementAt(clientX: number, clientY: number): HTMLElement | null {
422
+ if (typeof document === 'undefined' || typeof document.elementsFromPoint !== 'function') {
423
+ return null;
424
+ }
425
+ const stack = document.elementsFromPoint(clientX, clientY);
426
+ for (const el of stack) {
427
+ const stamped = el.closest?.('[data-cd-id]') as HTMLElement | null;
428
+ if (!stamped) continue;
429
+ const kids = Array.from(stamped.childNodes);
430
+ const isLeafText = kids.length > 0 && kids.every((n) => n.nodeType === 3);
431
+ return isLeafText ? stamped : null;
432
+ }
433
+ return null;
434
+ }
435
+
436
+ /**
437
+ * Phase 4 (unified-text-editing) — the Text tool's click-through target for
438
+ * ANNOTATION strokes. Deliberately GEOMETRIC (world-coord bbox containment),
439
+ * not DOM elementsFromPoint: while a draw tool is armed, every stroke node
440
+ * renders with pointer-events:none (`hitMode`), which makes it invisible to
441
+ * DOM hit-testing — measured in the WKWebView harness. Walks strokes topmost
442
+ * (last-rendered) first. An anchored text is skipped — its HOST shape's bbox
443
+ * already covers it and resolves to the same editor. A section matches only
444
+ * on its label CHIP (geometry mirrored from SectionLabelChip) so a click
445
+ * inside the region body still drops a NEW text there.
446
+ */
447
+ const TEXT_EDITABLE_TOOLS = new Set(['text', 'sticky', 'rect', 'ellipse', 'polygon', 'section']);
448
+
449
+ function findTextStrokeAt(
450
+ wx: number,
451
+ wy: number,
452
+ strokes: readonly Stroke[],
453
+ zoom: number
454
+ ): string | null {
455
+ for (let i = strokes.length - 1; i >= 0; i--) {
456
+ const s = strokes[i];
457
+ if (!s || !TEXT_EDITABLE_TOOLS.has(s.tool)) continue;
458
+ if (s.tool === 'section') {
459
+ const x = Math.min(s.x, s.x + s.w);
460
+ const y = Math.min(s.y, s.y + s.h);
461
+ const fontSize = SECTION_LABEL_FONT / zoom;
462
+ const chipH = SECTION_LABEL_H / zoom;
463
+ const gap = 4 / zoom;
464
+ const chipW = Math.max(56 / zoom, s.label.length * fontSize * 0.62 + 18 / zoom);
465
+ if (wx >= x && wx <= x + chipW && wy >= y - chipH - gap && wy <= y - gap) return s.id;
466
+ continue;
467
+ }
468
+ if (s.tool === 'text' && s.anchorId != null && s.anchorId !== '') continue;
469
+ const bb = strokeBBox(s);
470
+ if (!bb) continue;
471
+ if (wx >= bb.x && wx <= bb.x + bb.w && wy >= bb.y && wy <= bb.y + bb.h) return s.id;
472
+ }
473
+ return null;
474
+ }
475
+
402
476
  // ─────────────────────────────────────────────────────────────────────────────
403
477
  // Styles
404
478
 
@@ -573,6 +647,12 @@ const ANNOT_CSS = `
573
647
  See DDR-067. (No backticks in this comment: the whole block is a JS template
574
648
  literal, so a backtick here would terminate the string.) */
575
649
  .dc-annot-editor, .dc-annot-editor * { cursor: text !important; }
650
+ /* Phase 7 (unified-text-editing) — hover affordance parity with artboard leaf
651
+ text: a standalone text stroke invites editing with the I-beam in Move mode
652
+ (double-click / Text-tool click enters its editor in place). Shapes and
653
+ stickies keep the selection arrow — their whole body is a move/select
654
+ target first. */
655
+ .dc-annot-svg text[data-tool="text"] { cursor: text; }
576
656
  /* FigJam v3 — connection dots on a selected bindable shape. The important flag
577
657
  beats use-tool-mode's blanket star-cursor rule (same fight as the editor +
578
658
  resize handles — DDR-067). */
@@ -645,7 +725,7 @@ export function useStrokesStore(): StrokesStoreValue | null {
645
725
  // the main tool palette, the in-canvas draw chrome, the minimap, and the
646
726
  // right-click menu. Clicks on these route to their own handlers.
647
727
  const CHROME_SELECTOR =
648
- '.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';
728
+ '.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, [data-group-resize-corner]';
649
729
 
650
730
  const HINTS_KEY = 'maude-annot-hints-v1';
651
731
 
@@ -670,6 +750,81 @@ function showOnceHint(key: string, msg: string): void {
670
750
  // can read the same state. Re-exported here for back-compat.
671
751
  export { useAnnotationsVisibility } from './use-annotations-visibility.tsx';
672
752
 
753
+ /**
754
+ * Phase 2 (whiteboard-improvements) — proportional group resize. Maps one
755
+ * stroke's geometry from its position inside the group's START bbox (`b0`)
756
+ * to the equivalent position inside the RESIZED bbox (`b1`) — the same
757
+ * affine transform applied to every selected member, so the whole selection
758
+ * scales as one rigid composition anchored at whichever corner `bboxResize`
759
+ * derived `b1` from (reused verbatim for the group's own outer bbox — see
760
+ * the pointerdown handler below). Returns null for a stroke with nothing to
761
+ * scale: anchored text inherits its host's bbox at render time already, so
762
+ * scaling it too would double-transform it.
763
+ */
764
+ function scaleStrokeInGroup(
765
+ s: Stroke,
766
+ b0: { x: number; y: number; w: number; h: number },
767
+ b1: { x: number; y: number; w: number; h: number }
768
+ ): Partial<Stroke> | null {
769
+ if (s.tool === 'text' && s.anchorId != null && s.anchorId !== '') return null;
770
+ const sx = b0.w === 0 ? 1 : b1.w / b0.w;
771
+ const sy = b0.h === 0 ? 1 : b1.h / b0.h;
772
+ // Non-uniform group stretches (no Shift held) still need ONE scalar for
773
+ // things that don't have an independent width/height, like font size.
774
+ const avgScale = (sx + sy) / 2;
775
+ const tp = (px: number, py: number): [number, number] => [
776
+ b1.x + (px - b0.x) * sx,
777
+ b1.y + (py - b0.y) * sy,
778
+ ];
779
+ if (s.tool === 'pen') {
780
+ return { points: s.points.map(([px, py]) => tp(px, py)) } as Partial<PenStroke>;
781
+ }
782
+ if (s.tool === 'arrow') {
783
+ const [x1, y1] = tp(s.x1, s.y1);
784
+ const [x2, y2] = tp(s.x2, s.y2);
785
+ return { x1, y1, x2, y2 } as Partial<ArrowStroke>;
786
+ }
787
+ if (s.tool === 'ellipse') {
788
+ const [cx, cy] = tp(s.cx, s.cy);
789
+ return {
790
+ cx,
791
+ cy,
792
+ rx: Math.max(1, s.rx * sx),
793
+ ry: Math.max(1, s.ry * sy),
794
+ } as Partial<EllipseStroke>;
795
+ }
796
+ if (s.tool === 'text') {
797
+ // Standalone (unanchored) text — its own origin scales with the group;
798
+ // font size scales by the average factor so a non-uniform stretch keeps
799
+ // the text legible instead of only stretching its box.
800
+ const [x, y] = tp(s.x ?? 0, s.y ?? 0);
801
+ return {
802
+ x,
803
+ y,
804
+ fontSize: Math.max(6, Math.round(s.fontSize * avgScale)),
805
+ } as Partial<TextStroke>;
806
+ }
807
+ if (
808
+ s.tool === 'rect' ||
809
+ s.tool === 'polygon' ||
810
+ s.tool === 'link' ||
811
+ s.tool === 'mediaref' ||
812
+ s.tool === 'section' ||
813
+ s.tool === 'image' ||
814
+ s.tool === 'sticky'
815
+ ) {
816
+ const bb = strokeBBox(s);
817
+ if (!bb) return null;
818
+ const [x, y] = tp(bb.x, bb.y);
819
+ const patch: Partial<Stroke> = { x, y, w: Math.max(1, bb.w * sx), h: Math.max(1, bb.h * sy) };
820
+ if (s.tool === 'sticky') {
821
+ (patch as Partial<StickyStroke>).fontSize = Math.max(6, Math.round(s.fontSize * avgScale));
822
+ }
823
+ return patch;
824
+ }
825
+ return null;
826
+ }
827
+
673
828
  // ─────────────────────────────────────────────────────────────────────────────
674
829
  // Component
675
830
 
@@ -757,6 +912,12 @@ export function AnnotationsLayer() {
757
912
  [visibilityCtx]
758
913
  );
759
914
  const [editingId, setEditingId] = useState<string | null>(null);
915
+ // Phase 3 (unified-text-editing) — the click that OPENED the editor, so the
916
+ // editor can place a collapsed caret at that exact character on mount
917
+ // instead of select-all. Set only by pointer entry paths (double-click /
918
+ // text-tool click-through); keyboard entries (Enter, fresh-create,
919
+ // ⌘Enter chain) leave it null → select-all, the rename convention.
920
+ const [editCaretPoint, setEditCaretPoint] = useState<{ x: number; y: number } | null>(null);
760
921
 
761
922
  const fileRef = useRef<string | undefined>(undefined);
762
923
  const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -1068,6 +1229,7 @@ export function AnnotationsLayer() {
1068
1229
  useEffect(() => {
1069
1230
  if (typeof window === 'undefined') return;
1070
1231
  const onMessage = (e: MessageEvent) => {
1232
+ if (e.source !== window.parent) return;
1071
1233
  const m = e.data as { dgn?: string; visible?: boolean } | null;
1072
1234
  if (!m || typeof m !== 'object' || !m.dgn) return;
1073
1235
  if (m.dgn === 'view-annotations') {
@@ -1101,6 +1263,39 @@ export function AnnotationsLayer() {
1101
1263
  : ({ ...s, src: path } as MediaRefStroke);
1102
1264
  });
1103
1265
  commitStrokes(before, after, 'replace media');
1266
+ return;
1267
+ }
1268
+ // Phase 4 (whiteboard-improvements) — the shell's StickerPicker (main-
1269
+ // origin) already uploaded the picked bundled sticker to a PROJECT asset
1270
+ // path via /_api/asset (canvas-origin-allowlisted — the round-trip
1271
+ // through the shell exists only because /_stickers/* itself is main-
1272
+ // origin-only, same posture as AssetPicker/replace-annotation-media
1273
+ // above). Drop it at the current viewport center — no cross-origin
1274
+ // cursor position to reuse. Inlines the screenToWorld formula (that
1275
+ // callback isn't declared until below this effect) against vpRef, which
1276
+ // is.
1277
+ if (m.dgn === 'insert-sticker' && typeof (m as { path?: unknown }).path === 'string') {
1278
+ const path = (m as { path: string }).path;
1279
+ const v = vpRef.current ?? { x: 0, y: 0, zoom: 1 };
1280
+ const z = v.zoom || 1;
1281
+ const cx = typeof window !== 'undefined' ? window.innerWidth / 2 : 0;
1282
+ const cy = typeof window !== 'undefined' ? window.innerHeight / 2 : 0;
1283
+ const wx = (cx - v.x) / z;
1284
+ const wy = (cy - v.y) / z;
1285
+ const size = STICKER_DROP_SIZE;
1286
+ const id = rid();
1287
+ const stroke: ImageStroke = {
1288
+ id,
1289
+ tool: 'image',
1290
+ x: wx - size / 2,
1291
+ y: wy - size / 2,
1292
+ w: size,
1293
+ h: size,
1294
+ href: path,
1295
+ };
1296
+ const before = strokesRef.current;
1297
+ commitStrokes(before, [...before, stroke], 'add sticker');
1298
+ annotSel?.replace([id]);
1104
1299
  }
1105
1300
  };
1106
1301
  window.addEventListener('message', onMessage);
@@ -1387,6 +1582,9 @@ export function AnnotationsLayer() {
1387
1582
  } else if (tool === 'sticky') {
1388
1583
  // Phase 21 — drag-create a paper card. Default size if the user just
1389
1584
  // taps (no drag) is applied in endStroke.
1585
+ // Phase 3 (whiteboard-improvements) — stamp who drew it from presence
1586
+ // identity (git user.name via useCollab, else its anonymous-* / no-op
1587
+ // fallback); collab is optional (test harnesses without a provider).
1390
1588
  setDrawing({
1391
1589
  id,
1392
1590
  tool: 'sticky',
@@ -1398,6 +1596,7 @@ export function AnnotationsLayer() {
1398
1596
  text: '',
1399
1597
  fontSize: DEFAULT_FONT_SIZE,
1400
1598
  cornerRadius: STICKY_CORNER_RADIUS,
1599
+ ...(collab?.myName ? { authorName: collab.myName, authorId: collab.myConnId } : null),
1401
1600
  });
1402
1601
  } else if (tool === 'section') {
1403
1602
  // FigJam v3 — drag-create a labelled container; a bare tap drops the
@@ -1413,6 +1612,42 @@ export function AnnotationsLayer() {
1413
1612
  color: DEFAULT_SECTION_COLOR,
1414
1613
  });
1415
1614
  } else if (tool === 'text') {
1615
+ // Dogfood fix + Phase 4 (unified-text-editing) — the Text tool
1616
+ // clicking EXISTING editable text edits it in place rather than
1617
+ // dropping a new standalone annotation on top of it. Precedence is
1618
+ // z-order: annotation strokes paint above artboard content, so the
1619
+ // geometric stroke hit-test (findTextStrokeAt — DOM hit-testing can't
1620
+ // see pointer-events:none stroke nodes while a tool is armed) runs
1621
+ // first; then artboard leaf-text (delegated to canvas-shell.tsx,
1622
+ // which owns the DOM/contentEditable side, via maude:enter-text-edit).
1623
+ const strokeId = findTextStrokeAt(wx, wy, strokesRef.current, vpRef.current?.zoom || 1);
1624
+ if (strokeId) {
1625
+ setEditCaretPoint({ x: e.clientX, y: e.clientY });
1626
+ setEditingId(strokeId);
1627
+ if (annotSel) annotSel.replace(strokeId);
1628
+ setTool('move');
1629
+ return true;
1630
+ }
1631
+ const editableTarget = findEditableElementAt(e.clientX, e.clientY);
1632
+ if (editableTarget) {
1633
+ // Phase 6 — leaf-looking text the engine would refuse (mixed /
1634
+ // expression source; no build-time data-cd-editable marker) gets an
1635
+ // honest hint, not a new annotation dropped on top of it.
1636
+ if (!editableTarget.hasAttribute('data-cd-editable')) {
1637
+ showCanvasToast(
1638
+ 'This text is filled in from code (a variable) — edit it via chat or /design:edit.'
1639
+ );
1640
+ setTool('move');
1641
+ return true;
1642
+ }
1643
+ document.dispatchEvent(
1644
+ new CustomEvent('maude:enter-text-edit', {
1645
+ detail: { el: editableTarget, clientX: e.clientX, clientY: e.clientY },
1646
+ })
1647
+ );
1648
+ setTool('move');
1649
+ return true;
1650
+ }
1416
1651
  // Phase 21 — single click drops an editable caret at the click point.
1417
1652
  // No stroke is created until the user commits real text (mirrors the
1418
1653
  // anchored double-click flow), so an empty caret leaves nothing behind.
@@ -1577,10 +1812,18 @@ export function AnnotationsLayer() {
1577
1812
  // AnnotationsLayer without the provider), so guard the call.
1578
1813
  if (annotSel) annotSel.replace(committed.id);
1579
1814
  // Phase 21 — a fresh sticky opens in edit mode (FigJam parity: drop a
1580
- // note, type immediately). Only meaningful deviation from rect/ellipse.
1581
- if (committed.tool === 'sticky') {
1815
+ // note, type immediately). Phase 1 whiteboard-improvements shapes get
1816
+ // the same treatment (rect/ellipse/polygon are all AnchorHost, so
1817
+ // setEditingId resolves them via editingTarget's 'anchored' branch same
1818
+ // as a double-click would).
1819
+ if (
1820
+ committed.tool === 'sticky' ||
1821
+ committed.tool === 'rect' ||
1822
+ committed.tool === 'ellipse' ||
1823
+ committed.tool === 'polygon'
1824
+ ) {
1582
1825
  setEditingId(committed.id);
1583
- showOnceHint('chain', '⌘Enter commits and creates the next sticky beside it.');
1826
+ showOnceHint('chain', '⌘Enter commits and creates the next one beside it.');
1584
1827
  }
1585
1828
  }
1586
1829
  // T18 / T19 — flip the tool back to Move after every commit UNLESS sticky
@@ -1697,6 +1940,20 @@ export function AnnotationsLayer() {
1697
1940
  altDup: boolean;
1698
1941
  } | null>(null);
1699
1942
 
1943
+ // Phase 2 (whiteboard-improvements) — proportional group resize drag state.
1944
+ // See the gesture effect below (registered after strokesStoreRef).
1945
+ const groupResizeRef = useRef<{
1946
+ pointerId: number;
1947
+ corner: Corner;
1948
+ /** The selection's own union bbox at drag start — unpadded (AnnotGroupBbox
1949
+ * renders it with a screen-constant pad; the resize math works in the raw
1950
+ * content bbox, same convention single-resize's padDX/padDY correct for). */
1951
+ groupB0: { x: number; y: number; w: number; h: number };
1952
+ ids: string[];
1953
+ /** Full-strokes undo baseline (the drag-start snapshot). */
1954
+ undoBase: Stroke[];
1955
+ } | null>(null);
1956
+
1700
1957
  // Drag-select marquee state. World-coord rectangle (anchor + cursor); the
1701
1958
  // cursor end animates with pointermove. `null` = no marquee active.
1702
1959
  const [marquee, setMarquee] = useState<{
@@ -1754,7 +2011,18 @@ export function AnnotationsLayer() {
1754
2011
  // branch below and DESELECTED them.
1755
2012
  let ids: string[] | null = null;
1756
2013
  if (strokeId) {
1757
- elementSel?.clear();
2014
+ // feature-photo-editor — an image-stroke hit routes to the Photo-only
2015
+ // Inspector tab (`edit-annotation-photo-request`, the singleSelectedImageId
2016
+ // effect below), which the shell shows via `photoSel` taking priority over
2017
+ // the DOM `selected` channel. `elementSel.clear()` posts a DEBOUNCED
2018
+ // (50ms, use-selection-set.tsx POST_DEBOUNCE_MS) `select-set: null` — that
2019
+ // lands AFTER the (undebounced) photo-request and the shell's `select-set`
2020
+ // handler unconditionally nulls `photoSel` back out, so the Photo tab
2021
+ // flickers open then goes blank. Skip the clear for an image stroke: it
2022
+ // isn't needed (photoSel already takes precedence in tab resolution) and
2023
+ // is the only case that races with a still-in-flight photo-tab request.
2024
+ const strokeTool = target?.closest?.('[data-id][data-tool]')?.getAttribute('data-tool');
2025
+ if (strokeTool !== 'image') elementSel?.clear();
1758
2026
  // FigJam v3 — clicking a group member acts on the WHOLE outermost
1759
2027
  // group. Double-click deep-selects the member (see the dblclick
1760
2028
  // handler); an already-selected stroke keeps the current selection, so
@@ -2024,6 +2292,114 @@ export function AnnotationsLayer() {
2024
2292
  const strokesStoreRef = useRef(strokesStore);
2025
2293
  strokesStoreRef.current = strokesStore;
2026
2294
 
2295
+ // Phase 2 (whiteboard-improvements) — proportional group resize. A grabbed
2296
+ // corner (AnnotGroupBbox's `[data-group-resize-corner]` handles) scales the
2297
+ // SELECTION's own outer bbox via bboxResize — the exact single-shape corner-
2298
+ // anchor / Shift-aspect-lock / Alt-center-scale math, applied here to the
2299
+ // group's union bbox instead of one stroke's — then propagates that affine
2300
+ // transform to every selected stroke via scaleStrokeInGroup. One local
2301
+ // preview per pointermove, one commitStrokes on pointerup: mirrors the
2302
+ // hull-drag gesture above. Registering pointermove/up unconditionally
2303
+ // (no-op via the groupResizeRef null-check) rather than adding them inside
2304
+ // onDown matches AnnotationResizeOverlay's own pattern in
2305
+ // use-annotation-resize.tsx.
2306
+ useEffect(() => {
2307
+ if (typeof document === 'undefined') return;
2308
+ if (tool !== 'move') return;
2309
+ if (!annotSel) return;
2310
+
2311
+ const onDown = (e: PointerEvent) => {
2312
+ const target = e.target as Element | null;
2313
+ const cornerEl = target?.closest?.('[data-group-resize-corner]') ?? null;
2314
+ if (!cornerEl) return;
2315
+ const corner = cornerEl.getAttribute('data-group-resize-corner') as Corner | null;
2316
+ if (!corner) return;
2317
+ const ids = annotSel.selectedIds;
2318
+ if (ids.length < 2) return;
2319
+ const idSet = new Set(ids);
2320
+ const strokesNow = strokesStoreRef.current.strokes;
2321
+ let xMin = Number.POSITIVE_INFINITY;
2322
+ let yMin = Number.POSITIVE_INFINITY;
2323
+ let xMax = Number.NEGATIVE_INFINITY;
2324
+ let yMax = Number.NEGATIVE_INFINITY;
2325
+ let any = false;
2326
+ for (const s of strokesNow) {
2327
+ if (!idSet.has(s.id)) continue;
2328
+ const b = strokeBBox(s, anchorsById);
2329
+ if (!b) continue;
2330
+ any = true;
2331
+ if (b.x < xMin) xMin = b.x;
2332
+ if (b.y < yMin) yMin = b.y;
2333
+ if (b.x + b.w > xMax) xMax = b.x + b.w;
2334
+ if (b.y + b.h > yMax) yMax = b.y + b.h;
2335
+ }
2336
+ if (!any) return;
2337
+ e.preventDefault();
2338
+ e.stopPropagation();
2339
+ groupResizeRef.current = {
2340
+ pointerId: e.pointerId,
2341
+ corner,
2342
+ groupB0: { x: xMin, y: yMin, w: xMax - xMin, h: yMax - yMin },
2343
+ ids,
2344
+ undoBase: strokesNow,
2345
+ };
2346
+ try {
2347
+ (cornerEl as Element & { setPointerCapture?: (id: number) => void }).setPointerCapture?.(
2348
+ e.pointerId
2349
+ );
2350
+ } catch {
2351
+ /* some browsers reject capture on synthetic events */
2352
+ }
2353
+ };
2354
+ const onMove = (e: PointerEvent) => {
2355
+ const d = groupResizeRef.current;
2356
+ if (!d || e.pointerId !== d.pointerId) return;
2357
+ const [wxRaw, wyRaw] = screenToWorld(e.clientX, e.clientY);
2358
+ const zoom = vpRef.current?.zoom || 1;
2359
+ // Handles render padded OFF the group bbox (screen-constant, same as
2360
+ // AnnotGroupBbox's `pad`) — shift the cursor back onto the true corner
2361
+ // so the first move doesn't jump-grow by the pad amount (mirrors
2362
+ // single-resize applyResize's padDX/padDY correction).
2363
+ const padWorld = HALO_PAD_PX / zoom;
2364
+ const wx = wxRaw + padDX(d.corner) * padWorld;
2365
+ const wy = wyRaw + padDY(d.corner) * padWorld;
2366
+ const mods: ResizeMods = { shift: e.shiftKey, alt: e.altKey };
2367
+ const groupB1 = bboxResize(d.groupB0, d.corner, wx, wy, mods, false);
2368
+ const idSet = new Set(d.ids);
2369
+ // Local React state only — no commitStrokes, no PUT, matching the
2370
+ // hull-drag / single-resize preview convention (one undo record lands
2371
+ // on pointerup, not one per pixel moved).
2372
+ const next = recomputeBoundArrows(
2373
+ d.undoBase.map((s) => {
2374
+ if (!idSet.has(s.id)) return s;
2375
+ const patch = scaleStrokeInGroup(s, d.groupB0, groupB1);
2376
+ return patch ? ({ ...s, ...patch } as Stroke) : s;
2377
+ })
2378
+ );
2379
+ setStrokesState(next);
2380
+ };
2381
+ const onUp = (e: PointerEvent) => {
2382
+ const d = groupResizeRef.current;
2383
+ if (!d || e.pointerId !== d.pointerId) return;
2384
+ groupResizeRef.current = null;
2385
+ const final = strokesRef.current;
2386
+ // No-op drag (grabbed a handle, released without moving past the
2387
+ // resize's own resolution) skips the undo record.
2388
+ if (strokesShallowEqual(d.undoBase, final)) return;
2389
+ commitStrokes(d.undoBase, final, `resize ${d.ids.length} strokes`);
2390
+ };
2391
+ document.addEventListener('pointerdown', onDown, true);
2392
+ document.addEventListener('pointermove', onMove, true);
2393
+ document.addEventListener('pointerup', onUp, true);
2394
+ document.addEventListener('pointercancel', onUp, true);
2395
+ return () => {
2396
+ document.removeEventListener('pointerdown', onDown, true);
2397
+ document.removeEventListener('pointermove', onMove, true);
2398
+ document.removeEventListener('pointerup', onUp, true);
2399
+ document.removeEventListener('pointercancel', onUp, true);
2400
+ };
2401
+ }, [tool, annotSel, screenToWorld, anchorsById, commitStrokes]);
2402
+
2027
2403
  // Double-click enters text-edit mode: rect/ellipse (anchored text), sticky
2028
2404
  // (its own body), or a standalone text node (re-edit in place). Anchored text
2029
2405
  // nodes are edited via their host, so a data-anchor-id text node is skipped.
@@ -2045,15 +2421,24 @@ export function AnnotationsLayer() {
2045
2421
  const stroke = strokesRef.current.find((s) => s.id === id);
2046
2422
  if (stroke && outermostGroupOf(stroke) && annotSel) {
2047
2423
  e.preventDefault();
2424
+ e.stopPropagation();
2048
2425
  annotSel.replace(id);
2049
2426
  }
2050
2427
  if (t === 'rect' || t === 'ellipse' || t === 'polygon' || t === 'sticky' || t === 'section') {
2428
+ // A double-click we've claimed for editing must not also reach the
2429
+ // canvas-shell dblclick-empty→fit() handler (bubble phase, below us) —
2430
+ // without this it fired right after, so renaming a section or editing
2431
+ // a shape's text jumped the viewport to fit().
2051
2432
  e.preventDefault();
2433
+ e.stopPropagation();
2434
+ setEditCaretPoint({ x: e.clientX, y: e.clientY });
2052
2435
  setEditingId(id);
2053
2436
  return;
2054
2437
  }
2055
2438
  if (t === 'text' && !node.getAttribute('data-anchor-id')) {
2056
2439
  e.preventDefault();
2440
+ e.stopPropagation();
2441
+ setEditCaretPoint({ x: e.clientX, y: e.clientY });
2057
2442
  setEditingId(id);
2058
2443
  }
2059
2444
  };
@@ -2192,6 +2577,7 @@ export function AnnotationsLayer() {
2192
2577
  const target = editingTargetRef.current;
2193
2578
  setEditingId(null);
2194
2579
  setPendingText(null);
2580
+ setEditCaretPoint(null);
2195
2581
  if (!target) return;
2196
2582
  if (target.kind === 'anchored') commitText(target.anchorId, text, fmt);
2197
2583
  else if (target.kind === 'sticky') commitStickyText(target.sticky.id, text, fmt);
@@ -2211,6 +2597,7 @@ export function AnnotationsLayer() {
2211
2597
  const cancelEditing = useCallback(() => {
2212
2598
  setEditingId(null);
2213
2599
  setPendingText(null);
2600
+ setEditCaretPoint(null);
2214
2601
  }, []);
2215
2602
 
2216
2603
  /**
@@ -2780,6 +3167,21 @@ export function AnnotationsLayer() {
2780
3167
  } catch {
2781
3168
  /* detached / cross-origin teardown */
2782
3169
  }
3170
+ } else if (action === 'edit-photo') {
3171
+ // feature-photo-editor (Task 17) — "Edit Photo…" on an ImageStroke. Same
3172
+ // untrusted-origin REQUEST shape as Replace (DDR-054); the shell opens the
3173
+ // Photo-only Inspector tab on this stroke's content-addressed asset.
3174
+ if (sel.length !== 1) return;
3175
+ const target = strokesById.get(sel[0] as string);
3176
+ if (target?.tool !== 'image') return;
3177
+ try {
3178
+ window.parent.postMessage(
3179
+ { dgn: 'edit-annotation-photo-request', id: target.id, asset: target.href },
3180
+ '*'
3181
+ );
3182
+ } catch {
3183
+ /* detached / cross-origin teardown */
3184
+ }
2783
3185
  }
2784
3186
  },
2785
3187
  [annotSel, copySelection, pasteStrokesText, strokesById]
@@ -2797,6 +3199,34 @@ export function AnnotationsLayer() {
2797
3199
  }
2798
3200
  }, [selCount]);
2799
3201
 
3202
+ // feature-photo-editor — auto-open the Inspector's Photo tab whenever the
3203
+ // annotation selection becomes exactly one ImageStroke. Previously this
3204
+ // only fired from the "Edit Photo…" context-menu action; a plain click/
3205
+ // select left the Inspector showing its no-selection placeholder, since an
3206
+ // annotation stroke has no data-cd-id / DOM selection to ride the normal
3207
+ // `select`/`select-set` path. An ImageStroke has no OTHER inspectable
3208
+ // surface (Task 13: Photo is the only tab for this content type), so
3209
+ // requiring a right-click detour to reach it was pure friction. Same
3210
+ // `edit-annotation-photo-request` message + confused-deputy gate (DDR-054)
3211
+ // the context-menu action already used.
3212
+ const singleSelectedImageId =
3213
+ selCount === 1 && strokesById.get(annotSel?.selectedIds[0] as string)?.tool === 'image'
3214
+ ? (annotSel?.selectedIds[0] as string)
3215
+ : null;
3216
+ useEffect(() => {
3217
+ if (!singleSelectedImageId) return;
3218
+ const s = strokesById.get(singleSelectedImageId);
3219
+ if (s?.tool !== 'image') return;
3220
+ try {
3221
+ window.parent.postMessage(
3222
+ { dgn: 'edit-annotation-photo-request', id: s.id, asset: s.href },
3223
+ '*'
3224
+ );
3225
+ } catch {
3226
+ /* detached / cross-origin teardown */
3227
+ }
3228
+ }, [singleSelectedImageId, strokesById]);
3229
+
2800
3230
  // Selected stroke halos — bboxes in world coords, vector-effect non-scaling-stroke.
2801
3231
  const selectedStrokes = useMemo(() => {
2802
3232
  if (!annotSel || annotSel.selectedIds.length === 0) return [] as Stroke[];
@@ -2834,6 +3264,7 @@ export function AnnotationsLayer() {
2834
3264
  addTextHintId={editingTarget ? null : addTextHintId}
2835
3265
  ghost={ghostPreview}
2836
3266
  editingTarget={editingTarget}
3267
+ editCaretPoint={editCaretPoint}
2837
3268
  inkColor={color}
2838
3269
  onCommitEdit={commitEditing}
2839
3270
  onCancelEdit={cancelEditing}
@@ -2863,6 +3294,11 @@ export function AnnotationsLayer() {
2863
3294
  selectedStrokes.length === 1 &&
2864
3295
  (selectedStrokes[0]?.tool === 'image' || selectedStrokes[0]?.tool === 'mediaref')
2865
3296
  }
3297
+ canEditPhoto={
3298
+ selectedStrokes.length === 1 &&
3299
+ selectedStrokes[0]?.tool === 'image' &&
3300
+ /assets\/[0-9a-f]{8}\.[a-z0-9]+/i.test((selectedStrokes[0] as ImageStroke).href || '')
3301
+ }
2866
3302
  onAction={onMenuAction}
2867
3303
  onClose={() => setCtxMenu(null)}
2868
3304
  />
@@ -2988,6 +3424,7 @@ function AnnotationsSvg({
2988
3424
  addTextHintId,
2989
3425
  ghost,
2990
3426
  editingTarget,
3427
+ editCaretPoint,
2991
3428
  inkColor,
2992
3429
  onCommitEdit,
2993
3430
  onCancelEdit,
@@ -3020,6 +3457,8 @@ function AnnotationsSvg({
3020
3457
  addTextHintId: string | null;
3021
3458
  ghost: GhostDescriptor | null;
3022
3459
  editingTarget: EditingTarget;
3460
+ /** The click that opened the editor — caret-at-click on mount (Phase 3). */
3461
+ editCaretPoint: { x: number; y: number } | null;
3023
3462
  /** Live default ink (theme-aware) for a not-yet-born pending text caret. */
3024
3463
  inkColor: string;
3025
3464
  onCommitEdit: (text: string, fmt?: EditorFmt) => void;
@@ -3036,154 +3475,234 @@ function AnnotationsSvg({
3036
3475
  // A sticky whose body is being edited hides its read-only text so the
3037
3476
  // editor textarea (rendered below at the same bbox) isn't double-painted.
3038
3477
  const editingStickyId = editingTarget?.kind === 'sticky' ? editingTarget.sticky.id : null;
3478
+ // Same double-paint issue for a section being renamed — its label chip must
3479
+ // hide while the StandaloneTextEditor sits at the same spot, else the old
3480
+ // label reads through behind the new text.
3481
+ const editingSectionId = editingTarget?.kind === 'section' ? editingTarget.section.id : null;
3039
3482
  const anchoredExisting =
3040
3483
  editingTarget?.kind === 'anchored'
3041
3484
  ? (strokes.find((s) => s.tool === 'text' && s.anchorId === editingTarget.anchorId) as
3042
3485
  | TextStroke
3043
3486
  | undefined)
3044
3487
  : undefined;
3045
- return createPortal(
3046
- <svg className="dc-annot-svg" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
3047
- <defs>
3048
- {/* Phase 21 soft "lifted paper" drop shadow for sticky notes. */}
3049
- <filter id="dc-sticky-shadow" x="-25%" y="-25%" width="150%" height="170%">
3050
- <feDropShadow dx="0" dy="4" stdDeviation="8" floodColor="#000000" floodOpacity="0.28" />
3051
- </filter>
3052
- </defs>
3053
- {strokes.map((s) => (
3054
- <StrokeNode
3055
- key={s.id}
3056
- stroke={s}
3057
- anchorsById={anchorsById}
3058
- interactive={selectMode}
3059
- editing={s.id === editingStickyId}
3060
- />
3061
- ))}
3062
- {selectedStrokes.map((s) => (
3063
- <SelectionHalo
3064
- key={`halo-${s.id}`}
3065
- stroke={s}
3066
- anchorsById={anchorsById}
3067
- multi={selectedStrokes.length > 1}
3068
- />
3069
- ))}
3070
- <AnnotGroupBbox selectedStrokes={selectedStrokes} anchorsById={anchorsById} />
3071
- {marquee ? (
3072
- <rect
3073
- className="dc-annot-marquee"
3074
- x={Math.min(marquee.ax, marquee.bx)}
3075
- y={Math.min(marquee.ay, marquee.by)}
3076
- width={Math.abs(marquee.bx - marquee.ax)}
3077
- height={Math.abs(marquee.by - marquee.ay)}
3078
- vectorEffect="non-scaling-stroke"
3079
- />
3080
- ) : null}
3081
- {/* FigJam v3 — smart guides: solid 1px accent lines at the snapped edge/
3488
+ // Same double-paint issue for a shape's anchored text — hide the read-only
3489
+ // <text> stroke while TextEditor sits at the same bbox (Phase 1 jump-fix
3490
+ // companion). Only applies once the TextStroke exists; a not-yet-created
3491
+ // one has nothing to hide.
3492
+ const editingAnchoredTextId = anchoredExisting?.id ?? null;
3493
+ // A standalone text being re-edited hides its read-only <text> too — the
3494
+ // editor paints at the same x/y, so leaving it visible double-paints (the
3495
+ // pre-Phase-2 "ghost" under the editor).
3496
+ const editingStandaloneTextId =
3497
+ editingTarget?.kind === 'standalone' ? editingTarget.text.id : null;
3498
+ return (
3499
+ <>
3500
+ {createPortal(
3501
+ <svg className="dc-annot-svg" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
3502
+ <defs>
3503
+ {/* Phase 21 — soft "lifted paper" drop shadow for sticky notes. */}
3504
+ <filter id="dc-sticky-shadow" x="-25%" y="-25%" width="150%" height="170%">
3505
+ <feDropShadow
3506
+ dx="0"
3507
+ dy="4"
3508
+ stdDeviation="8"
3509
+ floodColor="#000000"
3510
+ floodOpacity="0.28"
3511
+ />
3512
+ </filter>
3513
+ </defs>
3514
+ {strokes.map((s) => (
3515
+ <StrokeNode
3516
+ key={s.id}
3517
+ stroke={s}
3518
+ anchorsById={anchorsById}
3519
+ interactive={selectMode}
3520
+ editing={
3521
+ s.id === editingStickyId ||
3522
+ s.id === editingSectionId ||
3523
+ s.id === editingAnchoredTextId ||
3524
+ s.id === editingStandaloneTextId
3525
+ }
3526
+ />
3527
+ ))}
3528
+ {selectedStrokes.map((s) => (
3529
+ <SelectionHalo
3530
+ key={`halo-${s.id}`}
3531
+ stroke={s}
3532
+ anchorsById={anchorsById}
3533
+ multi={selectedStrokes.length > 1}
3534
+ />
3535
+ ))}
3536
+ <AnnotGroupBbox selectedStrokes={selectedStrokes} anchorsById={anchorsById} />
3537
+ {marquee ? (
3538
+ <rect
3539
+ className="dc-annot-marquee"
3540
+ x={Math.min(marquee.ax, marquee.bx)}
3541
+ y={Math.min(marquee.ay, marquee.by)}
3542
+ width={Math.abs(marquee.bx - marquee.ax)}
3543
+ height={Math.abs(marquee.by - marquee.ay)}
3544
+ vectorEffect="non-scaling-stroke"
3545
+ />
3546
+ ) : null}
3547
+ {/* FigJam v3 — smart guides: solid 1px accent lines at the snapped edge/
3082
3548
  center, painted only while a drag is actively snapping. */}
3083
- {snapGuides?.map((g, i) => (
3084
- <line
3085
- // biome-ignore lint/suspicious/noArrayIndexKey: guides are positional + rebuilt per move tick
3086
- key={`guide-${i}`}
3087
- x1={g.axis === 'x' ? g.at : g.from}
3088
- y1={g.axis === 'x' ? g.from : g.at}
3089
- x2={g.axis === 'x' ? g.at : g.to}
3090
- y2={g.axis === 'x' ? g.to : g.at}
3091
- stroke="var(--maude-hud-accent, #d63b1f)"
3092
- strokeWidth={1}
3093
- vectorEffect="non-scaling-stroke"
3094
- pointerEvents="none"
3095
- />
3096
- ))}
3097
- {/* FigJam v3 — bind hint: halo the host a dragged arrow endpoint would
3549
+ {snapGuides?.map((g, i) => (
3550
+ <line
3551
+ // biome-ignore lint/suspicious/noArrayIndexKey: guides are positional + rebuilt per move tick
3552
+ key={`guide-${i}`}
3553
+ x1={g.axis === 'x' ? g.at : g.from}
3554
+ y1={g.axis === 'x' ? g.from : g.at}
3555
+ x2={g.axis === 'x' ? g.at : g.to}
3556
+ y2={g.axis === 'x' ? g.to : g.at}
3557
+ stroke="var(--maude-hud-accent, #d63b1f)"
3558
+ strokeWidth={1}
3559
+ vectorEffect="non-scaling-stroke"
3560
+ pointerEvents="none"
3561
+ />
3562
+ ))}
3563
+ {/* FigJam v3 — bind hint: halo the host a dragged arrow endpoint would
3098
3564
  magnetically attach to. */}
3099
- <BindHintHalo strokes={strokes} bindHintId={bindHintId} />
3100
- {/* FigJam v3 — connection dots on a single selected bindable shape;
3565
+ <BindHintHalo strokes={strokes} bindHintId={bindHintId} />
3566
+ {/* FigJam v3 — connection dots on a single selected bindable shape;
3101
3567
  dragging one draws a bound connector (rendered below as a draft). */}
3102
- {selectMode && !connDraft && selectedStrokes.length === 1 && selectedStrokes[0] ? (
3103
- <ConnectorDots stroke={selectedStrokes[0]} />
3104
- ) : null}
3105
- {connDraft ? (
3106
- <g
3107
- stroke={inkColor}
3108
- strokeWidth={2.5}
3109
- strokeLinecap="round"
3110
- strokeLinejoin="round"
3111
- vectorEffect="non-scaling-stroke"
3112
- fill="none"
3113
- pointerEvents="none"
3114
- >
3115
- {arrowPrimitives({
3116
- x1: connDraft.x1,
3117
- y1: connDraft.y1,
3118
- x2: connDraft.x2,
3119
- y2: connDraft.y2,
3120
- width: 2.5,
3121
- color: inkColor,
3122
- lineType: 'curved',
3123
- startBind: connDraft.startBind,
3124
- ...(connDraft.endBind ? { endBind: connDraft.endBind } : {}),
3125
- }).map((prim, i) => renderArrowPrimitive(prim, i))}
3126
- </g>
3127
- ) : null}
3128
- {/* FigJam v3 — hover affordance: an empty shape invites text. */}
3129
- <AddTextHint strokes={strokes} hintId={addTextHintId} />
3130
- {/* FigJam v3 — resize chrome: live W × H label at the box corner plus a
3568
+ {selectMode && !connDraft && selectedStrokes.length === 1 && selectedStrokes[0] ? (
3569
+ <ConnectorDots stroke={selectedStrokes[0]} />
3570
+ ) : null}
3571
+ {connDraft ? (
3572
+ <g
3573
+ stroke={inkColor}
3574
+ strokeWidth={2.5}
3575
+ strokeLinecap="round"
3576
+ strokeLinejoin="round"
3577
+ vectorEffect="non-scaling-stroke"
3578
+ fill="none"
3579
+ pointerEvents="none"
3580
+ >
3581
+ {arrowPrimitives({
3582
+ x1: connDraft.x1,
3583
+ y1: connDraft.y1,
3584
+ x2: connDraft.x2,
3585
+ y2: connDraft.y2,
3586
+ width: 2.5,
3587
+ color: inkColor,
3588
+ lineType: 'curved',
3589
+ startBind: connDraft.startBind,
3590
+ ...(connDraft.endBind ? { endBind: connDraft.endBind } : {}),
3591
+ }).map((prim, i) => renderArrowPrimitive(prim, i))}
3592
+ </g>
3593
+ ) : null}
3594
+ {/* FigJam v3 — hover affordance: an empty shape invites text. */}
3595
+ <AddTextHint strokes={strokes} hintId={addTextHintId} />
3596
+ {/* FigJam v3 — resize chrome: live W × H label at the box corner plus a
3131
3597
  dashed halo on any neighbour whose dimension the resize just matched
3132
3598
  (the "same size as that one" quota). */}
3133
- {resizeInfo?.box ? (
3134
- <g pointerEvents="none">
3135
- {resizeInfo.matchIds.map((id) => {
3136
- const m = strokes.find((s) => s.id === id);
3137
- const bb = m ? strokeBBox(m) : null;
3138
- if (!bb) return null;
3139
- return (
3140
- <rect
3141
- key={`dim-${id}`}
3142
- x={bb.x - 2}
3143
- y={bb.y - 2}
3144
- width={bb.w + 4}
3145
- height={bb.h + 4}
3146
- fill="none"
3147
- stroke="var(--maude-hud-accent, #d63b1f)"
3148
- strokeWidth={1.5}
3149
- strokeDasharray="5 3"
3150
- vectorEffect="non-scaling-stroke"
3151
- rx={2}
3152
- />
3153
- );
3154
- })}
3155
- <text
3156
- x={resizeInfo.box.x + resizeInfo.box.w / 2}
3157
- y={resizeInfo.box.y + resizeInfo.box.h + 18}
3158
- textAnchor="middle"
3159
- fontSize={11}
3160
- fill="var(--maude-hud-accent, #d63b1f)"
3161
- style={{
3162
- fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
3163
- }}
3164
- >
3165
- {`${Math.round(resizeInfo.box.w)} × ${Math.round(resizeInfo.box.h)}`}
3166
- </text>
3167
- </g>
3168
- ) : null}
3169
- {ghost ? <GhostPreview ghost={ghost} /> : null}
3170
- {editingTarget?.kind === 'anchored' ? (
3599
+ {resizeInfo?.box ? (
3600
+ <g pointerEvents="none">
3601
+ {resizeInfo.matchIds.map((id) => {
3602
+ const m = strokes.find((s) => s.id === id);
3603
+ const bb = m ? strokeBBox(m) : null;
3604
+ if (!bb) return null;
3605
+ return (
3606
+ <rect
3607
+ key={`dim-${id}`}
3608
+ x={bb.x - 2}
3609
+ y={bb.y - 2}
3610
+ width={bb.w + 4}
3611
+ height={bb.h + 4}
3612
+ fill="none"
3613
+ stroke="var(--maude-hud-accent, #d63b1f)"
3614
+ strokeWidth={1.5}
3615
+ strokeDasharray="5 3"
3616
+ vectorEffect="non-scaling-stroke"
3617
+ rx={2}
3618
+ />
3619
+ );
3620
+ })}
3621
+ <text
3622
+ x={resizeInfo.box.x + resizeInfo.box.w / 2}
3623
+ y={resizeInfo.box.y + resizeInfo.box.h + 18}
3624
+ textAnchor="middle"
3625
+ fontSize={11}
3626
+ fill="var(--maude-hud-accent, #d63b1f)"
3627
+ style={{
3628
+ fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
3629
+ }}
3630
+ >
3631
+ {`${Math.round(resizeInfo.box.w)} × ${Math.round(resizeInfo.box.h)}`}
3632
+ </text>
3633
+ </g>
3634
+ ) : null}
3635
+ {ghost ? <GhostPreview ghost={ghost} /> : null}
3636
+ </svg>,
3637
+ target
3638
+ )}
3639
+ <AnnotEditors
3640
+ worldRef={worldRef}
3641
+ editingTarget={editingTarget}
3642
+ anchoredExisting={anchoredExisting}
3643
+ caretPoint={editCaretPoint}
3644
+ inkColor={inkColor}
3645
+ onCommitEdit={onCommitEdit}
3646
+ onCancelEdit={onCancelEdit}
3647
+ />
3648
+ </>
3649
+ );
3650
+ }
3651
+
3652
+ /**
3653
+ * The active annotation text editor, rendered as PLAIN HTML absolutely
3654
+ * positioned in the world div — NOT as SVG foreignObject. Same architectural
3655
+ * move as MediaRefPlayers below (read its docblock): foreignObject content
3656
+ * under the transformed `.dc-world` mis-hit-tests clicks at most zoom levels
3657
+ * (WebKit + Chromium), and the `.dc-annot-svg` root's pointer-events:none
3658
+ * additionally swallowed in-editor clicks — so caret-at-click could never
3659
+ * work. HTML children of the transformed div hit-test correctly by
3660
+ * construction. World coords map 1:1 (the div carries the pan/zoom
3661
+ * transform), so each editor's old foreignObject x/y/w/h becomes left/top/
3662
+ * width/height verbatim. The [data-annot-editor] attr keeps document-capture
3663
+ * annotation handlers out (isAnnotEditorTarget guard, mirroring
3664
+ * [data-mediaref-player]).
3665
+ */
3666
+ function AnnotEditors({
3667
+ worldRef,
3668
+ editingTarget,
3669
+ anchoredExisting,
3670
+ caretPoint,
3671
+ inkColor,
3672
+ onCommitEdit,
3673
+ onCancelEdit,
3674
+ }: {
3675
+ worldRef: ReturnType<typeof useWorldRefContext>;
3676
+ editingTarget: EditingTarget;
3677
+ anchoredExisting: TextStroke | undefined;
3678
+ caretPoint: { x: number; y: number } | null;
3679
+ inkColor: string;
3680
+ onCommitEdit: (text: string, fmt?: EditorFmt) => void;
3681
+ onCancelEdit: () => void;
3682
+ }) {
3683
+ const target = worldRef?.current ?? null;
3684
+ if (!target || !editingTarget) return null;
3685
+ return createPortal(
3686
+ <>
3687
+ {editingTarget.kind === 'anchored' ? (
3171
3688
  <TextEditor
3172
3689
  anchorId={editingTarget.anchorId}
3173
3690
  host={editingTarget.host}
3174
3691
  existing={anchoredExisting}
3692
+ caretPoint={caretPoint}
3175
3693
  onCommit={(_anchorId, text, fmt) => onCommitEdit(text, fmt)}
3176
3694
  onCancel={onCancelEdit}
3177
3695
  />
3178
3696
  ) : null}
3179
- {editingTarget?.kind === 'sticky' ? (
3697
+ {editingTarget.kind === 'sticky' ? (
3180
3698
  <StickyEditor
3181
3699
  sticky={editingTarget.sticky}
3700
+ caretPoint={caretPoint}
3182
3701
  onCommit={onCommitEdit}
3183
3702
  onCancel={onCancelEdit}
3184
3703
  />
3185
3704
  ) : null}
3186
- {editingTarget?.kind === 'standalone' ? (
3705
+ {editingTarget.kind === 'standalone' ? (
3187
3706
  <StandaloneTextEditor
3188
3707
  x={editingTarget.text.x ?? 0}
3189
3708
  y={editingTarget.text.y ?? 0}
@@ -3196,11 +3715,12 @@ function AnnotationsSvg({
3196
3715
  underline={editingTarget.text.underline}
3197
3716
  align={editingTarget.text.align ?? 'left'}
3198
3717
  listType={editingTarget.text.listType}
3718
+ caretPoint={caretPoint}
3199
3719
  onCommit={onCommitEdit}
3200
3720
  onCancel={onCancelEdit}
3201
3721
  />
3202
3722
  ) : null}
3203
- {editingTarget?.kind === 'pending' ? (
3723
+ {editingTarget.kind === 'pending' ? (
3204
3724
  <StandaloneTextEditor
3205
3725
  x={editingTarget.x}
3206
3726
  y={editingTarget.y}
@@ -3211,24 +3731,15 @@ function AnnotationsSvg({
3211
3731
  onCancel={onCancelEdit}
3212
3732
  />
3213
3733
  ) : null}
3214
- {editingTarget?.kind === 'section' ? (
3215
- <StandaloneTextEditor
3216
- x={
3217
- Math.min(editingTarget.section.x, editingTarget.section.x + editingTarget.section.w) + 2
3218
- }
3219
- y={
3220
- Math.min(editingTarget.section.y, editingTarget.section.y + editingTarget.section.h) -
3221
- SECTION_LABEL_H -
3222
- 2
3223
- }
3224
- fontSize={SECTION_LABEL_FONT}
3225
- color={editingTarget.section.color}
3226
- initialText={editingTarget.section.label}
3734
+ {editingTarget.kind === 'section' ? (
3735
+ <SectionTitleEditor
3736
+ section={editingTarget.section}
3737
+ caretPoint={caretPoint}
3227
3738
  onCommit={onCommitEdit}
3228
3739
  onCancel={onCancelEdit}
3229
3740
  />
3230
3741
  ) : null}
3231
- </svg>,
3742
+ </>,
3232
3743
  target
3233
3744
  );
3234
3745
  }
@@ -3319,16 +3830,89 @@ function MediaRefPlayers({
3319
3830
  );
3320
3831
  }
3321
3832
 
3833
+ // An empty flex-centered contentEditable has no line box for `justifyContent:
3834
+ // 'center'` to center — WebKit parks the caret at the box's top, then jumps it
3835
+ // to the true centered position the instant a real character exists. A
3836
+ // zero-width space gives the box real (invisible) content from mount, so the
3837
+ // caret starts centered and never jumps. Stripped back off on commit.
3838
+ const JUMP_SENTINEL = '\u200B';
3839
+ function stripJumpSentinel(text: string): string {
3840
+ return text.startsWith(JUMP_SENTINEL) ? text.slice(JUMP_SENTINEL.length) : text;
3841
+ }
3842
+
3843
+ // Unified caret style across every contentEditable text surface (annotation
3844
+ // editors here + the artboard inline editor's `.dc-text-editing` CSS in
3845
+ // canvas-shell.tsx use the SAME `--maude-hud-accent` so the caret reads the
3846
+ // same everywhere). An explicit caretColor makes the caret visible against any
3847
+ // background. NOTE: deliberately NO `transform: translateZ(0)` / `will-change`
3848
+ // here \u2014 promoting a contentEditable onto its own compositing layer is a known
3849
+ // WebKit caret-BLINK killer (the compositor caches the layer and never repaints
3850
+ // the blink, so the caret shows as a static line). A prior dogfood pass added
3851
+ // translateZ(0) to "restore" the caret and instead froze its blink; removing it
3852
+ // lets WebKit run the native blink. The editor already lives inside the
3853
+ // transformed `.dc-world`, but that ancestor transform alone does not stop the
3854
+ // blink \u2014 only a compositing trigger ON the editable does.
3855
+ const CARET_FIX_STYLE = {
3856
+ caretColor: 'var(--maude-hud-accent, #4a63e7)',
3857
+ } as const;
3858
+
3859
+ /**
3860
+ * Phase 3 (unified-text-editing) — shared caret behavior for every annotation
3861
+ * editor. On mount: focus, place a collapsed caret at the entry click point
3862
+ * (`placeCaretAt`, the SAME chain the artboard's enterEditModeAt uses;
3863
+ * keyboard entry has no point → select-all, the rename convention), and mount
3864
+ * the custom blinking caret (text-caret.ts) for the session. Afterwards every
3865
+ * plain in-editor click re-places the caret from its coordinates on pointerup
3866
+ * so repositioning never depends on native hit-testing (synthetic e2e clicks
3867
+ * take the same path — untrusted events get no UA caret action at all).
3868
+ * Shift-clicks and drag-selections keep native behavior; ⌘A stays native.
3869
+ */
3870
+ function useEditorCaret(
3871
+ ref: RefObject<HTMLDivElement | null>,
3872
+ caretPoint: { x: number; y: number } | null | undefined
3873
+ ) {
3874
+ const entryPointRef = useRef(caretPoint ?? null);
3875
+ useEffect(() => {
3876
+ const el = ref.current;
3877
+ if (!el) return;
3878
+ el.focus();
3879
+ placeCaretAt(el, window, entryPointRef.current ?? undefined);
3880
+ return mountCaret(el, window);
3881
+ }, [ref]);
3882
+ const downRef = useRef<{ x: number; y: number } | null>(null);
3883
+ const onPointerDown = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
3884
+ downRef.current = e.button === 0 && !e.shiftKey ? { x: e.clientX, y: e.clientY } : null;
3885
+ }, []);
3886
+ const onPointerUp = useCallback(
3887
+ (e: ReactPointerEvent<HTMLDivElement>) => {
3888
+ const d = downRef.current;
3889
+ downRef.current = null;
3890
+ if (!d || e.shiftKey) return;
3891
+ // A real drag is a range-selection gesture — leave it to the engine.
3892
+ if (Math.hypot(e.clientX - d.x, e.clientY - d.y) > 3) return;
3893
+ const el = ref.current;
3894
+ if (!el) return;
3895
+ const sel = window.getSelection();
3896
+ if (sel && !sel.isCollapsed) return; // double-click word-select etc.
3897
+ placeCaretAt(el, window, { x: e.clientX, y: e.clientY }, false);
3898
+ },
3899
+ [ref]
3900
+ );
3901
+ return { onPointerDown, onPointerUp };
3902
+ }
3903
+
3322
3904
  function TextEditor({
3323
3905
  anchorId,
3324
3906
  host,
3325
3907
  existing,
3908
+ caretPoint,
3326
3909
  onCommit,
3327
3910
  onCancel,
3328
3911
  }: {
3329
3912
  anchorId: string;
3330
3913
  host: AnchorHost | null;
3331
3914
  existing: TextStroke | undefined;
3915
+ caretPoint?: { x: number; y: number } | null;
3332
3916
  onCommit: (anchorId: string, text: string, fmt?: EditorFmt) => void;
3333
3917
  onCancel: () => void;
3334
3918
  }) {
@@ -3351,24 +3935,18 @@ function TextEditor({
3351
3935
  fontSize: existing?.fontSize ?? DEFAULT_FONT_SIZE,
3352
3936
  align: existing?.align ?? 'center',
3353
3937
  });
3938
+ // Both commit sites below (outside-click + Cmd/Ctrl+Enter) need the same
3939
+ // sentinel-strip + marker-strip pipeline. Memoized so the outside-click
3940
+ // effect below can depend on it directly instead of its own copy of
3941
+ // existing?.listType (lint/correctness/useExhaustiveDependencies).
3942
+ const toCommittedText = useCallback(
3943
+ (raw: string) => stripEditorMarkers(stripJumpSentinel(raw), existing?.listType),
3944
+ [existing?.listType]
3945
+ );
3354
3946
 
3355
- useEffect(() => {
3356
- const el = ref.current;
3357
- if (!el) return;
3358
- el.focus();
3359
- // Select all so a re-edit replaces existing text easily.
3360
- try {
3361
- const r = document.createRange();
3362
- r.selectNodeContents(el);
3363
- const sel = window.getSelection();
3364
- if (sel) {
3365
- sel.removeAllRanges();
3366
- sel.addRange(r);
3367
- }
3368
- } catch {
3369
- /* selection API blocked */
3370
- }
3371
- }, []);
3947
+ // Caret-at-click on entry + custom blinking caret + click re-placement
3948
+ // (select-all only for keyboard entry — see useEditorCaret).
3949
+ const caretHandlers = useEditorCaret(ref, caretPoint);
3372
3950
 
3373
3951
  // Commit on outside click; cancel-on-Esc handled in onKeyDown below.
3374
3952
  useEffect(() => {
@@ -3381,15 +3959,11 @@ function TextEditor({
3381
3959
  // FigJam v3 — the edit-mode text toolbar drives THIS editor; clicking
3382
3960
  // it must not commit-and-close the session.
3383
3961
  if ((e.target as Element | null)?.closest?.('.dc-annot-ctx')) return;
3384
- onCommit(
3385
- anchorId,
3386
- stripEditorMarkers(el.innerText || '', existing?.listType),
3387
- fmtRef.current
3388
- );
3962
+ onCommit(anchorId, toCommittedText(el.innerText || ''), fmtRef.current);
3389
3963
  };
3390
3964
  document.addEventListener('pointerdown', onDown, true);
3391
3965
  return () => document.removeEventListener('pointerdown', onDown, true);
3392
- }, [anchorId, onCommit, existing?.listType, fmtRef]);
3966
+ }, [anchorId, onCommit, fmtRef, toCommittedText]);
3393
3967
 
3394
3968
  if (!host) return null;
3395
3969
  const bbox = strokeBBox(host);
@@ -3399,7 +3973,19 @@ function TextEditor({
3399
3973
  // default align = centre).
3400
3974
  const align = existing?.align ?? 'center';
3401
3975
  return (
3402
- <foreignObject x={bbox.x} y={bbox.y} width={Math.max(20, bbox.w)} height={Math.max(20, bbox.h)}>
3976
+ // Plain HTML host in the world div (NOT foreignObject — see AnnotEditors'
3977
+ // docblock): world coords map 1:1 to left/top, clicks hit-test correctly.
3978
+ <div
3979
+ data-annot-editor="1"
3980
+ style={{
3981
+ position: 'absolute',
3982
+ left: bbox.x,
3983
+ top: bbox.y,
3984
+ width: Math.max(20, bbox.w),
3985
+ height: Math.max(20, bbox.h),
3986
+ zIndex: 5,
3987
+ }}
3988
+ >
3403
3989
  <div
3404
3990
  ref={ref}
3405
3991
  className="dc-annot-editor"
@@ -3429,7 +4015,10 @@ function TextEditor({
3429
4015
  outline: 'none',
3430
4016
  background: 'transparent',
3431
4017
  cursor: 'text',
4018
+ ...CARET_FIX_STYLE,
3432
4019
  }}
4020
+ onPointerDown={caretHandlers.onPointerDown}
4021
+ onPointerUp={caretHandlers.onPointerUp}
3433
4022
  onKeyDown={(e) => {
3434
4023
  if (onFormatKey(e)) return; // Cmd/Ctrl+B/I/U
3435
4024
  if (e.key === 'Escape') {
@@ -3437,44 +4026,46 @@ function TextEditor({
3437
4026
  onCancel();
3438
4027
  return;
3439
4028
  }
3440
- if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
4029
+ // Unified across every text surface: plain Enter commits,
4030
+ // Shift+Enter inserts a newline (falls through untouched). ⌘/Ctrl
4031
+ // +Enter also commits AND chains a connected sibling (quick-create).
4032
+ if (e.key === 'Enter' && !e.shiftKey) {
3441
4033
  e.preventDefault();
3442
4034
  const el = ref.current;
3443
- onCommit(
3444
- anchorId,
3445
- stripEditorMarkers(el?.innerText || '', existing?.listType),
3446
- fmtRef.current
3447
- );
3448
- // FigJam v3 — ⌘Enter chains: commit, then spawn a CONNECTED
3449
- // sibling shape (quick-create) from the host.
3450
- document.dispatchEvent(
3451
- new CustomEvent('maude:chain-create', { detail: { id: anchorId } })
3452
- );
4035
+ onCommit(anchorId, toCommittedText(el?.innerText || ''), fmtRef.current);
4036
+ if (e.metaKey || e.ctrlKey) {
4037
+ document.dispatchEvent(
4038
+ new CustomEvent('maude:chain-create', { detail: { id: anchorId } })
4039
+ );
4040
+ }
3453
4041
  }
3454
4042
  }}
3455
4043
  >
3456
- {initial}
4044
+ {initial || JUMP_SENTINEL}
3457
4045
  </div>
3458
- </foreignObject>
4046
+ </div>
3459
4047
  );
3460
4048
  }
3461
4049
 
3462
- // Phase 21 — sticky body editor. A textarea hosted in a foreignObject at the
3463
- // card's bbox, so it word-wraps + moves with CSS zoom natively. Commit on blur,
3464
- // cancel on Esc; Enter inserts a newline (sticky is multi-line).
4050
+ // Phase 21 — sticky body editor, hosted as plain HTML at the card's bbox in
4051
+ // the world div (word-wrap + zoom come from the div's own box + the world
4052
+ // transform). Commit on blur, cancel on Esc; Enter commits, Shift+Enter
4053
+ // inserts a newline.
3465
4054
  function StickyEditor({
3466
4055
  sticky,
4056
+ caretPoint,
3467
4057
  onCommit,
3468
4058
  onCancel,
3469
4059
  }: {
3470
4060
  sticky: StickyStroke;
4061
+ caretPoint?: { x: number; y: number } | null;
3471
4062
  onCommit: (text: string, fmt?: EditorFmt) => void;
3472
4063
  onCancel: () => void;
3473
4064
  }) {
3474
4065
  // A flex-centered contentEditable (NOT a textarea) so the edit view matches
3475
4066
  // the committed `.dc-sticky-body` exactly — text stays centered, no jump on
3476
- // commit. Multi-line: Enter inserts a line break; Esc cancels; blur / Cmd+Enter
3477
- // commit; Cmd/Ctrl+B/I/U format (unified with the other editors — item 4d).
4067
+ // commit. Multi-line: Shift+Enter inserts a line break, plain Enter commits;
4068
+ // Esc cancels; blur commits; Cmd/Ctrl+B/I/U format (unified with the others).
3478
4069
  const ref = useRef<HTMLDivElement | null>(null);
3479
4070
  const doneRef = useRef(false);
3480
4071
  const {
@@ -3502,37 +4093,33 @@ function StickyEditor({
3502
4093
  if (to?.closest?.('.dc-annot-ctx')) return;
3503
4094
  commit();
3504
4095
  };
3505
- useEffect(() => {
3506
- const el = ref.current;
3507
- if (!el) return;
3508
- el.focus();
3509
- try {
3510
- const r = document.createRange();
3511
- r.selectNodeContents(el);
3512
- const sel = window.getSelection();
3513
- if (sel) {
3514
- sel.removeAllRanges();
3515
- sel.addRange(r);
3516
- }
3517
- } catch {
3518
- /* selection API blocked */
3519
- }
3520
- }, []);
4096
+ // Caret-at-click on entry + custom blinking caret + click re-placement.
4097
+ const caretHandlers = useEditorCaret(ref, caretPoint);
3521
4098
  const x = Math.min(sticky.x, sticky.x + sticky.w);
3522
4099
  const y = Math.min(sticky.y, sticky.y + sticky.h);
3523
4100
  const w = Math.abs(sticky.w);
3524
4101
  const h = Math.abs(sticky.h);
3525
4102
  return (
3526
- <foreignObject x={x} y={y} width={w} height={h}>
4103
+ <div
4104
+ data-annot-editor="1"
4105
+ style={{ position: 'absolute', left: x, top: y, width: w, height: h, zIndex: 5 }}
4106
+ >
3527
4107
  <div
3528
- xmlns="http://www.w3.org/1999/xhtml"
3529
4108
  ref={ref}
3530
4109
  className="dc-annot-editor dc-sticky-body"
3531
4110
  contentEditable
3532
4111
  suppressContentEditableWarning
3533
4112
  aria-label="Edit sticky note text"
3534
- style={{ ...stickyBodyStyle(sticky), ...fmtStyle, outline: 'none', cursor: 'text' }}
4113
+ style={{
4114
+ ...stickyBodyStyle(sticky),
4115
+ ...fmtStyle,
4116
+ outline: 'none',
4117
+ cursor: 'text',
4118
+ ...CARET_FIX_STYLE,
4119
+ }}
3535
4120
  onBlur={onBlur}
4121
+ onPointerDown={caretHandlers.onPointerDown}
4122
+ onPointerUp={caretHandlers.onPointerUp}
3536
4123
  onKeyDown={(e) => {
3537
4124
  if (onFormatKey(e)) return; // Cmd/Ctrl+B/I/U
3538
4125
  if (e.key === 'Escape') {
@@ -3541,14 +4128,16 @@ function StickyEditor({
3541
4128
  onCancel();
3542
4129
  return;
3543
4130
  }
3544
- if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
4131
+ // Unified: plain Enter commits, Shift+Enter inserts a newline.
4132
+ // ⌘/Ctrl+Enter also commits AND chains the next sticky beside it.
4133
+ if (e.key === 'Enter' && !e.shiftKey) {
3545
4134
  e.preventDefault();
3546
4135
  commit();
3547
- // FigJam v3 ⌘Enter chains: commit this body, then ask the layer
3548
- // to spawn + edit the next sticky beside it.
3549
- document.dispatchEvent(
3550
- new CustomEvent('maude:chain-create', { detail: { id: sticky.id } })
3551
- );
4136
+ if (e.metaKey || e.ctrlKey) {
4137
+ document.dispatchEvent(
4138
+ new CustomEvent('maude:chain-create', { detail: { id: sticky.id } })
4139
+ );
4140
+ }
3552
4141
  }
3553
4142
  }}
3554
4143
  >
@@ -3556,7 +4145,7 @@ function StickyEditor({
3556
4145
  doesn't flicker; stripped back to raw text on commit. */}
3557
4146
  {stickyBodyText(sticky)}
3558
4147
  </div>
3559
- </foreignObject>
4148
+ </div>
3560
4149
  );
3561
4150
  }
3562
4151
 
@@ -3574,6 +4163,9 @@ function StandaloneTextEditor({
3574
4163
  underline,
3575
4164
  align,
3576
4165
  listType,
4166
+ singleLine,
4167
+ boxStyle,
4168
+ caretPoint,
3577
4169
  onCommit,
3578
4170
  onCancel,
3579
4171
  }: {
@@ -3588,6 +4180,14 @@ function StandaloneTextEditor({
3588
4180
  underline?: boolean;
3589
4181
  align?: TextAlign;
3590
4182
  listType?: ListType;
4183
+ /** The click that opened the editor — caret lands there (Phase 3). */
4184
+ caretPoint?: { x: number; y: number } | null;
4185
+ /** A one-line field (e.g. a section title rename) — plain Enter commits
4186
+ * instead of inserting a newline, matching a native text-input's Enter. */
4187
+ singleLine?: boolean;
4188
+ /** Extra style merged onto the editable box — e.g. a section rename wants
4189
+ * the same chip background/padding/radius the read-only label chip has. */
4190
+ boxStyle?: CSSProperties;
3591
4191
  onCommit: (text: string, fmt?: EditorFmt) => void;
3592
4192
  onCancel: () => void;
3593
4193
  }) {
@@ -3617,22 +4217,8 @@ function StandaloneTextEditor({
3617
4217
  },
3618
4218
  [onCommit, listType, fmtRef]
3619
4219
  );
3620
- useEffect(() => {
3621
- const el = ref.current;
3622
- if (!el) return;
3623
- el.focus();
3624
- try {
3625
- const r = document.createRange();
3626
- r.selectNodeContents(el);
3627
- const sel = window.getSelection();
3628
- if (sel) {
3629
- sel.removeAllRanges();
3630
- sel.addRange(r);
3631
- }
3632
- } catch {
3633
- /* selection API blocked */
3634
- }
3635
- }, []);
4220
+ // Caret-at-click on entry + custom blinking caret + click re-placement.
4221
+ const caretHandlers = useEditorCaret(ref, caretPoint);
3636
4222
  // Commit on outside click.
3637
4223
  useEffect(() => {
3638
4224
  if (typeof document === 'undefined') return;
@@ -3649,12 +4235,23 @@ function StandaloneTextEditor({
3649
4235
  return () => document.removeEventListener('pointerdown', onDown, true);
3650
4236
  }, [commitOnce]);
3651
4237
  return (
3652
- // Generous box so multi-line text isn't clipped while typing (item 4a). The
3653
- // empty area is transparent + pointer-pass-through (the SVG root is
3654
- // pointer-events:none), so outside-click still commits.
3655
- <foreignObject x={x} y={y} width={640} height={480}>
4238
+ // Generous box so multi-line text isn't clipped while typing (item 4a).
4239
+ // The host passes pointer events through (empty area is not the editor
4240
+ // outside-click must still commit); only the editable itself is
4241
+ // interactive, so clicks in it place the caret.
4242
+ <div
4243
+ data-annot-editor="1"
4244
+ style={{
4245
+ position: 'absolute',
4246
+ left: x,
4247
+ top: y,
4248
+ width: 640,
4249
+ height: 480,
4250
+ zIndex: 5,
4251
+ pointerEvents: 'none',
4252
+ }}
4253
+ >
3656
4254
  <div
3657
- xmlns="http://www.w3.org/1999/xhtml"
3658
4255
  ref={ref}
3659
4256
  className="dc-annot-editor"
3660
4257
  contentEditable
@@ -3663,6 +4260,9 @@ function StandaloneTextEditor({
3663
4260
  style={{
3664
4261
  display: 'inline-block',
3665
4262
  minWidth: '8px',
4263
+ // The pass-through host (above) is inert — re-enable events HERE so
4264
+ // in-editor clicks place the caret instead of falling through.
4265
+ pointerEvents: 'auto',
3666
4266
  // pre-wrap so Enter inserts a real newline (multi-line text), not a
3667
4267
  // commit; long lines also wrap within the box.
3668
4268
  whiteSpace: 'pre-wrap',
@@ -3676,8 +4276,12 @@ function StandaloneTextEditor({
3676
4276
  outline: 'none',
3677
4277
  background: 'transparent',
3678
4278
  cursor: 'text',
4279
+ ...CARET_FIX_STYLE,
4280
+ ...boxStyle,
3679
4281
  }}
3680
4282
  onBlur={() => commitOnce(ref.current?.innerText || '')}
4283
+ onPointerDown={caretHandlers.onPointerDown}
4284
+ onPointerUp={caretHandlers.onPointerUp}
3681
4285
  onKeyDown={(e) => {
3682
4286
  if (onFormatKey(e)) return; // Cmd/Ctrl+B/I/U
3683
4287
  if (e.key === 'Escape') {
@@ -3687,8 +4291,10 @@ function StandaloneTextEditor({
3687
4291
  onCancel();
3688
4292
  return;
3689
4293
  }
3690
- // Cmd/Ctrl+Enter commits; plain Enter inserts a newline (item 4a).
3691
- if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
4294
+ // Unified: plain Enter commits, Shift+Enter inserts a newline. A
4295
+ // singleLine field (section rename) is a title Shift+Enter commits
4296
+ // too rather than adding a newline the one-line chip can't show.
4297
+ if (e.key === 'Enter' && (!e.shiftKey || singleLine)) {
3692
4298
  e.preventDefault();
3693
4299
  commitOnce(ref.current?.innerText || '');
3694
4300
  }
@@ -3696,7 +4302,7 @@ function StandaloneTextEditor({
3696
4302
  >
3697
4303
  {listPrefixedBody(initialText, listType)}
3698
4304
  </div>
3699
- </foreignObject>
4305
+ </div>
3700
4306
  );
3701
4307
  }
3702
4308
 
@@ -3798,6 +4404,7 @@ function AnnotationContextMenu({
3798
4404
  selCount,
3799
4405
  canUngroup,
3800
4406
  canReplace,
4407
+ canEditPhoto,
3801
4408
  onAction,
3802
4409
  onClose,
3803
4410
  }: {
@@ -3806,6 +4413,8 @@ function AnnotationContextMenu({
3806
4413
  canUngroup: boolean;
3807
4414
  /** Stage F3 — exactly one ImageStroke/MediaRefStroke is selected. */
3808
4415
  canReplace: boolean;
4416
+ /** feature-photo-editor (Task 17) — exactly one content-addressed ImageStroke. */
4417
+ canEditPhoto: boolean;
3809
4418
  onAction: (action: string) => void;
3810
4419
  onClose: () => void;
3811
4420
  }) {
@@ -3873,6 +4482,7 @@ function AnnotationContextMenu({
3873
4482
  {item('paste', 'Paste', '⌘V')}
3874
4483
  {item('duplicate', 'Duplicate', '⌘D')}
3875
4484
  {canReplace ? item('replace', 'Replace…') : null}
4485
+ {canEditPhoto ? item('edit-photo', 'Edit Photo…') : null}
3876
4486
  <div className="dc-menu-sep" aria-hidden="true" />
3877
4487
  {item('front', 'Bring to front', ']')}
3878
4488
  {item('forward', 'Bring forward', '⌘]')}
@@ -4004,6 +4614,16 @@ function AnnotGroupBbox({
4004
4614
  const h = yMax - yMin + pad * 2;
4005
4615
  const handle = 6;
4006
4616
  const inset = 3;
4617
+ // Screen-constant hit target — bigger than the visible 6×6 square (matches
4618
+ // the single-resize DOM handles' generous grab area) so the corner stays
4619
+ // easy to grab at any zoom.
4620
+ const hit = 16 / zoom;
4621
+ const cursorFor: Record<string, string> = {
4622
+ nw: 'nwse-resize',
4623
+ se: 'nwse-resize',
4624
+ ne: 'nesw-resize',
4625
+ sw: 'nesw-resize',
4626
+ };
4007
4627
  const handles = [
4008
4628
  { corner: 'nw', x: x - inset, y: y - inset },
4009
4629
  { corner: 'ne', x: x + w - handle + inset, y: y - inset },
@@ -4024,20 +4644,42 @@ function AnnotGroupBbox({
4024
4644
  vectorEffect="non-scaling-stroke"
4025
4645
  rx={2}
4026
4646
  />
4027
- {handles.map((c) => (
4028
- <rect
4029
- key={c.corner}
4030
- x={c.x}
4031
- y={c.y}
4032
- width={handle}
4033
- height={handle}
4034
- fill="var(--maude-hud-accent, #d63b1f)"
4035
- stroke="var(--maude-chrome-bg-0, #ffffff)"
4036
- strokeWidth={1}
4037
- vectorEffect="non-scaling-stroke"
4038
- rx={1}
4039
- />
4040
- ))}
4647
+ {/* Phase 2 (whiteboard-improvements) proportional group resize: these
4648
+ corners used to be decorative (pointerEvents inherited 'none' from
4649
+ the wrapping <g>). Each now sets its OWN pointerEvents, which SVG
4650
+ lets a descendant override independent of an ancestor's value, and
4651
+ carries `data-group-resize-corner` for the drag gesture registered
4652
+ in AnnotationsLayer to identify which corner was grabbed. */}
4653
+ {handles.map((c) => {
4654
+ const cx = c.x + handle / 2;
4655
+ const cy = c.y + handle / 2;
4656
+ return (
4657
+ <g key={c.corner}>
4658
+ <rect
4659
+ x={cx - hit / 2}
4660
+ y={cy - hit / 2}
4661
+ width={hit}
4662
+ height={hit}
4663
+ fill="transparent"
4664
+ pointerEvents="all"
4665
+ data-group-resize-corner={c.corner}
4666
+ style={{ cursor: cursorFor[c.corner] }}
4667
+ />
4668
+ <rect
4669
+ x={c.x}
4670
+ y={c.y}
4671
+ width={handle}
4672
+ height={handle}
4673
+ fill="var(--maude-hud-accent, #d63b1f)"
4674
+ stroke="var(--maude-chrome-bg-0, #ffffff)"
4675
+ strokeWidth={1}
4676
+ vectorEffect="non-scaling-stroke"
4677
+ rx={1}
4678
+ pointerEvents="none"
4679
+ />
4680
+ </g>
4681
+ );
4682
+ })}
4041
4683
  </g>
4042
4684
  );
4043
4685
  }
@@ -4166,6 +4808,106 @@ function GhostPreview({ ghost }: { ghost: GhostDescriptor }) {
4166
4808
  * Pointer events pass through the group, so hit-testing + the ctx-toolbar's
4167
4809
  * getBoundingClientRect positioning keep working on the rotated form.
4168
4810
  */
4811
+ /** Section title chip — deliberately screen-size-constant. The body/border
4812
+ * scale with the world like everything else, but a title that shrinks to
4813
+ * unreadable at zoom-out defeats the point of a label (matches the `r={5/zoom}`
4814
+ * counter-scale convention used for halos/connector-dots elsewhere in this file). */
4815
+ function SectionLabelChip({
4816
+ stroke,
4817
+ x,
4818
+ y,
4819
+ hitMode,
4820
+ }: {
4821
+ stroke: SectionStroke;
4822
+ x: number;
4823
+ y: number;
4824
+ hitMode: 'visiblePainted' | 'none';
4825
+ }) {
4826
+ const controller = useViewportControllerContext();
4827
+ const zoom = controller?.viewport?.zoom || 1;
4828
+ const fontSize = SECTION_LABEL_FONT / zoom;
4829
+ const chipH = SECTION_LABEL_H / zoom;
4830
+ const gap = 4 / zoom;
4831
+ const padX = 9 / zoom;
4832
+ const chipW = Math.max(56 / zoom, stroke.label.length * fontSize * 0.62 + 18 / zoom);
4833
+ // NOTE: this chip geometry (chipW/chipH/gap vs the region's y) is mirrored
4834
+ // by findTextStrokeAt's section branch — the Text tool's click-through
4835
+ // renames a section only from its label chip. Keep the two in sync.
4836
+ return (
4837
+ <g pointerEvents={hitMode}>
4838
+ <rect
4839
+ x={x}
4840
+ y={y - chipH - gap}
4841
+ width={chipW}
4842
+ height={chipH}
4843
+ rx={5 / zoom}
4844
+ ry={5 / zoom}
4845
+ fill={stroke.color}
4846
+ fillOpacity={0.16}
4847
+ />
4848
+ <text
4849
+ x={x + padX}
4850
+ y={y - chipH / 2 - gap}
4851
+ dominantBaseline="middle"
4852
+ fontSize={fontSize}
4853
+ fill={stroke.color}
4854
+ style={{
4855
+ fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
4856
+ }}
4857
+ >
4858
+ {stroke.label}
4859
+ </text>
4860
+ </g>
4861
+ );
4862
+ }
4863
+
4864
+ /** Section title RENAME field — same chip visuals + zoom-invariant sizing as
4865
+ * SectionLabelChip (read state), so switching into edit mode doesn't swap
4866
+ * the pill for a bare, ambient-zoomed sliver of text (it used to: the editor
4867
+ * had no background and its font-size wasn't counter-scaled, so at any zoom
4868
+ * below 1× the chip effectively vanished mid-rename). */
4869
+ function SectionTitleEditor({
4870
+ section,
4871
+ caretPoint,
4872
+ onCommit,
4873
+ onCancel,
4874
+ }: {
4875
+ section: SectionStroke;
4876
+ caretPoint?: { x: number; y: number } | null;
4877
+ onCommit: (text: string, fmt?: EditorFmt) => void;
4878
+ onCancel: () => void;
4879
+ }) {
4880
+ const controller = useViewportControllerContext();
4881
+ const zoom = controller?.viewport?.zoom || 1;
4882
+ const fontSize = SECTION_LABEL_FONT / zoom;
4883
+ const chipH = SECTION_LABEL_H / zoom;
4884
+ const gap = 4 / zoom;
4885
+ const padX = 9 / zoom;
4886
+ const x = Math.min(section.x, section.x + section.w);
4887
+ const y = Math.min(section.y, section.y + section.h);
4888
+ return (
4889
+ <StandaloneTextEditor
4890
+ x={x}
4891
+ y={y - chipH - gap}
4892
+ fontSize={fontSize}
4893
+ color={section.color}
4894
+ initialText={section.label}
4895
+ caretPoint={caretPoint}
4896
+ singleLine
4897
+ boxStyle={{
4898
+ background: `color-mix(in oklab, ${section.color} 16%, transparent)`,
4899
+ borderRadius: `${5 / zoom}px`,
4900
+ padding: `0 ${padX}px`,
4901
+ minHeight: `${chipH}px`,
4902
+ lineHeight: `${chipH}px`,
4903
+ whiteSpace: 'nowrap',
4904
+ }}
4905
+ onCommit={onCommit}
4906
+ onCancel={onCancel}
4907
+ />
4908
+ );
4909
+ }
4910
+
4169
4911
  function StrokeNode(props: {
4170
4912
  stroke: Stroke;
4171
4913
  anchorsById: Map<string, AnchorHost>;
@@ -4194,7 +4936,8 @@ function StrokeNodeBase({
4194
4936
  stroke: Stroke;
4195
4937
  anchorsById: Map<string, AnchorHost>;
4196
4938
  interactive: boolean;
4197
- /** Phase 21 — sticky-only: hide the read-only body while its editor is up. */
4939
+ /** Hide the read-only body/text while its editor is up (sticky, section, or
4940
+ * anchored shape text — whichever this stroke is). */
4198
4941
  editing?: boolean;
4199
4942
  }) {
4200
4943
  // In Move mode, individual stroke nodes claim pointer events so we can
@@ -4217,6 +4960,11 @@ function StrokeNodeBase({
4217
4960
  const host = anchorsById.get(stroke.anchorId);
4218
4961
  const bbox = host ? strokeBBox(host) : null;
4219
4962
  if (!bbox) return null;
4963
+ // Its editor (TextEditor) paints the same bbox while active — skip the
4964
+ // read-only <text> so the two don't double-paint (Phase 1 jump-fix
4965
+ // companion: the editor was already exempt via editingStickyId's
4966
+ // sibling, this stroke type never was).
4967
+ if (editing) return null;
4220
4968
  const cy = bbox.y + bbox.h / 2;
4221
4969
  const align = stroke.align ?? 'center';
4222
4970
  const pad = 8;
@@ -4245,6 +4993,9 @@ function StrokeNodeBase({
4245
4993
  </text>
4246
4994
  );
4247
4995
  }
4996
+ // Its editor (StandaloneTextEditor) paints at the same x/y while active —
4997
+ // skip the read-only <text> so the two don't double-paint (the "ghost").
4998
+ if (editing) return null;
4248
4999
  const align = stroke.align ?? 'left';
4249
5000
  const anchor = align === 'left' ? 'start' : align === 'right' ? 'end' : 'middle';
4250
5001
  const tx = stroke.x ?? 0;
@@ -4297,6 +5048,54 @@ function StrokeNodeBase({
4297
5048
  </div>
4298
5049
  </foreignObject>
4299
5050
  )}
5051
+ {/* Phase 3 (whiteboard-improvements) — author badge, bottom-right
5052
+ corner. A name label (not an avatar — a full name/nickname reads
5053
+ faster than initials and doesn't need a legend to decode). Color
5054
+ re-derives from the (sanitized) name via colorForName — NEVER a
5055
+ stored/wire color — so it matches the author's live presence hue
5056
+ (cursor/avatar use the same function). foreignObject width is a
5057
+ fixed generous box right-anchored via flex, since a name's pixel
5058
+ width isn't known without measuring the DOM. */}
5059
+ {stroke.authorName && (
5060
+ <foreignObject
5061
+ x={x + w - 160}
5062
+ y={y + h - 20}
5063
+ width={160}
5064
+ height={20}
5065
+ pointerEvents="none"
5066
+ >
5067
+ <div
5068
+ xmlns="http://www.w3.org/1999/xhtml"
5069
+ style={{
5070
+ display: 'flex',
5071
+ justifyContent: 'flex-end',
5072
+ alignItems: 'center',
5073
+ height: '100%',
5074
+ paddingRight: 4,
5075
+ }}
5076
+ >
5077
+ <span
5078
+ title={stroke.authorName}
5079
+ style={{
5080
+ maxWidth: '100%',
5081
+ overflow: 'hidden',
5082
+ textOverflow: 'ellipsis',
5083
+ whiteSpace: 'nowrap',
5084
+ fontSize: 9,
5085
+ fontWeight: 600,
5086
+ fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
5087
+ color: colorForName(stroke.authorName),
5088
+ background: 'rgba(255,255,255,0.78)',
5089
+ padding: '1px 5px',
5090
+ borderRadius: 8,
5091
+ lineHeight: 1.4,
5092
+ }}
5093
+ >
5094
+ {stroke.authorName}
5095
+ </span>
5096
+ </div>
5097
+ </foreignObject>
5098
+ )}
4300
5099
  </g>
4301
5100
  );
4302
5101
  }
@@ -4482,7 +5281,6 @@ function StrokeNodeBase({
4482
5281
  const y = Math.min(stroke.y, stroke.y + stroke.h);
4483
5282
  const w = Math.abs(stroke.w);
4484
5283
  const h = Math.abs(stroke.h);
4485
- const chipW = Math.max(56, stroke.label.length * SECTION_LABEL_FONT * 0.62 + 18);
4486
5284
  return (
4487
5285
  <g data-id={stroke.id} data-tool="section">
4488
5286
  {/* Region body — pure backdrop, CLICK-THROUGH (FigJam: content on a
@@ -4518,31 +5316,10 @@ function StrokeNodeBase({
4518
5316
  pointerEvents="stroke"
4519
5317
  />
4520
5318
  ) : null}
4521
- {/* Label chip above the top-left corner — also a grab handle. */}
4522
- <g pointerEvents={hitMode}>
4523
- <rect
4524
- x={x}
4525
- y={y - SECTION_LABEL_H - 4}
4526
- width={chipW}
4527
- height={SECTION_LABEL_H}
4528
- rx={5}
4529
- ry={5}
4530
- fill={stroke.color}
4531
- fillOpacity={0.16}
4532
- />
4533
- <text
4534
- x={x + 9}
4535
- y={y - SECTION_LABEL_H / 2 - 4}
4536
- dominantBaseline="middle"
4537
- fontSize={SECTION_LABEL_FONT}
4538
- fill={stroke.color}
4539
- style={{
4540
- fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
4541
- }}
4542
- >
4543
- {stroke.label}
4544
- </text>
4545
- </g>
5319
+ {/* Label chip above the top-left corner — also a grab handle. Hidden
5320
+ while the rename editor (StandaloneTextEditor) is up at the same
5321
+ spot, else the old label reads through behind the new text. */}
5322
+ {editing ? null : <SectionLabelChip stroke={stroke} x={x} y={y} hitMode={hitMode} />}
4546
5323
  </g>
4547
5324
  );
4548
5325
  }