@1agh/maude 0.42.0 → 0.44.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 (332) 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/acp/bridge.ts +44 -10
  4. package/apps/studio/acp/env.ts +23 -0
  5. package/apps/studio/acp/login-state.ts +263 -0
  6. package/apps/studio/acp/plugin-bootstrap.ts +31 -25
  7. package/apps/studio/acp/probe.ts +152 -1
  8. package/apps/studio/annotations-layer.tsx +1451 -324
  9. package/apps/studio/annotations-model.ts +58 -0
  10. package/apps/studio/api.ts +568 -24
  11. package/apps/studio/bin/_audio-search.mjs +140 -0
  12. package/apps/studio/bin/_import-asset-pdf-worker.mjs +34 -0
  13. package/apps/studio/bin/_import-asset.mjs +815 -0
  14. package/apps/studio/bin/_import-brand.mjs +635 -0
  15. package/apps/studio/bin/_import-tokens-alias-resolver.mjs +95 -0
  16. package/apps/studio/bin/_import-tokens.mjs +1201 -0
  17. package/apps/studio/bin/_ingest-footage.mjs +386 -0
  18. package/apps/studio/bin/_probe-footage-playwright.mjs +269 -0
  19. package/apps/studio/bin/_transcribe.mjs +419 -0
  20. package/apps/studio/bin/_transcribe.test.mjs +80 -0
  21. package/apps/studio/bin/_video-playwright.mjs +141 -71
  22. package/apps/studio/bin/audio-search.sh +28 -0
  23. package/apps/studio/bin/generate.sh +154 -0
  24. package/apps/studio/bin/import-asset.sh +34 -0
  25. package/apps/studio/bin/import-brand.sh +29 -0
  26. package/apps/studio/bin/import-tokens.sh +33 -0
  27. package/apps/studio/bin/ingest-footage.sh +27 -0
  28. package/apps/studio/bin/photo-adjust.sh +163 -0
  29. package/apps/studio/bin/photo-bg-remove.sh +258 -0
  30. package/apps/studio/bin/probe-footage.sh +28 -0
  31. package/apps/studio/bin/read-annotations.mjs +125 -2
  32. package/apps/studio/bin/transcribe.sh +39 -0
  33. package/apps/studio/canvas-edit.ts +656 -12
  34. package/apps/studio/canvas-lib.tsx +637 -10
  35. package/apps/studio/canvas-pipeline.ts +65 -7
  36. package/apps/studio/canvas-shell.tsx +156 -16
  37. package/apps/studio/client/app.jsx +2385 -699
  38. package/apps/studio/client/export-center.jsx +35 -1
  39. package/apps/studio/client/generate-dialog.jsx +352 -0
  40. package/apps/studio/client/github.js +18 -0
  41. package/apps/studio/client/inspector-controls.jsx +781 -0
  42. package/apps/studio/client/panels/BrandUploadPanel.jsx +233 -0
  43. package/apps/studio/client/panels/ChatPanel.jsx +15 -33
  44. package/apps/studio/client/panels/GitPanel.jsx +8 -0
  45. package/apps/studio/client/panels/IntroVideoDialog.jsx +44 -0
  46. package/apps/studio/client/panels/OnboardingWizard.jsx +12 -0
  47. package/apps/studio/client/panels/ReadinessList.jsx +227 -7
  48. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +133 -21
  49. package/apps/studio/client/panels/SettingsPanel.jsx +828 -0
  50. package/apps/studio/client/panels/SetupChecklist.jsx +223 -0
  51. package/apps/studio/client/panels/StickerPicker.jsx +160 -0
  52. package/apps/studio/client/panels/TimelinePanel.jsx +15 -2
  53. package/apps/studio/client/photo-knobs.jsx +432 -0
  54. package/apps/studio/client/styles/3-shell-maude.css +376 -28
  55. package/apps/studio/client/styles/4-components.css +223 -0
  56. package/apps/studio/client/styles/6-acp-chat.css +86 -0
  57. package/apps/studio/client/tour/quick-setup-tour.js +39 -0
  58. package/apps/studio/commands/annotation-strokes-command.ts +13 -3
  59. package/apps/studio/commands/edit-source-command.ts +31 -4
  60. package/apps/studio/config.schema.json +70 -0
  61. package/apps/studio/context-menu.tsx +42 -8
  62. package/apps/studio/context.ts +16 -0
  63. package/apps/studio/design-setup-readiness.ts +115 -0
  64. package/apps/studio/dist/client.bundle.js +5572 -21
  65. package/apps/studio/dist/comment-mount.js +2 -2
  66. package/apps/studio/dist/runtime/.min-sizes.json +1 -0
  67. package/apps/studio/dist/runtime/@imgly_background-removal.js +5543 -0
  68. package/apps/studio/dist/runtime/pixi-js.js +519 -519
  69. package/apps/studio/dist/styles.css +1 -1
  70. package/apps/studio/dom-selection.ts +25 -0
  71. package/apps/studio/draw/palette.ts +34 -0
  72. package/apps/studio/exporters/jobs.ts +47 -5
  73. package/apps/studio/exporters/video.ts +24 -4
  74. package/apps/studio/footage/schema.test.ts +287 -0
  75. package/apps/studio/footage/schema.ts +662 -0
  76. package/apps/studio/footage-store.ts +235 -0
  77. package/apps/studio/fs-watch.ts +10 -0
  78. package/apps/studio/generation/adapters/elevenlabs.ts +370 -0
  79. package/apps/studio/generation/adapters/gemini.ts +542 -0
  80. package/apps/studio/generation/adapters/groq.test.ts +80 -0
  81. package/apps/studio/generation/adapters/groq.ts +247 -0
  82. package/apps/studio/generation/audio-library.test.ts +95 -0
  83. package/apps/studio/generation/audio-library.ts +156 -0
  84. package/apps/studio/generation/captions.test.ts +78 -0
  85. package/apps/studio/generation/captions.ts +132 -0
  86. package/apps/studio/generation/download.ts +76 -0
  87. package/apps/studio/generation/elevenlabs.test.ts +223 -0
  88. package/apps/studio/generation/gemini.test.ts +350 -0
  89. package/apps/studio/generation/jobs.test.ts +128 -0
  90. package/apps/studio/generation/jobs.ts +243 -0
  91. package/apps/studio/generation/keys.test.ts +75 -0
  92. package/apps/studio/generation/keys.ts +162 -0
  93. package/apps/studio/generation/prefs.test.ts +95 -0
  94. package/apps/studio/generation/prefs.ts +82 -0
  95. package/apps/studio/generation/registry.ts +88 -0
  96. package/apps/studio/generation/types.test.ts +123 -0
  97. package/apps/studio/generation/types.ts +266 -0
  98. package/apps/studio/generation/whisper-models.test.ts +152 -0
  99. package/apps/studio/generation/whisper-models.ts +257 -0
  100. package/apps/studio/generation/whisper-spike-results.md +61 -0
  101. package/apps/studio/git/endpoints.ts +68 -7
  102. package/apps/studio/git/service.ts +199 -21
  103. package/apps/studio/github/service.ts +67 -0
  104. package/apps/studio/handoff.ts +3 -2
  105. package/apps/studio/http.ts +1039 -22
  106. package/apps/studio/input-router.tsx +10 -0
  107. package/apps/studio/inspect.ts +53 -1
  108. package/apps/studio/media/intro.mp4 +0 -0
  109. package/apps/studio/media-commit-chain.ts +119 -0
  110. package/apps/studio/paths.ts +20 -0
  111. package/apps/studio/photo/filters.ts +285 -0
  112. package/apps/studio/photo/pipeline.ts +607 -0
  113. package/apps/studio/photo/schema.ts +367 -0
  114. package/apps/studio/photo-store.ts +154 -0
  115. package/apps/studio/readiness.ts +108 -20
  116. package/apps/studio/runtime-bundle.ts +29 -6
  117. package/apps/studio/scaffold-design.ts +1145 -0
  118. package/apps/studio/server.ts +18 -2
  119. package/apps/studio/stickers/figjam-doodle/manifest.json +104 -0
  120. package/apps/studio/stickers/figjam-doodle/slice1.png +0 -0
  121. package/apps/studio/stickers/figjam-doodle/slice10.png +0 -0
  122. package/apps/studio/stickers/figjam-doodle/slice11.png +0 -0
  123. package/apps/studio/stickers/figjam-doodle/slice12.png +0 -0
  124. package/apps/studio/stickers/figjam-doodle/slice13.png +0 -0
  125. package/apps/studio/stickers/figjam-doodle/slice14.png +0 -0
  126. package/apps/studio/stickers/figjam-doodle/slice15.png +0 -0
  127. package/apps/studio/stickers/figjam-doodle/slice16.png +0 -0
  128. package/apps/studio/stickers/figjam-doodle/slice17.png +0 -0
  129. package/apps/studio/stickers/figjam-doodle/slice18.png +0 -0
  130. package/apps/studio/stickers/figjam-doodle/slice19.png +0 -0
  131. package/apps/studio/stickers/figjam-doodle/slice2.png +0 -0
  132. package/apps/studio/stickers/figjam-doodle/slice20.png +0 -0
  133. package/apps/studio/stickers/figjam-doodle/slice21.png +0 -0
  134. package/apps/studio/stickers/figjam-doodle/slice22.png +0 -0
  135. package/apps/studio/stickers/figjam-doodle/slice23.png +0 -0
  136. package/apps/studio/stickers/figjam-doodle/slice24.png +0 -0
  137. package/apps/studio/stickers/figjam-doodle/slice3.png +0 -0
  138. package/apps/studio/stickers/figjam-doodle/slice4.png +0 -0
  139. package/apps/studio/stickers/figjam-doodle/slice5.png +0 -0
  140. package/apps/studio/stickers/figjam-doodle/slice6.png +0 -0
  141. package/apps/studio/stickers/figjam-doodle/slice7.png +0 -0
  142. package/apps/studio/stickers/figjam-doodle/slice8.png +0 -0
  143. package/apps/studio/stickers/figjam-doodle/slice9.png +0 -0
  144. package/apps/studio/stickers/life-style/best.png +0 -0
  145. package/apps/studio/stickers/life-style/come-on.png +0 -0
  146. package/apps/studio/stickers/life-style/cut.png +0 -0
  147. package/apps/studio/stickers/life-style/detox.png +0 -0
  148. package/apps/studio/stickers/life-style/just.png +0 -0
  149. package/apps/studio/stickers/life-style/manifest.json +68 -0
  150. package/apps/studio/stickers/life-style/mirror.png +0 -0
  151. package/apps/studio/stickers/life-style/nice.png +0 -0
  152. package/apps/studio/stickers/life-style/objects.png +0 -0
  153. package/apps/studio/stickers/life-style/ok.png +0 -0
  154. package/apps/studio/stickers/life-style/queen.png +0 -0
  155. package/apps/studio/stickers/life-style/star.png +0 -0
  156. package/apps/studio/stickers/life-style/sun.png +0 -0
  157. package/apps/studio/stickers/life-style/water.png +0 -0
  158. package/apps/studio/stickers/life-style/yeah.png +0 -0
  159. package/apps/studio/stickers/life-style/you-can.png +0 -0
  160. package/apps/studio/stickers/opposing-thoughts/check-the-deets.png +0 -0
  161. package/apps/studio/stickers/opposing-thoughts/cut-it.png +0 -0
  162. package/apps/studio/stickers/opposing-thoughts/dope.png +0 -0
  163. package/apps/studio/stickers/opposing-thoughts/fresh.png +0 -0
  164. package/apps/studio/stickers/opposing-thoughts/hot.png +0 -0
  165. package/apps/studio/stickers/opposing-thoughts/idea.png +0 -0
  166. package/apps/studio/stickers/opposing-thoughts/keep-exploring.png +0 -0
  167. package/apps/studio/stickers/opposing-thoughts/killed-it.png +0 -0
  168. package/apps/studio/stickers/opposing-thoughts/love-it.png +0 -0
  169. package/apps/studio/stickers/opposing-thoughts/manifest.json +88 -0
  170. package/apps/studio/stickers/opposing-thoughts/not-sure.png +0 -0
  171. package/apps/studio/stickers/opposing-thoughts/ok.png +0 -0
  172. package/apps/studio/stickers/opposing-thoughts/pure-gold.png +0 -0
  173. package/apps/studio/stickers/opposing-thoughts/save-for-later.png +0 -0
  174. package/apps/studio/stickers/opposing-thoughts/steal-this.png +0 -0
  175. package/apps/studio/stickers/opposing-thoughts/take-a-peek.png +0 -0
  176. package/apps/studio/stickers/opposing-thoughts/this-or-that.png +0 -0
  177. package/apps/studio/stickers/opposing-thoughts/thoughts.png +0 -0
  178. package/apps/studio/stickers/opposing-thoughts/vibes.png +0 -0
  179. package/apps/studio/stickers/opposing-thoughts/winner.png +0 -0
  180. package/apps/studio/stickers/opposing-thoughts/wip.png +0 -0
  181. package/apps/studio/stickers/project-status/group-100.png +0 -0
  182. package/apps/studio/stickers/project-status/group-101.png +0 -0
  183. package/apps/studio/stickers/project-status/group-102.png +0 -0
  184. package/apps/studio/stickers/project-status/group-103.png +0 -0
  185. package/apps/studio/stickers/project-status/group-104.png +0 -0
  186. package/apps/studio/stickers/project-status/group-105.png +0 -0
  187. package/apps/studio/stickers/project-status/group-106.png +0 -0
  188. package/apps/studio/stickers/project-status/group-107.png +0 -0
  189. package/apps/studio/stickers/project-status/group-108.png +0 -0
  190. package/apps/studio/stickers/project-status/group-109.png +0 -0
  191. package/apps/studio/stickers/project-status/group-110.png +0 -0
  192. package/apps/studio/stickers/project-status/group-111.png +0 -0
  193. package/apps/studio/stickers/project-status/group-112.png +0 -0
  194. package/apps/studio/stickers/project-status/group-113.png +0 -0
  195. package/apps/studio/stickers/project-status/group-114.png +0 -0
  196. package/apps/studio/stickers/project-status/group-115.png +0 -0
  197. package/apps/studio/stickers/project-status/group-117.png +0 -0
  198. package/apps/studio/stickers/project-status/group-118.png +0 -0
  199. package/apps/studio/stickers/project-status/group-119.png +0 -0
  200. package/apps/studio/stickers/project-status/group-120.png +0 -0
  201. package/apps/studio/stickers/project-status/group-121.png +0 -0
  202. package/apps/studio/stickers/project-status/group-122.png +0 -0
  203. package/apps/studio/stickers/project-status/group-41.png +0 -0
  204. package/apps/studio/stickers/project-status/group-42.png +0 -0
  205. package/apps/studio/stickers/project-status/group-43.png +0 -0
  206. package/apps/studio/stickers/project-status/group-44.png +0 -0
  207. package/apps/studio/stickers/project-status/group-45.png +0 -0
  208. package/apps/studio/stickers/project-status/group-46.png +0 -0
  209. package/apps/studio/stickers/project-status/group-47.png +0 -0
  210. package/apps/studio/stickers/project-status/group-48.png +0 -0
  211. package/apps/studio/stickers/project-status/group-49.png +0 -0
  212. package/apps/studio/stickers/project-status/group-50.png +0 -0
  213. package/apps/studio/stickers/project-status/group-51.png +0 -0
  214. package/apps/studio/stickers/project-status/group-52.png +0 -0
  215. package/apps/studio/stickers/project-status/group-53.png +0 -0
  216. package/apps/studio/stickers/project-status/group-54.png +0 -0
  217. package/apps/studio/stickers/project-status/group-55.png +0 -0
  218. package/apps/studio/stickers/project-status/group-56.png +0 -0
  219. package/apps/studio/stickers/project-status/group-57.png +0 -0
  220. package/apps/studio/stickers/project-status/group-58.png +0 -0
  221. package/apps/studio/stickers/project-status/group-59.png +0 -0
  222. package/apps/studio/stickers/project-status/group-60.png +0 -0
  223. package/apps/studio/stickers/project-status/group-61.png +0 -0
  224. package/apps/studio/stickers/project-status/group-62.png +0 -0
  225. package/apps/studio/stickers/project-status/group-63.png +0 -0
  226. package/apps/studio/stickers/project-status/group-64.png +0 -0
  227. package/apps/studio/stickers/project-status/group-65.png +0 -0
  228. package/apps/studio/stickers/project-status/group-66.png +0 -0
  229. package/apps/studio/stickers/project-status/group-67.png +0 -0
  230. package/apps/studio/stickers/project-status/group-68.png +0 -0
  231. package/apps/studio/stickers/project-status/group-69.png +0 -0
  232. package/apps/studio/stickers/project-status/group-70.png +0 -0
  233. package/apps/studio/stickers/project-status/group-71.png +0 -0
  234. package/apps/studio/stickers/project-status/group-72.png +0 -0
  235. package/apps/studio/stickers/project-status/group-73.png +0 -0
  236. package/apps/studio/stickers/project-status/group-74.png +0 -0
  237. package/apps/studio/stickers/project-status/group-75.png +0 -0
  238. package/apps/studio/stickers/project-status/group-76.png +0 -0
  239. package/apps/studio/stickers/project-status/group-77.png +0 -0
  240. package/apps/studio/stickers/project-status/group-78.png +0 -0
  241. package/apps/studio/stickers/project-status/group-79.png +0 -0
  242. package/apps/studio/stickers/project-status/group-80.png +0 -0
  243. package/apps/studio/stickers/project-status/group-81.png +0 -0
  244. package/apps/studio/stickers/project-status/group-82.png +0 -0
  245. package/apps/studio/stickers/project-status/group-83.png +0 -0
  246. package/apps/studio/stickers/project-status/group-84.png +0 -0
  247. package/apps/studio/stickers/project-status/group-85.png +0 -0
  248. package/apps/studio/stickers/project-status/group-86.png +0 -0
  249. package/apps/studio/stickers/project-status/group-87.png +0 -0
  250. package/apps/studio/stickers/project-status/group-88.png +0 -0
  251. package/apps/studio/stickers/project-status/group-89.png +0 -0
  252. package/apps/studio/stickers/project-status/group-90.png +0 -0
  253. package/apps/studio/stickers/project-status/group-91.png +0 -0
  254. package/apps/studio/stickers/project-status/group-92.png +0 -0
  255. package/apps/studio/stickers/project-status/group-93.png +0 -0
  256. package/apps/studio/stickers/project-status/group-94.png +0 -0
  257. package/apps/studio/stickers/project-status/group-96.png +0 -0
  258. package/apps/studio/stickers/project-status/group-97.png +0 -0
  259. package/apps/studio/stickers/project-status/group-98.png +0 -0
  260. package/apps/studio/stickers/project-status/group-99.png +0 -0
  261. package/apps/studio/stickers/project-status/manifest.json +328 -0
  262. package/apps/studio/test/_helpers.ts +15 -2
  263. package/apps/studio/test/acp-bootstrap-brief.test.ts +11 -0
  264. package/apps/studio/test/acp-bridge.test.ts +34 -1
  265. package/apps/studio/test/acp-commands.test.ts +2 -1
  266. package/apps/studio/test/acp-env.test.ts +30 -0
  267. package/apps/studio/test/acp-plugin-bootstrap.test.ts +23 -57
  268. package/apps/studio/test/acp-session-plugins.test.ts +72 -1
  269. package/apps/studio/test/annotation-strokes-command.test.ts +17 -0
  270. package/apps/studio/test/annotations-layer.test.ts +174 -0
  271. package/apps/studio/test/asset-api.test.ts +3 -3
  272. package/apps/studio/test/canvas-edit.test.ts +17 -6
  273. package/apps/studio/test/canvas-origin-gate.test.ts +50 -0
  274. package/apps/studio/test/csp-canvas-shell.test.ts +11 -0
  275. package/apps/studio/test/csrf-write-guard.test.ts +23 -1
  276. package/apps/studio/test/design-setup-readiness.test.ts +155 -0
  277. package/apps/studio/test/dynamic-text-edit.test.ts +162 -0
  278. package/apps/studio/test/edit-source-command.test.ts +23 -0
  279. package/apps/studio/test/element-structural-edit.test.ts +154 -0
  280. package/apps/studio/test/fixtures/fake-claude-auth.mjs +13 -0
  281. package/apps/studio/test/footage-store.test.ts +100 -0
  282. package/apps/studio/test/generate-route.test.ts +106 -0
  283. package/apps/studio/test/git-branches.test.ts +97 -0
  284. package/apps/studio/test/github-api.test.ts +56 -1
  285. package/apps/studio/test/import-asset-browser.test.ts +64 -0
  286. package/apps/studio/test/import-asset-route.test.ts +71 -0
  287. package/apps/studio/test/import-asset.test.ts +399 -0
  288. package/apps/studio/test/import-brand-browser.test.ts +88 -0
  289. package/apps/studio/test/import-brand-route.test.ts +120 -0
  290. package/apps/studio/test/import-brand.test.ts +206 -0
  291. package/apps/studio/test/import-tokens.test.ts +722 -0
  292. package/apps/studio/test/input-router.test.ts +33 -0
  293. package/apps/studio/test/inspect-script-syntax.test.ts +53 -0
  294. package/apps/studio/test/media-commit-chain.test.ts +210 -0
  295. package/apps/studio/test/photo-bg-remove-validation.test.ts +71 -0
  296. package/apps/studio/test/photo-canvas-bundle.test.ts +61 -0
  297. package/apps/studio/test/photo-edit-api.test.ts +201 -0
  298. package/apps/studio/test/photo-filters.test.ts +149 -0
  299. package/apps/studio/test/photo-pipeline.test.ts +106 -0
  300. package/apps/studio/test/photo-taxonomy.test.ts +96 -0
  301. package/apps/studio/test/read-annotations.test.ts +164 -0
  302. package/apps/studio/test/readiness.test.ts +13 -11
  303. package/apps/studio/test/scaffold-design.test.ts +122 -0
  304. package/apps/studio/test/shell-importmap.test.ts +49 -0
  305. package/apps/studio/test/text-editability-stamp.test.ts +131 -0
  306. package/apps/studio/test/timeline-parse.test.ts +57 -0
  307. package/apps/studio/test/tool-palette-insert-anchor.test.ts +5 -3
  308. package/apps/studio/test/tour-overlay.test.tsx +16 -0
  309. package/apps/studio/test/undo-stack.test.ts +33 -0
  310. package/apps/studio/test/video-asset.test.ts +5 -5
  311. package/apps/studio/test/video-render-bridge.test.ts +23 -1
  312. package/apps/studio/text-caret.ts +239 -0
  313. package/apps/studio/tool-palette.tsx +49 -21
  314. package/apps/studio/ui-prefs.ts +130 -0
  315. package/apps/studio/undo-stack.ts +94 -2
  316. package/apps/studio/use-annotation-resize.tsx +4 -4
  317. package/apps/studio/use-canvas-media-drop.tsx +40 -1
  318. package/apps/studio/use-chrome-visibility.tsx +8 -2
  319. package/apps/studio/use-selection-set.tsx +21 -0
  320. package/apps/studio/video-comp.tsx +48 -7
  321. package/apps/studio/whats-new.json +149 -0
  322. package/apps/studio/ws.ts +5 -0
  323. package/cli/bin/maude.mjs +6 -6
  324. package/cli/commands/design.mjs +67 -3
  325. package/cli/lib/gitignore-block.mjs +2 -0
  326. package/cli/lib/pkg-root.mjs +107 -0
  327. package/cli/lib/pkg-root.test.mjs +79 -0
  328. package/cli/lib/reconstruct-toolset.test.mjs +194 -0
  329. package/cli/lib/update-check.mjs +8 -0
  330. package/package.json +13 -11
  331. package/plugins/design/dependencies.json +18 -0
  332. 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,10 +158,20 @@ 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 { createMediaCommitChain, type MediaCommitResult } from './media-commit-chain.ts';
