@1agh/maude 0.28.1 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (372) hide show
  1. package/README.md +3 -3
  2. package/{plugins/design/dev-server → apps/studio}/ai-banner.tsx +1 -2
  3. package/apps/studio/annotations-align.ts +149 -0
  4. package/apps/studio/annotations-bindings.ts +197 -0
  5. package/{plugins/design/dev-server → apps/studio}/annotations-context-toolbar.tsx +681 -151
  6. package/apps/studio/annotations-groups.ts +270 -0
  7. package/apps/studio/annotations-layer.tsx +4537 -0
  8. package/apps/studio/annotations-model.ts +2077 -0
  9. package/apps/studio/annotations-snap.ts +125 -0
  10. package/{plugins/design/dev-server → apps/studio}/api.ts +616 -70
  11. package/{plugins/design/dev-server → apps/studio}/artboard-activity-overlay.tsx +1 -1
  12. package/{plugins/design/dev-server → apps/studio}/artboard-marquee.tsx +1 -1
  13. package/apps/studio/bin/_motion-sample-playwright.mjs +129 -0
  14. package/{plugins/design/dev-server → apps/studio}/bin/_png-playwright.mjs +1 -1
  15. package/apps/studio/bin/annotate.mjs +732 -0
  16. package/apps/studio/bin/annotate.sh +17 -0
  17. package/{plugins/design/dev-server → apps/studio}/bin/canvas-edit.sh +1 -1
  18. package/{plugins/design/dev-server → apps/studio}/bin/draw-proof.sh +37 -2
  19. package/{plugins/design/dev-server → apps/studio}/bin/preflight.sh +1 -1
  20. package/apps/studio/bin/read-annotations.mjs +573 -0
  21. package/apps/studio/bin/read-annotations.sh +11 -0
  22. package/{plugins/design/dev-server → apps/studio}/bin/runtime-health.sh +1 -1
  23. package/{plugins/design/dev-server → apps/studio}/bin/screenshot.sh +62 -2
  24. package/{plugins/design/dev-server → apps/studio}/bin/server-up.sh +9 -3
  25. package/apps/studio/bin/to-lottie-verify.html +68 -0
  26. package/apps/studio/bin/to-lottie.sh +128 -0
  27. package/{plugins/design/dev-server → apps/studio}/build.ts +6 -4
  28. package/{plugins/design/dev-server → apps/studio}/canvas-arrowheads.ts +79 -10
  29. package/{plugins/design/dev-server → apps/studio}/canvas-comment-mount.tsx +1 -1
  30. package/apps/studio/canvas-create.ts +104 -0
  31. package/{plugins/design/dev-server → apps/studio}/canvas-cursors.ts +48 -12
  32. package/{plugins/design/dev-server → apps/studio}/canvas-edit.ts +257 -7
  33. package/{plugins/design/dev-server → apps/studio}/canvas-icons.tsx +165 -1
  34. package/{plugins/design/dev-server → apps/studio}/canvas-lib-inline.ts +1 -1
  35. package/{plugins/design/dev-server → apps/studio}/canvas-lib-resolver.ts +1 -1
  36. package/{plugins/design/dev-server → apps/studio}/canvas-lib.tsx +31 -12
  37. package/{plugins/design/dev-server → apps/studio}/canvas-shell.tsx +360 -48
  38. package/apps/studio/client/app.jsx +6742 -0
  39. package/apps/studio/client/canvas-url.js +81 -0
  40. package/{plugins/design/dev-server → apps/studio}/client/comments-overlay.css +41 -27
  41. package/apps/studio/client/index.html +16 -0
  42. package/apps/studio/client/styles/1-tokens-maude.css +296 -0
  43. package/apps/studio/client/styles/3-shell-maude.css +1161 -0
  44. package/{plugins/design/dev-server → apps/studio}/client/styles/3-shell.css +129 -0
  45. package/apps/studio/client/styles/4-components-maude.css +74 -0
  46. package/{plugins/design/dev-server → apps/studio}/client/styles/4-components.css +396 -34
  47. package/apps/studio/client/styles/5-maude-overrides.css +137 -0
  48. package/{plugins/design/dev-server → apps/studio}/client/styles/_index.css +9 -0
  49. package/{plugins/design/dev-server → apps/studio}/client/styles.css +1 -1
  50. package/apps/studio/client/tour/overlay.jsx +279 -0
  51. package/apps/studio/client/tour/usage-tour.js +48 -0
  52. package/apps/studio/client/whats-new-seen.js +52 -0
  53. package/apps/studio/client/whats-new.jsx +250 -0
  54. package/{plugins/design/dev-server → apps/studio}/commands/annotation-strokes-command.ts +2 -2
  55. package/{plugins/design/dev-server → apps/studio}/commands/equal-spacing-command.ts +1 -1
  56. package/{plugins/design/dev-server → apps/studio}/commands/move-artboards-command.ts +2 -2
  57. package/{plugins/design/dev-server → apps/studio}/comments-overlay.tsx +32 -11
  58. package/{plugins/design/dev-server → apps/studio}/context-menu.tsx +5 -4
  59. package/{plugins/design/dev-server → apps/studio}/contextual-toolbar.tsx +18 -7
  60. package/{plugins/design/dev-server → apps/studio}/cursors-overlay.tsx +21 -21
  61. package/apps/studio/dist/client.bundle.js +25545 -0
  62. package/apps/studio/dist/comment-mount.js +2046 -0
  63. package/apps/studio/dist/styles.css +8018 -0
  64. package/apps/studio/dom-selection.ts +342 -0
  65. package/apps/studio/draw/animate.ts +333 -0
  66. package/{plugins/design/dev-server → apps/studio}/draw/brush.ts +1 -1
  67. package/{plugins/design/dev-server → apps/studio}/draw/composition.ts +1 -1
  68. package/{plugins/design/dev-server → apps/studio}/draw/geometry.ts +1 -1
  69. package/{plugins/design/dev-server → apps/studio}/draw/index.ts +4 -1
  70. package/{plugins/design/dev-server → apps/studio}/draw/layout.ts +1 -1
  71. package/apps/studio/draw/morph.ts +204 -0
  72. package/{plugins/design/dev-server → apps/studio}/draw/optimize.ts +1 -1
  73. package/{plugins/design/dev-server → apps/studio}/draw/palette.ts +1 -1
  74. package/{plugins/design/dev-server → apps/studio}/draw/primitives.ts +1 -1
  75. package/apps/studio/draw/serialize-animate.ts +324 -0
  76. package/{plugins/design/dev-server → apps/studio}/draw/serialize.ts +9 -3
  77. package/apps/studio/draw/test/animate.test.ts +172 -0
  78. package/apps/studio/draw/test/morph.test.ts +107 -0
  79. package/apps/studio/draw/test/serialize-animate.test.ts +142 -0
  80. package/{plugins/design/dev-server → apps/studio}/equal-spacing-detector.ts +1 -1
  81. package/{plugins/design/dev-server → apps/studio}/equal-spacing-handles.tsx +7 -29
  82. package/{plugins/design/dev-server → apps/studio}/export-dialog.tsx +16 -4
  83. package/{plugins/design/dev-server → apps/studio}/history.ts +20 -3
  84. package/{plugins/design/dev-server → apps/studio}/http.ts +226 -6
  85. package/{plugins/design/dev-server → apps/studio}/input-router.tsx +15 -1
  86. package/{plugins/design/dev-server → apps/studio}/inspect.ts +50 -4
  87. package/{plugins/design/dev-server → apps/studio}/marquee-overlay.tsx +5 -2
  88. package/{plugins/design/dev-server → apps/studio}/participants-chrome.tsx +2 -8
  89. package/{plugins/design/dev-server → apps/studio}/paths.ts +15 -11
  90. package/{plugins/design/dev-server → apps/studio}/server.ts +23 -2
  91. package/{plugins/design/dev-server → apps/studio}/sync/agent.ts +231 -57
  92. package/{plugins/design/dev-server → apps/studio}/sync/codec.ts +45 -0
  93. package/apps/studio/sync/cold-start.ts +158 -0
  94. package/{plugins/design/dev-server → apps/studio}/sync/connection-state.ts +58 -2
  95. package/{plugins/design/dev-server → apps/studio}/sync/index.ts +563 -221
  96. package/apps/studio/sync/journal.ts +190 -0
  97. package/apps/studio/sync/migrate-seed.ts +321 -0
  98. package/{plugins/design/dev-server → apps/studio}/sync/projection.ts +22 -3
  99. package/{plugins/design/dev-server → apps/studio}/sync/status.ts +15 -4
  100. package/{plugins/design/dev-server → apps/studio}/test/activity.test.ts +1 -7
  101. package/apps/studio/test/annotate-write.test.ts +184 -0
  102. package/apps/studio/test/annotations-align.test.ts +88 -0
  103. package/apps/studio/test/annotations-bindings.test.ts +124 -0
  104. package/apps/studio/test/annotations-groups.test.ts +231 -0
  105. package/{plugins/design/dev-server → apps/studio}/test/annotations-layer.test.ts +63 -0
  106. package/{plugins/design/dev-server → apps/studio}/test/annotations-roundtrip.test.ts +97 -0
  107. package/apps/studio/test/annotations-snap.test.ts +79 -0
  108. package/apps/studio/test/annotations-text-format.test.ts +396 -0
  109. package/apps/studio/test/asset-api.test.ts +149 -0
  110. package/apps/studio/test/canvas-create-api.test.ts +361 -0
  111. package/{plugins/design/dev-server → apps/studio}/test/canvas-cursors.test.ts +2 -0
  112. package/apps/studio/test/canvas-edit.test.ts +319 -0
  113. package/{plugins/design/dev-server → apps/studio}/test/canvas-lib-resolver.test.ts +1 -1
  114. package/apps/studio/test/canvas-media-drop.test.ts +158 -0
  115. package/{plugins/design/dev-server → apps/studio}/test/canvas-origin-gate.test.ts +25 -1
  116. package/apps/studio/test/canvas-url.test.ts +99 -0
  117. package/{plugins/design/dev-server → apps/studio}/test/collab-bridge.test.ts +0 -2
  118. package/{plugins/design/dev-server → apps/studio}/test/collab-room.test.ts +2 -7
  119. package/{plugins/design/dev-server → apps/studio}/test/csp-canvas-shell.test.ts +5 -0
  120. package/apps/studio/test/csrf-write-guard.test.ts +52 -0
  121. package/apps/studio/test/figjam-v3-model.test.ts +342 -0
  122. package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
  123. package/apps/studio/test/index-data-canvas-ds.test.ts +97 -0
  124. package/apps/studio/test/motion-sample.test.ts +35 -0
  125. package/{plugins/design/dev-server → apps/studio}/test/perf-harness.ts +1 -1
  126. package/{plugins/design/dev-server → apps/studio}/test/phase-3.6-smoke.test.ts +3 -1
  127. package/apps/studio/test/read-annotations.test.ts +409 -0
  128. package/{plugins/design/dev-server → apps/studio}/test/sanitize-annotation-svg.test.ts +108 -2
  129. package/{plugins/design/dev-server → apps/studio}/test/shared-doc-convergence.test.ts +1 -1
  130. package/apps/studio/test/shared-doc-migrate.test.ts +369 -0
  131. package/apps/studio/test/sync-agent.test.ts +575 -0
  132. package/{plugins/design/dev-server → apps/studio}/test/sync-codec.test.ts +65 -0
  133. package/apps/studio/test/sync-cold-start.test.ts +244 -0
  134. package/{plugins/design/dev-server → apps/studio}/test/sync-connection-state.test.ts +68 -0
  135. package/{plugins/design/dev-server → apps/studio}/test/sync-hardening.test.ts +0 -8
  136. package/{plugins/design/dev-server → apps/studio}/test/sync-hubs-config.test.ts +0 -1
  137. package/apps/studio/test/sync-incident-replay.test.ts +211 -0
  138. package/apps/studio/test/sync-journal.test.ts +176 -0
  139. package/{plugins/design/dev-server → apps/studio}/test/sync-runtime.test.ts +360 -5
  140. package/{plugins/design/dev-server → apps/studio}/test/sync-status.test.ts +66 -0
  141. package/apps/studio/test/tour-overlay.test.tsx +56 -0
  142. package/apps/studio/test/use-annotation-resize.test.ts +402 -0
  143. package/{plugins/design/dev-server → apps/studio}/test/use-artboard-drag.test.ts +0 -1
  144. package/{plugins/design/dev-server → apps/studio}/test/use-collab.test.ts +0 -0
  145. package/{plugins/design/dev-server → apps/studio}/test/use-tool-mode.test.tsx +8 -2
  146. package/apps/studio/test/whats-new.test.ts +101 -0
  147. package/apps/studio/text-imports.d.ts +9 -0
  148. package/{plugins/design/dev-server → apps/studio}/tool-palette.tsx +15 -70
  149. package/{plugins/design/dev-server → apps/studio}/undo-hud.tsx +1 -2
  150. package/{plugins/design/dev-server → apps/studio}/undo-stack.ts +1 -1
  151. package/{plugins/design/dev-server → apps/studio}/use-agent-presence.tsx +5 -3
  152. package/apps/studio/use-annotation-resize.tsx +912 -0
  153. package/{plugins/design/dev-server → apps/studio}/use-annotation-selection.tsx +17 -5
  154. package/{plugins/design/dev-server → apps/studio}/use-annotations-visibility.tsx +1 -1
  155. package/{plugins/design/dev-server → apps/studio}/use-artboard-drag.tsx +1 -1
  156. package/{plugins/design/dev-server → apps/studio}/use-canvas-activity.tsx +3 -3
  157. package/apps/studio/use-canvas-media-drop.tsx +343 -0
  158. package/{plugins/design/dev-server → apps/studio}/use-collab.tsx +21 -16
  159. package/{plugins/design/dev-server → apps/studio}/use-cursor-modifiers.tsx +1 -1
  160. package/{plugins/design/dev-server → apps/studio}/use-keyboard-discipline.tsx +5 -2
  161. package/{plugins/design/dev-server → apps/studio}/use-selection-set.tsx +18 -1
  162. package/{plugins/design/dev-server → apps/studio}/use-snap-guides.tsx +1 -1
  163. package/{plugins/design/dev-server → apps/studio}/use-tool-mode.tsx +8 -2
  164. package/{plugins/design/dev-server → apps/studio}/use-undo-stack.tsx +1 -1
  165. package/apps/studio/whats-new.json +264 -0
  166. package/apps/studio/whats-new.schema.json +86 -0
  167. package/apps/studio/whats-new.ts +87 -0
  168. package/cli/bin/maude.mjs +10 -0
  169. package/cli/commands/design-link.test.mjs +84 -0
  170. package/cli/commands/design.mjs +16 -7
  171. package/cli/commands/design.test.mjs +40 -0
  172. package/cli/commands/help.mjs +8 -2
  173. package/cli/commands/hub.mjs +3 -3
  174. package/cli/commands/hub.test.mjs +1 -1
  175. package/cli/commands/scenario-report.mjs +2 -9
  176. package/cli/lib/design-link.mjs +51 -1
  177. package/cli/lib/flow-design-integration.test.mjs +1 -1
  178. package/cli/lib/gitignore-block.mjs +1 -0
  179. package/cli/lib/plugin-cli-reachability.test.mjs +8 -7
  180. package/cli/lib/preflight.mjs +1 -1
  181. package/package.json +15 -15
  182. package/plugins/design/templates/_shell.html +28 -4
  183. package/plugins/design/templates/brief-board.tsx.template +61 -0
  184. package/plugins/design/templates/canvas.tsx.template +1 -1
  185. package/plugins/design/templates/design-system-inspiration/core/config.json.tpl +1 -1
  186. package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +1 -1
  187. package/plugins/flow/.claude-plugin/config.schema.json +16 -0
  188. package/plugins/design/dev-server/annotations-layer.tsx +0 -3402
  189. package/plugins/design/dev-server/client/app.jsx +0 -2985
  190. package/plugins/design/dev-server/client/index.html +0 -15
  191. package/plugins/design/dev-server/dist/client.bundle.js +0 -20692
  192. package/plugins/design/dev-server/dist/comment-mount.js +0 -1924
  193. package/plugins/design/dev-server/dist/styles.css +0 -2710
  194. package/plugins/design/dev-server/dom-selection.ts +0 -156
  195. package/plugins/design/dev-server/sync/migrate-seed.ts +0 -163
  196. package/plugins/design/dev-server/test/canvas-edit.test.ts +0 -139
  197. package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +0 -159
  198. package/plugins/design/dev-server/test/sync-agent.test.ts +0 -305
  199. package/plugins/design/dev-server/test/use-annotation-resize.test.ts +0 -200
  200. package/plugins/design/dev-server/use-annotation-resize.tsx +0 -449
  201. /package/{plugins/design/dev-server → apps/studio}/activity.ts +0 -0
  202. /package/{plugins/design/dev-server → apps/studio}/bin/_enumerate-artboards-playwright.mjs +0 -0
  203. /package/{plugins/design/dev-server → apps/studio}/bin/_html-playwright.mjs +0 -0
  204. /package/{plugins/design/dev-server → apps/studio}/bin/_pdf-playwright.mjs +0 -0
  205. /package/{plugins/design/dev-server → apps/studio}/bin/_pptx-playwright.mjs +0 -0
  206. /package/{plugins/design/dev-server → apps/studio}/bin/_pw-launch.mjs +0 -0
  207. /package/{plugins/design/dev-server → apps/studio}/bin/_screenshot-playwright.mjs +0 -0
  208. /package/{plugins/design/dev-server → apps/studio}/bin/_svg-optimize.mjs +0 -0
  209. /package/{plugins/design/dev-server → apps/studio}/bin/_svg-playwright.mjs +0 -0
  210. /package/{plugins/design/dev-server → apps/studio}/bin/asset-sweep.sh +0 -0
  211. /package/{plugins/design/dev-server → apps/studio}/bin/bootstrap-check.sh +0 -0
  212. /package/{plugins/design/dev-server → apps/studio}/bin/check-runtime-bundles.sh +0 -0
  213. /package/{plugins/design/dev-server → apps/studio}/bin/draw-build.sh +0 -0
  214. /package/{plugins/design/dev-server → apps/studio}/bin/handoff.sh +0 -0
  215. /package/{plugins/design/dev-server → apps/studio}/bin/prep.sh +0 -0
  216. /package/{plugins/design/dev-server → apps/studio}/bin/scenario-report.mjs +0 -0
  217. /package/{plugins/design/dev-server → apps/studio}/bin/slug.sh +0 -0
  218. /package/{plugins/design/dev-server → apps/studio}/bin/smoke.sh +0 -0
  219. /package/{plugins/design/dev-server → apps/studio}/bin/svg-optimize.sh +0 -0
  220. /package/{plugins/design/dev-server → apps/studio}/bin/visual-sanity.sh +0 -0
  221. /package/{plugins/design/dev-server → apps/studio}/boot-self-heal.ts +0 -0
  222. /package/{plugins/design/dev-server → apps/studio}/canvas-build.ts +0 -0
  223. /package/{plugins/design/dev-server → apps/studio}/canvas-header.ts +0 -0
  224. /package/{plugins/design/dev-server → apps/studio}/canvas-meta.schema.json +0 -0
  225. /package/{plugins/design/dev-server → apps/studio}/canvas-pipeline.ts +0 -0
  226. /package/{plugins/design/dev-server → apps/studio}/client/hmr.mjs +0 -0
  227. /package/{plugins/design/dev-server → apps/studio}/client/iframe-lazy.mjs +0 -0
  228. /package/{plugins/design/dev-server → apps/studio}/client/styles/0-reset.css +0 -0
  229. /package/{plugins/design/dev-server → apps/studio}/client/styles/1-tokens.css +0 -0
  230. /package/{plugins/design/dev-server → apps/studio}/client/styles/2-layout.css +0 -0
  231. /package/{plugins/design/dev-server → apps/studio}/client/styles/5-utilities.css +0 -0
  232. /package/{plugins/design/dev-server → apps/studio}/collab/ai-activity.ts +0 -0
  233. /package/{plugins/design/dev-server → apps/studio}/collab/awareness-bridge.ts +0 -0
  234. /package/{plugins/design/dev-server → apps/studio}/collab/git-lifecycle.ts +0 -0
  235. /package/{plugins/design/dev-server → apps/studio}/collab/index.ts +0 -0
  236. /package/{plugins/design/dev-server → apps/studio}/collab/persistence.ts +0 -0
  237. /package/{plugins/design/dev-server → apps/studio}/collab/protocol.ts +0 -0
  238. /package/{plugins/design/dev-server → apps/studio}/collab/registry.ts +0 -0
  239. /package/{plugins/design/dev-server → apps/studio}/collab/room.ts +0 -0
  240. /package/{plugins/design/dev-server → apps/studio}/config.schema.json +0 -0
  241. /package/{plugins/design/dev-server → apps/studio}/context.ts +0 -0
  242. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/.min-sizes.json +0 -0
  243. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/lib0_decoding.js +0 -0
  244. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/lib0_encoding.js +0 -0
  245. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/motion.js +0 -0
  246. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/motion_react.js +0 -0
  247. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/pixi-js.js +0 -0
  248. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/react-dom.js +0 -0
  249. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/react-dom_client.js +0 -0
  250. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/react.js +0 -0
  251. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/react_jsx-dev-runtime.js +0 -0
  252. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/react_jsx-runtime.js +0 -0
  253. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/y-protocols_awareness.js +0 -0
  254. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/y-protocols_sync.js +0 -0
  255. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/yjs.js +0 -0
  256. /package/{plugins/design/dev-server → apps/studio}/draw/test/brush.test.ts +0 -0
  257. /package/{plugins/design/dev-server → apps/studio}/draw/test/composition.test.ts +0 -0
  258. /package/{plugins/design/dev-server → apps/studio}/draw/test/geometry.test.ts +0 -0
  259. /package/{plugins/design/dev-server → apps/studio}/draw/test/gradient.test.ts +0 -0
  260. /package/{plugins/design/dev-server → apps/studio}/draw/test/layout.test.ts +0 -0
  261. /package/{plugins/design/dev-server → apps/studio}/draw/test/optimize.test.ts +0 -0
  262. /package/{plugins/design/dev-server → apps/studio}/draw/test/palette.test.ts +0 -0
  263. /package/{plugins/design/dev-server → apps/studio}/draw/test/primitives.test.ts +0 -0
  264. /package/{plugins/design/dev-server → apps/studio}/draw/test/serialize.test.ts +0 -0
  265. /package/{plugins/design/dev-server → apps/studio}/examples/README.md +0 -0
  266. /package/{plugins/design/dev-server → apps/studio}/examples/perf-100-artboards.tsx +0 -0
  267. /package/{plugins/design/dev-server → apps/studio}/exporters/_browser-bundles.ts +0 -0
  268. /package/{plugins/design/dev-server → apps/studio}/exporters/canva-handoff-prompt.ts +0 -0
  269. /package/{plugins/design/dev-server → apps/studio}/exporters/canva.ts +0 -0
  270. /package/{plugins/design/dev-server → apps/studio}/exporters/html.ts +0 -0
  271. /package/{plugins/design/dev-server → apps/studio}/exporters/index.ts +0 -0
  272. /package/{plugins/design/dev-server → apps/studio}/exporters/pdf.ts +0 -0
  273. /package/{plugins/design/dev-server → apps/studio}/exporters/png.ts +0 -0
  274. /package/{plugins/design/dev-server → apps/studio}/exporters/pptx.ts +0 -0
  275. /package/{plugins/design/dev-server → apps/studio}/exporters/scope.ts +0 -0
  276. /package/{plugins/design/dev-server → apps/studio}/exporters/svg.ts +0 -0
  277. /package/{plugins/design/dev-server → apps/studio}/exporters/zip.ts +0 -0
  278. /package/{plugins/design/dev-server → apps/studio}/fs-watch.ts +0 -0
  279. /package/{plugins/design/dev-server → apps/studio}/handoff.ts +0 -0
  280. /package/{plugins/design/dev-server → apps/studio}/hmr-broadcast.ts +0 -0
  281. /package/{plugins/design/dev-server → apps/studio}/locator.ts +0 -0
  282. /package/{plugins/design/dev-server → apps/studio}/mem.ts +0 -0
  283. /package/{plugins/design/dev-server → apps/studio}/runtime-bundle.ts +0 -0
  284. /package/{plugins/design/dev-server → apps/studio}/server.mjs +0 -0
  285. /package/{plugins/design/dev-server → apps/studio}/sync/atomic-write.ts +0 -0
  286. /package/{plugins/design/dev-server → apps/studio}/sync/echo-guard.ts +0 -0
  287. /package/{plugins/design/dev-server → apps/studio}/sync/fs-mirror.ts +0 -0
  288. /package/{plugins/design/dev-server → apps/studio}/sync/hubs-config.ts +0 -0
  289. /package/{plugins/design/dev-server → apps/studio}/sync/materialize.ts +0 -0
  290. /package/{plugins/design/dev-server → apps/studio}/sync/origins.ts +0 -0
  291. /package/{plugins/design/dev-server → apps/studio}/sync/untrusted.ts +0 -0
  292. /package/{plugins/design/dev-server → apps/studio}/test/_helpers.ts +0 -0
  293. /package/{plugins/design/dev-server → apps/studio}/test/active-state.test.ts +0 -0
  294. /package/{plugins/design/dev-server → apps/studio}/test/ai-activity.test.ts +0 -0
  295. /package/{plugins/design/dev-server → apps/studio}/test/annotation-strokes-command.test.ts +0 -0
  296. /package/{plugins/design/dev-server → apps/studio}/test/annotations-api.test.ts +0 -0
  297. /package/{plugins/design/dev-server → apps/studio}/test/annotations-draw-modifiers.test.ts +0 -0
  298. /package/{plugins/design/dev-server → apps/studio}/test/artboard-activity-overlay.test.tsx +0 -0
  299. /package/{plugins/design/dev-server → apps/studio}/test/binary-smoke.test.ts +0 -0
  300. /package/{plugins/design/dev-server → apps/studio}/test/boot-self-heal.test.ts +0 -0
  301. /package/{plugins/design/dev-server → apps/studio}/test/bundle-smoke.test.ts +0 -0
  302. /package/{plugins/design/dev-server → apps/studio}/test/canvas-build.test.ts +0 -0
  303. /package/{plugins/design/dev-server → apps/studio}/test/canvas-header.test.ts +0 -0
  304. /package/{plugins/design/dev-server → apps/studio}/test/canvas-hmr-runtime.test.tsx +0 -0
  305. /package/{plugins/design/dev-server → apps/studio}/test/canvas-lib-inline.test.ts +0 -0
  306. /package/{plugins/design/dev-server → apps/studio}/test/canvas-lib-motion.test.ts +0 -0
  307. /package/{plugins/design/dev-server → apps/studio}/test/canvas-meta-api.test.ts +0 -0
  308. /package/{plugins/design/dev-server → apps/studio}/test/canvas-pipeline.test.ts +0 -0
  309. /package/{plugins/design/dev-server → apps/studio}/test/canvas-route.test.ts +0 -0
  310. /package/{plugins/design/dev-server → apps/studio}/test/collab-annotations-bridge.test.ts +0 -0
  311. /package/{plugins/design/dev-server → apps/studio}/test/collab-awareness-bridge.test.ts +0 -0
  312. /package/{plugins/design/dev-server → apps/studio}/test/collab-loopback.test.ts +0 -0
  313. /package/{plugins/design/dev-server → apps/studio}/test/collab-protocol.test.ts +0 -0
  314. /package/{plugins/design/dev-server → apps/studio}/test/collab-reseed-guard.test.ts +0 -0
  315. /package/{plugins/design/dev-server → apps/studio}/test/collab-stress.test.ts +0 -0
  316. /package/{plugins/design/dev-server → apps/studio}/test/comment-mount.test.ts +0 -0
  317. /package/{plugins/design/dev-server → apps/studio}/test/comments-api.test.ts +0 -0
  318. /package/{plugins/design/dev-server → apps/studio}/test/compile-entry.test.ts +0 -0
  319. /package/{plugins/design/dev-server → apps/studio}/test/context-resolve-tokens.test.ts +0 -0
  320. /package/{plugins/design/dev-server → apps/studio}/test/equal-spacing-detector.test.ts +0 -0
  321. /package/{plugins/design/dev-server → apps/studio}/test/exporters/canva.test.ts +0 -0
  322. /package/{plugins/design/dev-server → apps/studio}/test/exporters/endpoint.test.ts +0 -0
  323. /package/{plugins/design/dev-server → apps/studio}/test/exporters/history.test.ts +0 -0
  324. /package/{plugins/design/dev-server → apps/studio}/test/exporters/html.test.ts +0 -0
  325. /package/{plugins/design/dev-server → apps/studio}/test/exporters/pdf.test.ts +0 -0
  326. /package/{plugins/design/dev-server → apps/studio}/test/exporters/png.test.ts +0 -0
  327. /package/{plugins/design/dev-server → apps/studio}/test/exporters/pptx-deck.test.ts +0 -0
  328. /package/{plugins/design/dev-server → apps/studio}/test/exporters/pptx.test.ts +0 -0
  329. /package/{plugins/design/dev-server → apps/studio}/test/exporters/pw-launch.test.ts +0 -0
  330. /package/{plugins/design/dev-server → apps/studio}/test/exporters/scope.test.ts +0 -0
  331. /package/{plugins/design/dev-server → apps/studio}/test/exporters/svg.test.ts +0 -0
  332. /package/{plugins/design/dev-server → apps/studio}/test/exporters/zip.test.ts +0 -0
  333. /package/{plugins/design/dev-server → apps/studio}/test/fixtures/phase-20-annotations.svg +0 -0
  334. /package/{plugins/design/dev-server → apps/studio}/test/fixtures/phase-21-annotations.svg +0 -0
  335. /package/{plugins/design/dev-server → apps/studio}/test/fs-watch.test.ts +0 -0
  336. /package/{plugins/design/dev-server → apps/studio}/test/git-lifecycle.test.ts +0 -0
  337. /package/{plugins/design/dev-server → apps/studio}/test/handoff-static-frames.test.ts +0 -0
  338. /package/{plugins/design/dev-server → apps/studio}/test/handoff.test.ts +0 -0
  339. /package/{plugins/design/dev-server → apps/studio}/test/history-rollback.test.ts +0 -0
  340. /package/{plugins/design/dev-server → apps/studio}/test/hmr-broadcast.test.ts +0 -0
  341. /package/{plugins/design/dev-server → apps/studio}/test/hmr-classify.test.ts +0 -0
  342. /package/{plugins/design/dev-server → apps/studio}/test/input-router.test.ts +0 -0
  343. /package/{plugins/design/dev-server → apps/studio}/test/locator.test.ts +0 -0
  344. /package/{plugins/design/dev-server → apps/studio}/test/marquee-overlay.test.ts +0 -0
  345. /package/{plugins/design/dev-server → apps/studio}/test/move-artboards-command.test.ts +0 -0
  346. /package/{plugins/design/dev-server → apps/studio}/test/participants-chrome.test.ts +0 -0
  347. /package/{plugins/design/dev-server → apps/studio}/test/paths.test.ts +0 -0
  348. /package/{plugins/design/dev-server → apps/studio}/test/runtime-bundle-error-mapping.test.ts +0 -0
  349. /package/{plugins/design/dev-server → apps/studio}/test/runtime-bundle.test.ts +0 -0
  350. /package/{plugins/design/dev-server → apps/studio}/test/server-lifecycle.test.ts +0 -0
  351. /package/{plugins/design/dev-server → apps/studio}/test/shared-doc-foundation.test.ts +0 -0
  352. /package/{plugins/design/dev-server → apps/studio}/test/shared-doc-projection.test.ts +0 -0
  353. /package/{plugins/design/dev-server → apps/studio}/test/snap-distance-pill.test.ts +0 -0
  354. /package/{plugins/design/dev-server → apps/studio}/test/sync-atomic-write.test.ts +0 -0
  355. /package/{plugins/design/dev-server → apps/studio}/test/sync-echo-guard.test.ts +0 -0
  356. /package/{plugins/design/dev-server → apps/studio}/test/sync-fs-mirror.test.ts +0 -0
  357. /package/{plugins/design/dev-server → apps/studio}/test/sync-meta-codec.test.ts +0 -0
  358. /package/{plugins/design/dev-server → apps/studio}/test/sync-untrusted.test.ts +0 -0
  359. /package/{plugins/design/dev-server → apps/studio}/test/system-endpoint.test.ts +0 -0
  360. /package/{plugins/design/dev-server → apps/studio}/test/tool-palette.test.tsx +0 -0
  361. /package/{plugins/design/dev-server → apps/studio}/test/undo-stack.test.ts +0 -0
  362. /package/{plugins/design/dev-server → apps/studio}/test/use-agent-presence.test.tsx +0 -0
  363. /package/{plugins/design/dev-server → apps/studio}/test/use-annotation-selection.test.tsx +0 -0
  364. /package/{plugins/design/dev-server → apps/studio}/test/use-canvas-activity.test.tsx +0 -0
  365. /package/{plugins/design/dev-server → apps/studio}/test/use-cursor-modifiers.test.ts +0 -0
  366. /package/{plugins/design/dev-server → apps/studio}/test/use-keyboard-discipline.test.ts +0 -0
  367. /package/{plugins/design/dev-server → apps/studio}/test/use-selection-set.test.tsx +0 -0
  368. /package/{plugins/design/dev-server → apps/studio}/test/use-snap-guides.test.ts +0 -0
  369. /package/{plugins/design/dev-server → apps/studio}/test/use-undo-stack.test.tsx +0 -0
  370. /package/{plugins/design/dev-server → apps/studio}/test/ws-handshake.test.ts +0 -0
  371. /package/{plugins/design/dev-server → apps/studio}/tsconfig.json +0 -0
  372. /package/{plugins/design/dev-server → apps/studio}/ws.ts +0 -0
