@agent-native/core 0.79.27 → 0.80.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 (346) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +26 -0
  3. package/corpus/core/docs/content/locales/ar-SA/template-design.mdx +41 -0
  4. package/corpus/core/docs/content/locales/de-DE/template-design.mdx +51 -0
  5. package/corpus/core/docs/content/locales/es-ES/template-design.mdx +50 -0
  6. package/corpus/core/docs/content/locales/fr-FR/template-design.mdx +49 -0
  7. package/corpus/core/docs/content/locales/hi-IN/template-design.mdx +44 -0
  8. package/corpus/core/docs/content/locales/ja-JP/template-design.mdx +46 -0
  9. package/corpus/core/docs/content/locales/ko-KR/template-design.mdx +44 -0
  10. package/corpus/core/docs/content/locales/pt-BR/template-design.mdx +48 -0
  11. package/corpus/core/docs/content/locales/zh-CN/template-design.mdx +36 -0
  12. package/corpus/core/docs/content/locales/zh-TW/template-design.mdx +38 -0
  13. package/corpus/core/docs/content/template-design.mdx +45 -0
  14. package/corpus/core/package.json +2 -1
  15. package/corpus/core/src/agent/engine/index.ts +1 -0
  16. package/corpus/core/src/agent/engine/registry.ts +40 -0
  17. package/corpus/core/src/agent/production-agent.ts +6 -2
  18. package/corpus/core/src/cli/design-connect.ts +490 -0
  19. package/corpus/core/src/cli/index.ts +16 -0
  20. package/corpus/core/src/cli/skills.ts +126 -3
  21. package/corpus/core/src/client/AssistantChat.tsx +43 -7
  22. package/corpus/core/src/client/agent-chat.ts +27 -3
  23. package/corpus/core/src/client/blocks/library/MermaidBlock.tsx +1 -1
  24. package/corpus/core/src/client/blocks/library/diagram.tsx +2 -2
  25. package/corpus/core/src/client/blocks/library/wireframe.tsx +1 -1
  26. package/corpus/core/src/client/chat/message-components.tsx +1 -1
  27. package/corpus/core/src/client/chat-model-groups.ts +1 -2
  28. package/corpus/core/src/client/components/LiveCursorOverlay.tsx +33 -66
  29. package/corpus/core/src/client/composer/TiptapComposer.tsx +99 -24
  30. package/corpus/core/src/client/composer/useVoiceDictation.ts +429 -260
  31. package/corpus/core/src/client/frame-protocol.ts +17 -2
  32. package/corpus/core/src/client/rich-markdown-editor/RegistryBlockNode.tsx +2 -2
  33. package/corpus/core/src/client/sharing/ShareButton.tsx +20 -15
  34. package/corpus/core/src/client/use-agent-engine-configured.ts +80 -28
  35. package/corpus/core/src/server/transcribe-voice.ts +57 -22
  36. package/corpus/core/src/shared/mcp-embed-headers.ts +23 -1
  37. package/corpus/core/src/styles/agent-native.css +56 -7
  38. package/corpus/core/src/templates/workspace-core/.agents/skills/voice-transcription/SKILL.md +10 -0
  39. package/corpus/core/src/vite/client.ts +2 -0
  40. package/corpus/core/src/voice/index.ts +18 -0
  41. package/corpus/core/src/voice/voice-cleanup-prompt.ts +38 -0
  42. package/corpus/core/src/voice/voice-context.ts +217 -0
  43. package/corpus/core/src/voice/voice-replacements.ts +75 -0
  44. package/corpus/templates/analytics/AGENTS.md +4 -3
  45. package/corpus/templates/analytics/actions/list-session-recordings.ts +6 -1
  46. package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +557 -400
  47. package/corpus/templates/analytics/app/global.css +0 -4
  48. package/corpus/templates/analytics/app/i18n/zh-TW.ts +2 -0
  49. package/corpus/templates/analytics/app/i18n-data.ts +20 -0
  50. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +33 -8
  51. package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +7 -0
  52. package/corpus/templates/analytics/app/pages/sessions/SessionsPage.tsx +7 -0
  53. package/corpus/templates/analytics/changelog/2026-06-28-ask-tab-now-matches-the-darker-app-background.md +6 -0
  54. package/corpus/templates/analytics/changelog/2026-06-28-dashboard-email-reports-now-use-cleaner-light-mode-snapshots.md +6 -0
  55. package/corpus/templates/analytics/changelog/2026-06-28-left-sidebar-collapses-into-an-animated-icon-rail.md +6 -0
  56. package/corpus/templates/analytics/changelog/2026-06-28-sessions-now-show-only-signed-in-recordings-with-playable-re.md +6 -0
  57. package/corpus/templates/analytics/seeds/dashboards/agent-native-templates-first-party.json +3 -3
  58. package/corpus/templates/analytics/server/lib/dashboard-report.ts +29 -7
  59. package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +5 -2
  60. package/corpus/templates/analytics/server/lib/session-replay.ts +24 -4
  61. package/corpus/templates/assets/app/components/layout/Sidebar.tsx +4 -4
  62. package/corpus/templates/assets/changelog/2026-06-28-left-sidebar-collapse-motion-and-footer-chrome-are-quieter.md +6 -0
  63. package/corpus/templates/brain/app/components/layout/Layout.tsx +29 -2
  64. package/corpus/templates/brain/app/components/layout/Sidebar.tsx +131 -42
  65. package/corpus/templates/brain/app/i18n/zh-TW.ts +2 -0
  66. package/corpus/templates/brain/app/i18n-data.ts +20 -0
  67. package/corpus/templates/brain/changelog/2026-06-28-left-sidebar-collapses-into-an-animated-icon-rail.md +6 -0
  68. package/corpus/templates/calendar/app/components/layout/AppLayout.tsx +29 -1
  69. package/corpus/templates/calendar/app/components/layout/Sidebar.tsx +417 -299
  70. package/corpus/templates/calendar/app/i18n/zh-TW.ts +2 -0
  71. package/corpus/templates/calendar/app/i18n-data.ts +20 -0
  72. package/corpus/templates/calendar/changelog/2026-06-28-left-sidebar-collapses-into-an-animated-icon-rail.md +6 -0
  73. package/corpus/templates/chat/app/components/layout/Sidebar.tsx +6 -19
  74. package/corpus/templates/chat/changelog/2026-06-28-left-sidebar-collapse-motion-and-footer-chrome-are-quieter.md +6 -0
  75. package/corpus/templates/clips/AGENTS.md +4 -0
  76. package/corpus/templates/clips/actions/cleanup-transcript.ts +39 -9
  77. package/corpus/templates/clips/actions/finalize-meeting.ts +81 -12
  78. package/corpus/templates/clips/actions/lib/agents-md-context.ts +4 -1
  79. package/corpus/templates/clips/app/components/library/library-layout.tsx +3 -3
  80. package/corpus/templates/clips/app/components/meetings/share-meeting-dialog.tsx +1 -1
  81. package/corpus/templates/clips/app/components/player/share-dialog.tsx +2 -2
  82. package/corpus/templates/clips/changelog/2026-06-28-left-sidebar-footer-controls-use-less-divider-chrome.md +6 -0
  83. package/corpus/templates/clips/desktop/src/lib/personal-vocabulary.ts +13 -8
  84. package/corpus/templates/clips/desktop/src/lib/voice-dictation.ts +60 -1
  85. package/corpus/templates/content/app/components/editor/DocumentDatabase.tsx +1 -1
  86. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +1 -1
  87. package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +5 -5
  88. package/corpus/templates/content/changelog/2026-06-28-left-sidebar-collapse-motion-and-footer-chrome-are-quieter.md +6 -0
  89. package/corpus/templates/design/.agents/skills/visual-edit/SKILL.md +137 -0
  90. package/corpus/templates/design/AGENTS.md +95 -5
  91. package/corpus/templates/design/DESIGN.md +82 -0
  92. package/corpus/templates/design/DEVELOPING.md +43 -0
  93. package/corpus/templates/design/actions/add-localhost-screens.ts +426 -0
  94. package/corpus/templates/design/actions/apply-shader.ts +277 -0
  95. package/corpus/templates/design/actions/apply-visual-edit.ts +303 -0
  96. package/corpus/templates/design/actions/connect-localhost.ts +183 -0
  97. package/corpus/templates/design/actions/edit-design.ts +195 -42
  98. package/corpus/templates/design/actions/generate-design.ts +71 -1
  99. package/corpus/templates/design/actions/generate-screens.ts +193 -0
  100. package/corpus/templates/design/actions/get-code-layer-projection.ts +191 -0
  101. package/corpus/templates/design/actions/get-shader.ts +119 -0
  102. package/corpus/templates/design/actions/list-localhost-connections.ts +95 -0
  103. package/corpus/templates/design/actions/navigate.ts +55 -2
  104. package/corpus/templates/design/actions/view-screen.ts +68 -1
  105. package/corpus/templates/design/agent-native.app-skill.json +10 -0
  106. package/corpus/templates/design/app/components/design/CanvasContextMenu.tsx +761 -0
  107. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +2057 -103
  108. package/corpus/templates/design/app/components/design/DesignEditorSkeleton.tsx +74 -38
  109. package/corpus/templates/design/app/components/design/DesignExtensionsPanel.tsx +399 -0
  110. package/corpus/templates/design/app/components/design/EditPanel.tsx +3185 -439
  111. package/corpus/templates/design/app/components/design/LayersPanel.tsx +1124 -0
  112. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +3116 -147
  113. package/corpus/templates/design/app/components/design/QuestionFlow.tsx +700 -13
  114. package/corpus/templates/design/app/components/design/TweaksPanel.tsx +107 -63
  115. package/corpus/templates/design/app/components/design/index.ts +0 -5
  116. package/corpus/templates/design/app/components/design/inspector/AlignmentMatrix.tsx +286 -0
  117. package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +699 -0
  118. package/corpus/templates/design/app/components/design/inspector/ConstraintsWidget.tsx +461 -0
  119. package/corpus/templates/design/app/components/design/inspector/ExportSettingsPanel.tsx +166 -0
  120. package/corpus/templates/design/app/components/design/inspector/FigmaColorPicker.tsx +1420 -0
  121. package/corpus/templates/design/app/components/design/inspector/SHADER_INTEGRATION.md +420 -0
  122. package/corpus/templates/design/app/components/design/inspector/ScrubInput.tsx +215 -0
  123. package/corpus/templates/design/app/components/design/inspector/ShaderControls.stories.tsx +71 -0
  124. package/corpus/templates/design/app/components/design/inspector/ShaderControls.tsx +518 -0
  125. package/corpus/templates/design/app/components/design/inspector/ShaderEffectRow.tsx +106 -0
  126. package/corpus/templates/design/app/components/design/inspector/figma-icons.tsx +361 -0
  127. package/corpus/templates/design/app/components/design/inspector/index.ts +60 -0
  128. package/corpus/templates/design/app/components/design/inspector/scrub-input-utils.ts +201 -0
  129. package/corpus/templates/design/app/components/design/types.ts +23 -0
  130. package/corpus/templates/design/app/components/layout/Layout.tsx +24 -13
  131. package/corpus/templates/design/app/components/layout/Sidebar.tsx +4 -4
  132. package/corpus/templates/design/app/components/visual-editor/CanvasCommentPins.tsx +474 -36
  133. package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +22 -5
  134. package/corpus/templates/design/app/components/visual-editor/SaveStatusIndicator.tsx +24 -11
  135. package/corpus/templates/design/app/global.css +121 -3
  136. package/corpus/templates/design/app/hooks/use-agent-generating.ts +4 -0
  137. package/corpus/templates/design/app/hooks/use-navigation-state.ts +130 -8
  138. package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +329 -0
  139. package/corpus/templates/design/app/i18n/zh-TW.ts +264 -0
  140. package/corpus/templates/design/app/i18n-data.ts +2790 -1
  141. package/corpus/templates/design/app/pages/DesignEditor.tsx +5108 -851
  142. package/corpus/templates/design/changelog/2026-06-28-annotate-mode-now-combines-drawing-and-comment-pins-in-one-c.md +6 -0
  143. package/corpus/templates/design/changelog/2026-06-28-design-editor-panel-borders-are-cleaner-so-the-canvas-edge-s.md +6 -0
  144. package/corpus/templates/design/changelog/2026-06-28-drawing-prompts-now-reliably-open-and-submit-to-the-design-a.md +6 -0
  145. package/corpus/templates/design/changelog/2026-06-28-editor-app-menus-now-use-an-outline-agent-native-mark-with-b.md +6 -0
  146. package/corpus/templates/design/changelog/2026-06-28-figma-style-code-design-mode.md +6 -0
  147. package/corpus/templates/design/changelog/2026-06-28-layer-selection-now-updates-chat-context-without-opening-the.md +6 -0
  148. package/corpus/templates/design/changelog/2026-06-28-layer-selections-now-preserve-hidden-and-locked-canvas-state.md +6 -0
  149. package/corpus/templates/design/changelog/2026-06-28-left-sidebar-collapse-motion-and-footer-chrome-are-quieter.md +6 -0
  150. package/corpus/templates/design/changelog/2026-06-28-live-cursors-now-keep-names-beside-a-cleaner-pointer-marker.md +6 -0
  151. package/corpus/templates/design/changelog/2026-06-28-mode-switching-now-lives-in-a-compact-bottom-right-icon-dock.md +6 -0
  152. package/corpus/templates/design/changelog/2026-06-28-screen-overview-now-handles-zooming-drawing-opening-and-dele.md +6 -0
  153. package/corpus/templates/design/changelog/2026-06-28-screen-overview-now-selects-screens-on-click-and-opens-them-.md +6 -0
  154. package/corpus/templates/design/changelog/2026-06-28-screen-overview-zooms-into-the-active-screen-with-smoother-e.md +6 -0
  155. package/corpus/templates/design/changelog/2026-06-28-selected-element-chips-in-chat-are-shorter-and-easier-to-sca.md +6 -0
  156. package/corpus/templates/design/changelog/2026-06-28-the-design-editor-toolbar-is-more-compact-and-the-app-menu-m.md +6 -0
  157. package/corpus/templates/design/changelog/2026-06-28-tweaks-now-live-at-the-top-of-the-design-sidebar-instead-of-.md +6 -0
  158. package/corpus/templates/design/changelog/2026-06-28-tweaks-now-live-in-the-right-inspector-beside-the-design-tab.md +6 -0
  159. package/corpus/templates/design/changelog/2026-06-29-active-sidebar-rows-use-soft-highlights.md +6 -0
  160. package/corpus/templates/design/changelog/2026-06-29-agents-can-now-open-screen-overview-focus-screens-and-add.md +6 -0
  161. package/corpus/templates/design/changelog/2026-06-29-design-agents-now-edit-broad-copy-changes-more-reliably.md +6 -0
  162. package/corpus/templates/design/changelog/2026-06-29-design-editor-extensions-now-run-inline-beside-the-artboard.md +6 -0
  163. package/corpus/templates/design/changelog/2026-06-29-sidebars-use-darker-chrome-and-chat-warns-immediately.md +6 -0
  164. package/corpus/templates/design/changelog/2026-06-29-visual-edit-can-open-localhost-routes-as-url-backed-screens.md +6 -0
  165. package/corpus/templates/design/package.json +1 -0
  166. package/corpus/templates/design/server/db/schema.ts +26 -0
  167. package/corpus/templates/design/server/lib/design-snapshot.ts +1 -1
  168. package/corpus/templates/design/server/plugins/agent-chat.ts +3 -0
  169. package/corpus/templates/design/shared/api.ts +27 -0
  170. package/corpus/templates/design/shared/canvas-frames.ts +103 -0
  171. package/corpus/templates/design/shared/canvas-math.ts +1058 -0
  172. package/corpus/templates/design/shared/code-layer.ts +2212 -0
  173. package/corpus/templates/design/shared/color-utils.ts +180 -0
  174. package/corpus/templates/design/shared/generation-session.ts +61 -0
  175. package/corpus/templates/design/shared/shader-presets.ts +782 -0
  176. package/corpus/templates/design/shared/shader-safety.ts +137 -0
  177. package/corpus/templates/design/shared/source-mode.ts +237 -0
  178. package/corpus/templates/forms/app/components/layout/Sidebar.tsx +149 -6
  179. package/corpus/templates/forms/app/i18n/ar-SA.ts +2 -0
  180. package/corpus/templates/forms/app/i18n/de-DE.ts +2 -0
  181. package/corpus/templates/forms/app/i18n/en-US.ts +2 -0
  182. package/corpus/templates/forms/app/i18n/es-ES.ts +2 -0
  183. package/corpus/templates/forms/app/i18n/fr-FR.ts +2 -0
  184. package/corpus/templates/forms/app/i18n/hi-IN.ts +2 -0
  185. package/corpus/templates/forms/app/i18n/ja-JP.ts +2 -0
  186. package/corpus/templates/forms/app/i18n/ko-KR.ts +2 -0
  187. package/corpus/templates/forms/app/i18n/pt-BR.ts +2 -0
  188. package/corpus/templates/forms/app/i18n/zh-CN.ts +2 -0
  189. package/corpus/templates/forms/app/i18n/zh-TW.ts +2 -0
  190. package/corpus/templates/forms/changelog/2026-06-28-left-sidebar-collapses-into-an-animated-icon-rail.md +6 -0
  191. package/corpus/templates/macros/app/components/layout/AppLayout.tsx +2 -2
  192. package/corpus/templates/macros/changelog/2026-06-28-left-sidebar-footer-controls-use-less-divider-chrome.md +6 -0
  193. package/corpus/templates/mail/app/components/layout/AppLayout.tsx +408 -247
  194. package/corpus/templates/mail/app/i18n/ar-SA.ts +4 -0
  195. package/corpus/templates/mail/app/i18n/de-DE.ts +4 -0
  196. package/corpus/templates/mail/app/i18n/en-US.ts +4 -0
  197. package/corpus/templates/mail/app/i18n/es-ES.ts +4 -0
  198. package/corpus/templates/mail/app/i18n/fr-FR.ts +4 -0
  199. package/corpus/templates/mail/app/i18n/hi-IN.ts +4 -0
  200. package/corpus/templates/mail/app/i18n/ja-JP.ts +4 -0
  201. package/corpus/templates/mail/app/i18n/ko-KR.ts +4 -0
  202. package/corpus/templates/mail/app/i18n/pt-BR.ts +4 -0
  203. package/corpus/templates/mail/app/i18n/zh-CN.ts +4 -0
  204. package/corpus/templates/mail/app/i18n/zh-TW.ts +4 -0
  205. package/corpus/templates/mail/changelog/2026-06-28-pinned-sidebar-collapses-into-an-animated-icon-rail.md +6 -0
  206. package/corpus/templates/plan/app/components/layout/Sidebar.tsx +6 -8
  207. package/corpus/templates/plan/app/components/plan/wireframe/Wireframe.tsx +1 -1
  208. package/corpus/templates/plan/app/global.css +61 -78
  209. package/corpus/templates/plan/app/pages/PlansPage.tsx +8 -0
  210. package/corpus/templates/plan/changelog/2026-06-28-left-sidebar-collapse-motion-and-footer-chrome-are-quieter.md +6 -0
  211. package/corpus/templates/plan/changelog/2026-06-28-plans-use-the-shared-neutral-light-and-dark-theme.md +6 -0
  212. package/corpus/templates/plan/changelog/2026-06-28-sending-open-feedback-from-a-plan-now-opens-the-inline-plan-.md +6 -0
  213. package/corpus/templates/slides/app/components/layout/Sidebar.tsx +5 -5
  214. package/corpus/templates/slides/changelog/2026-06-28-left-sidebar-collapse-motion-and-footer-chrome-are-quieter.md +6 -0
  215. package/corpus/templates/videos/app/components/Sidebar.tsx +2 -2
  216. package/corpus/templates/videos/app/components/layout/Layout.tsx +29 -2
  217. package/corpus/templates/videos/app/components/layout/NavSidebar.tsx +113 -30
  218. package/corpus/templates/videos/app/i18n/ar-SA.ts +2 -0
  219. package/corpus/templates/videos/app/i18n/de-DE.ts +2 -0
  220. package/corpus/templates/videos/app/i18n/en-US.ts +2 -0
  221. package/corpus/templates/videos/app/i18n/es-ES.ts +2 -0
  222. package/corpus/templates/videos/app/i18n/fr-FR.ts +2 -0
  223. package/corpus/templates/videos/app/i18n/hi-IN.ts +2 -0
  224. package/corpus/templates/videos/app/i18n/ja-JP.ts +2 -0
  225. package/corpus/templates/videos/app/i18n/ko-KR.ts +2 -0
  226. package/corpus/templates/videos/app/i18n/pt-BR.ts +2 -0
  227. package/corpus/templates/videos/app/i18n/zh-CN.ts +2 -0
  228. package/corpus/templates/videos/app/i18n/zh-TW.ts +2 -0
  229. package/corpus/templates/videos/changelog/2026-06-28-left-sidebar-collapses-into-an-animated-icon-rail.md +6 -0
  230. package/dist/agent/engine/index.d.ts +1 -1
  231. package/dist/agent/engine/index.d.ts.map +1 -1
  232. package/dist/agent/engine/index.js +1 -1
  233. package/dist/agent/engine/index.js.map +1 -1
  234. package/dist/agent/engine/registry.d.ts +9 -0
  235. package/dist/agent/engine/registry.d.ts.map +1 -1
  236. package/dist/agent/engine/registry.js +37 -0
  237. package/dist/agent/engine/registry.js.map +1 -1
  238. package/dist/agent/production-agent.d.ts.map +1 -1
  239. package/dist/agent/production-agent.js +4 -3
  240. package/dist/agent/production-agent.js.map +1 -1
  241. package/dist/cli/design-connect.d.ts +52 -0
  242. package/dist/cli/design-connect.d.ts.map +1 -0
  243. package/dist/cli/design-connect.js +409 -0
  244. package/dist/cli/design-connect.js.map +1 -0
  245. package/dist/cli/index.js +15 -0
  246. package/dist/cli/index.js.map +1 -1
  247. package/dist/cli/skills.d.ts +3 -0
  248. package/dist/cli/skills.d.ts.map +1 -1
  249. package/dist/cli/skills.js +121 -3
  250. package/dist/cli/skills.js.map +1 -1
  251. package/dist/client/AssistantChat.d.ts.map +1 -1
  252. package/dist/client/AssistantChat.js +38 -8
  253. package/dist/client/AssistantChat.js.map +1 -1
  254. package/dist/client/agent-chat.d.ts +6 -0
  255. package/dist/client/agent-chat.d.ts.map +1 -1
  256. package/dist/client/agent-chat.js +17 -3
  257. package/dist/client/agent-chat.js.map +1 -1
  258. package/dist/client/blocks/library/MermaidBlock.js +1 -1
  259. package/dist/client/blocks/library/MermaidBlock.js.map +1 -1
  260. package/dist/client/blocks/library/diagram.js +1 -1
  261. package/dist/client/blocks/library/diagram.js.map +1 -1
  262. package/dist/client/blocks/library/wireframe.js +1 -1
  263. package/dist/client/blocks/library/wireframe.js.map +1 -1
  264. package/dist/client/chat/message-components.js +1 -1
  265. package/dist/client/chat/message-components.js.map +1 -1
  266. package/dist/client/chat-model-groups.d.ts.map +1 -1
  267. package/dist/client/chat-model-groups.js +1 -2
  268. package/dist/client/chat-model-groups.js.map +1 -1
  269. package/dist/client/components/LiveCursorOverlay.d.ts +1 -2
  270. package/dist/client/components/LiveCursorOverlay.d.ts.map +1 -1
  271. package/dist/client/components/LiveCursorOverlay.js +19 -40
  272. package/dist/client/components/LiveCursorOverlay.js.map +1 -1
  273. package/dist/client/composer/TiptapComposer.d.ts +3 -1
  274. package/dist/client/composer/TiptapComposer.d.ts.map +1 -1
  275. package/dist/client/composer/TiptapComposer.js +86 -24
  276. package/dist/client/composer/TiptapComposer.js.map +1 -1
  277. package/dist/client/composer/useVoiceDictation.d.ts +9 -0
  278. package/dist/client/composer/useVoiceDictation.d.ts.map +1 -1
  279. package/dist/client/composer/useVoiceDictation.js +154 -32
  280. package/dist/client/composer/useVoiceDictation.js.map +1 -1
  281. package/dist/client/frame-protocol.d.ts +17 -2
  282. package/dist/client/frame-protocol.d.ts.map +1 -1
  283. package/dist/client/frame-protocol.js.map +1 -1
  284. package/dist/client/rich-markdown-editor/RegistryBlockNode.js +2 -2
  285. package/dist/client/rich-markdown-editor/RegistryBlockNode.js.map +1 -1
  286. package/dist/client/sharing/ShareButton.d.ts +2 -0
  287. package/dist/client/sharing/ShareButton.d.ts.map +1 -1
  288. package/dist/client/sharing/ShareButton.js +7 -6
  289. package/dist/client/sharing/ShareButton.js.map +1 -1
  290. package/dist/client/use-agent-engine-configured.d.ts +5 -0
  291. package/dist/client/use-agent-engine-configured.d.ts.map +1 -1
  292. package/dist/client/use-agent-engine-configured.js +51 -25
  293. package/dist/client/use-agent-engine-configured.js.map +1 -1
  294. package/dist/collab/awareness.d.ts +2 -2
  295. package/dist/collab/awareness.d.ts.map +1 -1
  296. package/dist/collab/routes.d.ts +1 -1
  297. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  298. package/dist/notifications/routes.d.ts +3 -3
  299. package/dist/observability/routes.d.ts +8 -8
  300. package/dist/progress/routes.d.ts +1 -1
  301. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  302. package/dist/server/transcribe-voice.d.ts.map +1 -1
  303. package/dist/server/transcribe-voice.js +40 -24
  304. package/dist/server/transcribe-voice.js.map +1 -1
  305. package/dist/shared/mcp-embed-headers.d.ts +1 -0
  306. package/dist/shared/mcp-embed-headers.d.ts.map +1 -1
  307. package/dist/shared/mcp-embed-headers.js +20 -1
  308. package/dist/shared/mcp-embed-headers.js.map +1 -1
  309. package/dist/styles/agent-native.css +56 -7
  310. package/dist/templates/workspace-core/.agents/skills/voice-transcription/SKILL.md +10 -0
  311. package/dist/vite/client.d.ts.map +1 -1
  312. package/dist/vite/client.js +2 -0
  313. package/dist/vite/client.js.map +1 -1
  314. package/dist/voice/index.d.ts +5 -0
  315. package/dist/voice/index.d.ts.map +1 -0
  316. package/dist/voice/index.js +4 -0
  317. package/dist/voice/index.js.map +1 -0
  318. package/dist/voice/voice-cleanup-prompt.d.ts +6 -0
  319. package/dist/voice/voice-cleanup-prompt.d.ts.map +1 -0
  320. package/dist/voice/voice-cleanup-prompt.js +26 -0
  321. package/dist/voice/voice-cleanup-prompt.js.map +1 -0
  322. package/dist/voice/voice-context.d.ts +25 -0
  323. package/dist/voice/voice-context.d.ts.map +1 -0
  324. package/dist/voice/voice-context.js +156 -0
  325. package/dist/voice/voice-context.js.map +1 -0
  326. package/dist/voice/voice-replacements.d.ts +4 -0
  327. package/dist/voice/voice-replacements.d.ts.map +1 -0
  328. package/dist/voice/voice-replacements.js +52 -0
  329. package/dist/voice/voice-replacements.js.map +1 -0
  330. package/docs/content/locales/ar-SA/template-design.mdx +41 -0
  331. package/docs/content/locales/de-DE/template-design.mdx +51 -0
  332. package/docs/content/locales/es-ES/template-design.mdx +50 -0
  333. package/docs/content/locales/fr-FR/template-design.mdx +49 -0
  334. package/docs/content/locales/hi-IN/template-design.mdx +44 -0
  335. package/docs/content/locales/ja-JP/template-design.mdx +46 -0
  336. package/docs/content/locales/ko-KR/template-design.mdx +44 -0
  337. package/docs/content/locales/pt-BR/template-design.mdx +48 -0
  338. package/docs/content/locales/zh-CN/template-design.mdx +36 -0
  339. package/docs/content/locales/zh-TW/template-design.mdx +38 -0
  340. package/docs/content/template-design.mdx +45 -0
  341. package/package.json +2 -1
  342. package/src/templates/workspace-core/.agents/skills/voice-transcription/SKILL.md +10 -0
  343. package/corpus/templates/design/app/components/design/DesignToolbar.tsx +0 -339
  344. package/corpus/templates/design/app/components/design/PresentMode.tsx +0 -68
  345. package/corpus/templates/design/app/components/design/ViewportTabs.tsx +0 -149
  346. package/corpus/templates/design/app/components/design/ZoomControls.tsx +0 -157