162
+ import { mountCaret, placeCaretAt } from './text-caret.ts';
163
+ import {
164
+ AnnotationResizeOverlay,
165
+ bboxResize,
166
+ type Corner,
167
+ padDX,
168
+ padDY,
169
+ type ResizeMods,
170
+ } from './use-annotation-resize.tsx';
160
171
  import { useAnnotationSelectionOptional } from './use-annotation-selection.tsx';
161
172
  import { useAnnotationsVisibility } from './use-annotations-visibility.tsx';
162
173
  import {
174
+ BATCH_DROP_CASCADE_PX,
163
175
  isHttpUrl,
164
176
  linkDomain,
165
177
  prettifyUrl,
@@ -168,7 +180,7 @@ import {
168
180
  useCanvasMediaDrop,
169
181
  } from './use-canvas-media-drop.tsx';
170
182
  import { useChromeVisibility } from './use-chrome-visibility.tsx';
171
- import { useCollab } from './use-collab.tsx';
183
+ import { colorForName, useCollab } from './use-collab.tsx';
172
184
  import { useSelectionSetOptional } from './use-selection-set.tsx';
173
185
  import { type ShapeKind, useToolMode } from './use-tool-mode.tsx';
174
186
  import { useUndoSinks, useUndoStackOptional } from './use-undo-stack.tsx';
@@ -386,6 +398,123 @@ function resolveAssetHref(href: string): string {
386
398
  return /^assets\//.test(href) ? `/${canvasDesignRel()}/${href}` : href;
387
399
  }
388
400
 
401
+ /**
402
+ * An optimistic ImageStroke's `blob:`/`data:` href must never be PUT to the
403
+ * server. `sanitizeAnnotationSvg` (api.ts) strips an href it doesn't
404
+ * recognize — keeping the `<image>` element, dropping only the attribute —
405
+ * so the server's STORED + broadcast SVG silently diverges from whatever the
406
+ * client just sent. That divergence defeats the collab-echo self-suppression
407
+ * guard (the `recentSelfSvgsRef` history, near `putStrokes` below): the
408
+ * echo's content no longer matches anything we recorded as "already
409
+ * applied", so a real `setStrokesState` fires from the (href-stripped)
410
+ * server copy — which can wipe out a SIBLING stroke's still-in-flight
411
+ * optimistic insert that was never itself part of that PUT. This is the
412
+ * concrete failure a 3-file concurrent drop hit: one
413
+ * image ended up href-stripped (a blank frame) and another was dropped
414
+ * entirely once an unrelated commit's echo round-tripped while both were
415
+ * still uploading. See media-commit-chain.ts for the sibling accumulator fix
416
+ * — this closes the other half, at the persistence layer.
417
+ */
418
+ function isEphemeralHref(s: Stroke): boolean {
419
+ return s.tool === 'image' && /^(?:blob|data):/i.test(s.href);
420
+ }
421
+
422
+ /**
423
+ * Fold a local mutation's `next` against the live-rendered `prev`, restoring
424
+ * only strokes that are genuinely concurrent additions from ANOTHER
425
+ * in-flight commit — never reverting THIS mutation's own deletions.
426
+ * Disambiguated via `opBefore`, the baseline this mutation's `next` was
427
+ * itself computed from: an id present in `prev` but absent from BOTH
428
+ * `opBefore` and `next` was added by someone else after `opBefore` was
429
+ * captured (fold it in — this is the sibling-insert race this helper was
430
+ * originally written for). An id present in `opBefore` but absent from
431
+ * `next` was deliberately removed BY THIS MUTATION and must stay removed
432
+ * even though `prev` (React's rendered state) hasn't caught up to that
433
+ * removal yet.
434
+ *
435
+ * The prior version of this helper compared only `prev` against `next` —
436
+ * exactly the shape of every delete (an id in `prev`, absent from `next`) —
437
+ * so it silently folded every erased stroke straight back in locally, while
438
+ * the smaller, correct set still went out over PUT. Backspace looked like a
439
+ * no-op until a reload picked up the server's already-correct copy.
440
+ */
441
+ export function reconcileCommit(
442
+ prev: readonly Stroke[],
443
+ opBefore: readonly Stroke[],
444
+ next: readonly Stroke[]
445
+ ): Stroke[] {
446
+ const beforeIds = new Set(opBefore.map((s) => s.id));
447
+ const nextIds = new Set(next.map((s) => s.id));
448
+ const extra = prev.filter((s) => !beforeIds.has(s.id) && !nextIds.has(s.id));
449
+ return extra.length ? [...next, ...extra] : (next as Stroke[]);
450
+ }
451
+
452
+ /**
453
+ * Fold a FOREIGN collab-echo snapshot against local state. A foreign
454
+ * broadcast legitimately omits an id that was deleted (by us or a peer) —
455
+ * reviving it just because local `prev` hasn't caught up would make deletes
456
+ * unsyncable across tabs/peers. The only strokes worth resurrecting here are
457
+ * ones that are still purely local, not-yet-synced optimistic previews (an
458
+ * ephemeral blob:/data: href image mid-upload) that a foreign broadcast could
459
+ * never have known about in the first place.
460
+ */
461
+ export function reconcileForeignEcho(
462
+ prev: readonly Stroke[],
463
+ incoming: readonly Stroke[]
464
+ ): Stroke[] {
465
+ const incomingIds = new Set(incoming.map((s) => s.id));
466
+ const extra = prev.filter((s) => !incomingIds.has(s.id) && isEphemeralHref(s));
467
+ return extra.length ? [...incoming, ...extra] : (incoming as Stroke[]);
468
+ }
469
+
470
+ /**
471
+ * Decide what `createImageFromFile`'s post-upload swap should do with a
472
+ * given `before` chain link. Extracted as a pure function (no React) so the
473
+ * delete-during-upload race is unit-testable: `wasDeleted` reflects whether
474
+ * `deleteStrokes` recorded this id (via `deletedStrokeIdsRef`) before the
475
+ * upload resolved — checking that FIRST, rather than inferring intent from
476
+ * `before`'s membership alone, is what stops a Backspace-then-still-
477
+ * uploading image from resurrecting once the upload lands (and syncing the
478
+ * resurrection to every collab peer). When not deleted, an id absent from
479
+ * `before` is assumed to be render lag (this same file's optimistic insert
480
+ * hasn't rendered yet) rather than a delete, and gets folded back in.
481
+ */
482
+ export function resolveImageUploadSwap(
483
+ before: readonly Stroke[],
484
+ id: string,
485
+ optimistic: Stroke,
486
+ realHref: string,
487
+ wasDeleted: boolean
488
+ ): MediaCommitResult<Stroke> | null {
489
+ if (wasDeleted) {
490
+ if (!before.some((s) => s.id === id)) return null;
491
+ return { after: before.filter((s) => s.id !== id), label: 'add image' };
492
+ }
493
+ const base = before.some((s) => s.id === id) ? before : [...before, optimistic];
494
+ const after = base.map((s) => (s.id === id ? ({ ...s, href: realHref } as Stroke) : s));
495
+ // The optimistic blob: entry was never itself committed, so the undo
496
+ // "before" excludes it — undo should remove the image outright, not
497
+ // restore a revoked blob:.
498
+ const commitBefore = base.filter((s) => s.id !== id);
499
+ return { after, commitBefore, label: 'add image' };
500
+ }
501
+
502
+ // feature-bulk-media-insert Task 11 — classify an already-uploaded
503
+ // `assets/<sha8>.<ext>` path client-side, mirroring the same extension sets
504
+ // `listAssets` uses server-side (api.ts). The picker only ever surfaces paths
505
+ // it already listed via /_api/assets, so every path resolves to one of these;
506
+ // `null` (unrecognized) is treated as "skip" by the caller.
507
+ const ANNOTATION_IMAGE_EXT = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'svg']);
508
+ const ANNOTATION_VIDEO_EXT = new Set(['mp4', 'webm', 'mov', 'm4v', 'ogg']);
509
+ const ANNOTATION_AUDIO_EXT = new Set(['mp3', 'wav', 'm4a', 'aac', 'flac', 'oga', 'opus']);
510
+ function classifyAssetPathKind(path: string): 'image' | 'video' | 'audio' | null {
511
+ const ext = (path.split('.').pop() || '').toLowerCase();
512
+ if (ANNOTATION_IMAGE_EXT.has(ext)) return 'image';
513
+ if (ANNOTATION_VIDEO_EXT.has(ext)) return 'video';
514
+ if (ANNOTATION_AUDIO_EXT.has(ext)) return 'audio';
515
+ return null;
516
+ }
517
+
389
518
  /**
390
519
  * DDR-150 dogfood #8 — true when a pointer/click event targets the inline
391
520
  * media player inside a mediaref chip. Every document-capture annotation
@@ -399,6 +528,70 @@ function isMediaPlayerTarget(e: Event): boolean {
399
528
  return !!(t && typeof t.closest === 'function' && t.closest('[data-mediaref-player]'));
400
529
  }
401
530
 
531
+ /**
532
+ * Dogfood fix — the Text tool's click-through target for ARTBOARD text. This
533
+ * layer's own input-capture div sits above the artboard content (z-index), so
534
+ * `elementsFromPoint` (the full z-stack at that point, topmost first) is
535
+ * needed to see past it. Mirrors canvas-shell.tsx's own `isLeafText` check
536
+ * exactly (all children are text nodes) so "would this be a leaf-text edit
537
+ * target" agrees between the Text-tool click-through and the native
538
+ * double-click path it delegates to.
539
+ */
540
+ function findEditableElementAt(clientX: number, clientY: number): HTMLElement | null {
541
+ if (typeof document === 'undefined' || typeof document.elementsFromPoint !== 'function') {
542
+ return null;
543
+ }
544
+ const stack = document.elementsFromPoint(clientX, clientY);
545
+ for (const el of stack) {
546
+ const stamped = el.closest?.('[data-cd-id]') as HTMLElement | null;
547
+ if (!stamped) continue;
548
+ const kids = Array.from(stamped.childNodes);
549
+ const isLeafText = kids.length > 0 && kids.every((n) => n.nodeType === 3);
550
+ return isLeafText ? stamped : null;
551
+ }
552
+ return null;
553
+ }
554
+
555
+ /**
556
+ * Phase 4 (unified-text-editing) — the Text tool's click-through target for
557
+ * ANNOTATION strokes. Deliberately GEOMETRIC (world-coord bbox containment),
558
+ * not DOM elementsFromPoint: while a draw tool is armed, every stroke node
559
+ * renders with pointer-events:none (`hitMode`), which makes it invisible to
560
+ * DOM hit-testing — measured in the WKWebView harness. Walks strokes topmost
561
+ * (last-rendered) first. An anchored text is skipped — its HOST shape's bbox
562
+ * already covers it and resolves to the same editor. A section matches only
563
+ * on its label CHIP (geometry mirrored from SectionLabelChip) so a click
564
+ * inside the region body still drops a NEW text there.
565
+ */
566
+ const TEXT_EDITABLE_TOOLS = new Set(['text', 'sticky', 'rect', 'ellipse', 'polygon', 'section']);
567
+
568
+ function findTextStrokeAt(
569
+ wx: number,
570
+ wy: number,
571
+ strokes: readonly Stroke[],
572
+ zoom: number
573
+ ): string | null {
574
+ for (let i = strokes.length - 1; i >= 0; i--) {
575
+ const s = strokes[i];
576
+ if (!s || !TEXT_EDITABLE_TOOLS.has(s.tool)) continue;
577
+ if (s.tool === 'section') {
578
+ const x = Math.min(s.x, s.x + s.w);
579
+ const y = Math.min(s.y, s.y + s.h);
580
+ const fontSize = SECTION_LABEL_FONT / zoom;
581
+ const chipH = SECTION_LABEL_H / zoom;
582
+ const gap = 4 / zoom;
583
+ const chipW = Math.max(56 / zoom, s.label.length * fontSize * 0.62 + 18 / zoom);
584
+ if (wx >= x && wx <= x + chipW && wy >= y - chipH - gap && wy <= y - gap) return s.id;
585
+ continue;
586
+ }
587
+ if (s.tool === 'text' && s.anchorId != null && s.anchorId !== '') continue;
588
+ const bb = strokeBBox(s);
589
+ if (!bb) continue;
590
+ if (wx >= bb.x && wx <= bb.x + bb.w && wy >= bb.y && wy <= bb.y + bb.h) return s.id;
591
+ }
592
+ return null;
593
+ }
594
+
402
595
  // ─────────────────────────────────────────────────────────────────────────────