@@ -0,0 +1,912 @@
1
+ /**
2
+ * @file use-annotation-resize.tsx — Task 23 (Wave 2, G4)
3
+ * @scope apps/studio/use-annotation-resize.tsx
4
+ * @purpose Screen-space corner / endpoint handles for the selected
5
+ * annotation. Per-tool resize math with FigJam resize modifiers:
6
+ * Shift = lock aspect ratio (45° angle snap for arrows), Alt =
7
+ * scale from center, Shift+Alt = both. Modifiers update live on
8
+ * keydown/keyup mid-drag (re-applied at the last pointer position).
9
+ *
10
+ * Handles are `position: fixed` DOM siblings of the canvas (the
11
+ * same pattern element selection uses) — they stay 8 × 8 CSS px
12
+ * at any zoom and never get caught in the SVG `vector-effect`
13
+ * gymnastics.
14
+ *
15
+ * Mounts the overlay element exactly once via `AnnotationResizeOverlay`.
16
+ * The hook does the math + persistence; the overlay component
17
+ * owns the rAF loop that follows pan/zoom + pointer drags.
18
+ */
19
+
20
+ import { type ReactNode, useCallback, useEffect, useMemo, useRef } from 'react';
21
+ import { anchorPoint, BIND_THRESHOLD_PX, bindCandidate } from './annotations-bindings.ts';
22
+ import {
23
+ type ArrowStroke,
24
+ canRotate,
25
+ type EllipseStroke,
26
+ HALO_PAD_PX,
27
+ type ImageStroke,
28
+ type LinkStroke,
29
+ normalizeRotation,
30
+ type PenStroke,
31
+ type PolygonStroke,
32
+ type RectStroke,
33
+ rotatePoint,
34
+ type SectionStroke,
35
+ type StickyStroke,
36
+ type Stroke,
37
+ type StrokesStoreValue,
38
+ strokeBBox,
39
+ strokeCenter,
40
+ strokeRotation,
41
+ } from './annotations-layer.tsx';
42
+ import { useViewportControllerContext } from './canvas-lib.tsx';
43
+ import { useAnnotationSelection } from './use-annotation-selection.tsx';
44
+
45
+ const RESIZE_CSS = `
46
+ /* DS selection specimen .sel-handle recipe — accent square, 1px accent-fg
47
+ * border, --radius-xs corners. (8px box kept: the JS centers handles on the
48
+ * half-size offset; the specimen's 7px differs by a hairline only.) Comment
49
+ * must stay backtick-free — it lives inside the RESIZE_CSS template literal. */
50
+ .dc-annot-resize-handle {
51
+ position: fixed;
52
+ width: 8px;
53
+ height: 8px;
54
+ background: var(--maude-hud-accent, oklch(0.680 0.180 268));
55
+ border: 1px solid var(--maude-hud-accent-fg, oklch(0.180 0.030 268));
56
+ border-radius: 3px;
57
+ z-index: 6;
58
+ pointer-events: auto;
59
+ touch-action: none;
60
+ }
61
+ /* Phase 24 — '!important' so the scale/move affordance beats use-tool-mode's
62
+ blanket '* { cursor: <tool> !important }' (move mode). Without it the move
63
+ glyph clobbered the resize cursors and the user saw no scale affordance over
64
+ a handle. Specificity already wins ('.class[attr]' > '*'); the '!important'
65
+ is what lets it through against the other '!important' rule. See DDR-067.
66
+ NOTE: keep this comment backtick-free — it lives inside the RESIZE_CSS
67
+ template literal and a stray backtick closes it (bun parse fail, §6). */
68
+ .dc-annot-resize-handle[data-corner="nw"], .dc-annot-resize-handle[data-corner="se"] { cursor: nwse-resize !important; }
69
+ .dc-annot-resize-handle[data-corner="ne"], .dc-annot-resize-handle[data-corner="sw"] { cursor: nesw-resize !important; }
70
+ .dc-annot-resize-handle[data-corner="ep1"], .dc-annot-resize-handle[data-corner="ep2"] { cursor: move !important; }
71
+ /* FigJam v3 — mid-edge handles: single-axis resize. Slightly smaller pills so
72
+ the corner squares stay the primary affordance. */
73
+ .dc-annot-resize-handle[data-corner="n"], .dc-annot-resize-handle[data-corner="s"] { cursor: ns-resize !important; width: 14px; height: 6px; }
74
+ .dc-annot-resize-handle[data-corner="e"], .dc-annot-resize-handle[data-corner="w"] { cursor: ew-resize !important; width: 6px; height: 14px; }
75
+ /* Wave G — rotation lives in INVISIBLE hover zones just outside each corner
76
+ (FigJam): the cursor flips to a rotate glyph there and dragging turns the
77
+ stroke. No knob — the old below-the-bbox knob read as clutter and collided
78
+ with the connector dots. z-index below the handles so a corner square wins
79
+ where they overlap. */
80
+ .dc-annot-rotate-zone {
81
+ position: fixed;
82
+ width: 18px;
83
+ height: 18px;
84
+ background: transparent;
85
+ z-index: 5;
86
+ pointer-events: auto;
87
+ touch-action: none;
88
+ cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='white' stroke-width='4' d='M4.8 13 A6 6 0 1 1 15.2 13 M2.2 10.8 L4.8 13 L7.4 10.9 M12.6 10.9 L15.2 13 L17.8 10.8'/%3E%3Cpath stroke='black' stroke-width='1.8' d='M4.8 13 A6 6 0 1 1 15.2 13 M2.2 10.8 L4.8 13 L7.4 10.9 M12.6 10.9 L15.2 13 L17.8 10.8'/%3E%3C/g%3E%3C/svg%3E") 10 10, alias !important;
89
+ }
90
+ `.trim();
91
+
92
+ function ensureResizeStyles(): void {
93
+ if (typeof document === 'undefined') return;
94
+ if (document.getElementById('dc-annot-resize-css')) return;
95
+ const s = document.createElement('style');
96
+ s.id = 'dc-annot-resize-css';
97
+ s.textContent = RESIZE_CSS;
98
+ document.head.appendChild(s);
99
+ }
100
+
101
+ type Corner =
102
+ | 'nw'
103
+ | 'ne'
104
+ | 'sw'
105
+ | 'se'
106
+ | 'n'
107
+ | 'e'
108
+ | 's'
109
+ | 'w'
110
+ | 'rot-nw'
111
+ | 'rot-ne'
112
+ | 'rot-sw'
113
+ | 'rot-se'
114
+ | 'ep1'
115
+ | 'ep2';
116
+
117
+ /** FigJam v3 — mid-edge handles resize a single axis. */
118
+ function isEdgeCorner(c: Corner): boolean {
119
+ return c === 'n' || c === 'e' || c === 's' || c === 'w';
120
+ }
121
+
122
+ /** Wave G — the four invisible rotate zones outside the corners. */
123
+ function isRotCorner(c: Corner): boolean {
124
+ return c === 'rot-nw' || c === 'rot-ne' || c === 'rot-sw' || c === 'rot-se';
125
+ }
126
+
127
+ /**
128
+ * Wave G/H — handles float a step OFF the stroke (matching the halo's pad —
129
+ * `HALO_PAD_PX` lives in annotations-model as the single source) instead of
130
+ * sitting glued to the bbox. The pad is SCREEN-constant (`HALO_PAD_PX / zoom`
131
+ * world units) so the breathing room reads the same at every zoom.
132
+ * `padDX/padDY` shift a dragged handle's cursor back ONTO the true bbox
133
+ * corner/edge so the shape doesn't grow by the pad on first move.
134
+ */
135
+ const padDX = (c: Corner): number =>
136
+ c === 'nw' || c === 'sw' || c === 'w' ? 1 : c === 'ne' || c === 'se' || c === 'e' ? -1 : 0;
137
+ const padDY = (c: Corner): number =>
138
+ c === 'nw' || c === 'ne' || c === 'n' ? 1 : c === 'sw' || c === 'se' || c === 's' ? -1 : 0;
139
+
140
+ /** Stroke types that expose resize handles. Text inherits its anchor bbox. */
141
+ function isResizable(
142
+ s: Stroke
143
+ ): s is
144
+ | RectStroke
145
+ | EllipseStroke
146
+ | PolygonStroke
147
+ | ArrowStroke
148
+ | PenStroke
149
+ | StickyStroke
150
+ | ImageStroke
151
+ | LinkStroke
152
+ | SectionStroke {
153
+ return (
154
+ s.tool === 'rect' ||
155
+ s.tool === 'ellipse' ||
156
+ s.tool === 'polygon' ||
157
+ s.tool === 'arrow' ||
158
+ s.tool === 'pen' ||
159
+ s.tool === 'sticky' ||
160
+ s.tool === 'image' ||
161
+ s.tool === 'link' ||
162
+ s.tool === 'section'
163
+ );
164
+ }
165
+
166
+ /**
167
+ * Resize modifiers (FigJam-parity). Held during a handle drag:
168
+ * • `shift` — lock to the start aspect ratio (45° angle snap for arrows;
169
+ * always-1:1 stickies ignore it — they're square regardless).
170
+ * • `alt` — scale symmetrically around the stroke's center / midpoint.
171
+ * Both together combine (ratio-locked AND center-anchored). With neither held
172
+ * the result is byte-identical to the pre-modifier behaviour.
173
+ */
174
+ export interface ResizeMods {
175
+ shift: boolean;
176
+ alt: boolean;
177
+ }
178
+
179
+ const NO_MODS: ResizeMods = { shift: false, alt: false };
180
+
181
+ const isWestCorner = (c: Corner): boolean => c === 'nw' || c === 'sw';
182
+ const isNorthCorner = (c: Corner): boolean => c === 'nw' || c === 'ne';
183
+
184
+ /**
185
+ * Wave H — resizing a ROTATED stroke must keep the anchored corner/edge fixed
186
+ * in WORLD space. The axis-aligned math anchors it in the LOCAL frame, but the
187
+ * rotation pivot is the bbox CENTER — when w/h change, the center moves, so
188
+ * the whole rotated image drifts on screen. The anchor's local point is
189
+ * preserved by `bboxResize` (non-Alt), so its world drift is exactly the
190
+ * difference of rotating that same local point around the OLD vs NEW center.
191
+ * Returns the [dx, dy] world shift to add to the new box's x/y (translation
192
+ * commutes with center-rotation: rotate(p+t, c+t, θ) = rotate(p, c, θ) + t).
193
+ * Alt-resizes keep the center fixed, so there is nothing to compensate.
194
+ */
195
+ function rotatedAnchorShift(
196
+ b0: { x: number; y: number; w: number; h: number },
197
+ b1: { x: number; y: number; w: number; h: number },
198
+ corner: Corner,
199
+ rot: number
200
+ ): [number, number] {
201
+ // Anchor identity: the opposite corner (corner drags) / the opposite edge
202
+ // midpoint (edge drags), as a point of the START box in local coords.
203
+ let ux: number;
204
+ let uy: number;
205
+ if (isEdgeCorner(corner)) {
206
+ ux = corner === 'e' ? 0 : corner === 'w' ? 1 : 0.5;
207
+ uy = corner === 's' ? 0 : corner === 'n' ? 1 : 0.5;
208
+ } else {
209
+ ux = isWestCorner(corner) ? 1 : 0;
210
+ uy = isNorthCorner(corner) ? 1 : 0;
211
+ }
212
+ const px = b0.x + ux * b0.w;
213
+ const py = b0.y + uy * b0.h;
214
+ const [w0x, w0y] = rotatePoint(px, py, b0.x + b0.w / 2, b0.y + b0.h / 2, rot);
215
+ const [w1x, w1y] = rotatePoint(px, py, b1.x + b1.w / 2, b1.y + b1.h / 2, rot);
216
+ return [w0x - w1x, w0y - w1y];
217
+ }
218
+
219
+ /**
220
+ * Shared bbox resize for rect / polygon / sticky / ellipse. Returns the new
221
+ * axis-aligned box for the dragged `corner` moving to world (wx, wy):
222
+ * • normal — the diagonally-opposite corner is the fixed anchor.
223
+ * • Alt — the box's center is the fixed anchor (symmetric scale).
224
+ * • Shift — keep the start aspect ratio (the dominant axis drives scale).
225
+ * • square — force 1:1 regardless of Shift (sticky notes).
226
+ * The no-modifier branch is algebraically identical to the previous
227
+ * min/max corner math (verified against the resize round-trip tests).
228
+ */
229
+ function bboxResize(
230
+ bbox: { x: number; y: number; w: number; h: number },
231
+ corner: Corner,
232
+ wx: number,
233
+ wy: number,
234
+ mods: ResizeMods,
235
+ square: boolean
236
+ ): { x: number; y: number; w: number; h: number } {
237
+ const { x, y, w, h } = bbox;
238
+ const cx = x + w / 2;
239
+ const cy = y + h / 2;
240
+ // FigJam v3 — mid-edge handles move ONE axis: the opposite edge anchors
241
+ // (Alt = symmetric around the center). Always-square strokes (sticky) let
242
+ // the dragged axis drive the side, re-centered on the cross axis.
243
+ if (isEdgeCorner(corner)) {
244
+ const horizontal = corner === 'e' || corner === 'w';
245
+ let nx = x;
246
+ let ny = y;
247
+ let nw = w;
248
+ let nh = h;
249
+ if (horizontal) {
250
+ const anchorX = corner === 'w' ? x + w : x;
251
+ nw = Math.max(1, mods.alt ? 2 * Math.abs(wx - cx) : Math.abs(wx - anchorX));
252
+ nx = mods.alt ? cx - nw / 2 : wx < anchorX ? anchorX - nw : anchorX;
253
+ } else {
254
+ const anchorY = corner === 'n' ? y + h : y;
255
+ nh = Math.max(1, mods.alt ? 2 * Math.abs(wy - cy) : Math.abs(wy - anchorY));
256
+ ny = mods.alt ? cy - nh / 2 : wy < anchorY ? anchorY - nh : anchorY;
257
+ }
258
+ if (square) {
259
+ const side = horizontal ? nw : nh;
260
+ if (horizontal) {
261
+ nh = side;
262
+ ny = cy - side / 2;
263
+ } else {
264
+ nw = side;
265
+ nx = cx - side / 2;
266
+ }
267
+ }
268
+ return { x: nx, y: ny, w: nw, h: nh };
269
+ }
270
+ const isW = isWestCorner(corner);
271
+ const isN = isNorthCorner(corner);
272
+ const anchorX = isW ? x + w : x;
273
+ const anchorY = isN ? y + h : y;
274
+
275
+ // Raw extents from the dragged corner — center-relative under Alt.
276
+ let nw = mods.alt ? 2 * Math.abs(wx - cx) : Math.abs(wx - anchorX);
277
+ let nh = mods.alt ? 2 * Math.abs(wy - cy) : Math.abs(wy - anchorY);
278
+
279
+ if (square) {
280
+ const side = Math.max(nw, nh, 1);
281
+ nw = side;
282
+ nh = side;
283
+ } else if (mods.shift && w > 0 && h > 0) {
284
+ const s = Math.max(nw / w, nh / h);
285
+ nw = w * s;
286
+ nh = h * s;
287
+ }
288
+ nw = Math.max(1, nw);
289
+ nh = Math.max(1, nh);
290
+
291
+ let nx: number;
292
+ let ny: number;
293
+ if (mods.alt) {
294
+ nx = cx - nw / 2;
295
+ ny = cy - nh / 2;
296
+ } else {
297
+ // Grow away from the anchor toward the cursor's side (handles flips).
298
+ nx = wx < anchorX ? anchorX - nw : anchorX;
299
+ ny = wy < anchorY ? anchorY - nh : anchorY;
300
+ }
301
+ return { x: nx, y: ny, w: nw, h: nh };
302
+ }
303
+
304
+ /**
305
+ * Wave H — `bboxResize` plus the rotated-anchor compensation in one step. The
306
+ * box-shaped tools (rect family / image / ellipse) all need the same pair:
307
+ * run the axis-aligned math, then shift the result so the anchored corner /
308
+ * edge stays fixed in WORLD space (Alt keeps the center — nothing to fix).
309
+ */
310
+ function bboxResizeRotAware(
311
+ start: Stroke,
312
+ b0: { x: number; y: number; w: number; h: number },
313
+ corner: Corner,
314
+ wx: number,
315
+ wy: number,
316
+ mods: ResizeMods,
317
+ square: boolean
318
+ ): { x: number; y: number; w: number; h: number } {
319
+ const box = bboxResize(b0, corner, wx, wy, mods, square);
320
+ const rot = strokeRotation(start);
321
+ if (rot !== 0 && !mods.alt) {
322
+ const [dx, dy] = rotatedAnchorShift(b0, box, corner, rot);
323
+ box.x += dx;
324
+ box.y += dy;
325
+ }
326
+ return box;
327
+ }
328
+
329
+ /**
330
+ * Per-tool resize math. Given a stroke + the moved corner + the new world
331
+ * coords for that corner, returns a patched stroke. `start` is the stroke at
332
+ * the moment the drag began (used as the source-of-truth for scaling math —
333
+ * avoids drift from rounding successive deltas). `mods` carries the live
334
+ * FigJam resize modifiers (Shift aspect-lock, Alt scale-from-center).
335
+ */
336
+ export function resizeStroke(
337
+ start: Stroke,
338
+ corner: Corner,
339
+ wx: number,
340
+ wy: number,
341
+ mods: ResizeMods = NO_MODS,
342
+ /**
343
+ * Wave G — rotation reference for the corner rotate zones: the pointer
344
+ * angle (deg, around the stroke center) at drag start + the stroke's
345
+ * rotation at drag start. Rotation is RELATIVE — the grab point is the
346
+ * zero reference, so the shape follows the hand with no jump (FigJam).
347
+ * Without it, the raw pointer angle is used as-is.
348
+ */
349
+ rotRef?: { angle0: number; rot0: number }
350
+ ): Partial<Stroke> | null {
351
+ if (isRotCorner(corner)) {
352
+ if (!canRotate(start)) return null;
353
+ const c = strokeCenter(start);
354
+ if (!c) return null;
355
+ const cur = (Math.atan2(wy - c[1], wx - c[0]) * 180) / Math.PI;
356
+ let deg = normalizeRotation(rotRef ? rotRef.rot0 + (cur - rotRef.angle0) : cur);
357
+ if (mods.shift) {
358
+ deg = normalizeRotation(Math.round(deg / 15) * 15);
359
+ } else {
360
+ // Magnetic cardinals (FigJam): drifting within 2° of 0/±90/180 locks on,
361
+ // so "straighten it back out" doesn't need pixel-perfect aim.
362
+ for (const cand of [0, 90, 180, -90, -180]) {
363
+ if (Math.abs(deg - cand) <= 2) {
364
+ deg = cand;
365
+ break;
366
+ }
367
+ }
368
+ }
369
+ const norm = normalizeRotation(deg);
370
+ return { rotation: norm === 0 ? undefined : norm } as Partial<Stroke>;
371
+ }
372
+ if (
373
+ start.tool === 'rect' ||
374
+ start.tool === 'sticky' ||
375
+ start.tool === 'polygon' ||
376
+ start.tool === 'link' ||
377
+ start.tool === 'section'
378
+ ) {
379
+ // Rect / polygon / sticky / link all resize via their shared x / y / w / h
380
+ // bbox. Text re-wraps inside the foreignObject automatically. Sticky stays
381
+ // 1:1; the link card free-resizes (Shift still locks its current ratio).
382
+ const b0 = { x: start.x, y: start.y, w: start.w, h: start.h };
383
+ const box = bboxResizeRotAware(start, b0, corner, wx, wy, mods, start.tool === 'sticky');
384
+ return box as Partial<RectStroke | StickyStroke | PolygonStroke | LinkStroke>;
385
+ }
386
+ if (start.tool === 'image') {
387
+ // Phase 23 — images aspect-LOCK by default and free-resize with Shift held
388
+ // (the inverse of the shape tools — Figma/FigJam image behaviour). Invert
389
+ // the Shift flag into bboxResize, which keeps the START ratio when shift is
390
+ // set; the start ratio IS the image's intrinsic aspect.
391
+ const b0 = { x: start.x, y: start.y, w: start.w, h: start.h };
392
+ const box = bboxResizeRotAware(
393
+ start,
394
+ b0,
395
+ corner,
396
+ wx,
397
+ wy,
398
+ { shift: !mods.shift, alt: mods.alt },
399
+ false
400
+ );
401
+ return box as Partial<ImageStroke>;
402
+ }
403
+ if (start.tool === 'ellipse') {
404
+ // Treat the four corners as the bbox of the ellipse, then derive cx/cy/rx/ry.
405
+ const b0 = { x: start.cx - start.rx, y: start.cy - start.ry, w: start.rx * 2, h: start.ry * 2 };
406
+ const box = bboxResizeRotAware(start, b0, corner, wx, wy, mods, false);
407
+ return {
408
+ cx: box.x + box.w / 2,
409
+ cy: box.y + box.h / 2,
410
+ rx: Math.max(1, box.w / 2),
411
+ ry: Math.max(1, box.h / 2),
412
+ } as Partial<EllipseStroke>;
413
+ }
414
+ if (start.tool === 'arrow') {
415
+ if (corner !== 'ep1' && corner !== 'ep2') return null;
416
+ const otherX = corner === 'ep1' ? start.x2 : start.x1;
417
+ const otherY = corner === 'ep1' ? start.y2 : start.y1;
418
+ const midX = (start.x1 + start.x2) / 2;
419
+ const midY = (start.y1 + start.y2) / 2;
420
+ // Alt pins the midpoint (both ends mirror); otherwise the far end is fixed.
421
+ const refX = mods.alt ? midX : otherX;
422
+ const refY = mods.alt ? midY : otherY;
423
+ let dragX = wx;
424
+ let dragY = wy;
425
+ if (mods.shift) {
426
+ // Snap the shaft angle (relative to the reference) to 45° increments.
427
+ const dx = wx - refX;
428
+ const dy = wy - refY;
429
+ const dist = Math.hypot(dx, dy);
430
+ const step = Math.PI / 4;
431
+ const ang = Math.round(Math.atan2(dy, dx) / step) * step;
432
+ dragX = refX + Math.cos(ang) * dist;
433
+ dragY = refY + Math.sin(ang) * dist;
434
+ }
435
+ if (mods.alt) {
436
+ const mirrorX = 2 * midX - dragX;
437
+ const mirrorY = 2 * midY - dragY;
438
+ return corner === 'ep1'
439
+ ? ({ x1: dragX, y1: dragY, x2: mirrorX, y2: mirrorY } as Partial<ArrowStroke>)
440
+ : ({ x2: dragX, y2: dragY, x1: mirrorX, y1: mirrorY } as Partial<ArrowStroke>);
441
+ }
442
+ return corner === 'ep1'
443
+ ? ({ x1: dragX, y1: dragY } as Partial<ArrowStroke>)
444
+ : ({ x2: dragX, y2: dragY } as Partial<ArrowStroke>);
445
+ }
446
+ if (start.tool === 'pen') {
447
+ // Scale all points around an anchor — the opposite corner (normal) or the
448
+ // bbox center (Alt). Shift forces a uniform scale (dominant axis wins).
449
+ // A 0-extent axis (single-point pen stroke) keeps scale 1 (no div-by-zero).
450
+ // Edge handles aren't offered for ink (corner scale only).
451
+ if (isEdgeCorner(corner)) return null;
452
+ const bb = strokeBBox(start);
453
+ if (!bb) return null;
454
+ const cx = bb.x + bb.w / 2;
455
+ const cy = bb.y + bb.h / 2;
456
+ const isW = isWestCorner(corner);
457
+ const isN = isNorthCorner(corner);
458
+ let anchorX: number;
459
+ let anchorY: number;
460
+ let sx: number;
461
+ let sy: number;
462
+ if (mods.alt) {
463
+ anchorX = cx;
464
+ anchorY = cy;
465
+ sx = bb.w === 0 ? 1 : (wx - cx) / (isW ? -bb.w / 2 : bb.w / 2);
466
+ sy = bb.h === 0 ? 1 : (wy - cy) / (isN ? -bb.h / 2 : bb.h / 2);
467
+ } else {
468
+ anchorX = isW ? bb.x + bb.w : bb.x;
469
+ anchorY = isN ? bb.y + bb.h : bb.y;
470
+ const newLeft = isW ? wx : bb.x;
471
+ const newTop = isN ? wy : bb.y;
472
+ const newRight = isW ? bb.x + bb.w : wx;
473
+ const newBottom = isN ? bb.y + bb.h : wy;
474
+ sx = bb.w === 0 ? 1 : (newRight - newLeft) / bb.w;
475
+ sy = bb.h === 0 ? 1 : (newBottom - newTop) / bb.h;
476
+ }
477
+ if (mods.shift) {
478
+ const s = Math.max(Math.abs(sx), Math.abs(sy));
479
+ sx = (sx < 0 ? -1 : 1) * s;
480
+ sy = (sy < 0 ? -1 : 1) * s;
481
+ }
482
+ const scaled = start.points.map(
483
+ ([px, py]) =>
484
+ [anchorX + (px - anchorX) * sx, anchorY + (py - anchorY) * sy] as [number, number]
485
+ );
486
+ return { points: scaled } as Partial<PenStroke>;
487
+ }
488
+ return null;
489
+ }
490
+
491
+ /**
492
+ * Returns the four corners (or two endpoints, for arrow) of the selected
493
+ * stroke in world coordinates. Used by the overlay component to position
494
+ * the screen-space handle divs each rAF tick. `pad` is the halo offset in
495
+ * WORLD units (callers pass `HALO_PAD_PX / zoom` for a screen-constant gap).
496
+ */
497
+ function handlePositions(s: Stroke, pad: number): Array<{ corner: Corner; x: number; y: number }> {
498
+ if (s.tool === 'arrow') {
499
+ return [
500
+ { corner: 'ep1', x: s.x1, y: s.y1 },
501
+ { corner: 'ep2', x: s.x2, y: s.y2 },
502
+ ];
503
+ }
504
+ const bb0 = strokeBBox(s);
505
+ if (!bb0) return [];
506
+ // Wave G — handles sit on the HALO (bbox + pad), not glued to the stroke
507
+ // itself; applyResize shifts the cursor back by the same pad.
508
+ const bb = {
509
+ x: bb0.x - pad,
510
+ y: bb0.y - pad,
511
+ w: bb0.w + pad * 2,
512
+ h: bb0.h + pad * 2,
513
+ };
514
+ const corners: Array<{ corner: Corner; x: number; y: number }> = [
515
+ { corner: 'nw', x: bb.x, y: bb.y },
516
+ { corner: 'ne', x: bb.x + bb.w, y: bb.y },
517
+ { corner: 'sw', x: bb.x, y: bb.y + bb.h },
518
+ { corner: 'se', x: bb.x + bb.w, y: bb.y + bb.h },
519
+ ];
520
+ // FigJam v3 — mid-edge handles (single-axis resize) for the box-shaped
521
+ // strokes; ink (pen) keeps corner-scale only.
522
+ if (s.tool !== 'pen') {
523
+ corners.push(
524
+ { corner: 'n', x: bb.x + bb.w / 2, y: bb.y },
525
+ { corner: 'e', x: bb.x + bb.w, y: bb.y + bb.h / 2 },
526
+ { corner: 's', x: bb.x + bb.w / 2, y: bb.y + bb.h },
527
+ { corner: 'w', x: bb.x, y: bb.y + bb.h / 2 }
528
+ );
529
+ }
530
+ // Wave G — invisible rotate zones AT the padded corners; the rAF tick adds
531
+ // a screen-constant outward offset (along center→corner) so they hover just
532
+ // beyond the corner squares at every zoom + rotation.
533
+ if (canRotate(s)) {
534
+ corners.push(
535
+ { corner: 'rot-nw', x: bb.x, y: bb.y },
536
+ { corner: 'rot-ne', x: bb.x + bb.w, y: bb.y },
537
+ { corner: 'rot-sw', x: bb.x, y: bb.y + bb.h },
538
+ { corner: 'rot-se', x: bb.x + bb.w, y: bb.y + bb.h }
539
+ );
540
+ }
541
+ // FigJam v3 — a rotated stroke carries its handles with it: every handle
542
+ // point turns around the bbox center, so they sit on the visible outline.
543
+ const rot = strokeRotation(s);
544
+ if (rot !== 0) {
545
+ const cx = bb.x + bb.w / 2;
546
+ const cy = bb.y + bb.h / 2;
547
+ return corners.map((c) => {
548
+ const [rx, ry] = rotatePoint(c.x, c.y, cx, cy, rot);
549
+ return { corner: c.corner, x: rx, y: ry };
550
+ });
551
+ }
552
+ return corners;
553
+ }
554
+
555
+ export function AnnotationResizeOverlay({ store }: { store: StrokesStoreValue | null }): ReactNode {
556
+ ensureResizeStyles();
557
+ const annotSel = useAnnotationSelection();
558
+ const controller = useViewportControllerContext();
559
+ const vp = controller?.viewport ?? null;
560
+ const containerRef = useRef<HTMLDivElement | null>(null);
561
+ const rafRef = useRef<number | null>(null);
562
+ const dragRef = useRef<{
563
+ pointerId: number;
564
+ startStroke: Stroke;
565
+ /** Wave H — full strokes snapshot at drag start (the undo `before`). */
566
+ startStrokes: Stroke[];
567
+ corner: Corner;
568
+ /** FigJam v3 — neighbour bbox dims for the dimension-match snap. */
569
+ dims: Array<{ id: string; w: number; h: number }>;
570
+ /** Wave G — rotation drag reference (corner rotate zones only). */
571
+ rotRef?: { angle0: number; rot0: number };
572
+ } | null>(null);
573
+ // Last pointer position (client coords) during a drag — lets a mid-drag
574
+ // Shift/Alt keydown re-apply the resize without waiting for a pointermove.
575
+ const lastPointRef = useRef<{ x: number; y: number } | null>(null);
576
+
577
+ const selectedId = annotSel.selectedIds.length === 1 ? (annotSel.selectedIds[0] ?? null) : null;
578
+ const selectedStroke: Stroke | null = useMemo(() => {
579
+ if (!selectedId || !store) return null;
580
+ return store.strokes.find((s) => s.id === selectedId) ?? null;
581
+ }, [selectedId, store]);
582
+
583
+ const screenToWorld = useCallback(
584
+ (cx: number, cy: number): [number, number] => {
585
+ const v = vp ?? { x: 0, y: 0, zoom: 1 };
586
+ const z = v.zoom || 1;
587
+ return [(cx - v.x) / z, (cy - v.y) / z];
588
+ },
589
+ [vp]
590
+ );
591
+
592
+ // rAF loop — repositions handles on every frame while a single resizable
593
+ // stroke is selected. Cheaper than wiring pan/zoom observers because the
594
+ // halo overlays already follow the same pattern.
595
+ useEffect(() => {
596
+ if (!selectedStroke || !isResizable(selectedStroke)) {
597
+ const c = containerRef.current;
598
+ if (c) {
599
+ for (const child of Array.from(c.children)) {
600
+ (child as HTMLElement).style.display = 'none';
601
+ }
602
+ }
603
+ return;
604
+ }
605
+ const tick = () => {
606
+ rafRef.current = null;
607
+ const c = containerRef.current;
608
+ if (!c) return;
609
+ const v = vp ?? { x: 0, y: 0, zoom: 1 };
610
+ const z = v.zoom || 1;
611
+ // Wave H — screen-constant halo offset (HALO_PAD_PX at any zoom).
612
+ const positions = handlePositions(selectedStroke, HALO_PAD_PX / z);
613
+ // Ensure enough handle children exist (each corner = one absolutely-
614
+ // positioned div); the per-kind class is assigned below.
615
+ while (c.children.length < positions.length) {
616
+ c.appendChild(document.createElement('div'));
617
+ }
618
+ while (c.children.length > positions.length) {
619
+ c.lastChild && c.removeChild(c.lastChild);
620
+ }
621
+ // Wave G — rotate zones push a screen-constant step OUTWARD from the
622
+ // stroke center so they hover diagonally beyond the corner squares.
623
+ const wc = strokeCenter(selectedStroke);
624
+ const csx = wc ? wc[0] * z + v.x : 0;
625
+ const csy = wc ? wc[1] * z + v.y : 0;
626
+ for (let i = 0; i < positions.length; i++) {
627
+ const pos = positions[i];
628
+ const handle = c.children[i] as HTMLElement | undefined;
629
+ if (!pos || !handle) continue;
630
+ let sx = pos.x * z + v.x;
631
+ let sy = pos.y * z + v.y;
632
+ const isRot = isRotCorner(pos.corner);
633
+ if (isRot && wc) {
634
+ const dx = sx - csx;
635
+ const dy = sy - csy;
636
+ const len = Math.hypot(dx, dy) || 1;
637
+ const reach = 13;
638
+ sx += (dx / len) * reach;
639
+ sy += (dy / len) * reach;
640
+ }
641
+ // Edge pills are 14×6 / 6×14, rotate zones 18×18 (corners 8×8) —
642
+ // center each on its point.
643
+ const ns = pos.corner === 'n' || pos.corner === 's';
644
+ const ew = pos.corner === 'e' || pos.corner === 'w';
645
+ const halfW = isRot ? 9 : ns ? 7 : ew ? 3 : 4;
646
+ const halfH = isRot ? 9 : ns ? 3 : ew ? 7 : 4;
647
+ handle.className = isRot ? 'dc-annot-rotate-zone' : 'dc-annot-resize-handle';
648
+ handle.style.display = 'block';
649
+ handle.style.left = `${Math.round(sx - halfW)}px`;
650
+ handle.style.top = `${Math.round(sy - halfH)}px`;
651
+ handle.dataset.corner = pos.corner;
652
+ }
653
+ rafRef.current = requestAnimationFrame(tick);
654
+ };
655
+ rafRef.current = requestAnimationFrame(tick);
656
+ return () => {
657
+ if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
658
+ };
659
+ }, [selectedStroke, vp]);
660
+
661
+ // Pointer handling — pointerdown on a handle starts a drag; pointermove
662
+ // patches the stroke via `store.updateStroke`; pointerup commits.
663
+ useEffect(() => {
664
+ const c = containerRef.current;
665
+ if (!c) return;
666
+ const onDown = (e: PointerEvent) => {
667
+ const t = e.target as HTMLElement | null;
668
+ if (
669
+ !t?.classList.contains('dc-annot-resize-handle') &&
670
+ !t?.classList.contains('dc-annot-rotate-zone')
671
+ )
672
+ return;
673
+ if (!t || !selectedStroke || !store) return;
674
+ const corner = t.dataset.corner as Corner | undefined;
675
+ if (!corner) return;
676
+ e.preventDefault();
677
+ e.stopPropagation();
678
+ // Wave G — corner rotation is relative to the grab angle (no jump).
679
+ let rotRef: { angle0: number; rot0: number } | undefined;
680
+ if (isRotCorner(corner)) {
681
+ const cw = strokeCenter(selectedStroke);
682
+ if (cw) {
683
+ const [wx, wy] = screenToWorld(e.clientX, e.clientY);
684
+ rotRef = {
685
+ angle0: (Math.atan2(wy - cw[1], wx - cw[0]) * 180) / Math.PI,
686
+ rot0: strokeRotation(selectedStroke),
687
+ };
688
+ }
689
+ }
690
+ // FigJam v3 — dimension-match candidates, captured once per gesture: the
691
+ // bbox dims of every OTHER box-shaped stroke. While resizing, a width /
692
+ // height within the threshold of a neighbour's snaps to it exactly and
693
+ // the neighbour gets a match halo (the "make it the same size" quota).
694
+ const dims: Array<{ id: string; w: number; h: number }> = [];
695
+ for (const s of store.strokes) {
696
+ if (s.id === selectedStroke.id) continue;
697
+ if (s.tool === 'pen' || s.tool === 'arrow' || s.tool === 'text') continue;
698
+ const bb = strokeBBox(s);
699
+ if (bb && bb.w > 1 && bb.h > 1) dims.push({ id: s.id, w: bb.w, h: bb.h });
700
+ }
701
+ dragRef.current = {
702
+ pointerId: e.pointerId,
703
+ startStroke: selectedStroke,
704
+ startStrokes: store.strokes,
705
+ corner,
706
+ dims,
707
+ rotRef,
708
+ };
709
+ try {
710
+ t.setPointerCapture(e.pointerId);
711
+ } catch {
712
+ /* some browsers reject capture on synthetic events */
713
+ }
714
+ };
715
+ const applyResize = (
716
+ clientX: number,
717
+ clientY: number,
718
+ mods: ResizeMods,
719
+ suppressBind: boolean
720
+ ) => {
721
+ const d = dragRef.current;
722
+ if (!d || !store) return;
723
+ const [wx, wy] = screenToWorld(clientX, clientY);
724
+ // FigJam v3 — a rotated stroke resizes in its LOCAL frame: inverse-
725
+ // rotate the cursor around the start pivot, run the axis-aligned math,
726
+ // keep the rotation. (The rotation knob itself wants the RAW cursor.)
727
+ let lwx = wx;
728
+ let lwy = wy;
729
+ const startRot = strokeRotation(d.startStroke);
730
+ if (startRot !== 0 && !isRotCorner(d.corner)) {
731
+ const c = strokeCenter(d.startStroke);
732
+ if (c) {
733
+ const [ux, uy] = rotatePoint(wx, wy, c[0], c[1], -startRot);
734
+ lwx = ux;
735
+ lwy = uy;
736
+ }
737
+ }
738
+ // Wave G/H — handles render padded OFF the bbox (screen-constant); shift
739
+ // the cursor back onto the true corner/edge so the first move doesn't
740
+ // grow the shape.
741
+ const padWorld = HALO_PAD_PX / (vp?.zoom || 1);
742
+ lwx += padDX(d.corner) * padWorld;
743
+ lwy += padDY(d.corner) * padWorld;
744
+ let patch = resizeStroke(d.startStroke, d.corner, lwx, lwy, mods, d.rotRef);
745
+ if (!patch) return;
746
+ // FigJam v3 — dimension match + live size label for box resizes. ⌘
747
+ // suppresses matching (same convention as drag snapping).
748
+ if (
749
+ d.startStroke.tool !== 'arrow' &&
750
+ d.startStroke.tool !== 'pen' &&
751
+ 'w' in patch &&
752
+ 'h' in patch &&
753
+ typeof patch.w === 'number' &&
754
+ typeof patch.h === 'number' &&
755
+ typeof patch.x === 'number' &&
756
+ typeof patch.y === 'number'
757
+ ) {
758
+ const zoom = vp?.zoom || 1;
759
+ const thr = 6 / zoom;
760
+ const matchIds: string[] = [];
761
+ if (!suppressBind && d.dims.length) {
762
+ // Only the axes this handle actually drags participate — an east-
763
+ // edge drag must never quietly re-snap the HEIGHT to a neighbour.
764
+ const edge = isEdgeCorner(d.corner);
765
+ const affectsW = !edge || d.corner === 'e' || d.corner === 'w';
766
+ const affectsH = !edge || d.corner === 'n' || d.corner === 's';
767
+ let bestW: { id: string; w: number } | null = null;
768
+ let bestH: { id: string; h: number } | null = null;
769
+ for (const cand of d.dims) {
770
+ if (
771
+ affectsW &&
772
+ Math.abs(cand.w - patch.w) <= thr &&
773
+ (!bestW || Math.abs(cand.w - patch.w) < Math.abs(bestW.w - patch.w))
774
+ ) {
775
+ bestW = { id: cand.id, w: cand.w };
776
+ }
777
+ if (
778
+ affectsH &&
779
+ Math.abs(cand.h - patch.h) <= thr &&
780
+ (!bestH || Math.abs(cand.h - patch.h) < Math.abs(bestH.h - patch.h))
781
+ ) {
782
+ bestH = { id: cand.id, h: cand.h };
783
+ }
784
+ }
785
+ // Snap the box to the matched dim, keeping the anchored edge fixed
786
+ // (the cursor-side edge moves; x/y only shift when the west/north
787
+ // edge was the dragged one).
788
+ if (bestW) {
789
+ if (wx < patch.x + patch.w / 2) patch.x = patch.x + patch.w - bestW.w;
790
+ patch.w = bestW.w;
791
+ matchIds.push(bestW.id);
792
+ }
793
+ if (bestH) {
794
+ if (wy < patch.y + patch.h / 2) patch.y = patch.y + patch.h - bestH.h;
795
+ patch.h = bestH.h;
796
+ matchIds.push(bestH.id);
797
+ }
798
+ }
799
+ document.dispatchEvent(
800
+ new CustomEvent('maude:resize-info', {
801
+ detail: {
802
+ box: { x: patch.x, y: patch.y, w: patch.w, h: patch.h },
803
+ matchIds,
804
+ },
805
+ })
806
+ );
807
+ }
808
+ // FigJam v3 — dragging an arrow ENDPOINT re-anchors its bind: within the
809
+ // magnet threshold of a bindable host the endpoint snaps + binds; free
810
+ // space (or ⌘ held — Figma "suppress snap") clears the bind. The layer
811
+ // paints the candidate halo via the maude:bind-hint broadcast.
812
+ if (d.startStroke.tool === 'arrow' && (d.corner === 'ep1' || d.corner === 'ep2')) {
813
+ const zoom = vp?.zoom || 1;
814
+ const cand = suppressBind
815
+ ? null
816
+ : bindCandidate(
817
+ wx,
818
+ wy,
819
+ store.strokes,
820
+ BIND_THRESHOLD_PX / zoom,
821
+ new Set([d.startStroke.id])
822
+ );
823
+ if (cand) {
824
+ const host = store.strokes.find((s) => s.id === cand.hostId);
825
+ const pt = host ? anchorPoint(host, cand.nx, cand.ny) : null;
826
+ if (pt) {
827
+ // An explicit endpoint re-anchor PINS the magnet — auto-routing
828
+ // must not override a side the user chose by hand.
829
+ const pinned = { ...cand, pinned: true };
830
+ patch =
831
+ d.corner === 'ep1'
832
+ ? ({ ...patch, startBind: pinned, x1: pt[0], y1: pt[1] } as Partial<Stroke>)
833
+ : ({ ...patch, endBind: pinned, x2: pt[0], y2: pt[1] } as Partial<Stroke>);
834
+ }
835
+ } else {
836
+ patch = {
837
+ ...patch,
838
+ [d.corner === 'ep1' ? 'startBind' : 'endBind']: undefined,
839
+ } as Partial<Stroke>;
840
+ }
841
+ document.dispatchEvent(
842
+ new CustomEvent('maude:bind-hint', { detail: { hostId: cand?.hostId ?? null } })
843
+ );
844
+ }
845
+ // Wave H — transient preview tick: no undo record, no PUT. The gesture
846
+ // commits ONCE on pointerup (undo used to walk every resize pixel).
847
+ store.previewStroke(d.startStroke.id, patch);
848
+ };
849
+ const onMove = (e: PointerEvent) => {
850
+ const d = dragRef.current;
851
+ if (!d || e.pointerId !== d.pointerId) return;
852
+ lastPointRef.current = { x: e.clientX, y: e.clientY };
853
+ applyResize(
854
+ e.clientX,
855
+ e.clientY,
856
+ { shift: e.shiftKey, alt: e.altKey },
857
+ e.metaKey || e.ctrlKey
858
+ );
859
+ };
860
+ // Holding/releasing Shift / Alt / ⌘ mid-drag re-runs the resize at the
861
+ // last known pointer position so the constraint flips live, FigJam-style.
862
+ const onKey = (e: KeyboardEvent) => {
863
+ if (!dragRef.current) return;
864
+ if (e.key !== 'Shift' && e.key !== 'Alt' && e.key !== 'Meta' && e.key !== 'Control') return;
865
+ const p = lastPointRef.current;
866
+ if (!p) return;
867
+ e.preventDefault();
868
+ applyResize(p.x, p.y, { shift: e.shiftKey, alt: e.altKey }, e.metaKey || e.ctrlKey);
869
+ };
870
+ const onUp = (e: PointerEvent) => {
871
+ const d = dragRef.current;
872
+ if (!d || e.pointerId !== d.pointerId) return;
873
+ dragRef.current = null;
874
+ lastPointRef.current = null;
875
+ // Wave H — close the preview gesture as ONE undo record (no-op when
876
+ // the drag ended exactly where it started).
877
+ store?.commitGesture(
878
+ d.startStrokes,
879
+ isRotCorner(d.corner)
880
+ ? 'rotate'
881
+ : d.corner === 'ep1' || d.corner === 'ep2'
882
+ ? 'move endpoint'
883
+ : 'resize'
884
+ );
885
+ // Clear the bind-hint halo + size label regardless of what was dragged
886
+ // (no-ops when nothing was hinted).
887
+ document.dispatchEvent(new CustomEvent('maude:bind-hint', { detail: { hostId: null } }));
888
+ document.dispatchEvent(
889
+ new CustomEvent('maude:resize-info', { detail: { box: null, matchIds: [] } })
890
+ );
891
+ };
892
+ c.addEventListener('pointerdown', onDown);
893
+ document.addEventListener('pointermove', onMove);
894
+ document.addEventListener('pointerup', onUp);
895
+ document.addEventListener('pointercancel', onUp);
896
+ document.addEventListener('keydown', onKey, true);
897
+ document.addEventListener('keyup', onKey, true);
898
+ return () => {
899
+ c.removeEventListener('pointerdown', onDown);
900
+ document.removeEventListener('pointermove', onMove);
901
+ document.removeEventListener('pointerup', onUp);
902
+ document.removeEventListener('pointercancel', onUp);
903
+ document.removeEventListener('keydown', onKey, true);
904
+ document.removeEventListener('keyup', onKey, true);
905
+ };
906
+ }, [selectedStroke, store, screenToWorld, vp]);
907
+
908
+ // Only render when there's exactly one resizable stroke selected. Multi
909
+ // resize is undefined for v1 (no canonical UX); text inherits anchor bbox.
910
+ if (!selectedStroke || !isResizable(selectedStroke)) return null;
911
+ return <div ref={containerRef} aria-hidden="true" />;
912
+ }