@@ -1,10 +1,27 @@
1
- import { useRef, useState, useCallback, useEffect } from "react";
2
-
1
+ import { useT } from "@agent-native/core/client";
2
+ import {
3
+ DEFAULT_SNAP_THRESHOLD_SCREEN_PX,
4
+ appendPolylinePoint,
5
+ computeMoveSnap,
6
+ computeResizeSnap,
7
+ getDraftGeometryFromPoints,
8
+ getFrameGroupBounds,
9
+ getNudgeDelta,
10
+ getPanForZoomToCursor,
11
+ resizeFrameGroupFromDelta,
12
+ resizeFrameGroupToBounds,
13
+ screenToCanvasPoint,
14
+ type ArrowNudgeKey,
15
+ } from "@shared/canvas-math";
16
+ import { IconCopy, IconMaximize } from "@tabler/icons-react";
3
17
  import {
4
- Tooltip,
5
- TooltipContent,
6
- TooltipTrigger,
7
- } from "@/components/ui/tooltip";
18
+ useRef,
19
+ useState,
20
+ useCallback,
21
+ useEffect,
22
+ type ReactNode,
23
+ } from "react";
24
+
8
25
  import { prettyScreenName } from "@/lib/screen-names";
9
26
  import { cn } from "@/lib/utils";
10
27
 
@@ -12,42 +29,524 @@ interface ScreenFile {
12
29
  id: string;
13
30
  filename: string;
14
31
  content: string;
32
+ source?: string;
33
+ sourceType?: string;
34
+ lod?: string;
35
+ previewState?: string;
36
+ status?: string;
37
+ title?: string;
38
+ width?: number;
39
+ height?: number;
40
+ url?: string;
41
+ previewUrl?: string;
42
+ }
43
+
44
+ type ScreenSourceType = "localhost" | "fusion" | "inline";
45
+ type ScreenPreviewState = "live" | "snapshot" | "preview";
46
+ type MultiScreenCanvasTool =
47
+ | "move"
48
+ | "frame"
49
+ | "rect"
50
+ | "rectangle"
51
+ | "text"
52
+ | "pen"
53
+ | "hand"
54
+ | "comment"
55
+ | "draw"
56
+ | "scale"
57
+ | "overview";
58
+
59
+ interface CanvasToolProps {
60
+ fill?: string;
61
+ stroke?: string;
62
+ strokeWidth?: number;
63
+ text?: string;
64
+ }
65
+
66
+ export interface CanvasPrimitiveInsert {
67
+ kind: DraftPrimitiveKind;
68
+ geometry: FrameGeometry;
69
+ points?: Point[];
70
+ text?: string;
71
+ fill?: string;
72
+ stroke?: string;
73
+ strokeWidth?: number;
74
+ }
75
+
76
+ interface ScreenMetadata {
77
+ source?: string;
78
+ sourceType?: string;
79
+ lod?: string;
80
+ previewState?: string;
81
+ title?: string;
82
+ width?: number;
83
+ height?: number;
84
+ url?: string;
85
+ previewUrl?: string;
86
+ }
87
+
88
+ interface DuplicateRequest {
89
+ mode: "alt-click" | "alt-drag";
90
+ screen: ScreenFile;
91
+ canvasPosition: { x: number; y: number };
92
+ canvasOffset?: { x: number; y: number };
93
+ dropCanvasPosition?: { x: number; y: number };
15
94
  }
16
95
 
17
96
  interface MultiScreenCanvasProps {
18
97
  screens: ScreenFile[];
19
98
  zoom: number;
20
99
  activeId?: string | null;
100
+ activeTool?: MultiScreenCanvasTool;
101
+ toolProps?: CanvasToolProps;
102
+ onActiveToolChange?: (tool: MultiScreenCanvasTool) => void;
21
103
  onPick: (id: string) => void;
104
+ onEdit?: (id: string) => void;
105
+ metadataById?: Record<string, ScreenMetadata | undefined>;
106
+ getScreenMetadata?: (screen: ScreenFile) => ScreenMetadata | undefined;
107
+ onDuplicate?: (id: string, request: DuplicateRequest) => void;
108
+ geometryById?: Record<string, Partial<FrameGeometry> | undefined>;
109
+ onGeometryChange?: (geometryById: FrameGeometryById) => void;
110
+ onGeometryCommit?: (
111
+ before: FrameGeometryById,
112
+ after: FrameGeometryById,
113
+ ) => void;
114
+ onCreatePrimitive?: (
115
+ screenId: string,
116
+ primitive: CanvasPrimitiveInsert,
117
+ ) => boolean;
118
+ onDeleteSelection?: (ids: string[]) => boolean | void;
119
+ onZoomChange?: (zoom: number) => void;
120
+ onZoomToEdit?: (id: string) => void;
121
+ zoomToEditThreshold?: number;
122
+ renderScreenContent?: (
123
+ screen: ScreenFile,
124
+ metadata: ResolvedScreenMetadata,
125
+ geometry: FrameGeometry,
126
+ ) => ReactNode;
127
+ selectAllRequest?: number;
128
+ clearSelectionRequest?: number;
22
129
  }
23
130
 
24
131
  /**
25
- * Figma-style overview canvas. Renders every file in the design as a fixed-
26
- * size preview iframe, laid out in a wrap-flow inside an infinite, pannable
27
- * surface (drag with middle mouse OR left-click on background; click a screen
28
- * to enter the single-file editor for that file).
29
- *
30
- * Each screen is a 320×640 thumbnail; large enough to read, small enough to
31
- * fit several across. The dot grid background extends well past the screens
32
- * so panning never reveals the page outside.
132
+ * Figma-style overview canvas. Renders every file in the design as a movable,
133
+ * resizable frame inside an infinite, pannable surface.
33
134
  */
34
135
  const SCREEN_WIDTH = 320;
35
136
  const SCREEN_HEIGHT = 640;
36
137
  const SCREEN_CARD_HEIGHT = SCREEN_HEIGHT + 26;
37
138
  const SCREEN_GAP = 56;
38
139
  const SURFACE_PADDING = 240;
140
+ const DUPLICATE_DRAG_THRESHOLD = 6;
141
+ const DRAG_THRESHOLD = 3;
142
+ const FRAME_LABEL_HEIGHT = 28;
143
+ const MIN_ZOOM = 2;
144
+ const MAX_ZOOM = 800;
145
+ const ZOOM_SENSITIVITY = 0.002;
146
+ const MAX_WHEEL_ZOOM_DELTA = 80;
147
+ const MAX_WHEEL_PAN_DELTA = 140;
148
+ const PIXEL_GRID_ZOOM = 800;
149
+ const DRAFT_FRAME_WIDTH = 320;
150
+ const DRAFT_FRAME_HEIGHT = 240;
151
+ const DRAFT_RECT_WIDTH = 160;
152
+ const DRAFT_RECT_HEIGHT = 120;
153
+ const DRAFT_TEXT_WIDTH = 180;
154
+ const DRAFT_TEXT_HEIGHT = 48;
155
+ const DRAFT_PATH_MIN_SIZE = 12;
156
+ const PEN_SAMPLE_DISTANCE_SCREEN_PX = 5;
157
+
158
+ interface ResolvedScreenMetadata {
159
+ source: ScreenSourceType;
160
+ previewState: ScreenPreviewState;
161
+ title?: string;
162
+ width: number;
163
+ height: number;
164
+ previewUrl?: string;
165
+ }
166
+
167
+ interface DuplicatePreview {
168
+ display: string;
169
+ x: number;
170
+ y: number;
171
+ canDuplicate: boolean;
172
+ moved: boolean;
173
+ }
174
+
175
+ interface TransformBadge {
176
+ x: number;
177
+ y: number;
178
+ text: string;
179
+ }
180
+
181
+ export interface FrameGeometry {
182
+ x: number;
183
+ y: number;
184
+ width: number;
185
+ height: number;
186
+ rotation?: number;
187
+ z?: number;
188
+ }
189
+
190
+ type FrameGeometryById = Record<string, FrameGeometry>;
191
+
192
+ export interface Point {
193
+ x: number;
194
+ y: number;
195
+ }
196
+
197
+ export type DraftPrimitiveKind = "frame" | "rectangle" | "text" | "path";
198
+ type DraftCreationTool = "frame" | "rect" | "text" | "pen";
199
+
200
+ interface DraftPrimitive {
201
+ id: string;
202
+ kind: DraftPrimitiveKind;
203
+ geometry: FrameGeometry;
204
+ points?: Point[];
205
+ text?: string;
206
+ fill?: string;
207
+ stroke?: string;
208
+ strokeWidth?: number;
209
+ }
210
+
211
+ type DraftPrimitiveById = Record<string, DraftPrimitive>;
212
+
213
+ interface DraftPrimitiveInput {
214
+ tool: DraftCreationTool;
215
+ start: Point;
216
+ end: Point;
217
+ points?: Point[];
218
+ moved: boolean;
219
+ toolProps?: CanvasToolProps;
220
+ fallbackText: string;
221
+ }
222
+
223
+ interface MarqueeRect {
224
+ x: number;
225
+ y: number;
226
+ width: number;
227
+ height: number;
228
+ }
229
+
230
+ type ResizeHandle = "nw" | "n" | "ne" | "e" | "se" | "s" | "sw" | "w";
231
+
232
+ interface AlignmentGuide {
233
+ orientation: "vertical" | "horizontal";
234
+ position: number;
235
+ start: number;
236
+ end: number;
237
+ }
238
+
239
+ interface MoveDragState {
240
+ type: "move";
241
+ originClient: Point;
242
+ originFrames: FrameGeometryById;
243
+ targetIds: string[];
244
+ primaryId: string;
245
+ hasMoved: boolean;
246
+ }
247
+
248
+ interface ResizeDragState {
249
+ type: "resize";
250
+ originClient: Point;
251
+ originFrames: FrameGeometryById;
252
+ originBounds: FrameGeometry;
253
+ targetIds: string[];
254
+ handle: ResizeHandle;
255
+ hasMoved: boolean;
256
+ }
257
+
258
+ interface RotateDragState {
259
+ type: "rotate";
260
+ originClient: Point;
261
+ originFrame: FrameGeometry;
262
+ frameId: string;
263
+ originPointerAngle: number;
264
+ originRotation: number;
265
+ hasMoved: boolean;
266
+ }
267
+
268
+ interface MarqueeDragState {
269
+ type: "marquee";
270
+ originClient: Point;
271
+ originCanvas: Point;
272
+ baseSelectedIds: string[];
273
+ baseSelectedDraftIds: string[];
274
+ additive: boolean;
275
+ hasMoved: boolean;
276
+ }
277
+
278
+ interface PanDragState {
279
+ type: "pan";
280
+ originClient: Point;
281
+ originPan: Point;
282
+ }
283
+
284
+ interface DraftMoveDragState {
285
+ type: "draft-move";
286
+ originClient: Point;
287
+ originDrafts: DraftPrimitiveById;
288
+ targetIds: string[];
289
+ primaryId: string;
290
+ hasMoved: boolean;
291
+ }
292
+
293
+ interface DraftResizeDragState {
294
+ type: "draft-resize";
295
+ originClient: Point;
296
+ originDrafts: DraftPrimitiveById;
297
+ originBounds: FrameGeometry;
298
+ targetIds: string[];
299
+ handle: ResizeHandle;
300
+ hasMoved: boolean;
301
+ }
302
+
303
+ interface DraftCreateDragState {
304
+ type: "draft-create";
305
+ tool: DraftCreationTool;
306
+ originClient: Point;
307
+ originCanvas: Point;
308
+ points: Point[];
309
+ hasMoved: boolean;
310
+ }
311
+
312
+ interface DraftCreationPreview {
313
+ tool: DraftCreationTool;
314
+ geometry: FrameGeometry;
315
+ points?: Point[];
316
+ }
317
+
318
+ type DragState =
319
+ | MoveDragState
320
+ | ResizeDragState
321
+ | RotateDragState
322
+ | MarqueeDragState
323
+ | PanDragState
324
+ | DraftMoveDragState
325
+ | DraftResizeDragState
326
+ | DraftCreateDragState;
39
327
 