403
596
  // Styles
404
597
 
@@ -573,6 +766,12 @@ const ANNOT_CSS = `
573
766
  See DDR-067. (No backticks in this comment: the whole block is a JS template
574
767
  literal, so a backtick here would terminate the string.) */
575
768
  .dc-annot-editor, .dc-annot-editor * { cursor: text !important; }
769
+ /* Phase 7 (unified-text-editing) — hover affordance parity with artboard leaf
770
+ text: a standalone text stroke invites editing with the I-beam in Move mode
771
+ (double-click / Text-tool click enters its editor in place). Shapes and
772
+ stickies keep the selection arrow — their whole body is a move/select
773
+ target first. */
774
+ .dc-annot-svg text[data-tool="text"] { cursor: text; }
576
775
  /* FigJam v3 — connection dots on a selected bindable shape. The important flag
577
776
  beats use-tool-mode's blanket star-cursor rule (same fight as the editor +
578
777
  resize handles — DDR-067). */
@@ -645,7 +844,7 @@ export function useStrokesStore(): StrokesStoreValue | null {
645
844
  // the main tool palette, the in-canvas draw chrome, the minimap, and the
646
845
  // right-click menu. Clicks on these route to their own handlers.
647
846
  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';
847
+ '.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
848
 
650
849
  const HINTS_KEY = 'maude-annot-hints-v1';
651
850
 
@@ -670,6 +869,81 @@ function showOnceHint(key: string, msg: string): void {
670
869
  // can read the same state. Re-exported here for back-compat.
671
870
  export { useAnnotationsVisibility } from './use-annotations-visibility.tsx';
672
871
 
872
+ /**
873
+ * Phase 2 (whiteboard-improvements) — proportional group resize. Maps one
874
+ * stroke's geometry from its position inside the group's START bbox (`b0`)
875
+ * to the equivalent position inside the RESIZED bbox (`b1`) — the same
876
+ * affine transform applied to every selected member, so the whole selection
877
+ * scales as one rigid composition anchored at whichever corner `bboxResize`
878
+ * derived `b1` from (reused verbatim for the group's own outer bbox — see
879
+ * the pointerdown handler below). Returns null for a stroke with nothing to
880
+ * scale: anchored text inherits its host's bbox at render time already, so
881
+ * scaling it too would double-transform it.
882
+ */
883
+ function scaleStrokeInGroup(
884
+ s: Stroke,
885
+ b0: { x: number; y: number; w: number; h: number },
886
+ b1: { x: number; y: number; w: number; h: number }
887
+ ): Partial<Stroke> | null {
888
+ if (s.tool === 'text' && s.anchorId != null && s.anchorId !== '') return null;
889
+ const sx = b0.w === 0 ? 1 : b1.w / b0.w;
890
+ const sy = b0.h === 0 ? 1 : b1.h / b0.h;
891
+ // Non-uniform group stretches (no Shift held) still need ONE scalar for
892
+ // things that don't have an independent width/height, like font size.
893
+ const avgScale = (sx + sy) / 2;
894
+ const tp = (px: number, py: number): [number, number] => [
895
+ b1.x + (px - b0.x) * sx,
896
+ b1.y + (py - b0.y) * sy,
897
+ ];
898
+ if (s.tool === 'pen') {
899
+ return { points: s.points.map(([px, py]) => tp(px, py)) } as Partial<PenStroke>;
900
+ }
901
+ if (s.tool === 'arrow') {
902
+ const [x1, y1] = tp(s.x1, s.y1);
903
+ const [x2, y2] = tp(s.x2, s.y2);
904
+ return { x1, y1, x2, y2 } as Partial<ArrowStroke>;
905
+ }
906
+ if (s.tool === 'ellipse') {
907
+ const [cx, cy] = tp(s.cx, s.cy);
908
+ return {
909
+ cx,
910
+ cy,
911
+ rx: Math.max(1, s.rx * sx),
912
+ ry: Math.max(1, s.ry * sy),
913
+ } as Partial<EllipseStroke>;
914
+ }
915
+ if (s.tool === 'text') {
916
+ // Standalone (unanchored) text — its own origin scales with the group;
917
+ // font size scales by the average factor so a non-uniform stretch keeps
918
+ // the text legible instead of only stretching its box.
919
+ const [x, y] = tp(s.x ?? 0, s.y ?? 0);
920
+ return {
921
+ x,
922
+ y,
923
+ fontSize: Math.max(6, Math.round(s.fontSize * avgScale)),
924
+ } as Partial<TextStroke>;
925
+ }
926
+ if (
927
+ s.tool === 'rect' ||
928
+ s.tool === 'polygon' ||
929
+ s.tool === 'link' ||
930
+ s.tool === 'mediaref' ||
931
+ s.tool === 'section' ||
932
+ s.tool === 'image' ||
933
+ s.tool === 'sticky'
934
+ ) {
935
+ const bb = strokeBBox(s);
936
+ if (!bb) return null;
937
+ const [x, y] = tp(bb.x, bb.y);
938
+ const patch: Partial<Stroke> = { x, y, w: Math.max(1, bb.w * sx), h: Math.max(1, bb.h * sy) };
939
+ if (s.tool === 'sticky') {
940
+ (patch as Partial<StickyStroke>).fontSize = Math.max(6, Math.round(s.fontSize * avgScale));
941
+ }
942
+ return patch;
943
+ }
944
+ return null;
945
+ }
946
+
673
947
  // ─────────────────────────────────────────────────────────────────────────────
674
948
  // Component
675
949
 
@@ -757,6 +1031,12 @@ export function AnnotationsLayer() {
757
1031
  [visibilityCtx]
758
1032
  );
759
1033
  const [editingId, setEditingId] = useState<string | null>(null);
1034
+ // Phase 3 (unified-text-editing) — the click that OPENED the editor, so the
1035
+ // editor can place a collapsed caret at that exact character on mount
1036
+ // instead of select-all. Set only by pointer entry paths (double-click /
1037
+ // text-tool click-through); keyboard entries (Enter, fresh-create,
1038
+ // ⌘Enter chain) leave it null → select-all, the rename convention.
1039
+ const [editCaretPoint, setEditCaretPoint] = useState<{ x: number; y: number } | null>(null);
760
1040
 
761
1041
  const fileRef = useRef<string | undefined>(undefined);
762
1042
  const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -774,6 +1054,43 @@ export function AnnotationsLayer() {
774
1054
  */
775
1055
  const strokesRef = useRef<Stroke[]>(strokes);
776
1056
  strokesRef.current = strokes;
1057
+ // Phase 23 batch-drop fix — see media-commit-chain.ts. Every async media
1058
+ // completion (image upload swap, video/audio upload commit) that can be
1059
+ // triggered concurrently (N Finder files dropped at once) enqueues onto
1060
+ // this instead of calling commitStrokes directly off a `strokesRef.current`
1061
+ // read, so two completions landing before a render commit don't clobber
1062
+ // each other.
1063
+ const mediaCommitChainRef = useRef(
1064
+ createMediaCommitChain<Stroke>(
1065
+ () => strokesRef.current,
1066
+ (s) => s.id
1067
+ )
1068
+ );
1069
+ // Live-security-review finding (feature-bulk-media-insert follow-up) —
1070
+ // `createImageFromFile`'s upload-completion swap folds its optimistic
1071
+ // stroke back in whenever the chain's `before` doesn't have it yet,
1072
+ // reasoning "render lag, not a delete" (see the comment there). That
1073
+ // reasoning has no way to tell a genuine delete apart from render lag by
1074
+ // array membership alone — if the user drops an image then hits
1075
+ // Backspace before the upload resolves, the id is legitimately gone, and
1076
+ // the old code resurrected it anyway once the upload landed (and synced
1077
+ // the resurrection to every collab peer). Tracked explicitly here instead
1078
+ // of inferred: `deleteStrokes` records every id it removes; the swap
1079
+ // checks (and consumes) this before assuming absence means lag. Bounded
1080
+ // like `recentSelfSvgsRef` below — only ever holds ids a delete has
1081
+ // touched, which is small in practice, but capped for safety.
1082
+ const DELETED_STROKE_IDS_CAP = 128;
1083
+ const deletedStrokeIdsRef = useRef<Set<string>>(new Set());
1084
+ const rememberDeletedIds = useCallback((ids: readonly string[]) => {
1085
+ const set = deletedStrokeIdsRef.current;
1086
+ for (const id of ids) {
1087
+ set.add(id);
1088
+ if (set.size > DELETED_STROKE_IDS_CAP) {
1089
+ const oldest = set.values().next().value;
1090
+ if (oldest !== undefined) set.delete(oldest);
1091
+ }
1092
+ }
1093
+ }, []);
777
1094
 
778
1095
  const isDraw =
779
1096
  tool === 'pen' ||
@@ -800,10 +1117,28 @@ export function AnnotationsLayer() {
800
1117
  }, [ghostCapable, visible]);
801
1118
 
802
1119
  // Load existing annotations on mount.
803
- // Phase 8 Task 5 — seed lastAppliedSvgRef so the first Y.Map observe (when
804
- // collab connects shortly after this fetch lands) doesn't re-apply the
805
- // same content we just hydrated from REST.
806
- const lastAppliedSvgRef = useRef<string>('');
1120
+ // Self-echo suppression (Phase 8 Task 5, hardened feature-bulk-media-
1121
+ // insert follow-up). A single "last applied" string only catches the MOST
1122
+ // RECENT self-write: when the chain fires several rapid commits (a batch
1123
+ // drop), the server's broadcast of an EARLIER commit can arrive after a
1124
+ // LATER local commit has already moved "last applied" on — so the earlier
1125
+ // echo no longer matches, gets misread as a foreign change, and rolls
1126
+ // local state BACK to that stale snapshot. This is the confirmed cause of
1127
+ // images silently vanishing after a multi-file drop (live-tested: 12
1128
+ // mismatched/misapplied echoes correlated exactly with lost strokes across
1129
+ // a 30-batch stress run). Track a bounded HISTORY of our own recent writes
1130
+ // instead of just the latest one, so an out-of-order echo of any recent
1131
+ // self-write is still recognized and suppressed.
1132
+ const RECENT_SELF_SVG_CAP = 64;
1133
+ const recentSelfSvgsRef = useRef<Set<string>>(new Set());
1134
+ const rememberSelfSvg = useCallback((svg: string) => {
1135
+ const set = recentSelfSvgsRef.current;
1136
+ set.add(svg);
1137
+ if (set.size > RECENT_SELF_SVG_CAP) {
1138
+ const oldest = set.values().next().value;
1139
+ if (oldest !== undefined) set.delete(oldest);
1140
+ }
1141
+ }, []);
807
1142
  useEffect(() => {
808
1143
  const file = deriveFile();
809
1144
  fileRef.current = file;
@@ -818,7 +1153,7 @@ export function AnnotationsLayer() {
818
1153
  const loaded = svgToStrokes(text);
819
1154
  if (loaded.length) {
820
1155
  setStrokesState(loaded);
821
- lastAppliedSvgRef.current = text;
1156
+ rememberSelfSvg(text);
822
1157
  }
823
1158
  })
824
1159
  .catch(() => {
@@ -827,14 +1162,15 @@ export function AnnotationsLayer() {
827
1162
  return () => {
828
1163
  cancelled = true;
829
1164
  };
830
- }, []);
1165
+ }, [rememberSelfSvg]);
831
1166
 
832
1167
  // Phase 8 Task 5 — observe the Y.Map.annotations for live updates from
833
- // other tabs. Bail when the incoming SVG STRING is identical to the one
834
- // we last applied (covers the local echo round-trip without missing real
835
- // foreign changes). The prior length+first/last-id check was wrong: a
836
- // resize / move keeps the same id list, so all three predicates matched
837
- // even though geometry changed — foreign edits silently disappeared.
1168
+ // other tabs. Bail when the incoming SVG STRING matches any RECENT
1169
+ // self-write (covers the local echo round-trip, including an
1170
+ // out-of-order one, without missing real foreign changes). The prior
1171
+ // length+first/last-id check was wrong: a resize / move keeps the same id
1172
+ // list, so all three predicates matched even though geometry changed —
1173
+ // foreign edits silently disappeared.
838
1174
  const collab = useCollab();
839
1175
  useEffect(() => {
840
1176
  if (!collab) return;
@@ -842,9 +1178,10 @@ export function AnnotationsLayer() {
842
1178
  const apply = () => {
843
1179
  const svg = map.get('svg');
844
1180
  if (typeof svg !== 'string' || !svg) return;
845
- if (svg === lastAppliedSvgRef.current) return;
846
- lastAppliedSvgRef.current = svg;
847
- setStrokesState(svgToStrokes(svg));
1181
+ if (recentSelfSvgsRef.current.has(svg)) return;
1182
+ rememberSelfSvg(svg);
1183
+ const incoming = svgToStrokes(svg);
1184
+ setStrokesState((prev) => reconcileForeignEcho(prev, incoming));
848
1185
  };
849
1186
  apply();
850
1187
  map.observe(apply);
@@ -855,13 +1192,29 @@ export function AnnotationsLayer() {
855
1192
  /* doc destroyed before unmount */
856
1193
  }
857
1194
  };
858
- }, [collab]);
1195
+ }, [collab, rememberSelfSvg]);
859
1196
 
860
1197
  const undoStack = useUndoStackOptional();
861
1198
  const undoSinks = useUndoSinks();
862
1199
  const undoStackRef = useRef(undoStack);
863
1200
  undoStackRef.current = undoStack;
864
1201
 
1202
+ // feature-bulk-media-insert follow-up — dedicated PUT dispatch queue.
1203
+ // The undo-stack's own `inFlightRef` serializes its PUSH TASKS (each
1204
+ // awaits `cmd.do()` before the next task starts), but live network
1205
+ // capture during a rapid-fire batch drop showed the actual PUT REQUESTS
1206
+ // still overlapping (request N+1 starting before request N's response
1207
+ // arrived) — some other async hop between "task starts" and "fetch
1208
+ // fires" lets them interleave. Since every PUT body here is a strictly
1209
+ // growing superset (the media-commit-chain guarantees each commit is at
1210
+ // least as complete as the last), overlap is dangerous: if an EARLIER
1211
+ // (smaller) request's write lands on disk AFTER a LATER (larger) one's,
1212
+ // the smaller snapshot wins and silently erases the larger one's extra
1213
+ // strokes. A dedicated chain — mirroring `editApplyChainRef` in
1214
+ // client/app.jsx — makes the fetch DISPATCH itself wait for the
1215
+ // previous one's response, independent of whatever the undo-stack does.
1216
+ const putChainRef = useRef<Promise<void>>(Promise.resolve());
1217
+
865
1218
  /**
866
1219
  * Apply a `Stroke[]` snapshot: update local React state AND fire-and-forget
867
1220
  * PUT to the server. Used as the `putFn` injected into the
@@ -875,25 +1228,40 @@ export function AnnotationsLayer() {
875
1228
  * we push a command, so the server only sees one PUT per edit instead
876
1229
  * of two-step racing.
877
1230
  */
878
- const putStrokes = useCallback((next: readonly Stroke[]) => {
879
- setStrokesState(next as Stroke[]);
880
- const file = fileRef.current;
881
- if (!file) return Promise.resolve();
882
- const svg = strokesToSvg(next);
883
- // Phase 8 Task 5 record the SVG we just authored locally so the
884
- // server-broadcast echo (PUT → onAnnotationsChanged → syncRoom* →
885
- // Y.Map.observe) doesn't trigger a redundant setStrokesState.
886
- lastAppliedSvgRef.current = svg;
887
- return fetch('/_api/annotations', {
888
- method: 'PUT',
889
- headers: { 'Content-Type': 'application/json' },
890
- body: JSON.stringify({ file, svg }),
891
- })
892
- .then(() => undefined)
893
- .catch(() => {
894
- /* swallow user sees uncommitted state until the next stroke */
895
- });
896
- }, []);
1231
+ const putStrokes = useCallback(
1232
+ (next: readonly Stroke[], before: readonly Stroke[]) => {
1233
+ // See reconcileCommit — a direct setStrokesState(next) here can
1234
+ // clobber a sibling file's concurrent optimistic insert; folding
1235
+ // blindly against `prev` (no baseline) can just as easily revert this
1236
+ // very mutation's own delete. `before` (this command's own baseline)
1237
+ // disambiguates the two.
1238
+ setStrokesState((prev) => reconcileCommit(prev, before, next));
1239
+ const file = fileRef.current;
1240
+ if (!file) return Promise.resolve();
1241
+ const persistable = next.some(isEphemeralHref)
1242
+ ? next.filter((s) => !isEphemeralHref(s))
1243
+ : next;
1244
+ const svg = strokesToSvg(persistable);
1245
+ // Phase 8 Task 5 — record the SVG we just authored locally so the
1246
+ // server-broadcast echo (PUT onAnnotationsChanged → syncRoom* →
1247
+ // Y.Map.observe) doesn't trigger a redundant setStrokesState.
1248
+ rememberSelfSvg(svg);
1249
+ const dispatch = () =>
1250
+ fetch('/_api/annotations', {
1251
+ method: 'PUT',
1252
+ headers: { 'Content-Type': 'application/json' },
1253
+ body: JSON.stringify({ file, svg }),
1254
+ })
1255
+ .then(() => undefined)
1256
+ .catch(() => {
1257
+ /* swallow — user sees uncommitted state until the next stroke */
1258
+ });
1259
+ const chained = putChainRef.current.then(dispatch, dispatch);
1260
+ putChainRef.current = chained;
1261
+ return chained;
1262
+ },
1263
+ [rememberSelfSvg]
1264
+ );
897
1265
 
898
1266
  // Register the strokes put sink with the undo provider so the rebuilt
899
1267
  // AnnotationStrokesCommand (after a canvas switch + return) routes through
@@ -952,6 +1320,12 @@ export function AnnotationsLayer() {
952
1320
  (s) => !set.has(s.id) && !(s.tool === 'text' && s.anchorId != null && set.has(s.anchorId))
953
1321
  );
954
1322
  if (filtered.length === prev.length) return;
1323
+ // Record every id actually removed (not just the requested `ids` —
1324
+ // bound text anchors get swept too) so an in-flight image upload's
1325
+ // swap (see deletedStrokeIdsRef above) knows this id is a genuine
1326
+ // delete, not render lag, if it resolves after this point.
1327
+ const filteredIds = new Set(filtered.map((s) => s.id));
1328
+ rememberDeletedIds(prev.filter((s) => !filteredIds.has(s.id)).map((s) => s.id));
955
1329
  // FigJam v3 — deleting a bind host strips the bind (endpoint frozen,
956
1330
  // arrow survives); singleton/empty groups dissolve (tldraw lifecycle).
957
1331
  commitStrokes(prev, recomputeBoundArrows(normalizeGroups(filtered)));
@@ -1059,7 +1433,100 @@ export function AnnotationsLayer() {
1059
1433
  previewStroke,
1060
1434
  commitGesture,
1061
1435
  };
1062
- }, [strokes, setStrokes, commitStrokes]);
1436
+ }, [strokes, setStrokes, commitStrokes, rememberDeletedIds]);
1437
+
1438
+ // feature-bulk-media-insert Task 11 — the picker's "Add as annotation"
1439
+ // confirm posts N already-uploaded asset paths in ONE message; unlike
1440
+ // createImageFromFile/createMediaReference (which each race an independent
1441
+ // upload), every path here is already on disk, so this resolves every
1442
+ // image's natural size up front (Promise.all) and performs exactly ONE
1443
+ // commitStrokes for the whole batch — correct by construction, no chain
1444
+ // needed since there's no per-item async race once all N are resolved.
1445
+ const createMediaFromAssetPaths = useCallback(
1446
+ (paths: readonly string[], world: [number, number]) => {
1447
+ if (typeof window === 'undefined' || !paths.length) return;
1448
+ const classified = paths
1449
+ .map((path) => ({ path, kind: classifyAssetPathKind(path) }))
1450
+ .filter((it): it is { path: string; kind: 'image' | 'video' | 'audio' } => it.kind != null);
1451
+ if (!classified.length) return;
1452
+
1453
+ type Resolved = { path: string; kind: 'image' | 'video' | 'audio'; w: number; h: number };
1454
+ const probeOne = (path: string, kind: 'image' | 'video' | 'audio'): Promise<Resolved> => {
1455
+ if (kind !== 'image') {
1456
+ const h = kind === 'video' ? MEDIAREF_VIDEO_H : MEDIAREF_DEFAULT_H;
1457
+ return Promise.resolve({ path, kind, w: MEDIAREF_DEFAULT_W, h });
1458
+ }
1459
+ return new Promise<Resolved>((resolve) => {
1460
+ const probe = new Image();
1461
+ probe.onload = () => {
1462
+ const natW = probe.naturalWidth || IMAGE_MAX_DROP_SIDE;
1463
+ const natH = probe.naturalHeight || Math.round(IMAGE_MAX_DROP_SIDE * 0.66);
1464
+ const longest = Math.max(natW, natH) || 1;
1465
+ const scale = longest > IMAGE_MAX_DROP_SIDE ? IMAGE_MAX_DROP_SIDE / longest : 1;
1466
+ resolve({
1467
+ path,
1468
+ kind: 'image',
1469
+ w: Math.max(IMAGE_MIN_SIZE, Math.round(natW * scale)),
1470
+ h: Math.max(IMAGE_MIN_SIZE, Math.round(natH * scale)),
1471
+ });
1472
+ };
1473
+ // Couldn't decode — fall back to a small square rather than
1474
+ // dropping this item from the batch (the file is already on disk).
1475
+ probe.onerror = () => resolve({ path, kind: 'image', w: 64, h: 64 });
1476
+ probe.src = resolveAssetHref(path);
1477
+ });
1478
+ };
1479
+
1480
+ void Promise.all(classified.map(({ path, kind }) => probeOne(path, kind))).then(
1481
+ (resolved) => {
1482
+ // Ids generated up front (stable regardless of chain timing) so
1483
+ // they're available for the selection call below without waiting
1484
+ // on the chain to settle.
1485
+ const withIds = resolved.map((item, i) => ({ ...item, id: rid(), i }));
1486
+ const label = `add ${withIds.length} item${withIds.length === 1 ? '' : 's'}`;
1487
+ // Batch-drop fix (see media-commit-chain.ts) — every path here is
1488
+ // already resolved, so THIS call alone is race-free by
1489
+ // construction, but a SECOND overlapping bulk-insert (or a
1490
+ // concurrent drag-drop via createImageFromFile/createMediaReference)
1491
+ // can still land while this one's Promise.all was in flight (large
1492
+ // photos take real, non-trivial decode time) — both read/write the
1493
+ // same `strokes` state, so this must enqueue onto the shared chain
1494
+ // too, not read `strokesRef.current` + commit directly.
1495
+ void mediaCommitChainRef.current.enqueue((before) => {
1496
+ const added: Stroke[] = withIds.map((item) => {
1497
+ const cx = world[0] + item.i * BATCH_DROP_CASCADE_PX;
1498
+ const cy = world[1] + item.i * BATCH_DROP_CASCADE_PX;
1499
+ if (item.kind === 'image') {
1500
+ return {
1501
+ id: item.id,
1502
+ tool: 'image',
1503
+ x: cx - item.w / 2,
1504
+ y: cy - item.h / 2,
1505
+ w: item.w,
1506
+ h: item.h,
1507
+ href: item.path,
1508
+ } as ImageStroke;
1509
+ }
1510
+ return {
1511
+ id: item.id,
1512
+ tool: 'mediaref',
1513
+ x: cx - item.w / 2,
1514
+ y: cy - item.h / 2,
1515
+ w: item.w,
1516
+ h: item.h,
1517
+ src: item.path,
1518
+ mediaKind: item.kind,
1519
+ title: item.path.split('/').pop() || item.path,
1520
+ } as MediaRefStroke;
1521
+ });
1522
+ return { after: [...before, ...added], label };
1523
+ }, commitStrokes);
1524
+ annotSel?.replace(withIds.map((item) => item.id));
1525
+ }
1526
+ );
1527
+ },
1528
+ [commitStrokes, annotSel]
1529
+ );
1063
1530
 
1064
1531
  // Menubar bridge (Phase 5.1 Task 10) — listen for postMessages from the
1065
1532
  // dev-server shell. `selection-clear` + `tool-set` live in canvas-shell
@@ -1068,6 +1535,7 @@ export function AnnotationsLayer() {
1068
1535
  useEffect(() => {
1069
1536
  if (typeof window === 'undefined') return;
1070
1537
  const onMessage = (e: MessageEvent) => {
1538
+ if (e.source !== window.parent) return;
1071
1539
  const m = e.data as { dgn?: string; visible?: boolean } | null;
1072
1540
  if (!m || typeof m !== 'object' || !m.dgn) return;
1073
1541
  if (m.dgn === 'view-annotations') {
@@ -1101,11 +1569,63 @@ export function AnnotationsLayer() {
1101
1569
  : ({ ...s, src: path } as MediaRefStroke);
1102
1570
  });
1103
1571
  commitStrokes(before, after, 'replace media');
1572
+ return;
1573
+ }
1574
+ // Phase 4 (whiteboard-improvements) — the shell's StickerPicker (main-
1575
+ // origin) already uploaded the picked bundled sticker to a PROJECT asset
1576
+ // path via /_api/asset (canvas-origin-allowlisted — the round-trip
1577
+ // through the shell exists only because /_stickers/* itself is main-
1578
+ // origin-only, same posture as AssetPicker/replace-annotation-media
1579
+ // above). Drop it at the current viewport center — no cross-origin
1580
+ // cursor position to reuse. Inlines the screenToWorld formula (that
1581
+ // callback isn't declared until below this effect) against vpRef, which
1582
+ // is.
1583
+ if (m.dgn === 'insert-sticker' && typeof (m as { path?: unknown }).path === 'string') {
1584
+ const path = (m as { path: string }).path;
1585
+ const v = vpRef.current ?? { x: 0, y: 0, zoom: 1 };
1586
+ const z = v.zoom || 1;
1587
+ const cx = typeof window !== 'undefined' ? window.innerWidth / 2 : 0;
1588
+ const cy = typeof window !== 'undefined' ? window.innerHeight / 2 : 0;
1589
+ const wx = (cx - v.x) / z;
1590
+ const wy = (cy - v.y) / z;
1591
+ const size = STICKER_DROP_SIZE;
1592
+ const id = rid();
1593
+ const stroke: ImageStroke = {
1594
+ id,
1595
+ tool: 'image',
1596
+ x: wx - size / 2,
1597
+ y: wy - size / 2,
1598
+ w: size,
1599
+ h: size,
1600
+ href: path,
1601
+ };
1602
+ const before = strokesRef.current;
1603
+ commitStrokes(before, [...before, stroke], 'add sticker');
1604
+ annotSel?.replace([id]);
1605
+ }
1606
+ // feature-bulk-media-insert Task 11 — the shell's AssetPicker (main-
1607
+ // origin) multi-select "Add as annotation" confirm. Every path is
1608
+ // already an uploaded assets/… path (no upload step here); same
1609
+ // viewport-center placement as insert-sticker above (no cross-origin
1610
+ // cursor position to reuse).
1611
+ if (m.dgn === 'insert-annotation-media' && Array.isArray((m as { paths?: unknown }).paths)) {
1612
+ const paths = (m as { paths: unknown[] }).paths.filter(
1613
+ (p): p is string => typeof p === 'string'
1614
+ );
1615
+ if (paths.length) {
1616
+ const v = vpRef.current ?? { x: 0, y: 0, zoom: 1 };
1617
+ const z = v.zoom || 1;
1618
+ const cx = typeof window !== 'undefined' ? window.innerWidth / 2 : 0;
1619
+ const cy = typeof window !== 'undefined' ? window.innerHeight / 2 : 0;
1620
+ const wx = (cx - v.x) / z;
1621
+ const wy = (cy - v.y) / z;
1622
+ createMediaFromAssetPaths(paths, [wx, wy]);
1623
+ }
1104
1624
  }
1105
1625
  };
1106
1626
  window.addEventListener('message', onMessage);
1107
1627
  return () => window.removeEventListener('message', onMessage);
1108
- }, [annotSel, strokes, setVisible, commitStrokes]);
1628
+ }, [annotSel, strokes, setVisible, commitStrokes, createMediaFromAssetPaths]);
1109
1629
 
1110
1630
  // Document-level toggle: Shift+P (presentation). Annotation-shortcut help is
1111
1631
  // owned by the dev-server menubar (Help button); we no longer ship an
@@ -1160,17 +1680,35 @@ export function AnnotationsLayer() {
1160
1680
  };
1161
1681
  // Local-only insert — the blob: href must NOT reach the server (it's
1162
1682
  // ephemeral + would be stripped by the sanitizer); we commit only the
1163
- // assets/… form once the upload lands.
1164
- setStrokesState([...strokesRef.current, optimistic]);
1683
+ // assets/… form once the upload lands. Functional updater — a batch
1684
+ // drop fires this for every file independently, so two optimistic
1685
+ // inserts landing before a render commit must compose against each
1686
+ // other rather than each overwriting the other's array snapshot.
1687
+ setStrokesState((prev) => [...prev, optimistic]);
1165
1688
  void uploadAsset(file).then((res) => {
1166
- const cur = strokesRef.current;
1167
1689
  if ('path' in res) {
1168
- const after = cur.map((s) => (s.id === id ? ({ ...s, href: res.path } as Stroke) : s));
1169
- const beforeForUndo = cur.filter((s) => s.id !== id);
1170
- commitStrokes(beforeForUndo, after, 'add image');
1171
- annotSel?.replace([id]);
1690
+ // Batch-drop fix (see media-commit-chain.ts) N concurrent
1691
+ // uploads resolve at unpredictable relative speed; enqueue the
1692
+ // swap instead of reading strokesRef.current directly, so this
1693
+ // completion always builds on the latest accumulated state
1694
+ // rather than a stale pre-render snapshot another completion
1695
+ // already moved past.
1696
+ void mediaCommitChainRef.current
1697
+ .enqueue((before) => {
1698
+ const wasDeleted = deletedStrokeIdsRef.current.has(id);
1699
+ if (wasDeleted) deletedStrokeIdsRef.current.delete(id);
1700
+ return resolveImageUploadSwap(before, id, optimistic, res.path, wasDeleted);
1701
+ }, commitStrokes)
1702
+ .then((after) => {
1703
+ if (after.some((s) => s.id === id)) annotSel?.replace([id]);
1704
+ });
1172
1705
  } else {
1173
- setStrokesState(cur.filter((s) => s.id !== id));
1706
+ // Failure cleanup — no undo record (the optimistic stroke was
1707
+ // never committed), so a plain functional updater (not the
1708
+ // chain) is enough: it still composes correctly against any
1709
+ // chain-driven setStrokesState queued in the same batch.
1710
+ setStrokesState((prev) => prev.filter((s) => s.id !== id));
1711
+ deletedStrokeIdsRef.current.delete(id); // never reached the chain — nothing to consume there
1174
1712
  showCanvasToast(`Image upload failed — ${res.error}`);
1175
1713
  }
1176
1714
  URL.revokeObjectURL(blobUrl);
@@ -1238,8 +1776,15 @@ export function AnnotationsLayer() {
1238
1776
  mediaKind,
1239
1777
  title: (file.name || res.path).slice(0, 300),
1240
1778
  };
1241
- const before = strokesRef.current;
1242
- commitStrokes(before, [...before, ref], `add ${mediaKind} reference`);
1779
+ // Batch-drop fix (see media-commit-chain.ts) — same accumulator
1780
+ // chain as createImageFromFile's swap, so a video/audio upload
1781
+ // resolving interleaved with concurrent image uploads (or other
1782
+ // media uploads) in the same batch never commits against a stale
1783
+ // pre-render snapshot.
1784
+ void mediaCommitChainRef.current.enqueue(
1785
+ (before) => ({ after: [...before, ref], label: `add ${mediaKind} reference` }),
1786
+ commitStrokes
1787
+ );
1243
1788
  annotSel?.replace([id]);
1244
1789
  const sizeMb = file.size / (1024 * 1024);
1245
1790
  showCanvasToast(
@@ -1387,6 +1932,9 @@ export function AnnotationsLayer() {
1387
1932
  } else if (tool === 'sticky') {
1388
1933
  // Phase 21 — drag-create a paper card. Default size if the user just
1389
1934
  // taps (no drag) is applied in endStroke.
1935
+ // Phase 3 (whiteboard-improvements) — stamp who drew it from presence
1936
+ // identity (git user.name via useCollab, else its anonymous-* / no-op
1937
+ // fallback); collab is optional (test harnesses without a provider).
1390
1938
  setDrawing({
1391
1939
  id,
1392
1940
  tool: 'sticky',
@@ -1398,6 +1946,7 @@ export function AnnotationsLayer() {
1398
1946
  text: '',
1399
1947
  fontSize: DEFAULT_FONT_SIZE,
1400
1948
  cornerRadius: STICKY_CORNER_RADIUS,
1949
+ ...(collab?.myName ? { authorName: collab.myName, authorId: collab.myConnId } : null),
1401
1950
  });
1402
1951
  } else if (tool === 'section') {
1403
1952
  // FigJam v3 — drag-create a labelled container; a bare tap drops the
@@ -1413,6 +1962,42 @@ export function AnnotationsLayer() {
1413
1962
  color: DEFAULT_SECTION_COLOR,
1414
1963
  });
1415
1964
  } else if (tool === 'text') {
1965
+ // Dogfood fix + Phase 4 (unified-text-editing) — the Text tool
1966
+ // clicking EXISTING editable text edits it in place rather than
1967
+ // dropping a new standalone annotation on top of it. Precedence is
1968
+ // z-order: annotation strokes paint above artboard content, so the
1969
+ // geometric stroke hit-test (findTextStrokeAt — DOM hit-testing can't
1970
+ // see pointer-events:none stroke nodes while a tool is armed) runs
1971
+ // first; then artboard leaf-text (delegated to canvas-shell.tsx,
1972
+ // which owns the DOM/contentEditable side, via maude:enter-text-edit).
1973
+ const strokeId = findTextStrokeAt(wx, wy, strokesRef.current, vpRef.current?.zoom || 1);
1974
+ if (strokeId) {
1975
+ setEditCaretPoint({ x: e.clientX, y: e.clientY });
1976
+ setEditingId(strokeId);
1977
+ if (annotSel) annotSel.replace(strokeId);
1978
+ setTool('move');
1979
+ return true;
1980
+ }
1981
+ const editableTarget = findEditableElementAt(e.clientX, e.clientY);
1982
+ if (editableTarget) {
1983
+ // Phase 6 — leaf-looking text the engine would refuse (mixed /
1984
+ // expression source; no build-time data-cd-editable marker) gets an
1985
+ // honest hint, not a new annotation dropped on top of it.
1986
+ if (!editableTarget.hasAttribute('data-cd-editable')) {
1987
+ showCanvasToast(
1988
+ 'This text is filled in from code (a variable) — edit it via chat or /design:edit.'
1989
+ );
1990
+ setTool('move');
1991
+ return true;
1992
+ }
1993
+ document.dispatchEvent(
1994
+ new CustomEvent('maude:enter-text-edit', {
1995
+ detail: { el: editableTarget, clientX: e.clientX, clientY: e.clientY },
1996
+ })
1997
+ );
1998
+ setTool('move');
1999
+ return true;
2000
+ }
1416
2001
  // Phase 21 — single click drops an editable caret at the click point.
1417
2002
  // No stroke is created until the user commits real text (mirrors the
1418
2003
  // anchored double-click flow), so an empty caret leaves nothing behind.
@@ -1577,10 +2162,18 @@ export function AnnotationsLayer() {
1577
2162
  // AnnotationsLayer without the provider), so guard the call.
1578
2163
  if (annotSel) annotSel.replace(committed.id);
1579
2164
  // 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') {
2165
+ // note, type immediately). Phase 1 whiteboard-improvements shapes get
2166
+ // the same treatment (rect/ellipse/polygon are all AnchorHost, so
2167
+ // setEditingId resolves them via editingTarget's 'anchored' branch same
2168
+ // as a double-click would).
2169
+ if (
2170
+ committed.tool === 'sticky' ||
2171
+ committed.tool === 'rect' ||
2172
+ committed.tool === 'ellipse' ||
2173
+ committed.tool === 'polygon'
2174
+ ) {
1582
2175
  setEditingId(committed.id);
1583
- showOnceHint('chain', '⌘Enter commits and creates the next sticky beside it.');
2176
+ showOnceHint('chain', '⌘Enter commits and creates the next one beside it.');
1584
2177
  }
1585
2178
  }
1586
2179
  // T18 / T19 — flip the tool back to Move after every commit UNLESS sticky
@@ -1697,6 +2290,20 @@ export function AnnotationsLayer() {
1697
2290
  altDup: boolean;
1698
2291
  } | null>(null);
1699
2292
 
2293
+ // Phase 2 (whiteboard-improvements) — proportional group resize drag state.
2294
+ // See the gesture effect below (registered after strokesStoreRef).
2295
+ const groupResizeRef = useRef<{
2296
+ pointerId: number;
2297
+ corner: Corner;
2298
+ /** The selection's own union bbox at drag start — unpadded (AnnotGroupBbox
2299
+ * renders it with a screen-constant pad; the resize math works in the raw
2300
+ * content bbox, same convention single-resize's padDX/padDY correct for). */
2301
+ groupB0: { x: number; y: number; w: number; h: number };
2302
+ ids: string[];
2303
+ /** Full-strokes undo baseline (the drag-start snapshot). */
2304
+ undoBase: Stroke[];
2305
+ } | null>(null);
2306
+
1700
2307
  // Drag-select marquee state. World-coord rectangle (anchor + cursor); the
1701
2308
  // cursor end animates with pointermove. `null` = no marquee active.
1702
2309
  const [marquee, setMarquee] = useState<{
@@ -1754,7 +2361,18 @@ export function AnnotationsLayer() {
1754
2361
  // branch below and DESELECTED them.
1755
2362
  let ids: string[] | null = null;
1756
2363
  if (strokeId) {
1757
- elementSel?.clear();
2364
+ // feature-photo-editor — an image-stroke hit routes to the Photo-only
2365
+ // Inspector tab (`edit-annotation-photo-request`, the singleSelectedImageId
2366
+ // effect below), which the shell shows via `photoSel` taking priority over
2367
+ // the DOM `selected` channel. `elementSel.clear()` posts a DEBOUNCED
2368
+ // (50ms, use-selection-set.tsx POST_DEBOUNCE_MS) `select-set: null` — that
2369
+ // lands AFTER the (undebounced) photo-request and the shell's `select-set`
2370
+ // handler unconditionally nulls `photoSel` back out, so the Photo tab
2371
+ // flickers open then goes blank. Skip the clear for an image stroke: it
2372
+ // isn't needed (photoSel already takes precedence in tab resolution) and
2373
+ // is the only case that races with a still-in-flight photo-tab request.
2374
+ const strokeTool = target?.closest?.('[data-id][data-tool]')?.getAttribute('data-tool');
2375
+ if (strokeTool !== 'image') elementSel?.clear();
1758
2376
  // FigJam v3 — clicking a group member acts on the WHOLE outermost
1759
2377
  // group. Double-click deep-selects the member (see the dblclick
1760
2378
  // handler); an already-selected stroke keeps the current selection, so
@@ -2024,6 +2642,114 @@ export function AnnotationsLayer() {
2024
2642
  const strokesStoreRef = useRef(strokesStore);
2025
2643
  strokesStoreRef.current = strokesStore;
2026
2644
 
2645
+ // Phase 2 (whiteboard-improvements) — proportional group resize. A grabbed
2646
+ // corner (AnnotGroupBbox's `[data-group-resize-corner]` handles) scales the
2647
+ // SELECTION's own outer bbox via bboxResize — the exact single-shape corner-
2648
+ // anchor / Shift-aspect-lock / Alt-center-scale math, applied here to the
2649
+ // group's union bbox instead of one stroke's — then propagates that affine
2650
+ // transform to every selected stroke via scaleStrokeInGroup. One local
2651
+ // preview per pointermove, one commitStrokes on pointerup: mirrors the
2652
+ // hull-drag gesture above. Registering pointermove/up unconditionally
2653
+ // (no-op via the groupResizeRef null-check) rather than adding them inside
2654
+ // onDown matches AnnotationResizeOverlay's own pattern in
2655
+ // use-annotation-resize.tsx.
2656
+ useEffect(() => {
2657
+ if (typeof document === 'undefined') return;
2658
+ if (tool !== 'move') return;
2659
+ if (!annotSel) return;
2660
+
2661
+ const onDown = (e: PointerEvent) => {
2662
+ const target = e.target as Element | null;
2663
+ const cornerEl = target?.closest?.('[data-group-resize-corner]') ?? null;
2664
+ if (!cornerEl) return;
2665
+ const corner = cornerEl.getAttribute('data-group-resize-corner') as Corner | null;
2666
+ if (!corner) return;
2667
+ const ids = annotSel.selectedIds;
2668
+ if (ids.length < 2) return;
2669
+ const idSet = new Set(ids);
2670
+ const strokesNow = strokesStoreRef.current.strokes;
2671
+ let xMin = Number.POSITIVE_INFINITY;
2672
+ let yMin = Number.POSITIVE_INFINITY;
2673
+ let xMax = Number.NEGATIVE_INFINITY;
2674
+ let yMax = Number.NEGATIVE_INFINITY;
2675
+ let any = false;
2676
+ for (const s of strokesNow) {
2677
+ if (!idSet.has(s.id)) continue;
2678
+ const b = strokeBBox(s, anchorsById);
2679
+ if (!b) continue;
2680
+ any = true;
2681
+ if (b.x < xMin) xMin = b.x;
2682
+ if (b.y < yMin) yMin = b.y;
2683
+ if (b.x + b.w > xMax) xMax = b.x + b.w;
2684
+ if (b.y + b.h > yMax) yMax = b.y + b.h;
2685
+ }
2686
+ if (!any) return;
2687
+ e.preventDefault();
2688
+ e.stopPropagation();
2689
+ groupResizeRef.current = {
2690
+ pointerId: e.pointerId,
2691
+ corner,
2692
+ groupB0: { x: xMin, y: yMin, w: xMax - xMin, h: yMax - yMin },
2693
+ ids,
2694
+ undoBase: strokesNow,
2695
+ };
2696
+ try {
2697
+ (cornerEl as Element & { setPointerCapture?: (id: number) => void }).setPointerCapture?.(
2698
+ e.pointerId
2699
+ );
2700
+ } catch {
2701
+ /* some browsers reject capture on synthetic events */
2702
+ }
2703
+ };
2704
+ const onMove = (e: PointerEvent) => {
2705
+ const d = groupResizeRef.current;
2706
+ if (!d || e.pointerId !== d.pointerId) return;
2707
+ const [wxRaw, wyRaw] = screenToWorld(e.clientX, e.clientY);
2708
+ const zoom = vpRef.current?.zoom || 1;
2709
+ // Handles render padded OFF the group bbox (screen-constant, same as
2710
+ // AnnotGroupBbox's `pad`) — shift the cursor back onto the true corner
2711
+ // so the first move doesn't jump-grow by the pad amount (mirrors
2712
+ // single-resize applyResize's padDX/padDY correction).
2713
+ const padWorld = HALO_PAD_PX / zoom;
2714
+ const wx = wxRaw + padDX(d.corner) * padWorld;
2715
+ const wy = wyRaw + padDY(d.corner) * padWorld;
2716
+ const mods: ResizeMods = { shift: e.shiftKey, alt: e.altKey };
2717
+ const groupB1 = bboxResize(d.groupB0, d.corner, wx, wy, mods, false);
2718
+ const idSet = new Set(d.ids);
2719
+ // Local React state only — no commitStrokes, no PUT, matching the
2720
+ // hull-drag / single-resize preview convention (one undo record lands
2721
+ // on pointerup, not one per pixel moved).
2722
+ const next = recomputeBoundArrows(
2723
+ d.undoBase.map((s) => {
2724
+ if (!idSet.has(s.id)) return s;
2725
+ const patch = scaleStrokeInGroup(s, d.groupB0, groupB1);
2726
+ return patch ? ({ ...s, ...patch } as Stroke) : s;
2727
+ })
2728
+ );
2729
+ setStrokesState(next);
2730
+ };
2731
+ const onUp = (e: PointerEvent) => {
2732
+ const d = groupResizeRef.current;
2733
+ if (!d || e.pointerId !== d.pointerId) return;
2734
+ groupResizeRef.current = null;
2735
+ const final = strokesRef.current;
2736
+ // No-op drag (grabbed a handle, released without moving past the
2737
+ // resize's own resolution) skips the undo record.
2738
+ if (strokesShallowEqual(d.undoBase, final)) return;
2739
+ commitStrokes(d.undoBase, final, `resize ${d.ids.length} strokes`);
2740
+ };
2741
+ document.addEventListener('pointerdown', onDown, true);
2742
+ document.addEventListener('pointermove', onMove, true);
2743
+ document.addEventListener('pointerup', onUp, true);
2744
+ document.addEventListener('pointercancel', onUp, true);
2745
+ return () => {
2746
+ document.removeEventListener('pointerdown', onDown, true);
2747
+ document.removeEventListener('pointermove', onMove, true);
2748
+ document.removeEventListener('pointerup', onUp, true);
2749
+ document.removeEventListener('pointercancel', onUp, true);
2750
+ };
2751
+ }, [tool, annotSel, screenToWorld, anchorsById, commitStrokes]);
2752
+
2027
2753
  // Double-click enters text-edit mode: rect/ellipse (anchored text), sticky
2028
2754
  // (its own body), or a standalone text node (re-edit in place). Anchored text
2029
2755
  // nodes are edited via their host, so a data-anchor-id text node is skipped.
@@ -2045,15 +2771,24 @@ export function AnnotationsLayer() {
2045
2771
  const stroke = strokesRef.current.find((s) => s.id === id);
2046
2772
  if (stroke && outermostGroupOf(stroke) && annotSel) {
2047
2773
  e.preventDefault();
2774
+ e.stopPropagation();
2048
2775
  annotSel.replace(id);
2049
2776
  }
2050
2777
  if (t === 'rect' || t === 'ellipse' || t === 'polygon' || t === 'sticky' || t === 'section') {
2778
+ // A double-click we've claimed for editing must not also reach the
2779
+ // canvas-shell dblclick-empty→fit() handler (bubble phase, below us) —
2780
+ // without this it fired right after, so renaming a section or editing
2781
+ // a shape's text jumped the viewport to fit().
2051
2782
  e.preventDefault();
2783
+ e.stopPropagation();
2784
+ setEditCaretPoint({ x: e.clientX, y: e.clientY });
2052
2785
  setEditingId(id);
2053
2786
  return;
2054
2787
  }
2055
2788
  if (t === 'text' && !node.getAttribute('data-anchor-id')) {
2056
2789
  e.preventDefault();
2790
+ e.stopPropagation();
2791
+ setEditCaretPoint({ x: e.clientX, y: e.clientY });
2057
2792
  setEditingId(id);
2058
2793
  }
2059
2794
  };
@@ -2192,6 +2927,7 @@ export function AnnotationsLayer() {
2192
2927
  const target = editingTargetRef.current;
2193
2928
  setEditingId(null);
2194
2929
  setPendingText(null);
2930
+ setEditCaretPoint(null);
2195
2931
  if (!target) return;
2196
2932
  if (target.kind === 'anchored') commitText(target.anchorId, text, fmt);
2197
2933
  else if (target.kind === 'sticky') commitStickyText(target.sticky.id, text, fmt);
@@ -2211,6 +2947,7 @@ export function AnnotationsLayer() {
2211
2947
  const cancelEditing = useCallback(() => {
2212
2948
  setEditingId(null);
2213
2949
  setPendingText(null);
2950
+ setEditCaretPoint(null);
2214
2951
  }, []);
2215
2952
 
2216
2953
  /**
@@ -2780,6 +3517,21 @@ export function AnnotationsLayer() {
2780
3517
  } catch {
2781
3518
  /* detached / cross-origin teardown */
2782
3519
  }
3520
+ } else if (action === 'edit-photo') {
3521
+ // feature-photo-editor (Task 17) — "Edit Photo…" on an ImageStroke. Same
3522
+ // untrusted-origin REQUEST shape as Replace (DDR-054); the shell opens the
3523
+ // Photo-only Inspector tab on this stroke's content-addressed asset.
3524
+ if (sel.length !== 1) return;
3525
+ const target = strokesById.get(sel[0] as string);
3526
+ if (target?.tool !== 'image') return;
3527
+ try {
3528
+ window.parent.postMessage(
3529
+ { dgn: 'edit-annotation-photo-request', id: target.id, asset: target.href },
3530
+ '*'
3531
+ );
3532
+ } catch {
3533
+ /* detached / cross-origin teardown */
3534
+ }
2783
3535
  }
2784
3536
  },
2785
3537
  [annotSel, copySelection, pasteStrokesText, strokesById]
@@ -2797,6 +3549,34 @@ export function AnnotationsLayer() {
2797
3549
  }
2798
3550
  }, [selCount]);
2799
3551
 
3552
+ // feature-photo-editor — auto-open the Inspector's Photo tab whenever the
3553
+ // annotation selection becomes exactly one ImageStroke. Previously this
3554
+ // only fired from the "Edit Photo…" context-menu action; a plain click/
3555
+ // select left the Inspector showing its no-selection placeholder, since an
3556
+ // annotation stroke has no data-cd-id / DOM selection to ride the normal
3557
+ // `select`/`select-set` path. An ImageStroke has no OTHER inspectable
3558
+ // surface (Task 13: Photo is the only tab for this content type), so
3559
+ // requiring a right-click detour to reach it was pure friction. Same
3560
+ // `edit-annotation-photo-request` message + confused-deputy gate (DDR-054)
3561
+ // the context-menu action already used.
3562
+ const singleSelectedImageId =
3563
+ selCount === 1 && strokesById.get(annotSel?.selectedIds[0] as string)?.tool === 'image'
3564
+ ? (annotSel?.selectedIds[0] as string)
3565
+ : null;
3566
+ useEffect(() => {
3567
+ if (!singleSelectedImageId) return;
3568
+ const s = strokesById.get(singleSelectedImageId);
3569
+ if (s?.tool !== 'image') return;
3570
+ try {
3571
+ window.parent.postMessage(
3572
+ { dgn: 'edit-annotation-photo-request', id: s.id, asset: s.href },
3573
+ '*'
3574
+ );
3575
+ } catch {
3576
+ /* detached / cross-origin teardown */
3577
+ }
3578
+ }, [singleSelectedImageId, strokesById]);
3579
+
2800
3580
  // Selected stroke halos — bboxes in world coords, vector-effect non-scaling-stroke.
2801
3581
  const selectedStrokes = useMemo(() => {
2802
3582
  if (!annotSel || annotSel.selectedIds.length === 0) return [] as Stroke[];
@@ -2834,6 +3614,7 @@ export function AnnotationsLayer() {
2834
3614
  addTextHintId={editingTarget ? null : addTextHintId}
2835
3615
  ghost={ghostPreview}
2836
3616
  editingTarget={editingTarget}
3617
+ editCaretPoint={editCaretPoint}
2837
3618
  inkColor={color}
2838
3619
  onCommitEdit={commitEditing}
2839
3620
  onCancelEdit={cancelEditing}
@@ -2863,6 +3644,11 @@ export function AnnotationsLayer() {
2863
3644
  selectedStrokes.length === 1 &&
2864
3645
  (selectedStrokes[0]?.tool === 'image' || selectedStrokes[0]?.tool === 'mediaref')
2865
3646
  }
3647
+ canEditPhoto={
3648
+ selectedStrokes.length === 1 &&
3649
+ selectedStrokes[0]?.tool === 'image' &&
3650
+ /assets\/[0-9a-f]{8}\.[a-z0-9]+/i.test((selectedStrokes[0] as ImageStroke).href || '')
3651
+ }
2866
3652
  onAction={onMenuAction}
2867
3653
  onClose={() => setCtxMenu(null)}
2868
3654
  />
@@ -2988,6 +3774,7 @@ function AnnotationsSvg({
2988
3774
  addTextHintId,
2989
3775
  ghost,
2990
3776
  editingTarget,
3777
+ editCaretPoint,
2991
3778
  inkColor,
2992
3779
  onCommitEdit,
2993
3780
  onCancelEdit,
@@ -3020,6 +3807,8 @@ function AnnotationsSvg({
3020
3807
  addTextHintId: string | null;
3021
3808
  ghost: GhostDescriptor | null;
3022
3809
  editingTarget: EditingTarget;
3810
+ /** The click that opened the editor — caret-at-click on mount (Phase 3). */
3811
+ editCaretPoint: { x: number; y: number } | null;
3023
3812
  /** Live default ink (theme-aware) for a not-yet-born pending text caret. */
3024
3813
  inkColor: string;
3025
3814
  onCommitEdit: (text: string, fmt?: EditorFmt) => void;
@@ -3036,154 +3825,234 @@ function AnnotationsSvg({
3036
3825
  // A sticky whose body is being edited hides its read-only text so the
3037
3826
  // editor textarea (rendered below at the same bbox) isn't double-painted.
3038
3827
  const editingStickyId = editingTarget?.kind === 'sticky' ? editingTarget.sticky.id : null;
3828
+ // Same double-paint issue for a section being renamed — its label chip must
3829
+ // hide while the StandaloneTextEditor sits at the same spot, else the old
3830
+ // label reads through behind the new text.
3831
+ const editingSectionId = editingTarget?.kind === 'section' ? editingTarget.section.id : null;
3039
3832
  const anchoredExisting =
3040
3833
  editingTarget?.kind === 'anchored'
3041
3834
  ? (strokes.find((s) => s.tool === 'text' && s.anchorId === editingTarget.anchorId) as
3042
3835
  | TextStroke
3043
3836
  | undefined)
3044
3837
  : 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/
3838
+ // Same double-paint issue for a shape's anchored text — hide the read-only
3839
+ // <text> stroke while TextEditor sits at the same bbox (Phase 1 jump-fix
3840
+ // companion). Only applies once the TextStroke exists; a not-yet-created
3841
+ // one has nothing to hide.
3842
+ const editingAnchoredTextId = anchoredExisting?.id ?? null;
3843
+ // A standalone text being re-edited hides its read-only <text> too — the
3844
+ // editor paints at the same x/y, so leaving it visible double-paints (the
3845
+ // pre-Phase-2 "ghost" under the editor).
3846
+ const editingStandaloneTextId =
3847
+ editingTarget?.kind === 'standalone' ? editingTarget.text.id : null;
3848
+ return (
3849
+ <>
3850
+ {createPortal(
3851
+ <svg className="dc-annot-svg" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
3852
+ <defs>
3853
+ {/* Phase 21 — soft "lifted paper" drop shadow for sticky notes. */}
3854
+ <filter id="dc-sticky-shadow" x="-25%" y="-25%" width="150%" height="170%">
3855
+ <feDropShadow
3856
+ dx="0"
3857
+ dy="4"
3858
+ stdDeviation="8"
3859
+ floodColor="#000000"
3860
+ floodOpacity="0.28"
3861
+ />
3862
+ </filter>
3863
+ </defs>
3864
+ {strokes.map((s) => (
3865
+ <StrokeNode
3866
+ key={s.id}
3867
+ stroke={s}
3868
+ anchorsById={anchorsById}
3869
+ interactive={selectMode}
3870
+ editing={
3871
+ s.id === editingStickyId ||
3872
+ s.id === editingSectionId ||
3873
+ s.id === editingAnchoredTextId ||
3874
+ s.id === editingStandaloneTextId
3875
+ }
3876
+ />
3877
+ ))}
3878
+ {selectedStrokes.map((s) => (
3879
+ <SelectionHalo
3880
+ key={`halo-${s.id}`}
3881
+ stroke={s}
3882
+ anchorsById={anchorsById}
3883
+ multi={selectedStrokes.length > 1}
3884
+ />
3885
+ ))}
3886
+ <AnnotGroupBbox selectedStrokes={selectedStrokes} anchorsById={anchorsById} />
3887
+ {marquee ? (
3888
+ <rect
3889
+ className="dc-annot-marquee"
3890
+ x={Math.min(marquee.ax, marquee.bx)}
3891
+ y={Math.min(marquee.ay, marquee.by)}
3892
+ width={Math.abs(marquee.bx - marquee.ax)}
3893
+ height={Math.abs(marquee.by - marquee.ay)}
3894
+ vectorEffect="non-scaling-stroke"
3895
+ />
3896
+ ) : null}
3897
+ {/* FigJam v3 — smart guides: solid 1px accent lines at the snapped edge/
3082
3898
  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
3899
+ {snapGuides?.map((g, i) => (
3900
+ <line
3901
+ // biome-ignore lint/suspicious/noArrayIndexKey: guides are positional + rebuilt per move tick
3902
+ key={`guide-${i}`}
3903
+ x1={g.axis === 'x' ? g.at : g.from}
3904
+ y1={g.axis === 'x' ? g.from : g.at}
3905
+ x2={g.axis === 'x' ? g.at : g.to}
3906
+ y2={g.axis === 'x' ? g.to : g.at}
3907
+ stroke="var(--maude-hud-accent, #d63b1f)"
3908
+ strokeWidth={1}
3909
+ vectorEffect="non-scaling-stroke"
3910
+ pointerEvents="none"
3911
+ />
3912
+ ))}
3913
+ {/* FigJam v3 — bind hint: halo the host a dragged arrow endpoint would
3098
3914
  magnetically attach to. */}
3099
- <BindHintHalo strokes={strokes} bindHintId={bindHintId} />
3100
- {/* FigJam v3 — connection dots on a single selected bindable shape;
3915
+ <BindHintHalo strokes={strokes} bindHintId={bindHintId} />
3916
+ {/* FigJam v3 — connection dots on a single selected bindable shape;
3101
3917
  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
3918
+ {selectMode && !connDraft && selectedStrokes.length === 1 && selectedStrokes[0] ? (
3919
+ <ConnectorDots stroke={selectedStrokes[0]} />
3920
+ ) : null}
3921
+ {connDraft ? (
3922
+ <g
3923
+ stroke={inkColor}
3924
+ strokeWidth={2.5}
3925
+ strokeLinecap="round"
3926
+ strokeLinejoin="round"
3927
+ vectorEffect="non-scaling-stroke"
3928
+ fill="none"
3929
+ pointerEvents="none"
3930
+ >
3931
+ {arrowPrimitives({
3932
+ x1: connDraft.x1,
3933
+ y1: connDraft.y1,
3934
+ x2: connDraft.x2,
3935
+ y2: connDraft.y2,
3936
+ width: 2.5,
3937
+ color: inkColor,
3938
+ lineType: 'curved',
3939
+ startBind: connDraft.startBind,
3940
+ ...(connDraft.endBind ? { endBind: connDraft.endBind } : {}),
3941
+ }).map((prim, i) => renderArrowPrimitive(prim, i))}
3942
+ </g>
3943
+ ) : null}
3944
+ {/* FigJam v3 — hover affordance: an empty shape invites text. */}
3945
+ <AddTextHint strokes={strokes} hintId={addTextHintId} />
3946
+ {/* FigJam v3 — resize chrome: live W × H label at the box corner plus a
3131
3947
  dashed halo on any neighbour whose dimension the resize just matched
3132
3948
  (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' ? (
3949
+ {resizeInfo?.box ? (
3950
+ <g pointerEvents="none">
3951
+ {resizeInfo.matchIds.map((id) => {
3952
+ const m = strokes.find((s) => s.id === id);
3953
+ const bb = m ? strokeBBox(m) : null;
3954
+ if (!bb) return null;
3955
+ return (
3956
+ <rect
3957
+ key={`dim-${id}`}
3958
+ x={bb.x - 2}
3959
+ y={bb.y - 2}
3960
+ width={bb.w + 4}
3961
+ height={bb.h + 4}
3962
+ fill="none"
3963
+ stroke="var(--maude-hud-accent, #d63b1f)"
3964
+ strokeWidth={1.5}
3965
+ strokeDasharray="5 3"
3966
+ vectorEffect="non-scaling-stroke"
3967
+ rx={2}
3968
+ />
3969
+ );
3970
+ })}
3971
+ <text
3972
+ x={resizeInfo.box.x + resizeInfo.box.w / 2}
3973
+ y={resizeInfo.box.y + resizeInfo.box.h + 18}
3974
+ textAnchor="middle"
3975
+ fontSize={11}
3976
+ fill="var(--maude-hud-accent, #d63b1f)"
3977
+ style={{
3978
+ fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
3979
+ }}
3980
+ >
3981
+ {`${Math.round(resizeInfo.box.w)} × ${Math.round(resizeInfo.box.h)}`}
3982
+ </text>
3983
+ </g>
3984
+ ) : null}
3985
+ {ghost ? <GhostPreview ghost={ghost} /> : null}
3986
+ </svg>,
3987
+ target
3988
+ )}
3989
+ <AnnotEditors
3990
+ worldRef={worldRef}
3991
+ editingTarget={editingTarget}
3992
+ anchoredExisting={anchoredExisting}
3993
+ caretPoint={editCaretPoint}
3994
+ inkColor={inkColor}
3995
+ onCommitEdit={onCommitEdit}
3996
+ onCancelEdit={onCancelEdit}
3997
+ />
3998
+ </>
3999
+ );
4000
+ }
4001
+
4002
+ /**
4003
+ * The active annotation text editor, rendered as PLAIN HTML absolutely
4004
+ * positioned in the world div — NOT as SVG foreignObject. Same architectural
4005
+ * move as MediaRefPlayers below (read its docblock): foreignObject content
4006
+ * under the transformed `.dc-world` mis-hit-tests clicks at most zoom levels
4007
+ * (WebKit + Chromium), and the `.dc-annot-svg` root's pointer-events:none
4008
+ * additionally swallowed in-editor clicks — so caret-at-click could never
4009
+ * work. HTML children of the transformed div hit-test correctly by
4010
+ * construction. World coords map 1:1 (the div carries the pan/zoom
4011
+ * transform), so each editor's old foreignObject x/y/w/h becomes left/top/
4012
+ * width/height verbatim. The [data-annot-editor] attr keeps document-capture
4013
+ * annotation handlers out (isAnnotEditorTarget guard, mirroring
4014
+ * [data-mediaref-player]).
4015
+ */
4016
+ function AnnotEditors({
4017
+ worldRef,
4018
+ editingTarget,
4019
+ anchoredExisting,
4020
+ caretPoint,
4021
+ inkColor,
4022
+ onCommitEdit,
4023
+ onCancelEdit,
4024
+ }: {
4025
+ worldRef: ReturnType<typeof useWorldRefContext>;
4026
+ editingTarget: EditingTarget;
4027
+ anchoredExisting: TextStroke | undefined;
4028
+ caretPoint: { x: number; y: number } | null;
4029
+ inkColor: string;
4030
+ onCommitEdit: (text: string, fmt?: EditorFmt) => void;
4031
+ onCancelEdit: () => void;
4032
+ }) {
4033
+ const target = worldRef?.current ?? null;
4034
+ if (!target || !editingTarget) return null;
4035
+ return createPortal(
4036
+ <>
4037
+ {editingTarget.kind === 'anchored' ? (
3171
4038
  <TextEditor
3172
4039
  anchorId={editingTarget.anchorId}
3173
4040
  host={editingTarget.host}
3174
4041
  existing={anchoredExisting}
4042
+ caretPoint={caretPoint}
3175
4043
  onCommit={(_anchorId, text, fmt) => onCommitEdit(text, fmt)}
3176
4044
  onCancel={onCancelEdit}
3177
4045
  />
3178
4046
  ) : null}
3179
- {editingTarget?.kind === 'sticky' ? (
4047
+ {editingTarget.kind === 'sticky' ? (
3180
4048
  <StickyEditor
3181
4049
  sticky={editingTarget.sticky}
4050
+ caretPoint={caretPoint}
3182
4051
  onCommit={onCommitEdit}
3183
4052
  onCancel={onCancelEdit}
3184
4053
  />
3185
4054
  ) : null}
3186
- {editingTarget?.kind === 'standalone' ? (
4055
+ {editingTarget.kind === 'standalone' ? (
3187
4056
  <StandaloneTextEditor
3188
4057
  x={editingTarget.text.x ?? 0}
3189
4058
  y={editingTarget.text.y ?? 0}
@@ -3196,11 +4065,12 @@ function AnnotationsSvg({
3196
4065
  underline={editingTarget.text.underline}
3197
4066
  align={editingTarget.text.align ?? 'left'}
3198
4067
  listType={editingTarget.text.listType}
4068
+ caretPoint={caretPoint}
3199
4069
  onCommit={onCommitEdit}
3200
4070
  onCancel={onCancelEdit}
3201
4071
  />
3202
4072
  ) : null}
3203
- {editingTarget?.kind === 'pending' ? (
4073
+ {editingTarget.kind === 'pending' ? (
3204
4074
  <StandaloneTextEditor
3205
4075
  x={editingTarget.x}
3206
4076
  y={editingTarget.y}
@@ -3211,24 +4081,15 @@ function AnnotationsSvg({
3211
4081
  onCancel={onCancelEdit}
3212
4082
  />
3213
4083
  ) : 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}
4084
+ {editingTarget.kind === 'section' ? (
4085
+ <SectionTitleEditor
4086
+ section={editingTarget.section}
4087
+ caretPoint={caretPoint}
3227
4088
  onCommit={onCommitEdit}
3228
4089
  onCancel={onCancelEdit}
3229
4090
  />
3230
4091
  ) : null}
3231
- </svg>,
4092
+ </>,
3232
4093
  target
3233
4094
  );
3234
4095
  }
@@ -3319,16 +4180,89 @@ function MediaRefPlayers({
3319
4180
  );
3320
4181
  }
3321
4182
 
4183
+ // An empty flex-centered contentEditable has no line box for `justifyContent:
4184
+ // 'center'` to center — WebKit parks the caret at the box's top, then jumps it
4185
+ // to the true centered position the instant a real character exists. A
4186
+ // zero-width space gives the box real (invisible) content from mount, so the
4187
+ // caret starts centered and never jumps. Stripped back off on commit.
4188
+ const JUMP_SENTINEL = '\u200B';
4189
+ function stripJumpSentinel(text: string): string {
4190
+ return text.startsWith(JUMP_SENTINEL) ? text.slice(JUMP_SENTINEL.length) : text;
4191
+ }
4192
+
4193
+ // Unified caret style across every contentEditable text surface (annotation
4194
+ // editors here + the artboard inline editor's `.dc-text-editing` CSS in
4195
+ // canvas-shell.tsx use the SAME `--maude-hud-accent` so the caret reads the
4196
+ // same everywhere). An explicit caretColor makes the caret visible against any
4197
+ // background. NOTE: deliberately NO `transform: translateZ(0)` / `will-change`
4198
+ // here \u2014 promoting a contentEditable onto its own compositing layer is a known
4199
+ // WebKit caret-BLINK killer (the compositor caches the layer and never repaints
4200
+ // the blink, so the caret shows as a static line). A prior dogfood pass added
4201
+ // translateZ(0) to "restore" the caret and instead froze its blink; removing it
4202
+ // lets WebKit run the native blink. The editor already lives inside the
4203
+ // transformed `.dc-world`, but that ancestor transform alone does not stop the
4204
+ // blink \u2014 only a compositing trigger ON the editable does.
4205
+ const CARET_FIX_STYLE = {
4206
+ caretColor: 'var(--maude-hud-accent, #4a63e7)',
4207
+ } as const;
4208
+
4209
+ /**
4210
+ * Phase 3 (unified-text-editing) — shared caret behavior for every annotation
4211
+ * editor. On mount: focus, place a collapsed caret at the entry click point
4212
+ * (`placeCaretAt`, the SAME chain the artboard's enterEditModeAt uses;
4213
+ * keyboard entry has no point → select-all, the rename convention), and mount
4214
+ * the custom blinking caret (text-caret.ts) for the session. Afterwards every
4215
+ * plain in-editor click re-places the caret from its coordinates on pointerup
4216
+ * so repositioning never depends on native hit-testing (synthetic e2e clicks
4217
+ * take the same path — untrusted events get no UA caret action at all).
4218
+ * Shift-clicks and drag-selections keep native behavior; ⌘A stays native.
4219
+ */
4220
+ function useEditorCaret(
4221
+ ref: RefObject<HTMLDivElement | null>,
4222
+ caretPoint: { x: number; y: number } | null | undefined
4223
+ ) {
4224
+ const entryPointRef = useRef(caretPoint ?? null);
4225
+ useEffect(() => {
4226
+ const el = ref.current;
4227
+ if (!el) return;
4228
+ el.focus();
4229
+ placeCaretAt(el, window, entryPointRef.current ?? undefined);
4230
+ return mountCaret(el, window);
4231
+ }, [ref]);
4232
+ const downRef = useRef<{ x: number; y: number } | null>(null);
4233
+ const onPointerDown = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
4234
+ downRef.current = e.button === 0 && !e.shiftKey ? { x: e.clientX, y: e.clientY } : null;
4235
+ }, []);
4236
+ const onPointerUp = useCallback(
4237
+ (e: ReactPointerEvent<HTMLDivElement>) => {
4238
+ const d = downRef.current;
4239
+ downRef.current = null;
4240
+ if (!d || e.shiftKey) return;
4241
+ // A real drag is a range-selection gesture — leave it to the engine.
4242
+ if (Math.hypot(e.clientX - d.x, e.clientY - d.y) > 3) return;
4243
+ const el = ref.current;
4244
+ if (!el) return;
4245
+ const sel = window.getSelection();
4246
+ if (sel && !sel.isCollapsed) return; // double-click word-select etc.
4247
+ placeCaretAt(el, window, { x: e.clientX, y: e.clientY }, false);
4248
+ },
4249
+ [ref]
4250
+ );
4251
+ return { onPointerDown, onPointerUp };
4252
+ }
4253
+
3322
4254
  function TextEditor({
3323
4255
  anchorId,
3324
4256
  host,
3325
4257
  existing,
4258
+ caretPoint,
3326
4259
  onCommit,
3327
4260
  onCancel,
3328
4261
  }: {
3329
4262
  anchorId: string;
3330
4263
  host: AnchorHost | null;
3331
4264
  existing: TextStroke | undefined;
4265
+ caretPoint?: { x: number; y: number } | null;
3332
4266
  onCommit: (anchorId: string, text: string, fmt?: EditorFmt) => void;
3333
4267
  onCancel: () => void;
3334
4268
  }) {
@@ -3351,24 +4285,18 @@ function TextEditor({
3351
4285
  fontSize: existing?.fontSize ?? DEFAULT_FONT_SIZE,
3352
4286
  align: existing?.align ?? 'center',
3353
4287
  });
4288
+ // Both commit sites below (outside-click + Cmd/Ctrl+Enter) need the same
4289
+ // sentinel-strip + marker-strip pipeline. Memoized so the outside-click
4290
+ // effect below can depend on it directly instead of its own copy of
4291
+ // existing?.listType (lint/correctness/useExhaustiveDependencies).
4292
+ const toCommittedText = useCallback(
4293
+ (raw: string) => stripEditorMarkers(stripJumpSentinel(raw), existing?.listType),
4294
+ [existing?.listType]
4295
+ );
3354
4296
 
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
- }, []);
4297
+ // Caret-at-click on entry + custom blinking caret + click re-placement
4298
+ // (select-all only for keyboard entry — see useEditorCaret).
4299
+ const caretHandlers = useEditorCaret(ref, caretPoint);
3372
4300
 