40
328
  export function MultiScreenCanvas({
41
329
  screens,
42
330
  zoom,
43
331
  activeId,
332
+ activeTool,
333
+ toolProps,
334
+ onActiveToolChange,
44
335
  onPick,
336
+ onEdit,
337
+ metadataById,
338
+ getScreenMetadata,
339
+ onDuplicate,
340
+ geometryById,
341
+ onGeometryChange,
342
+ onGeometryCommit,
343
+ onCreatePrimitive,
344
+ onDeleteSelection,
345
+ onZoomChange,
346
+ onZoomToEdit,
347
+ zoomToEditThreshold,
348
+ renderScreenContent,
349
+ selectAllRequest,
350
+ clearSelectionRequest,
45
351
  }: MultiScreenCanvasProps) {
352
+ const t = useT();
46
353
  const surfaceRef = useRef<HTMLDivElement>(null);
47
354
  const [pan, setPan] = useState({ x: 0, y: 0 });
48
- const dragging = useRef<{ x: number; y: number } | null>(null);
355
+ const panRef = useRef(pan);
356
+ const [canvasZoom, setCanvasZoom] = useState(zoom);
357
+ const zoomRef = useRef(zoom);
358
+ const [frameGeometry, setFrameGeometry] = useState<FrameGeometryById>({});
359
+ const frameGeometryRef = useRef(frameGeometry);
360
+ const onGeometryChangeRef = useRef(onGeometryChange);
361
+ const onGeometryCommitRef = useRef(onGeometryCommit);
362
+ const [draftPrimitives, setDraftPrimitives] = useState<DraftPrimitive[]>([]);
363
+ const draftPrimitivesRef = useRef(draftPrimitives);
364
+ const [selectedDraftIds, setSelectedDraftIds] = useState<string[]>([]);
365
+ const selectedDraftIdsRef = useRef(selectedDraftIds);
366
+ const [creationPreview, setCreationPreview] =
367
+ useState<DraftCreationPreview | null>(null);
368
+ const [localActiveTool, setLocalActiveTool] =
369
+ useState<MultiScreenCanvasTool>("move");
370
+ const [selectedIds, setSelectedIds] = useState<string[]>(
371
+ activeId ? [activeId] : [],
372
+ );
373
+ const selectedIdsRef = useRef(selectedIds);
374
+ const dragState = useRef<DragState | null>(null);
49
375
  const dragCleanup = useRef<(() => void) | null>(null);
376
+ const duplicateCleanup = useRef<(() => void) | null>(null);
377
+ const handledSelectAllRequestRef = useRef(selectAllRequest);
378
+ const handledClearSelectionRequestRef = useRef(clearSelectionRequest);
50
379
  const [isDragging, setIsDragging] = useState(false);
380
+ const [isPanning, setIsPanning] = useState(false);
381
+ const [marquee, setMarquee] = useState<MarqueeRect | null>(null);
382
+ const [alignmentGuides, setAlignmentGuides] = useState<AlignmentGuide[]>([]);
383
+ const [duplicatePreview, setDuplicatePreview] =
384
+ useState<DuplicatePreview | null>(null);
385
+ const [transformBadge, setTransformBadge] = useState<TransformBadge | null>(
386
+ null,
387
+ );
388
+ const [dragCursor, setDragCursor] = useState<string | null>(null);
389
+ const suppressNextPick = useRef(false);
390
+ const feedbackTimerRef = useRef<number | null>(null);
391
+
392
+ useEffect(() => {
393
+ onGeometryChangeRef.current = onGeometryChange;
394
+ }, [onGeometryChange]);
395
+
396
+ useEffect(() => {
397
+ onGeometryCommitRef.current = onGeometryCommit;
398
+ }, [onGeometryCommit]);
399
+
400
+ const updateFrameGeometry = useCallback(
401
+ (updater: (current: FrameGeometryById) => FrameGeometryById) => {
402
+ setFrameGeometry((current) => {
403
+ const next = updater(current);
404
+ frameGeometryRef.current = next;
405
+ onGeometryChangeRef.current?.(next);
406
+ return next;
407
+ });
408
+ },
409
+ [],
410
+ );
411
+
412
+ const updateSelectedIds = useCallback(
413
+ (updater: (current: string[]) => string[]) => {
414
+ setSelectedIds((current) => {
415
+ const next = dedupeIds(updater(current));
416
+ if (sameIds(current, next)) {
417
+ selectedIdsRef.current = current;
418
+ return current;
419
+ }
420
+ selectedIdsRef.current = next;
421
+ return next;
422
+ });
423
+ },
424
+ [],
425
+ );
426
+
427
+ const updateDraftPrimitives = useCallback(
428
+ (updater: (current: DraftPrimitive[]) => DraftPrimitive[]) => {
429
+ setDraftPrimitives((current) => {
430
+ const next = updater(current);
431
+ draftPrimitivesRef.current = next;
432
+ return next;
433
+ });
434
+ },
435
+ [],
436
+ );
437
+
438
+ const updateSelectedDraftIds = useCallback(
439
+ (updater: (current: string[]) => string[]) => {
440
+ setSelectedDraftIds((current) => {
441
+ const currentIds = new Set(
442
+ draftPrimitivesRef.current.map(({ id }) => id),
443
+ );
444
+ const next = dedupeIds(updater(current)).filter((id) =>
445
+ currentIds.has(id),
446
+ );
447
+ if (sameIds(current, next)) {
448
+ selectedDraftIdsRef.current = current;
449
+ return current;
450
+ }
451
+ selectedDraftIdsRef.current = next;
452
+ return next;
453
+ });
454
+ },
455
+ [],
456
+ );
457
+
458
+ useEffect(() => {
459
+ panRef.current = pan;
460
+ }, [pan]);
461
+
462
+ useEffect(() => {
463
+ zoomRef.current = canvasZoom;
464
+ }, [canvasZoom]);
465
+
466
+ useEffect(() => {
467
+ frameGeometryRef.current = frameGeometry;
468
+ }, [frameGeometry]);
469
+
470
+ useEffect(() => {
471
+ selectedIdsRef.current = selectedIds;
472
+ }, [selectedIds]);
473
+
474
+ useEffect(() => {
475
+ draftPrimitivesRef.current = draftPrimitives;
476
+ }, [draftPrimitives]);
477
+
478
+ useEffect(() => {
479
+ selectedDraftIdsRef.current = selectedDraftIds;
480
+ }, [selectedDraftIds]);
481
+
482
+ useEffect(() => {
483
+ setCanvasZoom(zoom);
484
+ zoomRef.current = zoom;
485
+ }, [zoom]);
486
+
487
+ useEffect(() => {
488
+ const currentIds = new Set(screens.map((screen) => screen.id));
489
+ updateFrameGeometry((current) => {
490
+ const next: FrameGeometryById = {};
491
+ let changed = Object.keys(current).some((id) => !currentIds.has(id));
492
+
493
+ screens.forEach((screen, index) => {
494
+ const existing = current[screen.id];
495
+ const persisted = geometryById?.[screen.id];
496
+ const resolved = {
497
+ ...getInitialFrameGeometry(index),
498
+ ...persisted,
499
+ } as FrameGeometry;
500
+ next[screen.id] = persisted ? resolved : (existing ?? resolved);
501
+ if (
502
+ !existing ||
503
+ (persisted && !sameFrameGeometry(existing, resolved))
504
+ ) {
505
+ changed = true;
506
+ }
507
+ });
508
+
509
+ return changed ? next : current;
510
+ });
511
+ updateSelectedIds((current) => {
512
+ const next = current.filter((id) => currentIds.has(id));
513
+ return next.length === current.length ? current : next;
514
+ });
515
+ }, [geometryById, screens, updateFrameGeometry, updateSelectedIds]);
516
+
517
+ useEffect(() => {
518
+ if (!activeId) return;
519
+ updateSelectedIds((current) =>
520
+ current.includes(activeId) ? current : [activeId],
521
+ );
522
+ }, [activeId, updateSelectedIds]);
523
+
524
+ useEffect(() => {
525
+ if (
526
+ selectAllRequest === undefined ||
527
+ selectAllRequest === handledSelectAllRequestRef.current
528
+ ) {
529
+ return;
530
+ }
531
+ handledSelectAllRequestRef.current = selectAllRequest;
532
+ updateSelectedDraftIds(() => []);
533
+ updateSelectedIds(() => screens.map((screen) => screen.id));
534
+ }, [screens, selectAllRequest, updateSelectedDraftIds, updateSelectedIds]);
535
+
536
+ useEffect(() => {
537
+ if (
538
+ clearSelectionRequest === undefined ||
539
+ clearSelectionRequest === handledClearSelectionRequestRef.current
540
+ ) {
541
+ return;
542
+ }
543
+ handledClearSelectionRequestRef.current = clearSelectionRequest;
544
+ updateSelectedDraftIds(() => []);
545
+ updateSelectedIds(() => []);
546
+ setMarquee(null);
547
+ setAlignmentGuides([]);
548
+ setTransformBadge(null);
549
+ }, [clearSelectionRequest, updateSelectedDraftIds, updateSelectedIds]);
51
550
 
52
551
  // Center the lineup on first mount so the user sees screens, not whitespace.
53
552
  useEffect(() => {
@@ -55,169 +554,2639 @@ export function MultiScreenCanvas({
55
554
  const rect = surfaceRef.current.getBoundingClientRect();
56
555
  const columns = Math.min(screens.length, 3);
57
556
  const rows = Math.ceil(screens.length / columns);
58
- const scale = zoom / 100;
557
+ const scale = zoomRef.current / 100;
59
558
  const totalWidth = columns * SCREEN_WIDTH + (columns - 1) * SCREEN_GAP;
60
559
  const totalHeight = rows * SCREEN_CARD_HEIGHT + (rows - 1) * SCREEN_GAP;
61
560
  const visualLeft = Math.max(24, (rect.width - totalWidth * scale) / 2);
62
561
  const visualTop = Math.max(24, (rect.height - totalHeight * scale) / 2);
63
- setPan({
562
+ const nextPan = {
64
563
  x: visualLeft - SURFACE_PADDING * scale,
65
564
  y: visualTop - SURFACE_PADDING * scale,
66
- });
565
+ };
566
+ panRef.current = nextPan;
567
+ setPan(nextPan);
67
568
  // Only on mount or when screen count changes, not on every pan update.
68
569
  // eslint-disable-next-line react-hooks/exhaustive-deps
69
- }, [screens.length, zoom]);
570
+ }, [screens.length]);
70
571
 
71
572
  useEffect(() => {
72
573
  return () => {
73
574
  dragCleanup.current?.();
575
+ duplicateCleanup.current?.();
576
+ if (feedbackTimerRef.current !== null) {
577
+ window.clearTimeout(feedbackTimerRef.current);
578
+ }
74
579
  };
75
580
  }, []);
76
581
 
77
- const handleMouseDown = useCallback(
582
+ const canvasPointFromClient = useCallback(
583
+ (clientX: number, clientY: number) => {
584
+ const rect = surfaceRef.current?.getBoundingClientRect();
585
+ if (!rect) return { x: 0, y: 0 };
586
+ return screenToCanvasPoint(
587
+ { x: clientX, y: clientY },
588
+ { ...panRef.current, zoom: zoomRef.current },
589
+ { x: rect.left, y: rect.top },
590
+ SURFACE_PADDING,
591
+ true,
592
+ );
593
+ },
594
+ [],
595
+ );
596
+
597
+ const getCanvasPoint = useCallback((clientX: number, clientY: number) => {
598
+ const rect = surfaceRef.current?.getBoundingClientRect();
599
+ if (!rect) return { x: 0, y: 0 };
600
+ return screenToCanvasPoint(
601
+ { x: clientX, y: clientY },
602
+ { ...panRef.current, zoom: zoomRef.current },
603
+ { x: rect.left, y: rect.top },
604
+ SURFACE_PADDING,
605
+ );
606
+ }, []);
607
+
608
+ const getCurrentFrameEntries = useCallback(
609
+ () => getFrameEntries(screens, frameGeometryRef.current),
610
+ [screens],
611
+ );
612
+
613
+ const getCurrentDraftEntries = useCallback(
614
+ () =>
615
+ draftPrimitivesRef.current.map((draft) => ({
616
+ id: draft.id,
617
+ geometry: draft.geometry,
618
+ })),
619
+ [],
620
+ );
621
+
622
+ const getCurrentCanvasEntries = useCallback(
623
+ () => [...getCurrentFrameEntries(), ...getCurrentDraftEntries()],
624
+ [getCurrentDraftEntries, getCurrentFrameEntries],
625
+ );
626
+
627
+ const getFrameAtClientPoint = useCallback(
628
+ (clientX: number, clientY: number) => {
629
+ const point = canvasPointFromClient(clientX, clientY);
630
+ return getCurrentFrameEntries()
631
+ .map((entry, index) => ({ ...entry, index }))
632
+ .filter((entry) =>
633
+ rectContainsPoint(getSelectableBounds(entry.geometry), point),
634
+ )
635
+ .sort(
636
+ (a, b) =>
637
+ (b.geometry.z ?? 0) - (a.geometry.z ?? 0) || b.index - a.index,
638
+ )[0]?.id;
639
+ },
640
+ [canvasPointFromClient, getCurrentFrameEntries],
641
+ );
642
+
643
+ const deleteSelectedItems = useCallback(() => {
644
+ const frameIds = selectedIdsRef.current.filter(
645
+ (id) => frameGeometryRef.current[id],
646
+ );
647
+ const draftIds = selectedDraftIdsRef.current.filter((id) =>
648
+ draftPrimitivesRef.current.some((draft) => draft.id === id),
649
+ );
650
+ if (frameIds.length === 0 && draftIds.length === 0) return false;
651
+
652
+ if (draftIds.length > 0) {
653
+ updateDraftPrimitives((current) =>
654
+ current.filter((draft) => !draftIds.includes(draft.id)),
655
+ );
656
+ updateSelectedDraftIds((current) =>
657
+ current.filter((id) => !draftIds.includes(id)),
658
+ );
659
+ }
660
+
661
+ if (frameIds.length > 0) {
662
+ const accepted = onDeleteSelection?.(frameIds);
663
+ if (accepted !== false && onDeleteSelection) {
664
+ const before = cloneFrameGeometryById(frameGeometryRef.current);
665
+ const after = cloneFrameGeometryById(before);
666
+ frameIds.forEach((id) => {
667
+ delete after[id];
668
+ });
669
+ updateFrameGeometry(() => after);
670
+ onGeometryCommitRef.current?.(before, after);
671
+ updateSelectedIds((current) =>
672
+ current.filter((id) => !frameIds.includes(id)),
673
+ );
674
+ }
675
+ }
676
+
677
+ setMarquee(null);
678
+ setAlignmentGuides([]);
679
+ setTransformBadge(null);
680
+ return true;
681
+ }, [
682
+ onDeleteSelection,
683
+ updateDraftPrimitives,
684
+ updateFrameGeometry,
685
+ updateSelectedDraftIds,
686
+ updateSelectedIds,
687
+ ]);
688
+
689
+ const installDragListeners = useCallback(
690
+ (
691
+ handleMouseMove: (ev: MouseEvent) => void,
692
+ handleMouseUp: (ev: MouseEvent) => void,
693
+ ) => {
694
+ dragCleanup.current?.();
695
+ const move = (ev: MouseEvent) => {
696
+ ev.preventDefault();
697
+ handleMouseMove(ev);
698
+ };
699
+ const up = (ev: MouseEvent) => {
700
+ ev.preventDefault();
701
+ handleMouseUp(ev);
702
+ };
703
+ const cleanupOnBlur = () => handleMouseUp(new MouseEvent("mouseup"));
704
+ dragCleanup.current = () => {
705
+ window.removeEventListener("mousemove", move);
706
+ window.removeEventListener("mouseup", up);
707
+ window.removeEventListener("blur", cleanupOnBlur);
708
+ dragCleanup.current = null;
709
+ };
710
+ window.addEventListener("mousemove", move);
711
+ window.addEventListener("mouseup", up);
712
+ window.addEventListener("blur", cleanupOnBlur);
713
+ },
714
+ [],
715
+ );
716
+
717
+ const scheduleFeedbackClear = useCallback(() => {
718
+ if (feedbackTimerRef.current !== null) {
719
+ window.clearTimeout(feedbackTimerRef.current);
720
+ }
721
+ feedbackTimerRef.current = window.setTimeout(() => {
722
+ setAlignmentGuides([]);
723
+ setTransformBadge(null);
724
+ feedbackTimerRef.current = null;
725
+ }, 650);
726
+ }, []);
727
+
728
+ const showTransformFeedback = useCallback(
729
+ (text: string, clientX: number, clientY: number) => {
730
+ setTransformBadge({
731
+ text,
732
+ x: clientX + 12,
733
+ y: clientY + 12,
734
+ });
735
+ },
736
+ [],
737
+ );
738
+
739
+ const finishDrag = useCallback(() => {
740
+ if (feedbackTimerRef.current !== null) {
741
+ window.clearTimeout(feedbackTimerRef.current);
742
+ feedbackTimerRef.current = null;
743
+ }
744
+ dragState.current = null;
745
+ setIsDragging(false);
746
+ setIsPanning(false);
747
+ setMarquee(null);
748
+ setCreationPreview(null);
749
+ setAlignmentGuides([]);
750
+ setTransformBadge(null);
751
+ setDragCursor(null);
752
+ dragCleanup.current?.();
753
+ }, []);
754
+
755
+ const beginPan = useCallback(
756
+ (e: React.MouseEvent) => {
757
+ e.preventDefault();
758
+ e.stopPropagation();
759
+ dragState.current = {
760
+ type: "pan",
761
+ originClient: { x: e.clientX, y: e.clientY },
762
+ originPan: panRef.current,
763
+ };
764
+ setIsPanning(true);
765
+
766
+ const handleMouseMove = (ev: MouseEvent) => {
767
+ const state = dragState.current;
768
+ if (!state || state.type !== "pan") return;
769
+ const nextPan = {
770
+ x: state.originPan.x + ev.clientX - state.originClient.x,
771
+ y: state.originPan.y + ev.clientY - state.originClient.y,
772
+ };
773
+ panRef.current = nextPan;
774
+ setPan(nextPan);
775
+ };
776
+
777
+ installDragListeners(handleMouseMove, finishDrag);
778
+ },
779
+ [finishDrag, installDragListeners],
780
+ );
781
+
782
+ const beginMarquee = useCallback(
78
783
  (e: React.MouseEvent) => {
79
- // Middle mouse, or left mouse anywhere that is not a screen card.
80
- const target = e.target as HTMLElement;
81
- const onScreen = !!target.closest("[data-screen-card]");
82
- if (e.button !== 1 && !(e.button === 0 && !onScreen)) return;
83
784
  e.preventDefault();
84
785
  e.stopPropagation();
85
- const dragOrigin = { x: e.clientX - pan.x, y: e.clientY - pan.y };
86
- dragging.current = dragOrigin;
786
+ const originCanvas = getCanvasPoint(e.clientX, e.clientY);
787
+ dragState.current = {
788
+ type: "marquee",
789
+ originClient: { x: e.clientX, y: e.clientY },
790
+ originCanvas,
791
+ baseSelectedIds: selectedIdsRef.current,
792
+ baseSelectedDraftIds: selectedDraftIdsRef.current,
793
+ additive: e.shiftKey,
794
+ hasMoved: false,
795
+ };
796
+ setMarquee({ ...originCanvas, width: 0, height: 0 });
797
+ if (!e.shiftKey) {
798
+ updateSelectedIds(() => []);
799
+ updateSelectedDraftIds(() => []);
800
+ }
87
801
  setIsDragging(true);
88
802
 
89
803
  const handleMouseMove = (ev: MouseEvent) => {
90
- if (!dragging.current) return;
91
- setPan({
92
- x: ev.clientX - dragOrigin.x,
93
- y: ev.clientY - dragOrigin.y,
94
- });
804
+ const state = dragState.current;
805
+ if (!state || state.type !== "marquee") return;
806
+ const nextPoint = getCanvasPoint(ev.clientX, ev.clientY);
807
+ const rect = normalizeRectFromPoints(state.originCanvas, nextPoint);
808
+ if (
809
+ !state.hasMoved &&
810
+ Math.hypot(
811
+ ev.clientX - state.originClient.x,
812
+ ev.clientY - state.originClient.y,
813
+ ) >= DRAG_THRESHOLD
814
+ ) {
815
+ state.hasMoved = true;
816
+ }
817
+ setMarquee(rect);
818
+
819
+ const hitIds = getCurrentFrameEntries()
820
+ .filter((entry) =>
821
+ rectIntersects(rect, getSelectableBounds(entry.geometry)),
822
+ )
823
+ .map((entry) => entry.id);
824
+ const hitDraftIds = getCurrentDraftEntries()
825
+ .filter((entry) =>
826
+ rectIntersects(rect, getSelectableBounds(entry.geometry)),
827
+ )
828
+ .map((entry) => entry.id);
829
+ updateSelectedIds(() =>
830
+ state.additive
831
+ ? dedupeIds([...state.baseSelectedIds, ...hitIds])
832
+ : hitIds,
833
+ );
834
+ updateSelectedDraftIds(() =>
835
+ state.additive
836
+ ? dedupeIds([...state.baseSelectedDraftIds, ...hitDraftIds])
837
+ : hitDraftIds,
838
+ );
95
839
  };
96
840
 
97
841
  const handleMouseUp = () => {
98
- dragging.current = null;
99
- setIsDragging(false);
100
- window.removeEventListener("mousemove", handleMouseMove);
101
- window.removeEventListener("mouseup", handleMouseUp);
102
- dragCleanup.current = null;
842
+ const state = dragState.current;
843
+ if (state?.type === "marquee" && !state.hasMoved && !state.additive) {
844
+ updateSelectedIds(() => []);
845
+ updateSelectedDraftIds(() => []);
846
+ }
847
+ finishDrag();
103
848
  };
104
849
 
105
- dragCleanup.current?.();
106
- dragCleanup.current = handleMouseUp;
107
- window.addEventListener("mousemove", handleMouseMove);
108
- window.addEventListener("mouseup", handleMouseUp);
850
+ installDragListeners(handleMouseMove, handleMouseUp);
109
851
  },
110
- [pan],
852
+ [
853
+ finishDrag,
854
+ getCanvasPoint,
855
+ getCurrentDraftEntries,
856
+ getCurrentFrameEntries,
857
+ installDragListeners,
858
+ updateSelectedDraftIds,
859
+ updateSelectedIds,
860
+ ],
111
861
  );
112
862
 
113
- return (
114
- <div
115
- ref={surfaceRef}
116
- className="relative h-full w-full overflow-hidden"
117
- onMouseDown={handleMouseDown}
118
- style={{ cursor: isDragging ? "grabbing" : "grab" }}
119
- >
120
- {/* Dot grid extends past the surface so panning never shows page bg. */}
121
- <div
122
- className="pointer-events-none absolute inset-0"
123
- style={{
124
- backgroundImage:
125
- "radial-gradient(circle, hsl(var(--border)) 1px, transparent 1px)",
126
- backgroundSize: "24px 24px",
127
- }}
128
- />
863
+ const beginDraftCreation = useCallback(
864
+ (tool: DraftCreationTool, e: React.MouseEvent) => {
865
+ if (e.button !== 0) return;
866
+ e.preventDefault();
867
+ e.stopPropagation();
129
868
 
130
- {/* Lineup */}
131
- <div
132
- className="absolute"
133
- style={{
134
- left: pan.x,
135
- top: pan.y,
136
- padding: SURFACE_PADDING,
137
- transform: `scale(${zoom / 100})`,
138
- transformOrigin: "top left",
139
- }}
140
- >
141
- <div
142
- className="flex flex-wrap"
143
- style={{
144
- gap: SCREEN_GAP,
145
- maxWidth: SCREEN_WIDTH * 3 + SCREEN_GAP * 2,
146
- }}
147
- >
148
- {screens.map((screen) => (
149
- <Screen
150
- key={screen.id}
151
- screen={screen}
152
- isActive={screen.id === activeId}
153
- onPick={onPick}
154
- />
155
- ))}
156
- </div>
157
- </div>
158
- </div>
869
+ const originCanvas = getCanvasPoint(e.clientX, e.clientY);
870
+ const initialGeometry = getDraftGeometryForTool(
871
+ tool,
872
+ originCanvas,
873
+ originCanvas,
874
+ );
875
+ const initialPoints = tool === "pen" ? [originCanvas] : undefined;
876
+ dragState.current = {
877
+ type: "draft-create",
878
+ tool,
879
+ originClient: { x: e.clientX, y: e.clientY },
880
+ originCanvas,
881
+ points: initialPoints ?? [],
882
+ hasMoved: false,
883
+ };
884
+ setCreationPreview({
885
+ tool,
886
+ geometry: initialGeometry,
887
+ points: initialPoints,
888
+ });
889
+ setIsDragging(true);
890
+ setDragCursor(tool === "pen" ? "crosshair" : "crosshair");
891
+
892
+ const handleMouseMove = (ev: MouseEvent) => {
893
+ const state = dragState.current;
894
+ if (!state || state.type !== "draft-create") return;
895
+ const nextCanvas = getCanvasPoint(ev.clientX, ev.clientY);
896
+ if (
897
+ !state.hasMoved &&
898
+ Math.hypot(
899
+ ev.clientX - state.originClient.x,
900
+ ev.clientY - state.originClient.y,
901
+ ) >= DRAG_THRESHOLD
902
+ ) {
903
+ state.hasMoved = true;
904
+ }
905
+
906
+ if (state.tool === "pen") {
907
+ const minDistance =
908
+ PEN_SAMPLE_DISTANCE_SCREEN_PX / (zoomRef.current / 100);
909
+ state.points = appendPolylinePoint(
910
+ state.points,
911
+ nextCanvas,
912
+ minDistance,
913
+ );
914
+ setCreationPreview({
915
+ tool,
916
+ geometry: getPathGeometry(state.points),
917
+ points: state.points,
918
+ });
919
+ return;
920
+ }
921
+
922
+ setCreationPreview({
923
+ tool,
924
+ geometry: getDraftGeometryForTool(
925
+ tool,
926
+ state.originCanvas,
927
+ nextCanvas,
928
+ ),
929
+ });
930
+ };
931
+
932
+ const handleMouseUp = (ev: MouseEvent) => {
933
+ const state = dragState.current;
934
+ if (!state || state.type !== "draft-create") {
935
+ finishDrag();
936
+ return;
937
+ }
938
+
939
+ let endCanvas = getCanvasPoint(ev.clientX, ev.clientY);
940
+ let points = state.tool === "pen" ? state.points : undefined;
941
+ if (state.tool === "pen") {
942
+ points = appendPolylinePoint(state.points, endCanvas, 0);
943
+ if (!state.hasMoved || points.length < 2) {
944
+ finishDrag();
945
+ return;
946
+ }
947
+ endCanvas = points[points.length - 1] ?? endCanvas;
948
+ }
949
+ const nextDraft = createDraftPrimitive({
950
+ tool: state.tool,
951
+ start: state.originCanvas,
952
+ end: endCanvas,
953
+ points,
954
+ moved: state.hasMoved,
955
+ toolProps,
956
+ fallbackText: t("designEditor.tools.text"),
957
+ });
958
+ const targetFrame = getCurrentFrameEntries()
959
+ .filter(({ geometry }) =>
960
+ rectContainsPoint(
961
+ {
962
+ left: geometry.x,
963
+ top: geometry.y,
964
+ right: geometry.x + geometry.width,
965
+ bottom: geometry.y + geometry.height,
966
+ },
967
+ getFrameCenter(nextDraft.geometry),
968
+ ),
969
+ )
970
+ .sort((a, b) => (b.geometry.z ?? 0) - (a.geometry.z ?? 0))[0];
971
+
972
+ if (targetFrame && onCreatePrimitive) {
973
+ const localPrimitive = draftPrimitiveToInsert(
974
+ nextDraft,
975
+ targetFrame.geometry,
976
+ );
977
+ const persisted = onCreatePrimitive(targetFrame.id, localPrimitive);
978
+ if (persisted) {
979
+ updateDraftPrimitives((current) =>
980
+ current.filter((draft) => draft.id !== nextDraft.id),
981
+ );
982
+ updateSelectedDraftIds(() => []);
983
+ updateSelectedIds(() => [targetFrame.id]);
984
+ } else {
985
+ updateDraftPrimitives((current) => [...current, nextDraft]);
986
+ updateSelectedIds(() => []);
987
+ updateSelectedDraftIds(() => [nextDraft.id]);
988
+ }
989
+ } else {
990
+ updateDraftPrimitives((current) => [...current, nextDraft]);
991
+ updateSelectedIds(() => []);
992
+ updateSelectedDraftIds(() => [nextDraft.id]);
993
+ }
994
+ if (activeTool === undefined) {
995
+ setLocalActiveTool("move");
996
+ }
997
+ if (state.tool === "pen") {
998
+ if (activeTool === undefined) {
999
+ setLocalActiveTool("pen");
1000
+ }
1001
+ onActiveToolChange?.("pen");
1002
+ } else {
1003
+ onActiveToolChange?.("move");
1004
+ }
1005
+ finishDrag();
1006
+ };
1007
+
1008
+ installDragListeners(handleMouseMove, handleMouseUp);
1009
+ },
1010
+ [
1011
+ activeTool,
1012
+ finishDrag,
1013
+ getCanvasPoint,
1014
+ getCurrentFrameEntries,
1015
+ installDragListeners,
1016
+ onActiveToolChange,
1017
+ onCreatePrimitive,
1018
+ t,
1019
+ toolProps,
1020
+ updateDraftPrimitives,
1021
+ updateSelectedDraftIds,
1022
+ updateSelectedIds,
1023
+ ],
159
1024
  );
160
- }
161
1025
 
162
- function Screen({
163
- screen,
164
- isActive,
165
- onPick,
166
- }: {
167
- screen: ScreenFile;
168
- isActive: boolean;
169
- onPick: (id: string) => void;
170
- }) {
171
- const display = prettyScreenName(screen.filename);
172
- return (
173
- <div className="flex flex-col gap-2">
174
- <span
175
- className={cn(
176
- "px-1 text-[11px] font-medium",
177
- isActive ? "text-foreground" : "text-muted-foreground",
178
- )}
179
- title={screen.filename}
180
- >
181
- {display}
182
- </span>
183
- <Tooltip>
184
- <TooltipTrigger asChild>
185
- <button
186
- data-screen-card
187
- type="button"
188
- onClick={(e) => {
189
- e.stopPropagation();
190
- onPick(screen.id);
191
- }}
192
- onMouseDown={(e) => e.stopPropagation()}
193
- className={cn(
194
- "block overflow-hidden rounded-lg border-2 bg-white shadow-2xl transition-colors",
195
- isActive
196
- ? "border-primary"
197
- : "border-border hover:border-muted-foreground/50",
198
- )}
199
- style={{
200
- width: SCREEN_WIDTH,
201
- height: SCREEN_HEIGHT,
202
- cursor: "pointer",
203
- }}
204
- >
205
- <iframe
206
- srcDoc={screen.content}
207
- sandbox="allow-scripts"
208
- className="pointer-events-none border-0"
209
- style={{
210
- width: 1280,
211
- height: 2560,
212
- transform: `scale(${SCREEN_WIDTH / 1280})`,
213
- transformOrigin: "top left",
214
- }}
215
- title={screen.filename}
216
- />
217
- </button>
218
- </TooltipTrigger>
219
- <TooltipContent>{`Open ${display}`}</TooltipContent>
220
- </Tooltip>
221
- </div>
1026
+ const beginDraftDrag = useCallback(
1027
+ (id: string, e: React.MouseEvent) => {
1028
+ if (e.button !== 0 || e.shiftKey) return;
1029
+ e.preventDefault();
1030
+ e.stopPropagation();
1031
+
1032
+ const currentSelectedDraftIds = selectedDraftIdsRef.current;
1033
+ const targetIds = currentSelectedDraftIds.includes(id)
1034
+ ? currentSelectedDraftIds
1035
+ : [id];
1036
+ const originDrafts = Object.fromEntries(
1037
+ draftPrimitivesRef.current
1038
+ .filter((draft) => targetIds.includes(draft.id))
1039
+ .map((draft) => [draft.id, cloneDraftPrimitive(draft)]),
1040
+ ) as DraftPrimitiveById;
1041
+ if (!originDrafts[id]) return;
1042
+ updateSelectedIds(() => []);
1043
+ updateSelectedDraftIds((current) =>
1044
+ current.includes(id) ? current : [id],
1045
+ );
1046
+
1047
+ dragState.current = {
1048
+ type: "draft-move",
1049
+ originClient: { x: e.clientX, y: e.clientY },
1050
+ originDrafts,
1051
+ targetIds,
1052
+ primaryId: id,
1053
+ hasMoved: false,
1054
+ };
1055
+ setIsDragging(true);
1056
+ setDragCursor("grabbing");
1057
+
1058
+ const handleMouseMove = (ev: MouseEvent) => {
1059
+ const state = dragState.current;
1060
+ if (!state || state.type !== "draft-move") return;
1061
+ const scale = zoomRef.current / 100;
1062
+ const dx = (ev.clientX - state.originClient.x) / scale;
1063
+ const dy = (ev.clientY - state.originClient.y) / scale;
1064
+ if (
1065
+ !state.hasMoved &&
1066
+ Math.hypot(
1067
+ ev.clientX - state.originClient.x,
1068
+ ev.clientY - state.originClient.y,
1069
+ ) >= DRAG_THRESHOLD
1070
+ ) {
1071
+ state.hasMoved = true;
1072
+ }
1073
+
1074
+ const movingEntries = state.targetIds.map((targetId) => {
1075
+ const origin = state.originDrafts[targetId].geometry;
1076
+ return {
1077
+ id: targetId,
1078
+ geometry: {
1079
+ ...origin,
1080
+ x: origin.x + dx,
1081
+ y: origin.y + dy,
1082
+ },
1083
+ };
1084
+ });
1085
+ const stationaryEntries = getCurrentCanvasEntries().filter(
1086
+ (entry) => !state.targetIds.includes(entry.id),
1087
+ );
1088
+ const snap = computeMoveSnap(movingEntries, stationaryEntries, {
1089
+ thresholdScreenPx: DEFAULT_SNAP_THRESHOLD_SCREEN_PX,
1090
+ zoom: zoomRef.current,
1091
+ bypass: ev.metaKey || ev.ctrlKey,
1092
+ });
1093
+
1094
+ updateDraftPrimitives((current) =>
1095
+ current.map((draft) => {
1096
+ const origin = state.originDrafts[draft.id];
1097
+ if (!origin) return draft;
1098
+ return moveDraftPrimitive(origin, dx + snap.dx, dy + snap.dy);
1099
+ }),
1100
+ );
1101
+ setAlignmentGuides(snap.guides);
1102
+ const primary = state.originDrafts[state.primaryId].geometry;
1103
+ showTransformFeedback(
1104
+ `X ${Math.round(primary.x + dx + snap.dx)} Y ${Math.round(
1105
+ primary.y + dy + snap.dy,
1106
+ )}`,
1107
+ ev.clientX,
1108
+ ev.clientY,
1109
+ );
1110
+ };
1111
+
1112
+ const handleMouseUp = () => {
1113
+ finishDrag();
1114
+ };
1115
+
1116
+ installDragListeners(handleMouseMove, handleMouseUp);
1117
+ },
1118
+ [
1119
+ finishDrag,
1120
+ getCurrentCanvasEntries,
1121
+ installDragListeners,
1122
+ showTransformFeedback,
1123
+ updateDraftPrimitives,
1124
+ updateSelectedDraftIds,
1125
+ updateSelectedIds,
1126
+ ],
222
1127
  );
1128
+
1129
+ const beginDraftResize = useCallback(
1130
+ (id: string, handle: ResizeHandle, e: React.MouseEvent) => {
1131
+ if (e.button !== 0) return;
1132
+ e.preventDefault();
1133
+ e.stopPropagation();
1134
+
1135
+ const currentSelectedDraftIds = selectedDraftIdsRef.current;
1136
+ const targetIds = currentSelectedDraftIds.includes(id)
1137
+ ? currentSelectedDraftIds
1138
+ : [id];
1139
+ const originDrafts = Object.fromEntries(
1140
+ draftPrimitivesRef.current
1141
+ .filter((draft) => targetIds.includes(draft.id))
1142
+ .map((draft) => [draft.id, cloneDraftPrimitive(draft)]),
1143
+ ) as DraftPrimitiveById;
1144
+ const originEntries = Object.values(originDrafts).map((draft) => ({
1145
+ id: draft.id,
1146
+ geometry: draft.geometry,
1147
+ }));
1148
+ const originBounds = getFrameGroupBounds(originEntries);
1149
+ if (!originBounds || !originDrafts[id]) return;
1150
+ updateSelectedIds(() => []);
1151
+ updateSelectedDraftIds((current) =>
1152
+ current.includes(id) ? current : [id],
1153
+ );
1154
+
1155
+ dragState.current = {
1156
+ type: "draft-resize",
1157
+ originClient: { x: e.clientX, y: e.clientY },
1158
+ originDrafts,
1159
+ originBounds: frameBoundsToGeometry(originBounds),
1160
+ targetIds,
1161
+ handle,
1162
+ hasMoved: false,
1163
+ };
1164
+ setIsDragging(true);
1165
+ setDragCursor(getResizeCursor(handle));
1166
+
1167
+ const handleMouseMove = (ev: MouseEvent) => {
1168
+ const state = dragState.current;
1169
+ if (!state || state.type !== "draft-resize") return;
1170
+ const scale = zoomRef.current / 100;
1171
+ const dx = (ev.clientX - state.originClient.x) / scale;
1172
+ const dy = (ev.clientY - state.originClient.y) / scale;
1173
+ if (
1174
+ !state.hasMoved &&
1175
+ Math.hypot(
1176
+ ev.clientX - state.originClient.x,
1177
+ ev.clientY - state.originClient.y,
1178
+ ) >= DRAG_THRESHOLD
1179
+ ) {
1180
+ state.hasMoved = true;
1181
+ }
1182
+
1183
+ const originEntries = state.targetIds.map((targetId) => ({
1184
+ id: targetId,
1185
+ geometry: state.originDrafts[targetId].geometry,
1186
+ }));
1187
+ const resized = resizeFrameGroupFromDelta(
1188
+ originEntries,
1189
+ state.originBounds,
1190
+ state.handle,
1191
+ dx,
1192
+ dy,
1193
+ {
1194
+ preserveAspectRatio: ev.shiftKey,
1195
+ resizeFromCenter: ev.altKey,
1196
+ minWidth: 8,
1197
+ minHeight: 8,
1198
+ },
1199
+ );
1200
+ const snap = computeResizeSnap(
1201
+ resized.bounds,
1202
+ getCurrentCanvasEntries().filter(
1203
+ (entry) => !state.targetIds.includes(entry.id),
1204
+ ),
1205
+ state.handle,
1206
+ {
1207
+ thresholdScreenPx: DEFAULT_SNAP_THRESHOLD_SCREEN_PX,
1208
+ zoom: zoomRef.current,
1209
+ bypass: ev.metaKey || ev.ctrlKey,
1210
+ },
1211
+ );
1212
+ const resizedEntries = resizeFrameGroupToBounds(
1213
+ originEntries,
1214
+ state.originBounds,
1215
+ snap.frame,
1216
+ );
1217
+ const resizedById = Object.fromEntries(
1218
+ resizedEntries.map((entry) => [entry.id, entry.geometry]),
1219
+ ) as FrameGeometryById;
1220
+
1221
+ updateDraftPrimitives((current) =>
1222
+ current.map((draft) => {
1223
+ const origin = state.originDrafts[draft.id];
1224
+ const geometry = resizedById[draft.id];
1225
+ if (!origin || !geometry) return draft;
1226
+ return applyDraftGeometry(origin, geometry);
1227
+ }),
1228
+ );
1229
+ setAlignmentGuides(snap.guides);
1230
+ showTransformFeedback(
1231
+ `${Math.round(snap.frame.width)} x ${Math.round(snap.frame.height)}`,
1232
+ ev.clientX,
1233
+ ev.clientY,
1234
+ );
1235
+ };
1236
+
1237
+ installDragListeners(handleMouseMove, finishDrag);
1238
+ },
1239
+ [
1240
+ finishDrag,
1241
+ getCurrentCanvasEntries,
1242
+ installDragListeners,
1243
+ showTransformFeedback,
1244
+ updateDraftPrimitives,
1245
+ updateSelectedDraftIds,
1246
+ updateSelectedIds,
1247
+ ],
1248
+ );
1249
+
1250
+ const beginDraftGroupResize = useCallback(
1251
+ (handle: ResizeHandle, e: React.MouseEvent) => {
1252
+ const firstSelectedId = selectedDraftIdsRef.current[0];
1253
+ if (!firstSelectedId) return;
1254
+ beginDraftResize(firstSelectedId, handle, e);
1255
+ },
1256
+ [beginDraftResize],
1257
+ );
1258
+
1259
+ const handleDraftClick = useCallback(
1260
+ (id: string, e: React.MouseEvent) => {
1261
+ e.stopPropagation();
1262
+ updateSelectedIds(() => []);
1263
+ updateSelectedDraftIds((current) => {
1264
+ if (e.shiftKey) {
1265
+ return current.includes(id)
1266
+ ? current.filter((selectedId) => selectedId !== id)
1267
+ : [...current, id];
1268
+ }
1269
+ return [id];
1270
+ });
1271
+ },
1272
+ [updateSelectedDraftIds, updateSelectedIds],
1273
+ );
1274
+
1275
+ const beginFrameDrag = useCallback(
1276
+ (id: string, e: React.MouseEvent) => {
1277
+ if (e.button !== 0 || e.shiftKey) return;
1278
+ e.preventDefault();
1279
+ e.stopPropagation();
1280
+
1281
+ const currentSelectedIds = selectedIdsRef.current;
1282
+ const targetIds = currentSelectedIds.includes(id)
1283
+ ? currentSelectedIds
1284
+ : [id];
1285
+ if (!currentSelectedIds.includes(id)) {
1286
+ updateSelectedIds(() => [id]);
1287
+ }
1288
+ updateSelectedDraftIds(() => []);
1289
+
1290
+ const entries = getCurrentFrameEntries();
1291
+ const originFrames = Object.fromEntries(
1292
+ entries
1293
+ .filter((entry) => targetIds.includes(entry.id))
1294
+ .map((entry) => [entry.id, entry.geometry]),
1295
+ ) as FrameGeometryById;
1296
+ if (!originFrames[id]) return;
1297
+
1298
+ dragState.current = {
1299
+ type: "move",
1300
+ originClient: { x: e.clientX, y: e.clientY },
1301
+ originFrames,
1302
+ targetIds,
1303
+ primaryId: id,
1304
+ hasMoved: false,
1305
+ };
1306
+ setIsDragging(true);
1307
+ setDragCursor("grabbing");
1308
+
1309
+ const handleMouseMove = (ev: MouseEvent) => {
1310
+ const state = dragState.current;
1311
+ if (!state || state.type !== "move") return;
1312
+ const scale = zoomRef.current / 100;
1313
+ const dx = (ev.clientX - state.originClient.x) / scale;
1314
+ const dy = (ev.clientY - state.originClient.y) / scale;
1315
+ if (
1316
+ !state.hasMoved &&
1317
+ Math.hypot(
1318
+ ev.clientX - state.originClient.x,
1319
+ ev.clientY - state.originClient.y,
1320
+ ) >= DRAG_THRESHOLD
1321
+ ) {
1322
+ state.hasMoved = true;
1323
+ }
1324
+
1325
+ const movingEntries = state.targetIds.map((targetId) => ({
1326
+ id: targetId,
1327
+ geometry: {
1328
+ ...state.originFrames[targetId],
1329
+ x: state.originFrames[targetId].x + dx,
1330
+ y: state.originFrames[targetId].y + dy,
1331
+ },
1332
+ }));
1333
+ const stationaryEntries = getCurrentFrameEntries().filter(
1334
+ (entry) => !state.targetIds.includes(entry.id),
1335
+ );
1336
+ const snap = computeMoveSnap(movingEntries, stationaryEntries, {
1337
+ thresholdScreenPx: DEFAULT_SNAP_THRESHOLD_SCREEN_PX,
1338
+ zoom: zoomRef.current,
1339
+ bypass: ev.metaKey || ev.ctrlKey,
1340
+ });
1341
+
1342
+ updateFrameGeometry((current) => {
1343
+ const next = { ...current };
1344
+ state.targetIds.forEach((targetId) => {
1345
+ const origin = state.originFrames[targetId];
1346
+ next[targetId] = {
1347
+ ...origin,
1348
+ x: origin.x + dx + snap.dx,
1349
+ y: origin.y + dy + snap.dy,
1350
+ };
1351
+ });
1352
+ return next;
1353
+ });
1354
+ setAlignmentGuides(snap.guides);
1355
+ const primary = state.originFrames[state.primaryId];
1356
+ showTransformFeedback(
1357
+ `X ${Math.round(primary.x + dx + snap.dx)} Y ${Math.round(
1358
+ primary.y + dy + snap.dy,
1359
+ )}`,
1360
+ ev.clientX,
1361
+ ev.clientY,
1362
+ );
1363
+ };
1364
+
1365
+ const handleMouseUp = () => {
1366
+ const state = dragState.current;
1367
+ if (state?.type === "move" && state.hasMoved) {
1368
+ const after = cloneFrameGeometryById(frameGeometryRef.current);
1369
+ onGeometryCommitRef.current?.(
1370
+ frameGeometryWithOverrides(after, state.originFrames),
1371
+ after,
1372
+ );
1373
+ suppressNextPick.current = true;
1374
+ }
1375
+ finishDrag();
1376
+ };
1377
+
1378
+ installDragListeners(handleMouseMove, handleMouseUp);
1379
+ },
1380
+ [
1381
+ finishDrag,
1382
+ getCurrentFrameEntries,
1383
+ installDragListeners,
1384
+ showTransformFeedback,
1385
+ updateFrameGeometry,
1386
+ updateSelectedDraftIds,
1387
+ updateSelectedIds,
1388
+ ],
1389
+ );
1390
+
1391
+ const beginResize = useCallback(
1392
+ (id: string, handle: ResizeHandle, e: React.MouseEvent) => {
1393
+ if (e.button !== 0) return;
1394
+ e.preventDefault();
1395
+ e.stopPropagation();
1396
+ suppressNextPick.current = true;
1397
+
1398
+ const currentSelectedIds = selectedIdsRef.current;
1399
+ const targetIds = currentSelectedIds.includes(id)
1400
+ ? currentSelectedIds
1401
+ : [id];
1402
+ const originEntries = getCurrentFrameEntries().filter((entry) =>
1403
+ targetIds.includes(entry.id),
1404
+ );
1405
+ const originBounds = getFrameGroupBounds(originEntries);
1406
+ if (!originBounds || originEntries.length === 0) return;
1407
+ updateSelectedIds((current) => (current.includes(id) ? current : [id]));
1408
+
1409
+ dragState.current = {
1410
+ type: "resize",
1411
+ originClient: { x: e.clientX, y: e.clientY },
1412
+ originFrames: Object.fromEntries(
1413
+ originEntries.map((entry) => [entry.id, entry.geometry]),
1414
+ ) as FrameGeometryById,
1415
+ originBounds: frameBoundsToGeometry(originBounds),
1416
+ targetIds: originEntries.map((entry) => entry.id),
1417
+ handle,
1418
+ hasMoved: false,
1419
+ };
1420
+ setIsDragging(true);
1421
+ setDragCursor(getResizeCursor(handle));
1422
+
1423
+ const handleMouseMove = (ev: MouseEvent) => {
1424
+ const state = dragState.current;
1425
+ if (!state || state.type !== "resize") return;
1426
+ const scale = zoomRef.current / 100;
1427
+ const dx = (ev.clientX - state.originClient.x) / scale;
1428
+ const dy = (ev.clientY - state.originClient.y) / scale;
1429
+ if (
1430
+ !state.hasMoved &&
1431
+ Math.hypot(
1432
+ ev.clientX - state.originClient.x,
1433
+ ev.clientY - state.originClient.y,
1434
+ ) >= DRAG_THRESHOLD
1435
+ ) {
1436
+ state.hasMoved = true;
1437
+ }
1438
+
1439
+ const originEntries = state.targetIds.map((targetId) => ({
1440
+ id: targetId,
1441
+ geometry: state.originFrames[targetId],
1442
+ }));
1443
+ const resized = resizeFrameGroupFromDelta(
1444
+ originEntries,
1445
+ state.originBounds,
1446
+ state.handle,
1447
+ dx,
1448
+ dy,
1449
+ {
1450
+ preserveAspectRatio: ev.shiftKey,
1451
+ resizeFromCenter: ev.altKey,
1452
+ },
1453
+ );
1454
+ const snap = computeResizeSnap(
1455
+ resized.bounds,
1456
+ getCurrentFrameEntries().filter(
1457
+ (entry) => !state.targetIds.includes(entry.id),
1458
+ ),
1459
+ state.handle,
1460
+ {
1461
+ thresholdScreenPx: DEFAULT_SNAP_THRESHOLD_SCREEN_PX,
1462
+ zoom: zoomRef.current,
1463
+ bypass: ev.metaKey || ev.ctrlKey,
1464
+ },
1465
+ );
1466
+ const resizedEntries = resizeFrameGroupToBounds(
1467
+ originEntries,
1468
+ state.originBounds,
1469
+ snap.frame,
1470
+ );
1471
+ updateFrameGeometry((current) => {
1472
+ const next = { ...current };
1473
+ resizedEntries.forEach((entry) => {
1474
+ next[entry.id] = {
1475
+ ...state.originFrames[entry.id],
1476
+ ...entry.geometry,
1477
+ };
1478
+ });
1479
+ return next;
1480
+ });
1481
+ setAlignmentGuides(snap.guides);
1482
+ showTransformFeedback(
1483
+ `${Math.round(snap.frame.width)} x ${Math.round(snap.frame.height)}`,
1484
+ ev.clientX,
1485
+ ev.clientY,
1486
+ );
1487
+ };
1488
+
1489
+ const handleMouseUp = () => {
1490
+ const state = dragState.current;
1491
+ if (state?.type === "resize" && state.hasMoved) {
1492
+ const after = cloneFrameGeometryById(frameGeometryRef.current);
1493
+ onGeometryCommitRef.current?.(
1494
+ frameGeometryWithOverrides(after, state.originFrames),
1495
+ after,
1496
+ );
1497
+ }
1498
+ finishDrag();
1499
+ };
1500
+
1501
+ installDragListeners(handleMouseMove, handleMouseUp);
1502
+ },
1503
+ [
1504
+ finishDrag,
1505
+ getCurrentFrameEntries,
1506
+ installDragListeners,
1507
+ showTransformFeedback,
1508
+ updateFrameGeometry,
1509
+ updateSelectedIds,
1510
+ ],
1511
+ );
1512
+
1513
+ const beginGroupResize = useCallback(
1514
+ (handle: ResizeHandle, e: React.MouseEvent) => {
1515
+ const firstSelectedId = selectedIdsRef.current[0];
1516
+ if (!firstSelectedId) return;
1517
+ beginResize(firstSelectedId, handle, e);
1518
+ },
1519
+ [beginResize],
1520
+ );
1521
+
1522
+ const beginRotate = useCallback(
1523
+ (id: string, e: React.MouseEvent) => {
1524
+ if (e.button !== 0) return;
1525
+ e.preventDefault();
1526
+ e.stopPropagation();
1527
+ suppressNextPick.current = true;
1528
+
1529
+ const originFrame = getCurrentFrameEntries().find(
1530
+ (entry) => entry.id === id,
1531
+ )?.geometry;
1532
+ if (!originFrame) return;
1533
+ updateSelectedIds((current) => (current.includes(id) ? current : [id]));
1534
+
1535
+ const pointer = getCanvasPoint(e.clientX, e.clientY);
1536
+ const center = getFrameCenter(originFrame);
1537
+ dragState.current = {
1538
+ type: "rotate",
1539
+ originClient: { x: e.clientX, y: e.clientY },
1540
+ originFrame,
1541
+ frameId: id,
1542
+ originPointerAngle: angleBetween(center, pointer),
1543
+ originRotation: originFrame.rotation ?? 0,
1544
+ hasMoved: false,
1545
+ };
1546
+ setIsDragging(true);
1547
+ setDragCursor("grabbing");
1548
+
1549
+ const handleMouseMove = (ev: MouseEvent) => {
1550
+ const state = dragState.current;
1551
+ if (!state || state.type !== "rotate") return;
1552
+ if (
1553
+ !state.hasMoved &&
1554
+ Math.hypot(
1555
+ ev.clientX - state.originClient.x,
1556
+ ev.clientY - state.originClient.y,
1557
+ ) >= DRAG_THRESHOLD
1558
+ ) {
1559
+ state.hasMoved = true;
1560
+ }
1561
+ const pointer = getCanvasPoint(ev.clientX, ev.clientY);
1562
+ const center = getFrameCenter(state.originFrame);
1563
+ const raw =
1564
+ state.originRotation +
1565
+ angleBetween(center, pointer) -
1566
+ state.originPointerAngle;
1567
+ const rotation = ev.shiftKey ? Math.round(raw / 15) * 15 : raw;
1568
+ updateFrameGeometry((current) => ({
1569
+ ...current,
1570
+ [state.frameId]: {
1571
+ ...state.originFrame,
1572
+ rotation: Math.round(rotation * 10) / 10,
1573
+ },
1574
+ }));
1575
+ showTransformFeedback(
1576
+ `${Math.round(rotation)}deg`,
1577
+ ev.clientX,
1578
+ ev.clientY,
1579
+ );
1580
+ };
1581
+
1582
+ const handleMouseUp = () => {
1583
+ const state = dragState.current;
1584
+ if (state?.type === "rotate" && state.hasMoved) {
1585
+ const after = cloneFrameGeometryById(frameGeometryRef.current);
1586
+ onGeometryCommitRef.current?.(
1587
+ frameGeometryWithOverrides(after, {
1588
+ [state.frameId]: state.originFrame,
1589
+ }),
1590
+ after,
1591
+ );
1592
+ }
1593
+ suppressNextPick.current = true;
1594
+ finishDrag();
1595
+ };
1596
+
1597
+ installDragListeners(handleMouseMove, handleMouseUp);
1598
+ },
1599
+ [
1600
+ finishDrag,
1601
+ getCanvasPoint,
1602
+ getCurrentFrameEntries,
1603
+ installDragListeners,
1604
+ showTransformFeedback,
1605
+ updateFrameGeometry,
1606
+ updateSelectedIds,
1607
+ ],
1608
+ );
1609
+
1610
+ const handleFrameClick = useCallback(
1611
+ (id: string, e: React.MouseEvent<HTMLElement>) => {
1612
+ e.stopPropagation();
1613
+ if (suppressNextPick.current) {
1614
+ suppressNextPick.current = false;
1615
+ return;
1616
+ }
1617
+
1618
+ if (e.shiftKey) {
1619
+ updateSelectedDraftIds(() => []);
1620
+ updateSelectedIds((current) =>
1621
+ current.includes(id)
1622
+ ? current.filter((selectedId) => selectedId !== id)
1623
+ : [...current, id],
1624
+ );
1625
+ return;
1626
+ }
1627
+
1628
+ updateSelectedDraftIds(() => []);
1629
+ updateSelectedIds(() => [id]);
1630
+ onPick(id);
1631
+ },
1632
+ [onPick, updateSelectedDraftIds, updateSelectedIds],
1633
+ );
1634
+
1635
+ const handleFrameDoubleClick = useCallback(
1636
+ (id: string, e: React.MouseEvent<HTMLElement>) => {
1637
+ e.preventDefault();
1638
+ e.stopPropagation();
1639
+ updateSelectedDraftIds(() => []);
1640
+ updateSelectedIds(() => [id]);
1641
+ onPick(id);
1642
+ onEdit?.(id);
1643
+ },
1644
+ [onEdit, onPick, updateSelectedDraftIds, updateSelectedIds],
1645
+ );
1646
+
1647
+ const beginDuplicateGesture = useCallback(
1648
+ (screen: ScreenFile, display: string, e: React.MouseEvent<HTMLElement>) => {
1649
+ if (e.button !== 0 || !e.altKey) return;
1650
+ e.preventDefault();
1651
+ e.stopPropagation();
1652
+ duplicateCleanup.current?.();
1653
+
1654
+ const surfaceRect = surfaceRef.current?.getBoundingClientRect();
1655
+ const origin = { x: e.clientX, y: e.clientY };
1656
+ const originCanvas = canvasPointFromClient(e.clientX, e.clientY);
1657
+ const sourceFrame = getCurrentFrameEntries().find(
1658
+ (entry) => entry.id === screen.id,
1659
+ );
1660
+ const pointerOffset = sourceFrame
1661
+ ? {
1662
+ x: originCanvas.x - sourceFrame.geometry.x,
1663
+ y: originCanvas.y - sourceFrame.geometry.y,
1664
+ }
1665
+ : { x: 0, y: 0 };
1666
+ const previewPoint = {
1667
+ x: surfaceRect ? e.clientX - surfaceRect.left + 16 : e.clientX,
1668
+ y: surfaceRect ? e.clientY - surfaceRect.top + 16 : e.clientY,
1669
+ };
1670
+
1671
+ setDuplicatePreview({
1672
+ display,
1673
+ x: previewPoint.x,
1674
+ y: previewPoint.y,
1675
+ canDuplicate: !!onDuplicate,
1676
+ moved: false,
1677
+ });
1678
+
1679
+ const handleMouseMove = (ev: MouseEvent) => {
1680
+ const dx = ev.clientX - origin.x;
1681
+ const dy = ev.clientY - origin.y;
1682
+ const moved = Math.hypot(dx, dy) >= DUPLICATE_DRAG_THRESHOLD;
1683
+ const rect = surfaceRef.current?.getBoundingClientRect();
1684
+ setDuplicatePreview({
1685
+ display,
1686
+ x: rect ? ev.clientX - rect.left + 16 : ev.clientX,
1687
+ y: rect ? ev.clientY - rect.top + 16 : ev.clientY,
1688
+ canDuplicate: !!onDuplicate,
1689
+ moved,
1690
+ });
1691
+ };
1692
+
1693
+ const cleanupDuplicateGesture = () => {
1694
+ setDuplicatePreview(null);
1695
+ window.removeEventListener("mousemove", handleMouseMove);
1696
+ window.removeEventListener("mouseup", handleMouseUp);
1697
+ duplicateCleanup.current = null;
1698
+ };
1699
+
1700
+ const handleMouseUp = (ev: MouseEvent) => {
1701
+ const moved =
1702
+ Math.hypot(ev.clientX - origin.x, ev.clientY - origin.y) >=
1703
+ DUPLICATE_DRAG_THRESHOLD;
1704
+ const mode = moved ? "alt-drag" : "alt-click";
1705
+
1706
+ if (onDuplicate) {
1707
+ const dropCanvasPosition = canvasPointFromClient(
1708
+ ev.clientX,
1709
+ ev.clientY,
1710
+ );
1711
+ const canvasPosition =
1712
+ moved || !sourceFrame
1713
+ ? {
1714
+ x: dropCanvasPosition.x - pointerOffset.x,
1715
+ y: dropCanvasPosition.y - pointerOffset.y,
1716
+ }
1717
+ : {
1718
+ x: sourceFrame.geometry.x + SCREEN_GAP / 2,
1719
+ y: sourceFrame.geometry.y + SCREEN_GAP / 2,
1720
+ };
1721
+ onDuplicate(screen.id, {
1722
+ mode,
1723
+ screen,
1724
+ canvasPosition,
1725
+ canvasOffset: pointerOffset,
1726
+ dropCanvasPosition,
1727
+ });
1728
+ } else if (!moved) {
1729
+ onPick(screen.id);
1730
+ }
1731
+
1732
+ cleanupDuplicateGesture();
1733
+ };
1734
+
1735
+ duplicateCleanup.current = cleanupDuplicateGesture;
1736
+ window.addEventListener("mousemove", handleMouseMove);
1737
+ window.addEventListener("mouseup", handleMouseUp);
1738
+ },
1739
+ [canvasPointFromClient, getCurrentFrameEntries, onDuplicate, onPick],
1740
+ );
1741
+
1742
+ const handleMouseDown = useCallback(
1743
+ (e: React.MouseEvent) => {
1744
+ const target = e.target as HTMLElement;
1745
+ const onFrame = !!target.closest("[data-frame-shell]");
1746
+ const tool = normalizeCanvasTool(activeTool ?? localActiveTool);
1747
+ if (e.button === 1) {
1748
+ beginPan(e);
1749
+ return;
1750
+ }
1751
+ if (e.button === 0 && tool === "hand") {
1752
+ beginPan(e);
1753
+ return;
1754
+ }
1755
+ if (e.button === 0 && !onFrame) {
1756
+ const creationTool = getDraftCreationTool(tool);
1757
+ if (creationTool) {
1758
+ beginDraftCreation(creationTool, e);
1759
+ return;
1760
+ }
1761
+ beginMarquee(e);
1762
+ }
1763
+ },
1764
+ [activeTool, beginDraftCreation, beginMarquee, beginPan, localActiveTool],
1765
+ );
1766
+
1767
+ const handleWheelEvent = useCallback(
1768
+ (event: WheelEvent) => {
1769
+ const rect = surfaceRef.current?.getBoundingClientRect();
1770
+ if (!rect) return;
1771
+ event.preventDefault();
1772
+ event.stopPropagation();
1773
+ const delta = getWheelDelta(event);
1774
+
1775
+ if (event.ctrlKey || event.metaKey) {
1776
+ const currentZoom = zoomRef.current;
1777
+ const zoomDeltaY = clamp(
1778
+ delta.y,
1779
+ -MAX_WHEEL_ZOOM_DELTA,
1780
+ MAX_WHEEL_ZOOM_DELTA,
1781
+ );
1782
+ const nextZoom = clamp(
1783
+ currentZoom * Math.exp(-zoomDeltaY * ZOOM_SENSITIVITY),
1784
+ MIN_ZOOM,
1785
+ MAX_ZOOM,
1786
+ );
1787
+ if (nextZoom === currentZoom) return;
1788
+
1789
+ const nextPan = getPanForZoomToCursor({
1790
+ pan: panRef.current,
1791
+ cursor: { x: event.clientX - rect.left, y: event.clientY - rect.top },
1792
+ oldZoom: currentZoom,
1793
+ nextZoom,
1794
+ });
1795
+ const zoomEditFrameId =
1796
+ zoomToEditThreshold !== undefined &&
1797
+ currentZoom < zoomToEditThreshold &&
1798
+ nextZoom >= zoomToEditThreshold
1799
+ ? getFrameAtClientPoint(event.clientX, event.clientY)
1800
+ : undefined;
1801
+
1802
+ zoomRef.current = nextZoom;
1803
+ panRef.current = nextPan;
1804
+ setCanvasZoom(nextZoom);
1805
+ setPan(nextPan);
1806
+ onZoomChange?.(nextZoom);
1807
+ if (zoomEditFrameId) {
1808
+ onZoomToEdit?.(zoomEditFrameId);
1809
+ }
1810
+ return;
1811
+ }
1812
+
1813
+ const deltaX = clamp(
1814
+ event.shiftKey && delta.x === 0 ? delta.y : delta.x,
1815
+ -MAX_WHEEL_PAN_DELTA,
1816
+ MAX_WHEEL_PAN_DELTA,
1817
+ );
1818
+ const deltaY = clamp(
1819
+ event.shiftKey && delta.x === 0 ? 0 : delta.y,
1820
+ -MAX_WHEEL_PAN_DELTA,
1821
+ MAX_WHEEL_PAN_DELTA,
1822
+ );
1823
+ const nextPan = {
1824
+ x: panRef.current.x - deltaX,
1825
+ y: panRef.current.y - deltaY,
1826
+ };
1827
+ panRef.current = nextPan;
1828
+ setPan(nextPan);
1829
+ },
1830
+ [getFrameAtClientPoint, onZoomChange, onZoomToEdit, zoomToEditThreshold],
1831
+ );
1832
+
1833
+ useEffect(() => {
1834
+ const surface = surfaceRef.current;
1835
+ if (!surface) return;
1836
+ surface.addEventListener("wheel", handleWheelEvent, {
1837
+ capture: true,
1838
+ passive: false,
1839
+ });
1840
+ return () => {
1841
+ surface.removeEventListener("wheel", handleWheelEvent, {
1842
+ capture: true,
1843
+ });
1844
+ };
1845
+ }, [handleWheelEvent]);
1846
+
1847
+ useEffect(() => {
1848
+ const handleKeyDown = (event: KeyboardEvent) => {
1849
+ if (!isArrowNudgeKey(event.key) || isEditableHotkeyTarget(event.target)) {
1850
+ return;
1851
+ }
1852
+
1853
+ const targetIds = selectedIdsRef.current.filter(
1854
+ (id) => frameGeometryRef.current[id],
1855
+ );
1856
+ const targetDraftIds = selectedDraftIdsRef.current.filter((id) =>
1857
+ draftPrimitivesRef.current.some((draft) => draft.id === id),
1858
+ );
1859
+ if (targetIds.length === 0 && targetDraftIds.length === 0) return;
1860
+
1861
+ event.preventDefault();
1862
+ event.stopPropagation();
1863
+
1864
+ const nudge = getNudgeDelta(event.key, {
1865
+ altKey: event.altKey,
1866
+ ctrlKey: event.ctrlKey,
1867
+ metaKey: event.metaKey,
1868
+ shiftKey: event.shiftKey,
1869
+ });
1870
+ const movingFrameEntries = targetIds.map((targetId) => {
1871
+ const origin = frameGeometryRef.current[targetId];
1872
+ return {
1873
+ id: targetId,
1874
+ geometry: {
1875
+ ...origin,
1876
+ x: origin.x + nudge.dx,
1877
+ y: origin.y + nudge.dy,
1878
+ },
1879
+ };
1880
+ });
1881
+ const movingDraftEntries = targetDraftIds
1882
+ .map((targetId) =>
1883
+ draftPrimitivesRef.current.find((draft) => draft.id === targetId),
1884
+ )
1885
+ .filter(isDraftPrimitive)
1886
+ .map((draft) => ({
1887
+ id: draft.id,
1888
+ geometry: {
1889
+ ...draft.geometry,
1890
+ x: draft.geometry.x + nudge.dx,
1891
+ y: draft.geometry.y + nudge.dy,
1892
+ },
1893
+ }));
1894
+ const movingEntries = [...movingFrameEntries, ...movingDraftEntries];
1895
+ const movingIds = [...targetIds, ...targetDraftIds];
1896
+ const stationaryEntries = getCurrentCanvasEntries().filter(
1897
+ (entry) => !movingIds.includes(entry.id),
1898
+ );
1899
+ const snap = computeMoveSnap(movingEntries, stationaryEntries, {
1900
+ thresholdScreenPx: DEFAULT_SNAP_THRESHOLD_SCREEN_PX,
1901
+ zoom: zoomRef.current,
1902
+ bypass: nudge.snap.bypass,
1903
+ });
1904
+
1905
+ if (targetIds.length > 0) {
1906
+ const before = cloneFrameGeometryById(frameGeometryRef.current);
1907
+ const next = { ...before };
1908
+ targetIds.forEach((targetId) => {
1909
+ const origin = before[targetId] ?? frameGeometryRef.current[targetId];
1910
+ next[targetId] = {
1911
+ ...origin,
1912
+ x: origin.x + nudge.dx + snap.dx,
1913
+ y: origin.y + nudge.dy + snap.dy,
1914
+ };
1915
+ });
1916
+ updateFrameGeometry(() => next);
1917
+ onGeometryCommitRef.current?.(before, cloneFrameGeometryById(next));
1918
+ }
1919
+ if (targetDraftIds.length > 0) {
1920
+ updateDraftPrimitives((current) =>
1921
+ current.map((draft) =>
1922
+ targetDraftIds.includes(draft.id)
1923
+ ? moveDraftPrimitive(
1924
+ draft,
1925
+ nudge.dx + snap.dx,
1926
+ nudge.dy + snap.dy,
1927
+ )
1928
+ : draft,
1929
+ ),
1930
+ );
1931
+ }
1932
+ setAlignmentGuides(snap.guides);
1933
+ scheduleFeedbackClear();
1934
+ };
1935
+
1936
+ window.addEventListener("keydown", handleKeyDown, true);
1937
+ return () => {
1938
+ window.removeEventListener("keydown", handleKeyDown, true);
1939
+ };
1940
+ }, [
1941
+ getCurrentCanvasEntries,
1942
+ scheduleFeedbackClear,
1943
+ updateDraftPrimitives,
1944
+ updateFrameGeometry,
1945
+ ]);
1946
+
1947
+ useEffect(() => {
1948
+ const handleKeyDown = (event: KeyboardEvent) => {
1949
+ if (
1950
+ (event.key !== "Delete" && event.key !== "Backspace") ||
1951
+ event.metaKey ||
1952
+ event.ctrlKey ||
1953
+ isEditableHotkeyTarget(event.target)
1954
+ ) {
1955
+ return;
1956
+ }
1957
+ if (!deleteSelectedItems()) return;
1958
+ event.preventDefault();
1959
+ event.stopPropagation();
1960
+ event.stopImmediatePropagation();
1961
+ };
1962
+
1963
+ window.addEventListener("keydown", handleKeyDown, true);
1964
+ return () => {
1965
+ window.removeEventListener("keydown", handleKeyDown, true);
1966
+ };
1967
+ }, [deleteSelectedItems]);
1968
+
1969
+ const scale = canvasZoom / 100;
1970
+ const showPixelGrid = canvasZoom >= PIXEL_GRID_ZOOM;
1971
+ const effectiveTool = normalizeCanvasTool(activeTool ?? localActiveTool);
1972
+ const selectedIdSet = new Set(selectedIds);
1973
+ const selectedDraftIdSet = new Set(selectedDraftIds);
1974
+ const surfaceCursor = isPanning
1975
+ ? "grabbing"
1976
+ : dragCursor
1977
+ ? dragCursor
1978
+ : isDragging && marquee
1979
+ ? "crosshair"
1980
+ : getDraftCreationTool(effectiveTool)
1981
+ ? "crosshair"
1982
+ : effectiveTool === "hand"
1983
+ ? "grab"
1984
+ : "default";
1985
+ const canvasFrames = screens.map((screen, index) => ({
1986
+ screen,
1987
+ geometry: frameGeometry[screen.id] ?? getInitialFrameGeometry(index),
1988
+ }));
1989
+ const selectedFrameEntries = canvasFrames
1990
+ .filter(({ screen }) => selectedIdSet.has(screen.id))
1991
+ .map(({ screen, geometry }) => ({ id: screen.id, geometry }));
1992
+ const selectedGroupBounds =
1993
+ selectedFrameEntries.length > 1
1994
+ ? getFrameGroupBounds(selectedFrameEntries)
1995
+ : null;
1996
+ const hasGroupSelection = !!selectedGroupBounds;
1997
+ const selectedDraftEntries = draftPrimitives
1998
+ .filter((draft) => selectedDraftIdSet.has(draft.id))
1999
+ .map((draft) => ({ id: draft.id, geometry: draft.geometry }));
2000
+ const selectedDraftGroupBounds =
2001
+ selectedDraftEntries.length > 1
2002
+ ? getFrameGroupBounds(selectedDraftEntries)
2003
+ : null;
2004
+ return (
2005
+ <div
2006
+ ref={surfaceRef}
2007
+ className="relative h-full w-full select-none overflow-hidden bg-background"
2008
+ onMouseDown={handleMouseDown}
2009
+ style={{ cursor: surfaceCursor, touchAction: "none" }}
2010
+ >
2011
+ {showPixelGrid ? (
2012
+ <div
2013
+ className="pointer-events-none absolute inset-0 opacity-60"
2014
+ style={{
2015
+ backgroundImage:
2016
+ "linear-gradient(to right, hsl(var(--border)) 1px, transparent 1px), linear-gradient(to bottom, hsl(var(--border)) 1px, transparent 1px)",
2017
+ backgroundPosition: `${pan.x}px ${pan.y}px`,
2018
+ backgroundSize: `${scale}px ${scale}px`,
2019
+ }}
2020
+ />
2021
+ ) : null}
2022
+
2023
+ <div
2024
+ className="pointer-events-none absolute"
2025
+ style={{
2026
+ left: pan.x,
2027
+ top: pan.y,
2028
+ transform: `scale(${scale})`,
2029
+ transformOrigin: "top left",
2030
+ }}
2031
+ >
2032
+ {canvasFrames.map(({ screen, geometry }) => {
2033
+ const metadata = resolveScreenMetadata(
2034
+ screen,
2035
+ metadataById?.[screen.id],
2036
+ getScreenMetadata?.(screen),
2037
+ );
2038
+ return (
2039
+ <Screen
2040
+ key={screen.id}
2041
+ screen={screen}
2042
+ metadata={metadata}
2043
+ geometry={geometry}
2044
+ screenContent={renderScreenContent?.(screen, metadata, geometry)}
2045
+ isActive={screen.id === activeId}
2046
+ isSelected={selectedIdSet.has(screen.id)}
2047
+ groupSelected={hasGroupSelection}
2048
+ handlesEnabled={!hasGroupSelection}
2049
+ onPick={handleFrameClick}
2050
+ onEdit={handleFrameDoubleClick}
2051
+ onStartFrameDrag={beginFrameDrag}
2052
+ onStartResize={beginResize}
2053
+ onStartRotate={beginRotate}
2054
+ onStartDuplicateGesture={beginDuplicateGesture}
2055
+ />
2056
+ );
2057
+ })}
2058
+
2059
+ {draftPrimitives.map((draft) => (
2060
+ <DraftPrimitiveLayer
2061
+ key={draft.id}
2062
+ draft={draft}
2063
+ isSelected={selectedDraftIdSet.has(draft.id)}
2064
+ groupSelected={Boolean(selectedDraftGroupBounds)}
2065
+ onClick={handleDraftClick}
2066
+ onStartDrag={beginDraftDrag}
2067
+ onStartResize={beginDraftResize}
2068
+ />
2069
+ ))}
2070
+
2071
+ {creationPreview ? (
2072
+ <DraftPrimitiveLayer
2073
+ draft={previewDraftPrimitive(creationPreview)}
2074
+ isSelected
2075
+ preview
2076
+ groupSelected={false}
2077
+ onClick={() => {}}
2078
+ onStartDrag={() => {}}
2079
+ onStartResize={() => {}}
2080
+ />
2081
+ ) : null}
2082
+
2083
+ {selectedGroupBounds ? (
2084
+ <GroupSelectionBox
2085
+ bounds={selectedGroupBounds}
2086
+ onStartResize={beginGroupResize}
2087
+ />
2088
+ ) : null}
2089
+
2090
+ {selectedDraftGroupBounds ? (
2091
+ <GroupSelectionBox
2092
+ bounds={selectedDraftGroupBounds}
2093
+ onStartResize={beginDraftGroupResize}
2094
+ />
2095
+ ) : null}
2096
+
2097
+ {alignmentGuides.map((guide, index) => (
2098
+ <span
2099
+ key={`${guide.orientation}-${guide.position}-${index}`}
2100
+ className="pointer-events-none absolute z-30 bg-destructive/90"
2101
+ style={
2102
+ guide.orientation === "vertical"
2103
+ ? {
2104
+ left: SURFACE_PADDING + guide.position,
2105
+ top: SURFACE_PADDING + guide.start,
2106
+ width: 1,
2107
+ height: Math.max(1, guide.end - guide.start),
2108
+ }
2109
+ : {
2110
+ left: SURFACE_PADDING + guide.start,
2111
+ top: SURFACE_PADDING + guide.position,
2112
+ width: Math.max(1, guide.end - guide.start),
2113
+ height: 1,
2114
+ }
2115
+ }
2116
+ />
2117
+ ))}
2118
+
2119
+ {marquee ? (
2120
+ <span
2121
+ className="pointer-events-none absolute z-40 border border-[var(--design-editor-accent-color)] bg-[var(--design-editor-selection-color)]"
2122
+ style={{
2123
+ left: SURFACE_PADDING + marquee.x,
2124
+ top: SURFACE_PADDING + marquee.y,
2125
+ width: marquee.width,
2126
+ height: marquee.height,
2127
+ }}
2128
+ />
2129
+ ) : null}
2130
+ </div>
2131
+
2132
+ {duplicatePreview ? (
2133
+ <div
2134
+ className={cn(
2135
+ "pointer-events-none absolute z-20 rounded-lg border bg-background/90 shadow-2xl backdrop-blur-sm transition-colors",
2136
+ duplicatePreview.canDuplicate
2137
+ ? "border-primary/80 ring-4 ring-primary/15"
2138
+ : "border-dashed border-muted-foreground/45",
2139
+ )}
2140
+ style={{
2141
+ left: duplicatePreview.x,
2142
+ top: duplicatePreview.y,
2143
+ width: SCREEN_WIDTH * Math.min(scale, 1),
2144
+ height: SCREEN_HEIGHT * Math.min(scale, 1),
2145
+ maxWidth: SCREEN_WIDTH,
2146
+ maxHeight: SCREEN_HEIGHT,
2147
+ }}
2148
+ >
2149
+ <div className="flex h-full w-full items-start justify-between rounded-lg bg-muted/20 p-2">
2150
+ <span className="max-w-[190px] truncate text-[11px] font-medium text-foreground">
2151
+ {duplicatePreview.display}
2152
+ </span>
2153
+ <span className="flex items-center gap-1 rounded-md border border-border bg-background px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground shadow-sm">
2154
+ <IconCopy className="h-3 w-3" />
2155
+ {duplicatePreview.canDuplicate
2156
+ ? duplicatePreview.moved
2157
+ ? t("multiScreenCanvas.fork")
2158
+ : t("multiScreenCanvas.duplicate")
2159
+ : t("multiScreenCanvas.preview")}
2160
+ </span>
2161
+ </div>
2162
+ </div>
2163
+ ) : null}
2164
+
2165
+ {transformBadge ? (
2166
+ <div
2167
+ className="pointer-events-none absolute z-50 rounded border border-border bg-background/95 px-1.5 py-0.5 font-mono text-[11px] leading-5 text-foreground shadow-lg backdrop-blur"
2168
+ style={{ left: transformBadge.x, top: transformBadge.y }}
2169
+ >
2170
+ {transformBadge.text}
2171
+ </div>
2172
+ ) : null}
2173
+ </div>
2174
+ );
2175
+ }
2176
+
2177
+ function DraftPrimitiveLayer({
2178
+ draft,
2179
+ isSelected,
2180
+ groupSelected,
2181
+ preview = false,
2182
+ onClick,
2183
+ onStartDrag,
2184
+ onStartResize,
2185
+ }: {
2186
+ draft: DraftPrimitive;
2187
+ isSelected: boolean;
2188
+ groupSelected: boolean;
2189
+ preview?: boolean;
2190
+ onClick: (id: string, e: React.MouseEvent) => void;
2191
+ onStartDrag: (id: string, e: React.MouseEvent) => void;
2192
+ onStartResize: (
2193
+ id: string,
2194
+ handle: ResizeHandle,
2195
+ e: React.MouseEvent,
2196
+ ) => void;
2197
+ }) {
2198
+ const { geometry } = draft;
2199
+ const selected = isSelected && !groupSelected;
2200
+ return (
2201
+ <button
2202
+ data-frame-shell
2203
+ type="button"
2204
+ className={cn(
2205
+ "group/artboard pointer-events-auto absolute block overflow-visible text-left outline-none",
2206
+ preview ? "cursor-crosshair" : "cursor-move",
2207
+ )}
2208
+ style={{
2209
+ left: SURFACE_PADDING + geometry.x,
2210
+ top: SURFACE_PADDING + geometry.y,
2211
+ width: geometry.width,
2212
+ height: geometry.height,
2213
+ zIndex: geometry.z ?? 40,
2214
+ transform: geometry.rotation
2215
+ ? `rotate(${geometry.rotation}deg)`
2216
+ : undefined,
2217
+ }}
2218
+ onClick={(event) => {
2219
+ if (!preview) onClick(draft.id, event);
2220
+ }}
2221
+ onMouseDown={(event) => {
2222
+ if (!preview) onStartDrag(draft.id, event);
2223
+ }}
2224
+ >
2225
+ <DraftPrimitiveContent draft={draft} preview={preview} />
2226
+ <span
2227
+ className={cn(
2228
+ "pointer-events-none absolute -inset-[5px] rounded-sm border transition-opacity",
2229
+ selected
2230
+ ? "border-[var(--design-editor-accent-color)] opacity-100"
2231
+ : "border-[var(--design-editor-accent-color)] opacity-0 group-hover/artboard:opacity-100",
2232
+ )}
2233
+ />
2234
+ <ResizeHandles
2235
+ active={selected || preview}
2236
+ enabled={(isSelected && !groupSelected) || preview}
2237
+ showRotate={false}
2238
+ onStartResize={(handle, event) =>
2239
+ onStartResize(draft.id, handle, event)
2240
+ }
2241
+ onStartRotate={() => {}}
2242
+ />
2243
+ </button>
2244
+ );
2245
+ }
2246
+
2247
+ function DraftPrimitiveContent({
2248
+ draft,
2249
+ preview,
2250
+ }: {
2251
+ draft: DraftPrimitive;
2252
+ preview: boolean;
2253
+ }) {
2254
+ const muted = preview ? "opacity-70" : "";
2255
+ if (draft.kind === "path") {
2256
+ return (
2257
+ <svg
2258
+ className={cn("block size-full overflow-visible", muted)}
2259
+ viewBox={`${draft.geometry.x} ${draft.geometry.y} ${draft.geometry.width} ${draft.geometry.height}`}
2260
+ >
2261
+ <path
2262
+ d={pointsToPath(draft.points ?? [])}
2263
+ fill="none"
2264
+ stroke={draft.stroke ?? "hsl(var(--primary))"}
2265
+ strokeLinecap="round"
2266
+ strokeLinejoin="round"
2267
+ strokeWidth={draft.strokeWidth ?? 3}
2268
+ />
2269
+ </svg>
2270
+ );
2271
+ }
2272
+
2273
+ if (draft.kind === "text") {
2274
+ return (
2275
+ <div
2276
+ className={cn(
2277
+ "flex size-full items-start rounded-sm border border-dashed border-primary/60 bg-primary/5 px-2 py-1 text-sm font-medium text-foreground",
2278
+ muted,
2279
+ )}
2280
+ >
2281
+ <span className="truncate">{draft.text}</span>
2282
+ </div>
2283
+ );
2284
+ }
2285
+
2286
+ if (draft.kind === "frame") {
2287
+ return (
2288
+ <div
2289
+ className={cn(
2290
+ "size-full rounded-sm border-2 border-dashed border-primary/70 bg-primary/5",
2291
+ muted,
2292
+ )}
2293
+ />
2294
+ );
2295
+ }
2296
+
2297
+ return (
2298
+ <div
2299
+ className={cn("size-full rounded-sm border", muted)}
2300
+ style={{
2301
+ background: draft.fill ?? "hsl(var(--primary) / 0.12)",
2302
+ borderColor: draft.stroke ?? "hsl(var(--primary) / 0.7)",
2303
+ borderWidth: draft.strokeWidth ?? 1,
2304
+ }}
2305
+ />
2306
+ );
2307
+ }
2308
+
2309
+ function Screen({
2310
+ screen,
2311
+ metadata,
2312
+ geometry,
2313
+ isActive,
2314
+ isSelected,
2315
+ groupSelected,
2316
+ handlesEnabled,
2317
+ onPick,
2318
+ onEdit,
2319
+ onStartFrameDrag,
2320
+ onStartResize,
2321
+ onStartRotate,
2322
+ onStartDuplicateGesture,
2323
+ screenContent,
2324
+ }: {
2325
+ screen: ScreenFile;
2326
+ metadata: ResolvedScreenMetadata;
2327
+ geometry: FrameGeometry;
2328
+ isActive: boolean;
2329
+ isSelected: boolean;
2330
+ groupSelected: boolean;
2331
+ handlesEnabled: boolean;
2332
+ screenContent?: ReactNode;
2333
+ onPick: (id: string, e: React.MouseEvent<HTMLElement>) => void;
2334
+ onEdit: (id: string, e: React.MouseEvent<HTMLElement>) => void;
2335
+ onStartFrameDrag: (id: string, e: React.MouseEvent) => void;
2336
+ onStartResize: (
2337
+ id: string,
2338
+ handle: ResizeHandle,
2339
+ e: React.MouseEvent,
2340
+ ) => void;
2341
+ onStartRotate: (id: string, e: React.MouseEvent) => void;
2342
+ onStartDuplicateGesture: (
2343
+ screen: ScreenFile,
2344
+ display: string,
2345
+ e: React.MouseEvent<HTMLElement>,
2346
+ ) => void;
2347
+ }) {
2348
+ const t = useT();
2349
+ const display = metadata.title ?? prettyScreenName(screen.filename);
2350
+ const previewUrl = metadata.previewUrl ?? getPreviewUrl(screen.content);
2351
+ const suppressNextClick = useRef(false);
2352
+ const emphasized = isActive || isSelected;
2353
+ const selectionOutlined = isSelected && !groupSelected;
2354
+ const showHoverOutline = !isSelected || !groupSelected;
2355
+
2356
+ return (
2357
+ <div
2358
+ data-frame-shell
2359
+ className="group/frame pointer-events-auto absolute"
2360
+ style={{
2361
+ left: SURFACE_PADDING + geometry.x,
2362
+ top: SURFACE_PADDING + geometry.y - FRAME_LABEL_HEIGHT,
2363
+ width: geometry.width,
2364
+ transform: geometry.rotation
2365
+ ? `rotate(${geometry.rotation}deg)`
2366
+ : undefined,
2367
+ transformOrigin: `${geometry.width / 2}px ${FRAME_LABEL_HEIGHT + geometry.height / 2}px`,
2368
+ zIndex: geometry.z,
2369
+ }}
2370
+ >
2371
+ <div
2372
+ className="flex h-7 w-full cursor-default items-center justify-between gap-2 px-1"
2373
+ onMouseDown={(e) => {
2374
+ if (e.button !== 0) return;
2375
+ if (e.shiftKey) {
2376
+ e.stopPropagation();
2377
+ return;
2378
+ }
2379
+ onStartFrameDrag(screen.id, e);
2380
+ }}
2381
+ >
2382
+ <div className="flex min-w-0 items-center gap-1.5">
2383
+ <span
2384
+ className={cn(
2385
+ "h-1.5 w-1.5 shrink-0 rounded-full",
2386
+ emphasized ? "bg-primary" : "bg-muted-foreground/40",
2387
+ )}
2388
+ />
2389
+ <span
2390
+ className={cn(
2391
+ "truncate text-[11px] font-medium",
2392
+ emphasized ? "text-foreground" : "text-muted-foreground",
2393
+ )}
2394
+ title={screen.filename}
2395
+ >
2396
+ {display}
2397
+ </span>
2398
+ <span className="hidden shrink-0 text-[10px] tabular-nums text-muted-foreground/70 sm:inline">
2399
+ {metadata.width} x {metadata.height}
2400
+ </span>
2401
+ </div>
2402
+ <div className="h-5 shrink-0" />
2403
+ </div>
2404
+ <div
2405
+ data-screen-card
2406
+ role="button"
2407
+ tabIndex={0}
2408
+ onClick={(e) => {
2409
+ e.stopPropagation();
2410
+ if (suppressNextClick.current) {
2411
+ suppressNextClick.current = false;
2412
+ return;
2413
+ }
2414
+ if (e.detail > 1) return;
2415
+ onPick(screen.id, e);
2416
+ }}
2417
+ onDoubleClick={(e) => {
2418
+ e.preventDefault();
2419
+ e.stopPropagation();
2420
+ }}
2421
+ onMouseDown={(e) => {
2422
+ if (e.detail > 1) {
2423
+ e.stopPropagation();
2424
+ return;
2425
+ }
2426
+ if (e.altKey && e.button === 0) {
2427
+ suppressNextClick.current = true;
2428
+ onStartDuplicateGesture(screen, display, e);
2429
+ return;
2430
+ }
2431
+ if (e.button === 0) {
2432
+ if (e.shiftKey) {
2433
+ e.stopPropagation();
2434
+ return;
2435
+ }
2436
+ onStartFrameDrag(screen.id, e);
2437
+ }
2438
+ }}
2439
+ className={cn(
2440
+ "group/artboard relative block overflow-visible rounded-lg bg-background text-left outline-none transition-colors",
2441
+ "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
2442
+ emphasized
2443
+ ? "text-foreground"
2444
+ : "text-muted-foreground hover:text-foreground",
2445
+ )}
2446
+ style={{
2447
+ width: geometry.width,
2448
+ height: geometry.height,
2449
+ cursor: isSelected ? "move" : "pointer",
2450
+ touchAction: "none",
2451
+ }}
2452
+ >
2453
+ <span
2454
+ className={cn(
2455
+ "pointer-events-none absolute -inset-[5px] rounded-[13px] border transition-opacity",
2456
+ selectionOutlined
2457
+ ? "border-[var(--design-editor-accent-color)] opacity-100"
2458
+ : showHoverOutline
2459
+ ? "border-[var(--design-editor-accent-color)] opacity-0 group-hover/artboard:opacity-100"
2460
+ : "border-transparent opacity-0",
2461
+ )}
2462
+ />
2463
+ <span
2464
+ className={cn(
2465
+ "relative block h-full w-full overflow-hidden rounded-lg border bg-white shadow-2xl transition-colors",
2466
+ "border-border group-hover/artboard:border-muted-foreground/60",
2467
+ )}
2468
+ >
2469
+ {screenContent ?? (
2470
+ <iframe
2471
+ src={previewUrl}
2472
+ srcDoc={previewUrl ? undefined : screen.content}
2473
+ sandbox="allow-scripts"
2474
+ loading="lazy"
2475
+ className="pointer-events-none border-0"
2476
+ style={{
2477
+ width: metadata.width,
2478
+ height: metadata.height,
2479
+ transform: `scale(${geometry.width / metadata.width}, ${
2480
+ geometry.height / metadata.height
2481
+ })`,
2482
+ transformOrigin: "top left",
2483
+ }}
2484
+ title={screen.filename}
2485
+ />
2486
+ )}
2487
+ <span className="pointer-events-none absolute inset-0 rounded-[7px] border border-black/5" />
2488
+ <button
2489
+ type="button"
2490
+ className={cn(
2491
+ "absolute right-2 top-2 z-20 flex h-7 max-w-[calc(100%-1rem)] translate-y-1 items-center gap-1 rounded-md border border-border bg-background/95 px-2 text-[10px] font-medium text-foreground opacity-0 shadow-sm backdrop-blur transition-all",
2492
+ "hover:bg-accent hover:text-accent-foreground focus-visible:translate-y-0 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
2493
+ "group-hover/artboard:translate-y-0 group-hover/artboard:opacity-100 group-focus-visible/artboard:translate-y-0 group-focus-visible/artboard:opacity-100",
2494
+ emphasized && "translate-y-0 opacity-100",
2495
+ )}
2496
+ aria-label={t("multiScreenCanvas.fullView")}
2497
+ title={t("multiScreenCanvas.fullView")}
2498
+ onClick={(event) => onEdit(screen.id, event)}
2499
+ onMouseDown={(event) => {
2500
+ event.preventDefault();
2501
+ event.stopPropagation();
2502
+ }}
2503
+ >
2504
+ <IconMaximize className="size-3" />
2505
+ <span>{t("multiScreenCanvas.fullView")}</span>
2506
+ </button>
2507
+ </span>
2508
+ <ResizeHandles
2509
+ active={selectionOutlined}
2510
+ enabled={handlesEnabled}
2511
+ showRotate={false}
2512
+ onStartResize={(handle, e) => onStartResize(screen.id, handle, e)}
2513
+ onStartRotate={(e) => onStartRotate(screen.id, e)}
2514
+ />
2515
+ </div>
2516
+ </div>
2517
+ );
2518
+ }
2519
+
2520
+ function GroupSelectionBox({
2521
+ bounds,
2522
+ onStartResize,
2523
+ }: {
2524
+ bounds: NonNullable<ReturnType<typeof getFrameGroupBounds>>;
2525
+ onStartResize: (handle: ResizeHandle, e: React.MouseEvent) => void;
2526
+ }) {
2527
+ return (
2528
+ <div
2529
+ data-frame-shell
2530
+ className="pointer-events-none absolute z-30 rounded-[13px] border border-[var(--design-editor-accent-color)]"
2531
+ style={{
2532
+ left: SURFACE_PADDING + bounds.left,
2533
+ top: SURFACE_PADDING + bounds.top,
2534
+ width: bounds.width,
2535
+ height: bounds.height,
2536
+ }}
2537
+ >
2538
+ <ResizeHandles
2539
+ active
2540
+ enabled
2541
+ showRotate={false}
2542
+ onStartResize={onStartResize}
2543
+ onStartRotate={() => {}}
2544
+ />
2545
+ </div>
2546
+ );
2547
+ }
2548
+
2549
+ function ResizeHandles({
2550
+ active,
2551
+ enabled,
2552
+ showRotate = true,
2553
+ onStartResize,
2554
+ onStartRotate,
2555
+ }: {
2556
+ active: boolean;
2557
+ enabled: boolean;
2558
+ showRotate?: boolean;
2559
+ onStartResize: (handle: ResizeHandle, e: React.MouseEvent) => void;
2560
+ onStartRotate: (e: React.MouseEvent) => void;
2561
+ }) {
2562
+ if (!enabled) return null;
2563
+
2564
+ const visibleHandleClass = cn(
2565
+ "pointer-events-auto absolute z-20 size-2 rounded-[2px] border border-[var(--design-editor-accent-color)] bg-[var(--design-editor-accent-contrast-color)] shadow-sm transition-opacity",
2566
+ active
2567
+ ? "opacity-100"
2568
+ : "opacity-0 group-hover/artboard:opacity-100 group-focus-visible/artboard:opacity-100",
2569
+ );
2570
+ const edgeHandleClass =
2571
+ "pointer-events-auto absolute z-10 bg-transparent opacity-0";
2572
+
2573
+ return (
2574
+ <>
2575
+ {EDGE_RESIZE_HANDLE_CONFIGS.map((config) => (
2576
+ <span
2577
+ key={config.handle}
2578
+ data-resize-handle
2579
+ className={cn(edgeHandleClass, config.className)}
2580
+ style={{ cursor: config.cursor }}
2581
+ onMouseDown={(e) => onStartResize(config.handle, e)}
2582
+ />
2583
+ ))}
2584
+ {CORNER_RESIZE_HANDLE_CONFIGS.map((config) => (
2585
+ <span
2586
+ key={config.handle}
2587
+ data-resize-handle
2588
+ className={cn(visibleHandleClass, config.className)}
2589
+ style={{ cursor: config.cursor }}
2590
+ onMouseDown={(e) => onStartResize(config.handle, e)}
2591
+ />
2592
+ ))}
2593
+ {showRotate
2594
+ ? ROTATE_HANDLE_CONFIGS.map((config) => (
2595
+ <span
2596
+ key={config.corner}
2597
+ data-rotate-handle
2598
+ className={cn(
2599
+ "pointer-events-auto absolute z-10 size-5 rounded-full transition-opacity active:cursor-grabbing",
2600
+ active
2601
+ ? "opacity-100"
2602
+ : "opacity-0 group-hover/artboard:opacity-100 group-focus-visible/artboard:opacity-100",
2603
+ config.className,
2604
+ )}
2605
+ style={{ cursor: "grab" }}
2606
+ onMouseDown={onStartRotate}
2607
+ />
2608
+ ))
2609
+ : null}
2610
+ </>
2611
+ );
2612
+ }
2613
+
2614
+ const CORNER_RESIZE_HANDLE_CONFIGS: Array<{
2615
+ handle: ResizeHandle;
2616
+ className: string;
2617
+ cursor: string;
2618
+ }> = [
2619
+ { handle: "nw", className: "-left-1 -top-1", cursor: "nwse-resize" },
2620
+ { handle: "ne", className: "-right-1 -top-1", cursor: "nesw-resize" },
2621
+ { handle: "se", className: "-bottom-1 -right-1", cursor: "nwse-resize" },
2622
+ { handle: "sw", className: "-bottom-1 -left-1", cursor: "nesw-resize" },
2623
+ ];
2624
+
2625
+ const EDGE_RESIZE_HANDLE_CONFIGS: Array<{
2626
+ handle: ResizeHandle;
2627
+ className: string;
2628
+ cursor: string;
2629
+ }> = [
2630
+ {
2631
+ handle: "n",
2632
+ className: "-top-1 left-0 right-0 h-2",
2633
+ cursor: "ns-resize",
2634
+ },
2635
+ {
2636
+ handle: "e",
2637
+ className: "-right-1 bottom-0 top-0 w-2",
2638
+ cursor: "ew-resize",
2639
+ },
2640
+ {
2641
+ handle: "s",
2642
+ className: "-bottom-1 left-0 right-0 h-2",
2643
+ cursor: "ns-resize",
2644
+ },
2645
+ {
2646
+ handle: "w",
2647
+ className: "-left-1 bottom-0 top-0 w-2",
2648
+ cursor: "ew-resize",
2649
+ },
2650
+ ];
2651
+
2652
+ const ALL_RESIZE_HANDLE_CONFIGS = [
2653
+ ...CORNER_RESIZE_HANDLE_CONFIGS,
2654
+ ...EDGE_RESIZE_HANDLE_CONFIGS,
2655
+ ];
2656
+
2657
+ const ROTATE_HANDLE_CONFIGS: Array<{
2658
+ corner: string;
2659
+ className: string;
2660
+ }> = [
2661
+ { corner: "nw", className: "-left-7 -top-7" },
2662
+ { corner: "ne", className: "-right-7 -top-7" },
2663
+ { corner: "se", className: "-bottom-7 -right-7" },
2664
+ { corner: "sw", className: "-bottom-7 -left-7" },
2665
+ ];
2666
+
2667
+ interface FrameEntry {
2668
+ id: string;
2669
+ geometry: FrameGeometry;
2670
+ }
2671
+
2672
+ interface BoundsRect {
2673
+ left: number;
2674
+ top: number;
2675
+ right: number;
2676
+ bottom: number;
2677
+ }
2678
+
2679
+ function getInitialFrameGeometry(index: number): FrameGeometry {
2680
+ const column = index % 3;
2681
+ const row = Math.floor(index / 3);
2682
+ return {
2683
+ x: column * (SCREEN_WIDTH + SCREEN_GAP),
2684
+ y: row * (SCREEN_CARD_HEIGHT + SCREEN_GAP),
2685
+ width: SCREEN_WIDTH,
2686
+ height: SCREEN_HEIGHT,
2687
+ };
2688
+ }
2689
+
2690
+ function getFrameEntries(
2691
+ screens: ScreenFile[],
2692
+ geometryById: FrameGeometryById,
2693
+ ): FrameEntry[] {
2694
+ return screens.map((screen, index) => ({
2695
+ id: screen.id,
2696
+ geometry: geometryById[screen.id] ?? getInitialFrameGeometry(index),
2697
+ }));
2698
+ }
2699
+
2700
+ function dedupeIds(ids: string[]) {
2701
+ return [...new Set(ids)];
2702
+ }
2703
+
2704
+ function sameIds(a: string[], b: string[]) {
2705
+ return a.length === b.length && a.every((id, index) => id === b[index]);
2706
+ }
2707
+
2708
+ function isArrowNudgeKey(key: string): key is ArrowNudgeKey {
2709
+ return (
2710
+ key === "ArrowUp" ||
2711
+ key === "ArrowRight" ||
2712
+ key === "ArrowDown" ||
2713
+ key === "ArrowLeft"
2714
+ );
2715
+ }
2716
+
2717
+ function isEditableHotkeyTarget(target: EventTarget | null) {
2718
+ if (!target || typeof Element === "undefined") return false;
2719
+ if (!(target instanceof Element)) return false;
2720
+ const editable = target.closest(
2721
+ [
2722
+ "input",
2723
+ "textarea",
2724
+ "select",
2725
+ "[contenteditable]",
2726
+ '[role="textbox"]',
2727
+ '[data-hotkeys-scope="text"]',
2728
+ ].join(","),
2729
+ );
2730
+ if (!editable) return false;
2731
+ if (editable instanceof HTMLElement && editable.isContentEditable) {
2732
+ return true;
2733
+ }
2734
+ const tagName = editable.tagName.toLowerCase();
2735
+ return tagName === "input" || tagName === "textarea" || tagName === "select";
2736
+ }
2737
+
2738
+ function frameBoundsToGeometry(bounds: {
2739
+ left: number;
2740
+ top: number;
2741
+ width: number;
2742
+ height: number;
2743
+ }): FrameGeometry {
2744
+ return {
2745
+ x: bounds.left,
2746
+ y: bounds.top,
2747
+ width: bounds.width,
2748
+ height: bounds.height,
2749
+ };
2750
+ }
2751
+
2752
+ function getResizeCursor(handle: ResizeHandle) {
2753
+ return (
2754
+ ALL_RESIZE_HANDLE_CONFIGS.find((config) => config.handle === handle)
2755
+ ?.cursor ?? "default"
2756
+ );
2757
+ }
2758
+
2759
+ function normalizeRectFromPoints(start: Point, end: Point): MarqueeRect {
2760
+ const x = Math.min(start.x, end.x);
2761
+ const y = Math.min(start.y, end.y);
2762
+ return {
2763
+ x,
2764
+ y,
2765
+ width: Math.abs(end.x - start.x),
2766
+ height: Math.abs(end.y - start.y),
2767
+ };
2768
+ }
2769
+
2770
+ function rectIntersects(rect: MarqueeRect, bounds: BoundsRect) {
2771
+ const right = rect.x + rect.width;
2772
+ const bottom = rect.y + rect.height;
2773
+ return (
2774
+ rect.x <= bounds.right &&
2775
+ right >= bounds.left &&
2776
+ rect.y <= bounds.bottom &&
2777
+ bottom >= bounds.top
2778
+ );
2779
+ }
2780
+
2781
+ function rectContainsPoint(bounds: BoundsRect, point: Point) {
2782
+ return (
2783
+ point.x >= bounds.left &&
2784
+ point.x <= bounds.right &&
2785
+ point.y >= bounds.top &&
2786
+ point.y <= bounds.bottom
2787
+ );
2788
+ }
2789
+
2790
+ function sameFrameGeometry(a: FrameGeometry, b: FrameGeometry) {
2791
+ return (
2792
+ a.x === b.x &&
2793
+ a.y === b.y &&
2794
+ a.width === b.width &&
2795
+ a.height === b.height &&
2796
+ (a.rotation ?? 0) === (b.rotation ?? 0) &&
2797
+ (a.z ?? 0) === (b.z ?? 0)
2798
+ );
2799
+ }
2800
+
2801
+ function cloneFrameGeometryById(
2802
+ geometryById: FrameGeometryById,
2803
+ ): FrameGeometryById {
2804
+ return Object.fromEntries(
2805
+ Object.entries(geometryById).map(([id, geometry]) => [id, { ...geometry }]),
2806
+ );
2807
+ }
2808
+
2809
+ function frameGeometryWithOverrides(
2810
+ base: FrameGeometryById,
2811
+ overrides: FrameGeometryById,
2812
+ ): FrameGeometryById {
2813
+ const next = cloneFrameGeometryById(base);
2814
+ Object.entries(overrides).forEach(([id, geometry]) => {
2815
+ next[id] = { ...geometry };
2816
+ });
2817
+ return next;
2818
+ }
2819
+
2820
+ function isDraftPrimitive(
2821
+ value: DraftPrimitive | undefined,
2822
+ ): value is DraftPrimitive {
2823
+ return Boolean(value);
2824
+ }
2825
+
2826
+ function getDraftGeometryForTool(
2827
+ tool: DraftCreationTool,
2828
+ start: Point,
2829
+ end: Point,
2830
+ ): FrameGeometry {
2831
+ if (tool === "pen") return getPathGeometry([start, end]);
2832
+ const options =
2833
+ tool === "frame"
2834
+ ? {
2835
+ minWidth: 24,
2836
+ minHeight: 24,
2837
+ defaultWidth: DRAFT_FRAME_WIDTH,
2838
+ defaultHeight: DRAFT_FRAME_HEIGHT,
2839
+ }
2840
+ : tool === "text"
2841
+ ? {
2842
+ minWidth: 24,
2843
+ minHeight: 18,
2844
+ defaultWidth: DRAFT_TEXT_WIDTH,
2845
+ defaultHeight: DRAFT_TEXT_HEIGHT,
2846
+ }
2847
+ : {
2848
+ minWidth: 8,
2849
+ minHeight: 8,
2850
+ defaultWidth: DRAFT_RECT_WIDTH,
2851
+ defaultHeight: DRAFT_RECT_HEIGHT,
2852
+ };
2853
+ return getDraftGeometryFromPoints(start, end, options);
2854
+ }
2855
+
2856
+ function getPathGeometry(points: readonly Point[]): FrameGeometry {
2857
+ if (points.length === 0) {
2858
+ return {
2859
+ x: 0,
2860
+ y: 0,
2861
+ width: DRAFT_PATH_MIN_SIZE,
2862
+ height: DRAFT_PATH_MIN_SIZE,
2863
+ };
2864
+ }
2865
+ const xs = points.map((point) => point.x);
2866
+ const ys = points.map((point) => point.y);
2867
+ const left = Math.min(...xs);
2868
+ const top = Math.min(...ys);
2869
+ const right = Math.max(...xs);
2870
+ const bottom = Math.max(...ys);
2871
+ return {
2872
+ x: left,
2873
+ y: top,
2874
+ width: Math.max(DRAFT_PATH_MIN_SIZE, right - left),
2875
+ height: Math.max(DRAFT_PATH_MIN_SIZE, bottom - top),
2876
+ };
2877
+ }
2878
+
2879
+ function createDraftPrimitive({
2880
+ tool,
2881
+ start,
2882
+ end,
2883
+ points,
2884
+ moved,
2885
+ toolProps,
2886
+ fallbackText,
2887
+ }: DraftPrimitiveInput): DraftPrimitive {
2888
+ const id = `draft-${tool}-${Date.now()}-${Math.random()
2889
+ .toString(36)
2890
+ .slice(2, 8)}`;
2891
+ const geometry = moved
2892
+ ? getDraftGeometryForTool(tool, start, end)
2893
+ : getDraftGeometryForTool(tool, start, start);
2894
+ if (tool === "pen") {
2895
+ const pathPoints =
2896
+ points && points.length > 1
2897
+ ? points
2898
+ : [
2899
+ start,
2900
+ { x: start.x + 64, y: start.y + 24 },
2901
+ { x: start.x + 128, y: start.y },
2902
+ ];
2903
+ return {
2904
+ id,
2905
+ kind: "path",
2906
+ geometry: getPathGeometry(pathPoints),
2907
+ points: pathPoints,
2908
+ stroke: toolProps?.stroke,
2909
+ strokeWidth: toolProps?.strokeWidth ?? 3,
2910
+ };
2911
+ }
2912
+ if (tool === "text") {
2913
+ return {
2914
+ id,
2915
+ kind: "text",
2916
+ geometry,
2917
+ text: toolProps?.text ?? fallbackText,
2918
+ fill: toolProps?.fill,
2919
+ stroke: toolProps?.stroke,
2920
+ };
2921
+ }
2922
+ return {
2923
+ id,
2924
+ kind: tool === "frame" ? "frame" : "rectangle",
2925
+ geometry,
2926
+ fill: toolProps?.fill,
2927
+ stroke: toolProps?.stroke,
2928
+ strokeWidth: toolProps?.strokeWidth,
2929
+ };
2930
+ }
2931
+
2932
+ function cloneDraftPrimitive(draft: DraftPrimitive): DraftPrimitive {
2933
+ return {
2934
+ ...draft,
2935
+ geometry: { ...draft.geometry },
2936
+ points: draft.points?.map((point) => ({ ...point })),
2937
+ };
2938
+ }
2939
+
2940
+ function draftPrimitiveToInsert(
2941
+ draft: DraftPrimitive,
2942
+ frameGeometry: FrameGeometry,
2943
+ ): CanvasPrimitiveInsert {
2944
+ return {
2945
+ kind: draft.kind,
2946
+ geometry: {
2947
+ ...draft.geometry,
2948
+ x: Math.round(draft.geometry.x - frameGeometry.x),
2949
+ y: Math.round(draft.geometry.y - frameGeometry.y),
2950
+ },
2951
+ points: draft.points?.map((point) => ({
2952
+ x: Math.round(point.x - frameGeometry.x),
2953
+ y: Math.round(point.y - frameGeometry.y),
2954
+ })),
2955
+ text: draft.text,
2956
+ fill: draft.fill,
2957
+ stroke: draft.stroke,
2958
+ strokeWidth: draft.strokeWidth,
2959
+ };
2960
+ }
2961
+
2962
+ function moveDraftPrimitive(
2963
+ draft: DraftPrimitive,
2964
+ dx: number,
2965
+ dy: number,
2966
+ ): DraftPrimitive {
2967
+ return {
2968
+ ...draft,
2969
+ geometry: {
2970
+ ...draft.geometry,
2971
+ x: draft.geometry.x + dx,
2972
+ y: draft.geometry.y + dy,
2973
+ },
2974
+ points: draft.points?.map((point) => ({
2975
+ x: point.x + dx,
2976
+ y: point.y + dy,
2977
+ })),
2978
+ };
2979
+ }
2980
+
2981
+ function applyDraftGeometry(
2982
+ draft: DraftPrimitive,
2983
+ geometry: FrameGeometry,
2984
+ ): DraftPrimitive {
2985
+ if (!draft.points?.length) return { ...draft, geometry };
2986
+ const origin = draft.geometry;
2987
+ const scaleX = geometry.width / Math.max(1, origin.width);
2988
+ const scaleY = geometry.height / Math.max(1, origin.height);
2989
+ return {
2990
+ ...draft,
2991
+ geometry,
2992
+ points: draft.points.map((point) => ({
2993
+ x: geometry.x + (point.x - origin.x) * scaleX,
2994
+ y: geometry.y + (point.y - origin.y) * scaleY,
2995
+ })),
2996
+ };
2997
+ }
2998
+
2999
+ function normalizeCanvasTool(
3000
+ tool: MultiScreenCanvasTool,
3001
+ ): MultiScreenCanvasTool {
3002
+ return tool === "rectangle" ? "rect" : tool;
3003
+ }
3004
+
3005
+ function getDraftCreationTool(
3006
+ tool: MultiScreenCanvasTool,
3007
+ ): DraftCreationTool | null {
3008
+ if (
3009
+ tool === "frame" ||
3010
+ tool === "rect" ||
3011
+ tool === "text" ||
3012
+ tool === "pen"
3013
+ ) {
3014
+ return tool;
3015
+ }
3016
+ return null;
3017
+ }
3018
+
3019
+ function pointsToPath(points: readonly Point[]) {
3020
+ if (points.length === 0) return "";
3021
+ const [first, ...rest] = points;
3022
+ return [
3023
+ `M ${roundCoord(first.x)} ${roundCoord(first.y)}`,
3024
+ ...rest.map((point) => `L ${roundCoord(point.x)} ${roundCoord(point.y)}`),
3025
+ ].join(" ");
3026
+ }
3027
+
3028
+ function roundCoord(value: number) {
3029
+ return Math.round(value * 10) / 10;
3030
+ }
3031
+
3032
+ function previewDraftPrimitive(preview: DraftCreationPreview): DraftPrimitive {
3033
+ return {
3034
+ id: "draft-preview",
3035
+ kind:
3036
+ preview.tool === "pen"
3037
+ ? "path"
3038
+ : preview.tool === "frame"
3039
+ ? "frame"
3040
+ : preview.tool === "text"
3041
+ ? "text"
3042
+ : "rectangle",
3043
+ geometry: preview.geometry,
3044
+ points: preview.points,
3045
+ text: "Text", // i18n-ignore preview-only canvas placeholder
3046
+ };
3047
+ }
3048
+
3049
+ function getFrameCenter(frame: FrameGeometry): Point {
3050
+ return {
3051
+ x: frame.x + frame.width / 2,
3052
+ y: frame.y + frame.height / 2,
3053
+ };
3054
+ }
3055
+
3056
+ function angleBetween(center: Point, point: Point) {
3057
+ return (Math.atan2(point.y - center.y, point.x - center.x) * 180) / Math.PI;
3058
+ }
3059
+
3060
+ function getSelectableBounds(geometry: FrameGeometry): BoundsRect {
3061
+ return {
3062
+ left: geometry.x,
3063
+ top: geometry.y - FRAME_LABEL_HEIGHT,
3064
+ right: geometry.x + geometry.width,
3065
+ bottom: geometry.y + geometry.height,
3066
+ };
3067
+ }
3068
+
3069
+ function getWheelDelta(event: WheelEvent) {
3070
+ const multiplier =
3071
+ event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 800 : 1;
3072
+ return {
3073
+ x: event.deltaX * multiplier,
3074
+ y: event.deltaY * multiplier,
3075
+ };
3076
+ }
3077
+
3078
+ function clamp(value: number, min: number, max: number) {
3079
+ return Math.min(max, Math.max(min, value));
3080
+ }
3081
+
3082
+ function resolveScreenMetadata(
3083
+ screen: ScreenFile,
3084
+ keyedMetadata?: ScreenMetadata,
3085
+ getterMetadata?: ScreenMetadata,
3086
+ ): ResolvedScreenMetadata {
3087
+ const metadata = { ...screen, ...keyedMetadata, ...getterMetadata };
3088
+ const previewUrl =
3089
+ metadata.url ??
3090
+ metadata.previewUrl ??
3091
+ screen.previewUrl ??
3092
+ getPreviewUrl(screen.content);
3093
+ const width = metadata.width && metadata.width > 0 ? metadata.width : 1280;
3094
+ const height =
3095
+ metadata.height && metadata.height > 0 ? metadata.height : 2560;
3096
+ return {
3097
+ source:
3098
+ normalizeSource(metadata.sourceType ?? metadata.source) ??
3099
+ deriveSource(screen, previewUrl),
3100
+ previewState:
3101
+ normalizePreviewState(
3102
+ metadata.lod ?? metadata.previewState ?? metadata.status,
3103
+ ) ?? derivePreviewState(screen, previewUrl),
3104
+ title: metadata.title,
3105
+ width,
3106
+ height,
3107
+ previewUrl,
3108
+ };
3109
+ }
3110
+
3111
+ function normalizeSource(value?: string): ScreenSourceType | undefined {
3112
+ const normalized = value?.toLowerCase();
3113
+ if (!normalized) return undefined;
3114
+ if (normalized === "local" || normalized === "localhost") return "localhost";
3115
+ if (normalized === "fusion" || normalized === "remote-fusion")
3116
+ return "fusion";
3117
+ if (normalized === "inline" || normalized === "code") return "inline";
3118
+ return undefined;
3119
+ }
3120
+
3121
+ function normalizePreviewState(value?: string): ScreenPreviewState | undefined {
3122
+ const normalized = value?.toLowerCase();
3123
+ if (!normalized) return undefined;
3124
+ if (normalized === "live") return "live";
3125
+ if (normalized === "snapshot" || normalized === "cached") return "snapshot";
3126
+ if (normalized === "preview" || normalized === "draft") return "preview";
3127
+ return undefined;
3128
+ }
3129
+
3130
+ function deriveSource(
3131
+ screen: ScreenFile,
3132
+ previewUrl?: string,
3133
+ ): ScreenSourceType {
3134
+ const haystack =
3135
+ `${screen.filename} ${screen.content.slice(0, 4000)}`.toLowerCase();
3136
+ const url = getUrl(previewUrl ?? screen.content);
3137
+
3138
+ if (
3139
+ url?.hostname === "localhost" ||
3140
+ url?.hostname === "127.0.0.1" ||
3141
+ url?.hostname.endsWith(".local") ||
3142
+ haystack.includes("localhost") ||
3143
+ haystack.includes("127.0.0.1")
3144
+ ) {
3145
+ return "localhost";
3146
+ }
3147
+
3148
+ if (haystack.includes("fusion") || url?.hostname.includes("fusion")) {
3149
+ return "fusion";
3150
+ }
3151
+
3152
+ return "inline";
3153
+ }
3154
+
3155
+ function derivePreviewState(
3156
+ screen: ScreenFile,
3157
+ previewUrl?: string,
3158
+ ): ScreenPreviewState {
3159
+ const haystack =
3160
+ `${screen.filename} ${screen.content.slice(0, 4000)}`.toLowerCase();
3161
+
3162
+ if (
3163
+ haystack.includes("snapshot") ||
3164
+ haystack.includes("screenshot") ||
3165
+ haystack.includes("cached") ||
3166
+ haystack.includes("data:image/")
3167
+ ) {
3168
+ return "snapshot";
3169
+ }
3170
+
3171
+ if (previewUrl || deriveSource(screen, previewUrl) !== "inline") {
3172
+ return "live";
3173
+ }
3174
+
3175
+ return "preview";
3176
+ }
3177
+
3178
+ function getPreviewUrl(content: string) {
3179
+ return getUrl(content.trim())?.toString();
3180
+ }
3181
+
3182
+ function getUrl(value: string | undefined) {
3183
+ if (!value) return undefined;
3184
+ try {
3185
+ const url = new URL(value);
3186
+ return url.protocol === "http:" || url.protocol === "https:"
3187
+ ? url
3188
+ : undefined;
3189
+ } catch {
3190
+ return undefined;
3191
+ }
223
3192
  }