3373
4301
  // Commit on outside click; cancel-on-Esc handled in onKeyDown below.
3374
4302
  useEffect(() => {
@@ -3381,15 +4309,11 @@ function TextEditor({
3381
4309
  // FigJam v3 — the edit-mode text toolbar drives THIS editor; clicking
3382
4310
  // it must not commit-and-close the session.
3383
4311
  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
- );
4312
+ onCommit(anchorId, toCommittedText(el.innerText || ''), fmtRef.current);
3389
4313
  };
3390
4314
  document.addEventListener('pointerdown', onDown, true);
3391
4315
  return () => document.removeEventListener('pointerdown', onDown, true);
3392
- }, [anchorId, onCommit, existing?.listType, fmtRef]);
4316
+ }, [anchorId, onCommit, fmtRef, toCommittedText]);
3393
4317
 
3394
4318
  if (!host) return null;
3395
4319
  const bbox = strokeBBox(host);
@@ -3399,7 +4323,19 @@ function TextEditor({
3399
4323
  // default align = centre).
3400
4324
  const align = existing?.align ?? 'center';
3401
4325
  return (
3402
- <foreignObject x={bbox.x} y={bbox.y} width={Math.max(20, bbox.w)} height={Math.max(20, bbox.h)}>
4326
+ // Plain HTML host in the world div (NOT foreignObject — see AnnotEditors'
4327
+ // docblock): world coords map 1:1 to left/top, clicks hit-test correctly.
4328
+ <div
4329
+ data-annot-editor="1"
4330
+ style={{
4331
+ position: 'absolute',
4332
+ left: bbox.x,
4333
+ top: bbox.y,
4334
+ width: Math.max(20, bbox.w),
4335
+ height: Math.max(20, bbox.h),
4336
+ zIndex: 5,
4337
+ }}
4338
+ >
3403
4339
  <div
3404
4340
  ref={ref}
3405
4341
  className="dc-annot-editor"
@@ -3429,7 +4365,10 @@ function TextEditor({
3429
4365
  outline: 'none',
3430
4366
  background: 'transparent',
3431
4367
  cursor: 'text',
4368
+ ...CARET_FIX_STYLE,
3432
4369
  }}
4370
+ onPointerDown={caretHandlers.onPointerDown}
4371
+ onPointerUp={caretHandlers.onPointerUp}
3433
4372
  onKeyDown={(e) => {
3434
4373
  if (onFormatKey(e)) return; // Cmd/Ctrl+B/I/U
3435
4374
  if (e.key === 'Escape') {
@@ -3437,44 +4376,46 @@ function TextEditor({
3437
4376
  onCancel();
3438
4377
  return;
3439
4378
  }
3440
- if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
4379
+ // Unified across every text surface: plain Enter commits,
4380
+ // Shift+Enter inserts a newline (falls through untouched). ⌘/Ctrl
4381
+ // +Enter also commits AND chains a connected sibling (quick-create).
4382
+ if (e.key === 'Enter' && !e.shiftKey) {
3441
4383
  e.preventDefault();
3442
4384
  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
- );
4385
+ onCommit(anchorId, toCommittedText(el?.innerText || ''), fmtRef.current);
4386
+ if (e.metaKey || e.ctrlKey) {
4387
+ document.dispatchEvent(
4388
+ new CustomEvent('maude:chain-create', { detail: { id: anchorId } })
4389
+ );
4390
+ }
3453
4391
  }
3454
4392
  }}
3455
4393
  >
3456
- {initial}
4394
+ {initial || JUMP_SENTINEL}
3457
4395
  </div>
3458
- </foreignObject>
4396
+ </div>
3459
4397
  );
3460
4398
  }
3461
4399
 
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).
4400
+ // Phase 21 — sticky body editor, hosted as plain HTML at the card's bbox in
4401
+ // the world div (word-wrap + zoom come from the div's own box + the world
4402
+ // transform). Commit on blur, cancel on Esc; Enter commits, Shift+Enter
4403
+ // inserts a newline.
3465
4404
  function StickyEditor({
3466
4405
  sticky,
4406
+ caretPoint,
3467
4407
  onCommit,
3468
4408
  onCancel,
3469
4409
  }: {
3470
4410
  sticky: StickyStroke;
4411
+ caretPoint?: { x: number; y: number } | null;
3471
4412
  onCommit: (text: string, fmt?: EditorFmt) => void;
3472
4413
  onCancel: () => void;
3473
4414
  }) {
3474
4415
  // A flex-centered contentEditable (NOT a textarea) so the edit view matches
3475
4416
  // 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).
4417
+ // commit. Multi-line: Shift+Enter inserts a line break, plain Enter commits;
4418
+ // Esc cancels; blur commits; Cmd/Ctrl+B/I/U format (unified with the others).
3478
4419
  const ref = useRef<HTMLDivElement | null>(null);
3479
4420
  const doneRef = useRef(false);
3480
4421
  const {
@@ -3502,37 +4443,33 @@ function StickyEditor({
3502
4443
  if (to?.closest?.('.dc-annot-ctx')) return;
3503
4444
  commit();
3504
4445
  };
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
- }, []);
4446
+ // Caret-at-click on entry + custom blinking caret + click re-placement.
4447
+ const caretHandlers = useEditorCaret(ref, caretPoint);
3521
4448
  const x = Math.min(sticky.x, sticky.x + sticky.w);
3522
4449
  const y = Math.min(sticky.y, sticky.y + sticky.h);
3523
4450
  const w = Math.abs(sticky.w);
3524
4451
  const h = Math.abs(sticky.h);
3525
4452
  return (
3526
- <foreignObject x={x} y={y} width={w} height={h}>
4453
+ <div
4454
+ data-annot-editor="1"
4455
+ style={{ position: 'absolute', left: x, top: y, width: w, height: h, zIndex: 5 }}
4456
+ >
3527
4457
  <div
3528
- xmlns="http://www.w3.org/1999/xhtml"
3529
4458
  ref={ref}
3530
4459
  className="dc-annot-editor dc-sticky-body"
3531
4460
  contentEditable
3532
4461
  suppressContentEditableWarning
3533
4462
  aria-label="Edit sticky note text"
3534
- style={{ ...stickyBodyStyle(sticky), ...fmtStyle, outline: 'none', cursor: 'text' }}
4463
+ style={{
4464
+ ...stickyBodyStyle(sticky),
4465
+ ...fmtStyle,
4466
+ outline: 'none',
4467
+ cursor: 'text',
4468
+ ...CARET_FIX_STYLE,
4469
+ }}
3535
4470
  onBlur={onBlur}
4471
+ onPointerDown={caretHandlers.onPointerDown}
4472
+ onPointerUp={caretHandlers.onPointerUp}
3536
4473
  onKeyDown={(e) => {
3537
4474
  if (onFormatKey(e)) return; // Cmd/Ctrl+B/I/U
3538
4475
  if (e.key === 'Escape') {
@@ -3541,14 +4478,16 @@ function StickyEditor({
3541
4478
  onCancel();
3542
4479
  return;
3543
4480
  }
3544
- if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
4481
+ // Unified: plain Enter commits, Shift+Enter inserts a newline.
4482
+ // ⌘/Ctrl+Enter also commits AND chains the next sticky beside it.
4483
+ if (e.key === 'Enter' && !e.shiftKey) {
3545
4484
  e.preventDefault();
3546
4485
  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
- );
4486
+ if (e.metaKey || e.ctrlKey) {
4487
+ document.dispatchEvent(
4488
+ new CustomEvent('maude:chain-create', { detail: { id: sticky.id } })
4489
+ );
4490
+ }
3552
4491
  }
3553
4492
  }}
3554
4493
  >
@@ -3556,7 +4495,7 @@ function StickyEditor({
3556
4495
  doesn't flicker; stripped back to raw text on commit. */}
3557
4496
  {stickyBodyText(sticky)}
3558
4497
  </div>
3559
- </foreignObject>
4498
+ </div>
3560
4499
  );
3561
4500
  }
3562
4501
 
@@ -3574,6 +4513,9 @@ function StandaloneTextEditor({
3574
4513
  underline,
3575
4514
  align,
3576
4515
  listType,
4516
+ singleLine,
4517
+ boxStyle,
4518
+ caretPoint,
3577
4519
  onCommit,
3578
4520
  onCancel,
3579
4521
  }: {
@@ -3588,6 +4530,14 @@ function StandaloneTextEditor({
3588
4530
  underline?: boolean;
3589
4531
  align?: TextAlign;
3590
4532
  listType?: ListType;
4533
+ /** The click that opened the editor — caret lands there (Phase 3). */
4534
+ caretPoint?: { x: number; y: number } | null;
4535
+ /** A one-line field (e.g. a section title rename) — plain Enter commits
4536
+ * instead of inserting a newline, matching a native text-input's Enter. */
4537
+ singleLine?: boolean;
4538
+ /** Extra style merged onto the editable box — e.g. a section rename wants
4539
+ * the same chip background/padding/radius the read-only label chip has. */
4540
+ boxStyle?: CSSProperties;
3591
4541
  onCommit: (text: string, fmt?: EditorFmt) => void;
3592
4542
  onCancel: () => void;
3593
4543
  }) {
@@ -3617,22 +4567,8 @@ function StandaloneTextEditor({
3617
4567
  },
3618
4568
  [onCommit, listType, fmtRef]
3619
4569
  );
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
- }, []);
4570
+ // Caret-at-click on entry + custom blinking caret + click re-placement.
4571
+ const caretHandlers = useEditorCaret(ref, caretPoint);
3636
4572
  // Commit on outside click.
3637
4573
  useEffect(() => {
3638
4574
  if (typeof document === 'undefined') return;
@@ -3649,12 +4585,23 @@ function StandaloneTextEditor({
3649
4585
  return () => document.removeEventListener('pointerdown', onDown, true);
3650
4586
  }, [commitOnce]);
3651
4587
  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}>
4588
+ // Generous box so multi-line text isn't clipped while typing (item 4a).
4589
+ // The host passes pointer events through (empty area is not the editor
4590
+ // outside-click must still commit); only the editable itself is
4591
+ // interactive, so clicks in it place the caret.
4592
+ <div
4593
+ data-annot-editor="1"
4594
+ style={{
4595
+ position: 'absolute',
4596
+ left: x,
4597
+ top: y,
4598
+ width: 640,
4599
+ height: 480,
4600
+ zIndex: 5,
4601
+ pointerEvents: 'none',
4602
+ }}
4603
+ >
3656
4604
  <div
3657
- xmlns="http://www.w3.org/1999/xhtml"
3658
4605
  ref={ref}
3659
4606
  className="dc-annot-editor"
3660
4607
  contentEditable
@@ -3663,6 +4610,9 @@ function StandaloneTextEditor({
3663
4610
  style={{
3664
4611
  display: 'inline-block',
3665
4612
  minWidth: '8px',
4613
+ // The pass-through host (above) is inert — re-enable events HERE so
4614
+ // in-editor clicks place the caret instead of falling through.
4615
+ pointerEvents: 'auto',
3666
4616
  // pre-wrap so Enter inserts a real newline (multi-line text), not a
3667
4617
  // commit; long lines also wrap within the box.
3668
4618
  whiteSpace: 'pre-wrap',
@@ -3676,8 +4626,12 @@ function StandaloneTextEditor({
3676
4626
  outline: 'none',
3677
4627
  background: 'transparent',
3678
4628
  cursor: 'text',
4629
+ ...CARET_FIX_STYLE,
4630
+ ...boxStyle,
3679
4631
  }}
3680
4632
  onBlur={() => commitOnce(ref.current?.innerText || '')}
4633
+ onPointerDown={caretHandlers.onPointerDown}
4634
+ onPointerUp={caretHandlers.onPointerUp}
3681
4635
  onKeyDown={(e) => {
3682
4636
  if (onFormatKey(e)) return; // Cmd/Ctrl+B/I/U
3683
4637
  if (e.key === 'Escape') {
@@ -3687,8 +4641,10 @@ function StandaloneTextEditor({
3687
4641
  onCancel();
3688
4642
  return;
3689
4643
  }
3690
- // Cmd/Ctrl+Enter commits; plain Enter inserts a newline (item 4a).
3691
- if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
4644
+ // Unified: plain Enter commits, Shift+Enter inserts a newline. A
4645
+ // singleLine field (section rename) is a title Shift+Enter commits
4646
+ // too rather than adding a newline the one-line chip can't show.
4647
+ if (e.key === 'Enter' && (!e.shiftKey || singleLine)) {
3692
4648
  e.preventDefault();
3693
4649
  commitOnce(ref.current?.innerText || '');
3694
4650
  }
@@ -3696,7 +4652,7 @@ function StandaloneTextEditor({
3696
4652
  >
3697
4653
  {listPrefixedBody(initialText, listType)}
3698
4654
  </div>
3699
- </foreignObject>
4655
+ </div>
3700
4656
  );
3701
4657
  }
3702
4658
 
@@ -3798,6 +4754,7 @@ function AnnotationContextMenu({
3798
4754
  selCount,
3799
4755
  canUngroup,
3800
4756
  canReplace,
4757
+ canEditPhoto,
3801
4758
  onAction,
3802
4759
  onClose,
3803
4760
  }: {
@@ -3806,6 +4763,8 @@ function AnnotationContextMenu({
3806
4763
  canUngroup: boolean;
3807
4764
  /** Stage F3 — exactly one ImageStroke/MediaRefStroke is selected. */
3808
4765
  canReplace: boolean;
4766
+ /** feature-photo-editor (Task 17) — exactly one content-addressed ImageStroke. */
4767
+ canEditPhoto: boolean;
3809
4768
  onAction: (action: string) => void;
3810
4769
  onClose: () => void;
3811
4770
  }) {
@@ -3873,6 +4832,7 @@ function AnnotationContextMenu({
3873
4832
  {item('paste', 'Paste', '⌘V')}
3874
4833
  {item('duplicate', 'Duplicate', '⌘D')}
3875
4834
  {canReplace ? item('replace', 'Replace…') : null}
4835
+ {canEditPhoto ? item('edit-photo', 'Edit Photo…') : null}
3876
4836
  <div className="dc-menu-sep" aria-hidden="true" />
3877
4837
  {item('front', 'Bring to front', ']')}
3878
4838
  {item('forward', 'Bring forward', '⌘]')}
@@ -4004,6 +4964,16 @@ function AnnotGroupBbox({
4004
4964
  const h = yMax - yMin + pad * 2;
4005
4965
  const handle = 6;
4006
4966
  const inset = 3;
4967
+ // Screen-constant hit target — bigger than the visible 6×6 square (matches
4968
+ // the single-resize DOM handles' generous grab area) so the corner stays
4969
+ // easy to grab at any zoom.
4970
+ const hit = 16 / zoom;
4971
+ const cursorFor: Record<string, string> = {
4972
+ nw: 'nwse-resize',
4973
+ se: 'nwse-resize',
4974
+ ne: 'nesw-resize',
4975
+ sw: 'nesw-resize',
4976
+ };
4007
4977
  const handles = [
4008
4978
  { corner: 'nw', x: x - inset, y: y - inset },
4009
4979
  { corner: 'ne', x: x + w - handle + inset, y: y - inset },
@@ -4024,20 +4994,42 @@ function AnnotGroupBbox({
4024
4994
  vectorEffect="non-scaling-stroke"
4025
4995
  rx={2}
4026
4996
  />
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
- ))}
4997
+ {/* Phase 2 (whiteboard-improvements) proportional group resize: these
4998
+ corners used to be decorative (pointerEvents inherited 'none' from
4999
+ the wrapping <g>). Each now sets its OWN pointerEvents, which SVG
5000
+ lets a descendant override independent of an ancestor's value, and
5001
+ carries `data-group-resize-corner` for the drag gesture registered
5002
+ in AnnotationsLayer to identify which corner was grabbed. */}
5003
+ {handles.map((c) => {
5004
+ const cx = c.x + handle / 2;
5005
+ const cy = c.y + handle / 2;
5006
+ return (
5007
+ <g key={c.corner}>
5008
+ <rect
5009
+ x={cx - hit / 2}
5010
+ y={cy - hit / 2}
5011
+ width={hit}
5012
+ height={hit}
5013
+ fill="transparent"
5014
+ pointerEvents="all"
5015
+ data-group-resize-corner={c.corner}
5016
+ style={{ cursor: cursorFor[c.corner] }}
5017
+ />
5018
+ <rect
5019
+ x={c.x}
5020
+ y={c.y}
5021
+ width={handle}
5022
+ height={handle}
5023
+ fill="var(--maude-hud-accent, #d63b1f)"
5024
+ stroke="var(--maude-chrome-bg-0, #ffffff)"
5025
+ strokeWidth={1}
5026
+ vectorEffect="non-scaling-stroke"
5027
+ rx={1}
5028
+ pointerEvents="none"
5029
+ />
5030
+ </g>
5031
+ );
5032
+ })}
4041
5033
  </g>
4042
5034
  );
4043
5035
  }
@@ -4166,6 +5158,106 @@ function GhostPreview({ ghost }: { ghost: GhostDescriptor }) {
4166
5158
  * Pointer events pass through the group, so hit-testing + the ctx-toolbar's
4167
5159
  * getBoundingClientRect positioning keep working on the rotated form.
4168
5160
  */
5161
+ /** Section title chip — deliberately screen-size-constant. The body/border
5162
+ * scale with the world like everything else, but a title that shrinks to
5163
+ * unreadable at zoom-out defeats the point of a label (matches the `r={5/zoom}`
5164
+ * counter-scale convention used for halos/connector-dots elsewhere in this file). */
5165
+ function SectionLabelChip({
5166
+ stroke,
5167
+ x,
5168
+ y,
5169
+ hitMode,
5170
+ }: {
5171
+ stroke: SectionStroke;
5172
+ x: number;
5173
+ y: number;
5174
+ hitMode: 'visiblePainted' | 'none';
5175
+ }) {
5176
+ const controller = useViewportControllerContext();
5177
+ const zoom = controller?.viewport?.zoom || 1;
5178
+ const fontSize = SECTION_LABEL_FONT / zoom;
5179
+ const chipH = SECTION_LABEL_H / zoom;
5180
+ const gap = 4 / zoom;
5181
+ const padX = 9 / zoom;
5182
+ const chipW = Math.max(56 / zoom, stroke.label.length * fontSize * 0.62 + 18 / zoom);
5183
+ // NOTE: this chip geometry (chipW/chipH/gap vs the region's y) is mirrored
5184
+ // by findTextStrokeAt's section branch — the Text tool's click-through
5185
+ // renames a section only from its label chip. Keep the two in sync.
5186
+ return (
5187
+ <g pointerEvents={hitMode}>
5188
+ <rect
5189
+ x={x}
5190
+ y={y - chipH - gap}
5191
+ width={chipW}
5192
+ height={chipH}
5193
+ rx={5 / zoom}
5194
+ ry={5 / zoom}
5195
+ fill={stroke.color}
5196
+ fillOpacity={0.16}
5197
+ />
5198
+ <text
5199
+ x={x + padX}
5200
+ y={y - chipH / 2 - gap}
5201
+ dominantBaseline="middle"
5202
+ fontSize={fontSize}
5203
+ fill={stroke.color}
5204
+ style={{
5205
+ fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
5206
+ }}
5207
+ >
5208
+ {stroke.label}
5209
+ </text>
5210
+ </g>
5211
+ );
5212
+ }
5213
+
5214
+ /** Section title RENAME field — same chip visuals + zoom-invariant sizing as
5215
+ * SectionLabelChip (read state), so switching into edit mode doesn't swap
5216
+ * the pill for a bare, ambient-zoomed sliver of text (it used to: the editor
5217
+ * had no background and its font-size wasn't counter-scaled, so at any zoom
5218
+ * below 1× the chip effectively vanished mid-rename). */
5219
+ function SectionTitleEditor({
5220
+ section,
5221
+ caretPoint,
5222
+ onCommit,
5223
+ onCancel,
5224
+ }: {
5225
+ section: SectionStroke;
5226
+ caretPoint?: { x: number; y: number } | null;
5227
+ onCommit: (text: string, fmt?: EditorFmt) => void;
5228
+ onCancel: () => void;
5229
+ }) {
5230
+ const controller = useViewportControllerContext();
5231
+ const zoom = controller?.viewport?.zoom || 1;
5232
+ const fontSize = SECTION_LABEL_FONT / zoom;
5233
+ const chipH = SECTION_LABEL_H / zoom;
5234
+ const gap = 4 / zoom;
5235
+ const padX = 9 / zoom;
5236
+ const x = Math.min(section.x, section.x + section.w);
5237
+ const y = Math.min(section.y, section.y + section.h);
5238
+ return (
5239
+ <StandaloneTextEditor
5240
+ x={x}
5241
+ y={y - chipH - gap}
5242
+ fontSize={fontSize}
5243
+ color={section.color}
5244
+ initialText={section.label}
5245
+ caretPoint={caretPoint}
5246
+ singleLine
5247
+ boxStyle={{
5248
+ background: `color-mix(in oklab, ${section.color} 16%, transparent)`,
5249
+ borderRadius: `${5 / zoom}px`,
5250
+ padding: `0 ${padX}px`,
5251
+ minHeight: `${chipH}px`,
5252
+ lineHeight: `${chipH}px`,
5253
+ whiteSpace: 'nowrap',
5254
+ }}
5255
+ onCommit={onCommit}
5256
+ onCancel={onCancel}
5257
+ />
5258
+ );
5259
+ }
5260
+
4169
5261
  function StrokeNode(props: {
4170
5262
  stroke: Stroke;
4171
5263
  anchorsById: Map<string, AnchorHost>;
@@ -4194,7 +5286,8 @@ function StrokeNodeBase({
4194
5286
  stroke: Stroke;
4195
5287
  anchorsById: Map<string, AnchorHost>;
4196
5288
  interactive: boolean;
4197
- /** Phase 21 — sticky-only: hide the read-only body while its editor is up. */
5289
+ /** Hide the read-only body/text while its editor is up (sticky, section, or
5290
+ * anchored shape text — whichever this stroke is). */
4198
5291
  editing?: boolean;
4199
5292
  }) {
4200
5293
  // In Move mode, individual stroke nodes claim pointer events so we can
@@ -4217,6 +5310,11 @@ function StrokeNodeBase({
4217
5310
  const host = anchorsById.get(stroke.anchorId);
4218
5311
  const bbox = host ? strokeBBox(host) : null;
4219
5312
  if (!bbox) return null;
5313
+ // Its editor (TextEditor) paints the same bbox while active — skip the
5314
+ // read-only <text> so the two don't double-paint (Phase 1 jump-fix
5315
+ // companion: the editor was already exempt via editingStickyId's
5316
+ // sibling, this stroke type never was).
5317
+ if (editing) return null;
4220
5318
  const cy = bbox.y + bbox.h / 2;
4221
5319
  const align = stroke.align ?? 'center';
4222
5320
  const pad = 8;
@@ -4245,6 +5343,9 @@ function StrokeNodeBase({
4245
5343
  </text>
4246
5344
  );
4247
5345
  }
5346
+ // Its editor (StandaloneTextEditor) paints at the same x/y while active —
5347
+ // skip the read-only <text> so the two don't double-paint (the "ghost").
5348
+ if (editing) return null;
4248
5349
  const align = stroke.align ?? 'left';
4249
5350
  const anchor = align === 'left' ? 'start' : align === 'right' ? 'end' : 'middle';
4250
5351
  const tx = stroke.x ?? 0;
@@ -4297,6 +5398,54 @@ function StrokeNodeBase({
4297
5398
  </div>
4298
5399
  </foreignObject>
4299
5400
  )}
5401
+ {/* Phase 3 (whiteboard-improvements) — author badge, bottom-right
5402
+ corner. A name label (not an avatar — a full name/nickname reads
5403
+ faster than initials and doesn't need a legend to decode). Color
5404
+ re-derives from the (sanitized) name via colorForName — NEVER a
5405
+ stored/wire color — so it matches the author's live presence hue
5406
+ (cursor/avatar use the same function). foreignObject width is a
5407
+ fixed generous box right-anchored via flex, since a name's pixel
5408
+ width isn't known without measuring the DOM. */}
5409
+ {stroke.authorName && (
5410
+ <foreignObject
5411
+ x={x + w - 160}
5412
+ y={y + h - 20}
5413
+ width={160}
5414
+ height={20}
5415
+ pointerEvents="none"
5416
+ >
5417
+ <div
5418
+ xmlns="http://www.w3.org/1999/xhtml"
5419
+ style={{
5420
+ display: 'flex',
5421
+ justifyContent: 'flex-end',
5422
+ alignItems: 'center',
5423
+ height: '100%',
5424
+ paddingRight: 4,
5425
+ }}
5426
+ >
5427
+ <span
5428
+ title={stroke.authorName}
5429
+ style={{
5430
+ maxWidth: '100%',
5431
+ overflow: 'hidden',
5432
+ textOverflow: 'ellipsis',
5433
+ whiteSpace: 'nowrap',
5434
+ fontSize: 9,
5435
+ fontWeight: 600,
5436
+ fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
5437
+ color: colorForName(stroke.authorName),
5438
+ background: 'rgba(255,255,255,0.78)',
5439
+ padding: '1px 5px',
5440
+ borderRadius: 8,
5441
+ lineHeight: 1.4,
5442
+ }}
5443
+ >
5444
+ {stroke.authorName}
5445
+ </span>
5446
+ </div>
5447
+ </foreignObject>
5448
+ )}
4300
5449
  </g>
4301
5450
  );
4302
5451
  }
@@ -4482,7 +5631,6 @@ function StrokeNodeBase({
4482
5631
  const y = Math.min(stroke.y, stroke.y + stroke.h);
4483
5632
  const w = Math.abs(stroke.w);
4484
5633
  const h = Math.abs(stroke.h);
4485
- const chipW = Math.max(56, stroke.label.length * SECTION_LABEL_FONT * 0.62 + 18);
4486
5634
  return (
4487
5635
  <g data-id={stroke.id} data-tool="section">
4488
5636
  {/* Region body — pure backdrop, CLICK-THROUGH (FigJam: content on a
@@ -4518,31 +5666,10 @@ function StrokeNodeBase({
4518
5666
  pointerEvents="stroke"
4519
5667
  />
4520
5668
  ) : 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>
5669
+ {/* Label chip above the top-left corner — also a grab handle. Hidden
5670
+ while the rename editor (StandaloneTextEditor) is up at the same
5671
+ spot, else the old label reads through behind the new text. */}
5672
+ {editing ? null : <SectionLabelChip stroke={stroke} x={x} y={y} hitMode={hitMode} />}
4546
5673
  </g>
4547
5674
  );
4548
5675
  }