@agent-native/core 0.79.27 → 0.80.1

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 (497) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +34 -0
  3. package/corpus/core/docs/content/locales/ar-SA/skills-guide.mdx +5 -0
  4. package/corpus/core/docs/content/locales/ar-SA/template-design.mdx +55 -1
  5. package/corpus/core/docs/content/locales/de-DE/skills-guide.mdx +5 -0
  6. package/corpus/core/docs/content/locales/de-DE/template-design.mdx +66 -1
  7. package/corpus/core/docs/content/locales/es-ES/skills-guide.mdx +5 -0
  8. package/corpus/core/docs/content/locales/es-ES/template-design.mdx +65 -1
  9. package/corpus/core/docs/content/locales/fr-FR/skills-guide.mdx +5 -0
  10. package/corpus/core/docs/content/locales/fr-FR/template-design.mdx +64 -1
  11. package/corpus/core/docs/content/locales/hi-IN/skills-guide.mdx +5 -0
  12. package/corpus/core/docs/content/locales/hi-IN/template-design.mdx +59 -1
  13. package/corpus/core/docs/content/locales/ja-JP/skills-guide.mdx +5 -0
  14. package/corpus/core/docs/content/locales/ja-JP/template-design.mdx +62 -1
  15. package/corpus/core/docs/content/locales/ko-KR/skills-guide.mdx +5 -0
  16. package/corpus/core/docs/content/locales/ko-KR/template-design.mdx +58 -1
  17. package/corpus/core/docs/content/locales/pt-BR/skills-guide.mdx +5 -0
  18. package/corpus/core/docs/content/locales/pt-BR/template-design.mdx +62 -1
  19. package/corpus/core/docs/content/locales/zh-CN/skills-guide.mdx +5 -0
  20. package/corpus/core/docs/content/locales/zh-CN/template-design.mdx +50 -1
  21. package/corpus/core/docs/content/locales/zh-TW/skills-guide.mdx +5 -0
  22. package/corpus/core/docs/content/locales/zh-TW/template-design.mdx +52 -1
  23. package/corpus/core/docs/content/skills-guide.mdx +5 -0
  24. package/corpus/core/docs/content/template-design.mdx +59 -1
  25. package/corpus/core/package.json +2 -1
  26. package/corpus/core/src/agent/engine/index.ts +1 -0
  27. package/corpus/core/src/agent/engine/registry.ts +40 -0
  28. package/corpus/core/src/agent/production-agent.ts +6 -2
  29. package/corpus/core/src/application-state/handlers.ts +1 -1
  30. package/corpus/core/src/brand-kit/index.ts +1 -1
  31. package/corpus/core/src/cli/design-connect.ts +490 -0
  32. package/corpus/core/src/cli/index.ts +16 -0
  33. package/corpus/core/src/cli/skills.ts +144 -3
  34. package/corpus/core/src/client/AgentPanel.tsx +1 -1
  35. package/corpus/core/src/client/AssistantChat.tsx +43 -7
  36. package/corpus/core/src/client/MultiTabAssistantChat.tsx +1 -1
  37. package/corpus/core/src/client/agent-chat.ts +27 -3
  38. package/corpus/core/src/client/blocks/library/MermaidBlock.tsx +1 -1
  39. package/corpus/core/src/client/blocks/library/diagram.tsx +2 -2
  40. package/corpus/core/src/client/blocks/library/wireframe.tsx +1 -1
  41. package/corpus/core/src/client/chat/message-components.tsx +1 -1
  42. package/corpus/core/src/client/chat-model-groups.ts +1 -2
  43. package/corpus/core/src/client/components/LiveCursorOverlay.tsx +33 -66
  44. package/corpus/core/src/client/composer/TiptapComposer.tsx +99 -24
  45. package/corpus/core/src/client/composer/useVoiceDictation.ts +429 -260
  46. package/corpus/core/src/client/extensions/portable-extension.ts +1 -1
  47. package/corpus/core/src/client/frame-protocol.ts +17 -2
  48. package/corpus/core/src/client/rich-markdown-editor/RegistryBlockNode.tsx +2 -2
  49. package/corpus/core/src/client/sharing/ShareButton.tsx +20 -15
  50. package/corpus/core/src/client/use-agent-engine-configured.ts +80 -28
  51. package/corpus/core/src/code-agents/transcript-normalizer.ts +1 -1
  52. package/corpus/core/src/extensions/path.ts +1 -4
  53. package/corpus/core/src/integrations/a2a-continuation-processor.ts +1 -1
  54. package/corpus/core/src/mcp/build-server.ts +1 -1
  55. package/corpus/core/src/org/handlers.ts +5 -5
  56. package/corpus/core/src/org/plugin.ts +2 -2
  57. package/corpus/core/src/scripts/call-agent.ts +1 -1
  58. package/corpus/core/src/scripts/db/safety.ts +1 -1
  59. package/corpus/core/src/server/auth.ts +3 -0
  60. package/corpus/core/src/server/builder-design-systems.ts +308 -0
  61. package/corpus/core/src/server/core-routes-plugin.ts +29 -3
  62. package/corpus/core/src/server/index.ts +8 -0
  63. package/corpus/core/src/server/transcribe-voice.ts +57 -22
  64. package/corpus/core/src/shared/mcp-embed-headers.ts +23 -1
  65. package/corpus/core/src/styles/agent-native.css +60 -4
  66. package/corpus/core/src/templates/workspace-core/.agents/skills/voice-transcription/SKILL.md +10 -0
  67. package/corpus/core/src/usage/store.ts +2 -2
  68. package/corpus/core/src/vite/client.ts +2 -0
  69. package/corpus/core/src/voice/index.ts +18 -0
  70. package/corpus/core/src/voice/voice-cleanup-prompt.ts +38 -0
  71. package/corpus/core/src/voice/voice-context.ts +217 -0
  72. package/corpus/core/src/voice/voice-replacements.ts +75 -0
  73. package/corpus/templates/analytics/AGENTS.md +4 -3
  74. package/corpus/templates/analytics/actions/dashboard-mutation-api.ts +298 -15
  75. package/corpus/templates/analytics/actions/get-sql-dashboard.ts +9 -4
  76. package/corpus/templates/analytics/actions/list-session-recordings.ts +6 -1
  77. package/corpus/templates/analytics/actions/mutate-dashboard.ts +18 -2
  78. package/corpus/templates/analytics/actions/query-agent-native-analytics.ts +12 -2
  79. package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +557 -400
  80. package/corpus/templates/analytics/app/global.css +0 -4
  81. package/corpus/templates/analytics/app/hooks/use-replay-storage-status.ts +66 -0
  82. package/corpus/templates/analytics/app/i18n/zh-TW.ts +39 -0
  83. package/corpus/templates/analytics/app/i18n-data.ts +424 -0
  84. package/corpus/templates/analytics/app/pages/Settings.tsx +333 -0
  85. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/PanelEditorDialog.tsx +2 -1
  86. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/dashboard-layout.ts +121 -3
  87. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +82 -34
  88. package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +7 -0
  89. package/corpus/templates/analytics/app/pages/sessions/SessionsPage.tsx +144 -36
  90. package/corpus/templates/analytics/changelog/2026-06-28-ask-tab-now-matches-the-darker-app-background.md +6 -0
  91. package/corpus/templates/analytics/changelog/2026-06-28-dashboard-email-reports-now-use-cleaner-light-mode-snapshots.md +6 -0
  92. package/corpus/templates/analytics/changelog/2026-06-28-left-sidebar-collapses-into-an-animated-icon-rail.md +6 -0
  93. package/corpus/templates/analytics/changelog/2026-06-28-sessions-now-show-only-signed-in-recordings-with-playable-re.md +6 -0
  94. package/corpus/templates/analytics/changelog/2026-06-29-analytics-agents-can-now-place-new-charts-into-requested-das.md +6 -0
  95. package/corpus/templates/analytics/changelog/2026-06-29-analytics-queries-now-report-failed-source-query-errors-inst.md +6 -0
  96. package/corpus/templates/analytics/changelog/2026-06-29-charts-can-now-be-dropped-between-full-dashboard-rows-to-cre.md +6 -0
  97. package/corpus/templates/analytics/changelog/2026-06-29-dashboard-email-reports-include-the-full-dashboard-image-wit.md +6 -0
  98. package/corpus/templates/analytics/changelog/2026-06-29-main-surface-border-reaches-the-top.md +6 -0
  99. package/corpus/templates/analytics/changelog/2026-06-29-session-replay-now-records-playable-sessions-in-production-a.md +6 -0
  100. package/corpus/templates/analytics/changelog/2026-06-29-set-up-session-replay-storage-from-settings-connect-builder-.md +6 -0
  101. package/corpus/templates/analytics/scripts/cleanup-empty-session-recordings.ts +102 -0
  102. package/corpus/templates/analytics/seeds/dashboards/agent-native-templates-first-party.json +3 -3
  103. package/corpus/templates/analytics/server/lib/dashboard-report.ts +75 -30
  104. package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +5 -2
  105. package/corpus/templates/analytics/server/lib/real-data-actions.ts +81 -20
  106. package/corpus/templates/analytics/server/lib/replay-storage.ts +79 -0
  107. package/corpus/templates/analytics/server/lib/s3-upload-provider.ts +255 -0
  108. package/corpus/templates/analytics/server/lib/session-replay.ts +53 -12
  109. package/corpus/templates/analytics/server/plugins/agent-chat.ts +11 -2
  110. package/corpus/templates/analytics/server/plugins/onboarding.ts +97 -0
  111. package/corpus/templates/assets/app/components/layout/Sidebar.tsx +4 -4
  112. package/corpus/templates/assets/changelog/2026-06-28-left-sidebar-collapse-motion-and-footer-chrome-are-quieter.md +6 -0
  113. package/corpus/templates/brain/app/components/layout/Layout.tsx +29 -2
  114. package/corpus/templates/brain/app/components/layout/Sidebar.tsx +131 -42
  115. package/corpus/templates/brain/app/i18n/zh-TW.ts +2 -0
  116. package/corpus/templates/brain/app/i18n-data.ts +20 -0
  117. package/corpus/templates/brain/changelog/2026-06-28-left-sidebar-collapses-into-an-animated-icon-rail.md +6 -0
  118. package/corpus/templates/calendar/app/components/layout/AppLayout.tsx +29 -1
  119. package/corpus/templates/calendar/app/components/layout/Sidebar.tsx +417 -299
  120. package/corpus/templates/calendar/app/i18n/zh-TW.ts +2 -0
  121. package/corpus/templates/calendar/app/i18n-data.ts +20 -0
  122. package/corpus/templates/calendar/changelog/2026-06-28-left-sidebar-collapses-into-an-animated-icon-rail.md +6 -0
  123. package/corpus/templates/chat/app/components/layout/Sidebar.tsx +6 -19
  124. package/corpus/templates/chat/changelog/2026-06-28-left-sidebar-collapse-motion-and-footer-chrome-are-quieter.md +6 -0
  125. package/corpus/templates/clips/AGENTS.md +4 -0
  126. package/corpus/templates/clips/actions/cleanup-transcript.ts +39 -9
  127. package/corpus/templates/clips/actions/finalize-meeting.ts +81 -12
  128. package/corpus/templates/clips/actions/lib/agents-md-context.ts +4 -1
  129. package/corpus/templates/clips/app/components/library/library-layout.tsx +3 -3
  130. package/corpus/templates/clips/app/components/meetings/share-meeting-dialog.tsx +1 -1
  131. package/corpus/templates/clips/app/components/player/share-dialog.tsx +2 -2
  132. package/corpus/templates/clips/changelog/2026-06-28-left-sidebar-footer-controls-use-less-divider-chrome.md +6 -0
  133. package/corpus/templates/clips/desktop/src/lib/personal-vocabulary.ts +13 -8
  134. package/corpus/templates/clips/desktop/src/lib/recorder.ts +24 -0
  135. package/corpus/templates/clips/desktop/src/lib/transcription-capture.ts +105 -5
  136. package/corpus/templates/clips/desktop/src/lib/voice-dictation.ts +60 -1
  137. package/corpus/templates/content/actions/set-image-alt-text.ts +1 -1
  138. package/corpus/templates/content/app/components/editor/DocumentDatabase.tsx +1 -1
  139. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +1 -1
  140. package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +5 -5
  141. package/corpus/templates/content/changelog/2026-06-28-left-sidebar-collapse-motion-and-footer-chrome-are-quieter.md +6 -0
  142. package/corpus/templates/content/shared/document-export.ts +1 -1
  143. package/corpus/templates/design/.agents/skills/design-systems/SKILL.md +8 -18
  144. package/corpus/templates/design/.agents/skills/visual-edit/SKILL.md +154 -0
  145. package/corpus/templates/design/AGENTS.md +95 -5
  146. package/corpus/templates/design/DESIGN.md +82 -0
  147. package/corpus/templates/design/DEVELOPING.md +43 -0
  148. package/corpus/templates/design/actions/add-localhost-screens.ts +453 -0
  149. package/corpus/templates/design/actions/apply-shader.ts +277 -0
  150. package/corpus/templates/design/actions/apply-visual-edit.ts +303 -0
  151. package/corpus/templates/design/actions/connect-localhost.ts +183 -0
  152. package/corpus/templates/design/actions/edit-design.ts +195 -42
  153. package/corpus/templates/design/actions/generate-design.ts +71 -1
  154. package/corpus/templates/design/actions/generate-screens.ts +193 -0
  155. package/corpus/templates/design/actions/get-code-layer-projection.ts +191 -0
  156. package/corpus/templates/design/actions/get-shader.ts +119 -0
  157. package/corpus/templates/design/actions/index-design-system-with-builder.ts +259 -0
  158. package/corpus/templates/design/actions/list-localhost-connections.ts +95 -0
  159. package/corpus/templates/design/actions/navigate.ts +55 -2
  160. package/corpus/templates/design/actions/update-file.ts +9 -2
  161. package/corpus/templates/design/actions/view-screen.ts +68 -1
  162. package/corpus/templates/design/agent-native.app-skill.json +10 -0
  163. package/corpus/templates/design/app/components/design/CanvasContextMenu.tsx +719 -0
  164. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +2120 -103
  165. package/corpus/templates/design/app/components/design/DesignEditorSkeleton.tsx +74 -38
  166. package/corpus/templates/design/app/components/design/DesignExtensionsPanel.tsx +401 -0
  167. package/corpus/templates/design/app/components/design/EditPanel.tsx +3966 -440
  168. package/corpus/templates/design/app/components/design/LayersPanel.tsx +1473 -0
  169. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +3940 -147
  170. package/corpus/templates/design/app/components/design/QuestionFlow.tsx +700 -13
  171. package/corpus/templates/design/app/components/design/TweaksPanel.tsx +105 -63
  172. package/corpus/templates/design/app/components/design/index.ts +0 -5
  173. package/corpus/templates/design/app/components/design/inspector/AlignmentMatrix.tsx +353 -0
  174. package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +1589 -0
  175. package/corpus/templates/design/app/components/design/inspector/ConstraintsWidget.tsx +511 -0
  176. package/corpus/templates/design/app/components/design/inspector/ExportSettingsPanel.tsx +395 -0
  177. package/corpus/templates/design/app/components/design/inspector/FigmaColorPicker.tsx +2010 -0
  178. package/corpus/templates/design/app/components/design/inspector/GradientEditor.tsx +601 -0
  179. package/corpus/templates/design/app/components/design/inspector/ImageFillControls.tsx +242 -0
  180. package/corpus/templates/design/app/components/design/inspector/SHADER_INTEGRATION.md +420 -0
  181. package/corpus/templates/design/app/components/design/inspector/ScrubInput.tsx +238 -0
  182. package/corpus/templates/design/app/components/design/inspector/ShaderControls.stories.tsx +71 -0
  183. package/corpus/templates/design/app/components/design/inspector/ShaderControls.tsx +575 -0
  184. package/corpus/templates/design/app/components/design/inspector/ShaderEffectRow.tsx +106 -0
  185. package/corpus/templates/design/app/components/design/inspector/ShaderFillsPanel.tsx +423 -0
  186. package/corpus/templates/design/app/components/design/inspector/figma-icons.tsx +741 -0
  187. package/corpus/templates/design/app/components/design/inspector/index.ts +87 -0
  188. package/corpus/templates/design/app/components/design/inspector/scrub-input-utils.ts +201 -0
  189. package/corpus/templates/design/app/components/design/types.ts +23 -0
  190. package/corpus/templates/design/app/components/layout/Layout.tsx +24 -13
  191. package/corpus/templates/design/app/components/layout/Sidebar.tsx +4 -4
  192. package/corpus/templates/design/app/components/visual-editor/CanvasCommentPins.tsx +474 -36
  193. package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +22 -5
  194. package/corpus/templates/design/app/components/visual-editor/SaveStatusIndicator.tsx +24 -11
  195. package/corpus/templates/design/app/global.css +149 -5
  196. package/corpus/templates/design/app/hooks/use-agent-generating.ts +4 -0
  197. package/corpus/templates/design/app/hooks/use-navigation-state.ts +130 -8
  198. package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +329 -0
  199. package/corpus/templates/design/app/i18n/zh-TW.ts +277 -0
  200. package/corpus/templates/design/app/i18n-data.ts +3028 -1
  201. package/corpus/templates/design/app/pages/DesignEditor.tsx +5831 -904
  202. package/corpus/templates/design/app/pages/DesignSystemSetup.tsx +57 -221
  203. package/corpus/templates/design/changelog/2026-06-28-annotate-mode-now-combines-drawing-and-comment-pins-in-one-c.md +6 -0
  204. package/corpus/templates/design/changelog/2026-06-28-design-editor-panel-borders-are-cleaner-so-the-canvas-edge-s.md +6 -0
  205. package/corpus/templates/design/changelog/2026-06-28-drawing-prompts-now-reliably-open-and-submit-to-the-design-a.md +6 -0
  206. package/corpus/templates/design/changelog/2026-06-28-editor-app-menus-now-use-an-outline-agent-native-mark-with-b.md +6 -0
  207. package/corpus/templates/design/changelog/2026-06-28-figma-style-code-design-mode.md +6 -0
  208. package/corpus/templates/design/changelog/2026-06-28-layer-selection-now-updates-chat-context-without-opening-the.md +6 -0
  209. package/corpus/templates/design/changelog/2026-06-28-layer-selections-now-preserve-hidden-and-locked-canvas-state.md +6 -0
  210. package/corpus/templates/design/changelog/2026-06-28-left-sidebar-collapse-motion-and-footer-chrome-are-quieter.md +6 -0
  211. package/corpus/templates/design/changelog/2026-06-28-live-cursors-now-keep-names-beside-a-cleaner-pointer-marker.md +6 -0
  212. package/corpus/templates/design/changelog/2026-06-28-mode-switching-now-lives-in-a-compact-bottom-right-icon-dock.md +6 -0
  213. package/corpus/templates/design/changelog/2026-06-28-screen-overview-now-handles-zooming-drawing-opening-and-dele.md +6 -0
  214. package/corpus/templates/design/changelog/2026-06-28-screen-overview-now-selects-screens-on-click-and-opens-them-.md +6 -0
  215. package/corpus/templates/design/changelog/2026-06-28-screen-overview-zooms-into-the-active-screen-with-smoother-e.md +6 -0
  216. package/corpus/templates/design/changelog/2026-06-28-selected-element-chips-in-chat-are-shorter-and-easier-to-sca.md +6 -0
  217. package/corpus/templates/design/changelog/2026-06-28-the-design-editor-toolbar-is-more-compact-and-the-app-menu-m.md +6 -0
  218. package/corpus/templates/design/changelog/2026-06-28-tweaks-now-live-at-the-top-of-the-design-sidebar-instead-of-.md +6 -0
  219. package/corpus/templates/design/changelog/2026-06-28-tweaks-now-live-in-the-right-inspector-beside-the-design-tab.md +6 -0
  220. package/corpus/templates/design/changelog/2026-06-29-active-sidebar-rows-use-soft-highlights.md +6 -0
  221. package/corpus/templates/design/changelog/2026-06-29-agents-can-now-open-screen-overview-focus-screens-and-add.md +6 -0
  222. package/corpus/templates/design/changelog/2026-06-29-canvas-selection-no-longer-flashes-broken-prototype-html-whe.md +6 -0
  223. package/corpus/templates/design/changelog/2026-06-29-command-menu-selections-are-clearer-in-dark-mode.md +6 -0
  224. package/corpus/templates/design/changelog/2026-06-29-design-agents-now-edit-broad-copy-changes-more-reliably.md +6 -0
  225. package/corpus/templates/design/changelog/2026-06-29-design-editor-extensions-now-run-inline-beside-the-artboard.md +6 -0
  226. package/corpus/templates/design/changelog/2026-06-29-design-mode-top-chrome-now-groups-collaborators-preview-shar.md +6 -0
  227. package/corpus/templates/design/changelog/2026-06-29-element-selection-now-stays-on-the-intended-layer-after-sele.md +6 -0
  228. package/corpus/templates/design/changelog/2026-06-29-screen-overview-full-view-controls-now-sit-above-frames-inst.md +6 -0
  229. package/corpus/templates/design/changelog/2026-06-29-screen-overview-tools-can-draw-shapes-create-screens-and-kee.md +6 -0
  230. package/corpus/templates/design/changelog/2026-06-29-sidebars-use-darker-chrome-and-chat-warns-immediately.md +6 -0
  231. package/corpus/templates/design/changelog/2026-06-29-the-pen-tool-now-creates-anchor-based-bezier-vectors-with-ha.md +6 -0
  232. package/corpus/templates/design/changelog/2026-06-29-visual-edit-can-open-localhost-routes-as-url-backed-screens.md +6 -0
  233. package/corpus/templates/design/package.json +1 -0
  234. package/corpus/templates/design/server/db/schema.ts +26 -0
  235. package/corpus/templates/design/server/handlers/import-figma-system.ts +38 -85
  236. package/corpus/templates/design/server/lib/design-snapshot.ts +9 -1
  237. package/corpus/templates/design/server/plugins/agent-chat.ts +3 -0
  238. package/corpus/templates/design/shared/api.ts +27 -0
  239. package/corpus/templates/design/shared/canvas-frames.ts +103 -0
  240. package/corpus/templates/design/shared/canvas-math.ts +1058 -0
  241. package/corpus/templates/design/shared/code-layer.ts +2412 -0
  242. package/corpus/templates/design/shared/color-utils.ts +180 -0
  243. package/corpus/templates/design/shared/generation-session.ts +61 -0
  244. package/corpus/templates/design/shared/html-content.ts +21 -0
  245. package/corpus/templates/design/shared/pen-path.ts +207 -0
  246. package/corpus/templates/design/shared/shader-presets.ts +782 -0
  247. package/corpus/templates/design/shared/shader-safety.ts +137 -0
  248. package/corpus/templates/design/shared/source-mode.ts +237 -0
  249. package/corpus/templates/forms/app/components/layout/Sidebar.tsx +149 -6
  250. package/corpus/templates/forms/app/i18n/ar-SA.ts +2 -0
  251. package/corpus/templates/forms/app/i18n/de-DE.ts +2 -0
  252. package/corpus/templates/forms/app/i18n/en-US.ts +2 -0
  253. package/corpus/templates/forms/app/i18n/es-ES.ts +2 -0
  254. package/corpus/templates/forms/app/i18n/fr-FR.ts +2 -0
  255. package/corpus/templates/forms/app/i18n/hi-IN.ts +2 -0
  256. package/corpus/templates/forms/app/i18n/ja-JP.ts +2 -0
  257. package/corpus/templates/forms/app/i18n/ko-KR.ts +2 -0
  258. package/corpus/templates/forms/app/i18n/pt-BR.ts +2 -0
  259. package/corpus/templates/forms/app/i18n/zh-CN.ts +2 -0
  260. package/corpus/templates/forms/app/i18n/zh-TW.ts +2 -0
  261. package/corpus/templates/forms/changelog/2026-06-28-left-sidebar-collapses-into-an-animated-icon-rail.md +6 -0
  262. package/corpus/templates/macros/app/components/layout/AppLayout.tsx +2 -2
  263. package/corpus/templates/macros/changelog/2026-06-28-left-sidebar-footer-controls-use-less-divider-chrome.md +6 -0
  264. package/corpus/templates/mail/app/components/layout/AppLayout.tsx +408 -247
  265. package/corpus/templates/mail/app/i18n/ar-SA.ts +4 -0
  266. package/corpus/templates/mail/app/i18n/de-DE.ts +4 -0
  267. package/corpus/templates/mail/app/i18n/en-US.ts +4 -0
  268. package/corpus/templates/mail/app/i18n/es-ES.ts +4 -0
  269. package/corpus/templates/mail/app/i18n/fr-FR.ts +4 -0
  270. package/corpus/templates/mail/app/i18n/hi-IN.ts +4 -0
  271. package/corpus/templates/mail/app/i18n/ja-JP.ts +4 -0
  272. package/corpus/templates/mail/app/i18n/ko-KR.ts +4 -0
  273. package/corpus/templates/mail/app/i18n/pt-BR.ts +4 -0
  274. package/corpus/templates/mail/app/i18n/zh-CN.ts +4 -0
  275. package/corpus/templates/mail/app/i18n/zh-TW.ts +4 -0
  276. package/corpus/templates/mail/app/lib/utils.ts +2 -2
  277. package/corpus/templates/mail/changelog/2026-06-28-pinned-sidebar-collapses-into-an-animated-icon-rail.md +6 -0
  278. package/corpus/templates/plan/app/components/layout/Sidebar.tsx +6 -8
  279. package/corpus/templates/plan/app/components/plan/wireframe/Wireframe.tsx +1 -1
  280. package/corpus/templates/plan/app/global.css +61 -78
  281. package/corpus/templates/plan/app/pages/PlansPage.tsx +8 -0
  282. package/corpus/templates/plan/changelog/2026-06-28-left-sidebar-collapse-motion-and-footer-chrome-are-quieter.md +6 -0
  283. package/corpus/templates/plan/changelog/2026-06-28-plans-use-the-shared-neutral-light-and-dark-theme.md +6 -0
  284. package/corpus/templates/plan/changelog/2026-06-28-sending-open-feedback-from-a-plan-now-opens-the-inline-plan-.md +6 -0
  285. package/corpus/templates/slides/.agents/skills/design-systems/SKILL.md +9 -10
  286. package/corpus/templates/slides/AGENTS.md +3 -2
  287. package/corpus/templates/slides/actions/import-file.ts +26 -17
  288. package/corpus/templates/slides/actions/index-design-system-with-builder.ts +259 -0
  289. package/corpus/templates/slides/app/components/design-system/DesignSystemSetup.tsx +35 -167
  290. package/corpus/templates/slides/app/components/design-system/fig-import-response.ts +9 -13
  291. package/corpus/templates/slides/app/components/layout/Sidebar.tsx +5 -5
  292. package/corpus/templates/slides/app/context/DeckContext.tsx +1 -1
  293. package/corpus/templates/slides/app/i18n/ar-SA.ts +9 -37
  294. package/corpus/templates/slides/app/i18n/de-DE.ts +10 -25
  295. package/corpus/templates/slides/app/i18n/en-US.ts +9 -26
  296. package/corpus/templates/slides/app/i18n/es-ES.ts +10 -28
  297. package/corpus/templates/slides/app/i18n/fr-FR.ts +10 -28
  298. package/corpus/templates/slides/app/i18n/hi-IN.ts +9 -26
  299. package/corpus/templates/slides/app/i18n/ja-JP.ts +10 -22
  300. package/corpus/templates/slides/app/i18n/ko-KR.ts +9 -23
  301. package/corpus/templates/slides/app/i18n/pt-BR.ts +10 -28
  302. package/corpus/templates/slides/app/i18n/zh-CN.ts +9 -22
  303. package/corpus/templates/slides/app/i18n/zh-TW.ts +9 -22
  304. package/corpus/templates/slides/changelog/2026-06-28-left-sidebar-collapse-motion-and-footer-chrome-are-quieter.md +6 -0
  305. package/corpus/templates/slides/server/handlers/import-figma-system.ts +44 -12
  306. package/corpus/templates/slides/server/lib/chat-attachments.ts +1 -1
  307. package/corpus/templates/videos/app/components/NewCompositionPopover.tsx +1 -1
  308. package/corpus/templates/videos/app/components/Sidebar.tsx +2 -2
  309. package/corpus/templates/videos/app/components/layout/Layout.tsx +29 -2
  310. package/corpus/templates/videos/app/components/layout/NavSidebar.tsx +113 -30
  311. package/corpus/templates/videos/app/i18n/ar-SA.ts +2 -0
  312. package/corpus/templates/videos/app/i18n/de-DE.ts +2 -0
  313. package/corpus/templates/videos/app/i18n/en-US.ts +2 -0
  314. package/corpus/templates/videos/app/i18n/es-ES.ts +2 -0
  315. package/corpus/templates/videos/app/i18n/fr-FR.ts +2 -0
  316. package/corpus/templates/videos/app/i18n/hi-IN.ts +2 -0
  317. package/corpus/templates/videos/app/i18n/ja-JP.ts +2 -0
  318. package/corpus/templates/videos/app/i18n/ko-KR.ts +2 -0
  319. package/corpus/templates/videos/app/i18n/pt-BR.ts +2 -0
  320. package/corpus/templates/videos/app/i18n/zh-CN.ts +2 -0
  321. package/corpus/templates/videos/app/i18n/zh-TW.ts +2 -0
  322. package/corpus/templates/videos/changelog/2026-06-28-left-sidebar-collapses-into-an-animated-icon-rail.md +6 -0
  323. package/dist/agent/engine/index.d.ts +1 -1
  324. package/dist/agent/engine/index.d.ts.map +1 -1
  325. package/dist/agent/engine/index.js +1 -1
  326. package/dist/agent/engine/index.js.map +1 -1
  327. package/dist/agent/engine/registry.d.ts +9 -0
  328. package/dist/agent/engine/registry.d.ts.map +1 -1
  329. package/dist/agent/engine/registry.js +37 -0
  330. package/dist/agent/engine/registry.js.map +1 -1
  331. package/dist/agent/production-agent.d.ts.map +1 -1
  332. package/dist/agent/production-agent.js +4 -3
  333. package/dist/agent/production-agent.js.map +1 -1
  334. package/dist/application-state/handlers.js +1 -1
  335. package/dist/application-state/handlers.js.map +1 -1
  336. package/dist/brand-kit/index.d.ts +1 -1
  337. package/dist/brand-kit/index.js +1 -1
  338. package/dist/brand-kit/index.js.map +1 -1
  339. package/dist/cli/design-connect.d.ts +52 -0
  340. package/dist/cli/design-connect.d.ts.map +1 -0
  341. package/dist/cli/design-connect.js +409 -0
  342. package/dist/cli/design-connect.js.map +1 -0
  343. package/dist/cli/index.js +15 -0
  344. package/dist/cli/index.js.map +1 -1
  345. package/dist/cli/skills.d.ts +3 -0
  346. package/dist/cli/skills.d.ts.map +1 -1
  347. package/dist/cli/skills.js +139 -3
  348. package/dist/cli/skills.js.map +1 -1
  349. package/dist/client/AgentPanel.js +1 -1
  350. package/dist/client/AgentPanel.js.map +1 -1
  351. package/dist/client/AssistantChat.d.ts.map +1 -1
  352. package/dist/client/AssistantChat.js +38 -8
  353. package/dist/client/AssistantChat.js.map +1 -1
  354. package/dist/client/MultiTabAssistantChat.js +1 -1
  355. package/dist/client/MultiTabAssistantChat.js.map +1 -1
  356. package/dist/client/agent-chat.d.ts +6 -0
  357. package/dist/client/agent-chat.d.ts.map +1 -1
  358. package/dist/client/agent-chat.js +17 -3
  359. package/dist/client/agent-chat.js.map +1 -1
  360. package/dist/client/blocks/library/MermaidBlock.js +1 -1
  361. package/dist/client/blocks/library/MermaidBlock.js.map +1 -1
  362. package/dist/client/blocks/library/diagram.js +1 -1
  363. package/dist/client/blocks/library/diagram.js.map +1 -1
  364. package/dist/client/blocks/library/wireframe.js +1 -1
  365. package/dist/client/blocks/library/wireframe.js.map +1 -1
  366. package/dist/client/chat/message-components.js +1 -1
  367. package/dist/client/chat/message-components.js.map +1 -1
  368. package/dist/client/chat-model-groups.d.ts.map +1 -1
  369. package/dist/client/chat-model-groups.js +1 -2
  370. package/dist/client/chat-model-groups.js.map +1 -1
  371. package/dist/client/components/LiveCursorOverlay.d.ts +1 -2
  372. package/dist/client/components/LiveCursorOverlay.d.ts.map +1 -1
  373. package/dist/client/components/LiveCursorOverlay.js +19 -40
  374. package/dist/client/components/LiveCursorOverlay.js.map +1 -1
  375. package/dist/client/composer/TiptapComposer.d.ts +3 -1
  376. package/dist/client/composer/TiptapComposer.d.ts.map +1 -1
  377. package/dist/client/composer/TiptapComposer.js +86 -24
  378. package/dist/client/composer/TiptapComposer.js.map +1 -1
  379. package/dist/client/composer/useVoiceDictation.d.ts +9 -0
  380. package/dist/client/composer/useVoiceDictation.d.ts.map +1 -1
  381. package/dist/client/composer/useVoiceDictation.js +154 -32
  382. package/dist/client/composer/useVoiceDictation.js.map +1 -1
  383. package/dist/client/extensions/portable-extension.d.ts +1 -1
  384. package/dist/client/extensions/portable-extension.d.ts.map +1 -1
  385. package/dist/client/extensions/portable-extension.js.map +1 -1
  386. package/dist/client/frame-protocol.d.ts +17 -2
  387. package/dist/client/frame-protocol.d.ts.map +1 -1
  388. package/dist/client/frame-protocol.js.map +1 -1
  389. package/dist/client/rich-markdown-editor/RegistryBlockNode.js +2 -2
  390. package/dist/client/rich-markdown-editor/RegistryBlockNode.js.map +1 -1
  391. package/dist/client/sharing/ShareButton.d.ts +2 -0
  392. package/dist/client/sharing/ShareButton.d.ts.map +1 -1
  393. package/dist/client/sharing/ShareButton.js +7 -6
  394. package/dist/client/sharing/ShareButton.js.map +1 -1
  395. package/dist/client/use-agent-engine-configured.d.ts +5 -0
  396. package/dist/client/use-agent-engine-configured.d.ts.map +1 -1
  397. package/dist/client/use-agent-engine-configured.js +51 -25
  398. package/dist/client/use-agent-engine-configured.js.map +1 -1
  399. package/dist/code-agents/transcript-normalizer.js +1 -1
  400. package/dist/code-agents/transcript-normalizer.js.map +1 -1
  401. package/dist/collab/routes.d.ts +1 -1
  402. package/dist/extensions/path.d.ts +1 -1
  403. package/dist/extensions/path.d.ts.map +1 -1
  404. package/dist/extensions/path.js.map +1 -1
  405. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  406. package/dist/integrations/a2a-continuation-processor.js +1 -1
  407. package/dist/integrations/a2a-continuation-processor.js.map +1 -1
  408. package/dist/mcp/build-server.d.ts +1 -1
  409. package/dist/mcp/build-server.d.ts.map +1 -1
  410. package/dist/mcp/build-server.js.map +1 -1
  411. package/dist/notifications/routes.d.ts +3 -3
  412. package/dist/observability/routes.d.ts +5 -5
  413. package/dist/org/handlers.js +5 -5
  414. package/dist/org/handlers.js.map +1 -1
  415. package/dist/org/plugin.js +2 -2
  416. package/dist/org/plugin.js.map +1 -1
  417. package/dist/scripts/call-agent.js +1 -1
  418. package/dist/scripts/call-agent.js.map +1 -1
  419. package/dist/scripts/db/safety.js +1 -1
  420. package/dist/scripts/db/safety.js.map +1 -1
  421. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  422. package/dist/server/auth.d.ts.map +1 -1
  423. package/dist/server/auth.js +3 -0
  424. package/dist/server/auth.js.map +1 -1
  425. package/dist/server/builder-design-systems.d.ts +28 -0
  426. package/dist/server/builder-design-systems.d.ts.map +1 -0
  427. package/dist/server/builder-design-systems.js +217 -0
  428. package/dist/server/builder-design-systems.js.map +1 -0
  429. package/dist/server/core-routes-plugin.d.ts +1 -0
  430. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  431. package/dist/server/core-routes-plugin.js +20 -3
  432. package/dist/server/core-routes-plugin.js.map +1 -1
  433. package/dist/server/index.d.ts +1 -0
  434. package/dist/server/index.d.ts.map +1 -1
  435. package/dist/server/index.js +1 -0
  436. package/dist/server/index.js.map +1 -1
  437. package/dist/server/transcribe-voice.d.ts +1 -1
  438. package/dist/server/transcribe-voice.d.ts.map +1 -1
  439. package/dist/server/transcribe-voice.js +40 -24
  440. package/dist/server/transcribe-voice.js.map +1 -1
  441. package/dist/shared/mcp-embed-headers.d.ts +1 -0
  442. package/dist/shared/mcp-embed-headers.d.ts.map +1 -1
  443. package/dist/shared/mcp-embed-headers.js +20 -1
  444. package/dist/shared/mcp-embed-headers.js.map +1 -1
  445. package/dist/styles/agent-native.css +60 -4
  446. package/dist/templates/workspace-core/.agents/skills/voice-transcription/SKILL.md +10 -0
  447. package/dist/usage/store.js +2 -2
  448. package/dist/usage/store.js.map +1 -1
  449. package/dist/vite/client.d.ts.map +1 -1
  450. package/dist/vite/client.js +2 -0
  451. package/dist/vite/client.js.map +1 -1
  452. package/dist/voice/index.d.ts +5 -0
  453. package/dist/voice/index.d.ts.map +1 -0
  454. package/dist/voice/index.js +4 -0
  455. package/dist/voice/index.js.map +1 -0
  456. package/dist/voice/voice-cleanup-prompt.d.ts +6 -0
  457. package/dist/voice/voice-cleanup-prompt.d.ts.map +1 -0
  458. package/dist/voice/voice-cleanup-prompt.js +26 -0
  459. package/dist/voice/voice-cleanup-prompt.js.map +1 -0
  460. package/dist/voice/voice-context.d.ts +25 -0
  461. package/dist/voice/voice-context.d.ts.map +1 -0
  462. package/dist/voice/voice-context.js +156 -0
  463. package/dist/voice/voice-context.js.map +1 -0
  464. package/dist/voice/voice-replacements.d.ts +4 -0
  465. package/dist/voice/voice-replacements.d.ts.map +1 -0
  466. package/dist/voice/voice-replacements.js +52 -0
  467. package/dist/voice/voice-replacements.js.map +1 -0
  468. package/docs/content/locales/ar-SA/skills-guide.mdx +5 -0
  469. package/docs/content/locales/ar-SA/template-design.mdx +55 -1
  470. package/docs/content/locales/de-DE/skills-guide.mdx +5 -0
  471. package/docs/content/locales/de-DE/template-design.mdx +66 -1
  472. package/docs/content/locales/es-ES/skills-guide.mdx +5 -0
  473. package/docs/content/locales/es-ES/template-design.mdx +65 -1
  474. package/docs/content/locales/fr-FR/skills-guide.mdx +5 -0
  475. package/docs/content/locales/fr-FR/template-design.mdx +64 -1
  476. package/docs/content/locales/hi-IN/skills-guide.mdx +5 -0
  477. package/docs/content/locales/hi-IN/template-design.mdx +59 -1
  478. package/docs/content/locales/ja-JP/skills-guide.mdx +5 -0
  479. package/docs/content/locales/ja-JP/template-design.mdx +62 -1
  480. package/docs/content/locales/ko-KR/skills-guide.mdx +5 -0
  481. package/docs/content/locales/ko-KR/template-design.mdx +58 -1
  482. package/docs/content/locales/pt-BR/skills-guide.mdx +5 -0
  483. package/docs/content/locales/pt-BR/template-design.mdx +62 -1
  484. package/docs/content/locales/zh-CN/skills-guide.mdx +5 -0
  485. package/docs/content/locales/zh-CN/template-design.mdx +50 -1
  486. package/docs/content/locales/zh-TW/skills-guide.mdx +5 -0
  487. package/docs/content/locales/zh-TW/template-design.mdx +52 -1
  488. package/docs/content/skills-guide.mdx +5 -0
  489. package/docs/content/template-design.mdx +59 -1
  490. package/package.json +2 -1
  491. package/src/templates/workspace-core/.agents/skills/voice-transcription/SKILL.md +10 -0
  492. package/corpus/templates/design/actions/import-figma-file.ts +0 -166
  493. package/corpus/templates/design/app/components/design/DesignToolbar.tsx +0 -339
  494. package/corpus/templates/design/app/components/design/PresentMode.tsx +0 -68
  495. package/corpus/templates/design/app/components/design/ViewportTabs.tsx +0 -149
  496. package/corpus/templates/design/app/components/design/ZoomControls.tsx +0 -157
  497. package/corpus/templates/slides/server/lib/fig-design-system.ts +0 -110
@@ -1,10 +1,43 @@
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";
3
16
  import {
4
- Tooltip,
5
- TooltipContent,
6
- TooltipTrigger,
7
- } from "@/components/ui/tooltip";
17
+ appendPenNode,
18
+ clonePenPath,
19
+ closePenPath,
20
+ constrainPointTo45Degrees,
21
+ createCornerNode,
22
+ createSmoothNode,
23
+ getPenPathGeometry,
24
+ isPenCloseTarget,
25
+ scalePenPathToGeometry,
26
+ serializePenPath,
27
+ translatePenPath,
28
+ type PenNode,
29
+ type PenPath,
30
+ } from "@shared/pen-path";
31
+ import { IconCopy, IconMaximize } from "@tabler/icons-react";
32
+ import {
33
+ useRef,
34
+ useState,
35
+ useCallback,
36
+ useEffect,
37
+ type CSSProperties,
38
+ type ReactNode,
39
+ } from "react";
40
+
8
41
  import { prettyScreenName } from "@/lib/screen-names";
9
42
  import { cn } from "@/lib/utils";
10
43
 
@@ -12,42 +45,575 @@ interface ScreenFile {
12
45
  id: string;
13
46
  filename: string;
14
47
  content: string;
48
+ source?: string;
49
+ sourceType?: string;
50
+ lod?: string;
51
+ previewState?: string;
52
+ status?: string;
53
+ title?: string;
54
+ width?: number;
55
+ height?: number;
56
+ url?: string;
57
+ previewUrl?: string;
58
+ }
59
+
60
+ type ScreenSourceType = "localhost" | "fusion" | "inline";
61
+ type ScreenPreviewState = "live" | "snapshot" | "preview";
62
+ type MultiScreenCanvasTool =
63
+ | "move"
64
+ | "frame"
65
+ | "rect"
66
+ | "rectangle"
67
+ | "line"
68
+ | "arrow"
69
+ | "ellipse"
70
+ | "polygon"
71
+ | "star"
72
+ | "text"
73
+ | "pen"
74
+ | "hand"
75
+ | "comment"
76
+ | "draw"
77
+ | "scale";
78
+
79
+ interface CanvasToolProps {
80
+ fill?: string;
81
+ stroke?: string;
82
+ strokeWidth?: number;
83
+ text?: string;
84
+ }
85
+
86
+ export interface CanvasPrimitiveInsert {
87
+ kind: DraftPrimitiveKind;
88
+ geometry: FrameGeometry;
89
+ points?: Point[];
90
+ pathData?: string;
91
+ text?: string;
92
+ fill?: string;
93
+ stroke?: string;
94
+ strokeWidth?: number;
95
+ autoSize?: boolean;
96
+ }
97
+
98
+ interface ScreenMetadata {
99
+ source?: string;
100
+ sourceType?: string;
101
+ lod?: string;
102
+ previewState?: string;
103
+ title?: string;
104
+ width?: number;
105
+ height?: number;
106
+ url?: string;
107
+ previewUrl?: string;
108
+ }
109
+
110
+ interface DuplicateRequest {
111
+ mode: "alt-click" | "alt-drag";
112
+ screen: ScreenFile;
113
+ canvasPosition: { x: number; y: number };
114
+ canvasOffset?: { x: number; y: number };
115
+ dropCanvasPosition?: { x: number; y: number };
15
116
  }
16
117
 
17
118
  interface MultiScreenCanvasProps {
18
119
  screens: ScreenFile[];
19
120
  zoom: number;
20
121
  activeId?: string | null;
122
+ activeTool?: MultiScreenCanvasTool;
123
+ toolProps?: CanvasToolProps;
124
+ onActiveToolChange?: (tool: MultiScreenCanvasTool) => void;
21
125
  onPick: (id: string) => void;
126
+ onEdit?: (id: string) => void;
127
+ metadataById?: Record<string, ScreenMetadata | undefined>;
128
+ getScreenMetadata?: (screen: ScreenFile) => ScreenMetadata | undefined;
129
+ onDuplicate?: (id: string, request: DuplicateRequest) => void;
130
+ geometryById?: Record<string, Partial<FrameGeometry> | undefined>;
131
+ onGeometryChange?: (geometryById: FrameGeometryById) => void;
132
+ onGeometryCommit?: (
133
+ before: FrameGeometryById,
134
+ after: FrameGeometryById,
135
+ ) => void;
136
+ onCreatePrimitive?: (
137
+ screenId: string,
138
+ primitive: CanvasPrimitiveInsert,
139
+ ) => boolean;
140
+ onCreateScreenFrame?: (geometry: FrameGeometry) => void;
141
+ onDeleteSelection?: (ids: string[]) => boolean | void;
142
+ onZoomChange?: (zoom: number) => void;
143
+ onZoomToEdit?: (id: string) => void;
144
+ zoomToEditThreshold?: number;
145
+ renderScreenContent?: (
146
+ screen: ScreenFile,
147
+ metadata: ResolvedScreenMetadata,
148
+ geometry: FrameGeometry,
149
+ ) => ReactNode;
150
+ selectAllRequest?: number;
151
+ clearSelectionRequest?: number;
22
152
  }
23
153
 
24
154
  /**
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.
155
+ * Figma-style overview canvas. Renders every file in the design as a movable,
156
+ * resizable frame inside an infinite, pannable surface.
33
157
  */
34
158
  const SCREEN_WIDTH = 320;
35
159
  const SCREEN_HEIGHT = 640;
36
160
  const SCREEN_CARD_HEIGHT = SCREEN_HEIGHT + 26;
37
161
  const SCREEN_GAP = 56;
38
162
  const SURFACE_PADDING = 240;
163
+ const DUPLICATE_DRAG_THRESHOLD = 6;
164
+ const DRAG_THRESHOLD = 3;
165
+ const FRAME_LABEL_HEIGHT = 28;
166
+ const MIN_ZOOM = 2;
167
+ const MAX_ZOOM = 800;
168
+ const ZOOM_SENSITIVITY = 0.002;
169
+ const MAX_WHEEL_ZOOM_DELTA = 80;
170
+ const MAX_WHEEL_PAN_DELTA = 140;
171
+ const PIXEL_GRID_ZOOM = 800;
172
+ const DRAFT_FRAME_WIDTH = 320;
173
+ const DRAFT_FRAME_HEIGHT = 640;
174
+ const DRAFT_RECT_WIDTH = 160;
175
+ const DRAFT_RECT_HEIGHT = 120;
176
+ const DRAFT_TEXT_WIDTH = 180;
177
+ const DRAFT_TEXT_HEIGHT = 48;
178
+ const DRAFT_LINE_WIDTH = 160;
179
+ const DRAFT_PATH_MIN_SIZE = 12;
180
+ const PEN_SAMPLE_DISTANCE_SCREEN_PX = 5;
181
+ const PEN_CLOSE_HIT_RADIUS_SCREEN_PX = 10;
182
+
183
+ interface ResolvedScreenMetadata {
184
+ source: ScreenSourceType;
185
+ previewState: ScreenPreviewState;
186
+ title?: string;
187
+ width: number;
188
+ height: number;
189
+ previewUrl?: string;
190
+ }
191
+
192
+ interface DuplicatePreview {
193
+ display: string;
194
+ x: number;
195
+ y: number;
196
+ canDuplicate: boolean;
197
+ moved: boolean;
198
+ }
199
+
200
+ interface TransformBadge {
201
+ x: number;
202
+ y: number;
203
+ text: string;
204
+ }
205
+
206
+ export interface FrameGeometry {
207
+ x: number;
208
+ y: number;
209
+ width: number;
210
+ height: number;
211
+ rotation?: number;
212
+ z?: number;
213
+ }
214
+
215
+ type FrameGeometryById = Record<string, FrameGeometry>;
216
+
217
+ export interface Point {
218
+ x: number;
219
+ y: number;
220
+ }
221
+
222
+ export type DraftPrimitiveKind =
223
+ | "frame"
224
+ | "rectangle"
225
+ | "ellipse"
226
+ | "polygon"
227
+ | "star"
228
+ | "line"
229
+ | "arrow"
230
+ | "text"
231
+ | "path";
232
+ type DraftCreationTool =
233
+ | "frame"
234
+ | "rect"
235
+ | "line"
236
+ | "arrow"
237
+ | "ellipse"
238
+ | "polygon"
239
+ | "star"
240
+ | "text"
241
+ | "pen";
242
+
243
+ interface DraftPrimitive {
244
+ id: string;
245
+ kind: DraftPrimitiveKind;
246
+ geometry: FrameGeometry;
247
+ points?: Point[];
248
+ penPath?: PenPath;
249
+ pathData?: string;
250
+ text?: string;
251
+ fill?: string;
252
+ stroke?: string;
253
+ strokeWidth?: number;
254
+ autoSize?: boolean;
255
+ }
256
+
257
+ type DraftPrimitiveById = Record<string, DraftPrimitive>;
258
+
259
+ interface DraftPrimitiveInput {
260
+ tool: DraftCreationTool;
261
+ start: Point;
262
+ end: Point;
263
+ points?: Point[];
264
+ moved: boolean;
265
+ toolProps?: CanvasToolProps;
266
+ fallbackText: string;
267
+ }
268
+
269
+ interface MarqueeRect {
270
+ x: number;
271
+ y: number;
272
+ width: number;
273
+ height: number;
274
+ }
275
+
276
+ type ResizeHandle = "nw" | "n" | "ne" | "e" | "se" | "s" | "sw" | "w";
277
+
278
+ interface AlignmentGuide {
279
+ orientation: "vertical" | "horizontal";
280
+ position: number;
281
+ start: number;
282
+ end: number;
283
+ }
284
+
285
+ interface MoveDragState {
286
+ type: "move";
287
+ originClient: Point;
288
+ originFrames: FrameGeometryById;
289
+ targetIds: string[];
290
+ primaryId: string;
291
+ hasMoved: boolean;
292
+ }
293
+
294
+ interface ResizeDragState {
295
+ type: "resize";
296
+ originClient: Point;
297
+ originFrames: FrameGeometryById;
298
+ originBounds: FrameGeometry;
299
+ targetIds: string[];
300
+ handle: ResizeHandle;
301
+ hasMoved: boolean;
302
+ }
303
+
304
+ interface RotateDragState {
305
+ type: "rotate";
306
+ originClient: Point;
307
+ originFrame: FrameGeometry;
308
+ frameId: string;
309
+ originPointerAngle: number;
310
+ originRotation: number;
311
+ hasMoved: boolean;
312
+ }
313
+
314
+ interface MarqueeDragState {
315
+ type: "marquee";
316
+ originClient: Point;
317
+ originCanvas: Point;
318
+ baseSelectedIds: string[];
319
+ baseSelectedDraftIds: string[];
320
+ additive: boolean;
321
+ hasMoved: boolean;
322
+ }
323
+
324
+ interface PanDragState {
325
+ type: "pan";
326
+ originClient: Point;
327
+ originPan: Point;
328
+ }
329
+
330
+ interface DraftMoveDragState {
331
+ type: "draft-move";
332
+ originClient: Point;
333
+ originDrafts: DraftPrimitiveById;
334
+ targetIds: string[];
335
+ primaryId: string;
336
+ hasMoved: boolean;
337
+ }
338
+
339
+ interface DraftResizeDragState {
340
+ type: "draft-resize";
341
+ originClient: Point;
342
+ originDrafts: DraftPrimitiveById;
343
+ originBounds: FrameGeometry;
344
+ targetIds: string[];
345
+ handle: ResizeHandle;
346
+ hasMoved: boolean;
347
+ }
348
+
349
+ interface DraftCreateDragState {
350
+ type: "draft-create";
351
+ tool: DraftCreationTool;
352
+ originClient: Point;
353
+ originCanvas: Point;
354
+ points: Point[];
355
+ hasMoved: boolean;
356
+ }
357
+
358
+ interface PenNodeDragState {
359
+ type: "pen-node";
360
+ originClient: Point;
361
+ anchor: Point;
362
+ pathBefore: PenPath | null;
363
+ hasMoved: boolean;
364
+ }
365
+
366
+ interface DraftCreationPreview {
367
+ tool: DraftCreationTool;
368
+ geometry: FrameGeometry;
369
+ points?: Point[];
370
+ }
371
+
372
+ type DragState =
373
+ | MoveDragState
374
+ | ResizeDragState
375
+ | RotateDragState
376
+ | MarqueeDragState
377
+ | PanDragState
378
+ | DraftMoveDragState
379
+ | DraftResizeDragState
380
+ | DraftCreateDragState
381
+ | PenNodeDragState;
39
382
 
40
383
  export function MultiScreenCanvas({
41
384
  screens,
42
385
  zoom,
43
386
  activeId,
387
+ activeTool,
388
+ toolProps,
389
+ onActiveToolChange,
44
390
  onPick,
391
+ onEdit,
392
+ metadataById,
393
+ getScreenMetadata,
394
+ onDuplicate,
395
+ geometryById,
396
+ onGeometryChange,
397
+ onGeometryCommit,
398
+ onCreatePrimitive,
399
+ onCreateScreenFrame,
400
+ onDeleteSelection,
401
+ onZoomChange,
402
+ onZoomToEdit,
403
+ zoomToEditThreshold,
404
+ renderScreenContent,
405
+ selectAllRequest,
406
+ clearSelectionRequest,
45
407
  }: MultiScreenCanvasProps) {
408
+ const t = useT();
46
409
  const surfaceRef = useRef<HTMLDivElement>(null);
47
410
  const [pan, setPan] = useState({ x: 0, y: 0 });
48
- const dragging = useRef<{ x: number; y: number } | null>(null);
411
+ const panRef = useRef(pan);
412
+ const [canvasZoom, setCanvasZoom] = useState(zoom);
413
+ const zoomRef = useRef(zoom);
414
+ const [frameGeometry, setFrameGeometry] = useState<FrameGeometryById>({});
415
+ const frameGeometryRef = useRef(frameGeometry);
416
+ const onGeometryChangeRef = useRef(onGeometryChange);
417
+ const onGeometryCommitRef = useRef(onGeometryCommit);
418
+ const [draftPrimitives, setDraftPrimitives] = useState<DraftPrimitive[]>([]);
419
+ const draftPrimitivesRef = useRef(draftPrimitives);
420
+ const [selectedDraftIds, setSelectedDraftIds] = useState<string[]>([]);
421
+ const selectedDraftIdsRef = useRef(selectedDraftIds);
422
+ const [creationPreview, setCreationPreview] =
423
+ useState<DraftCreationPreview | null>(null);
424
+ const [activePenPath, setActivePenPath] = useState<PenPath | null>(null);
425
+ const activePenPathRef = useRef<PenPath | null>(activePenPath);
426
+ const [penGesturePreview, setPenGesturePreview] = useState<PenPath | null>(
427
+ null,
428
+ );
429
+ const [penPointer, setPenPointer] = useState<Point | null>(null);
430
+ const [penCloseHover, setPenCloseHover] = useState(false);
431
+ const [localActiveTool, setLocalActiveTool] =
432
+ useState<MultiScreenCanvasTool>("move");
433
+ const [selectedIds, setSelectedIds] = useState<string[]>(
434
+ activeId ? [activeId] : [],
435
+ );
436
+ const selectedIdsRef = useRef(selectedIds);
437
+ const dragState = useRef<DragState | null>(null);
49
438
  const dragCleanup = useRef<(() => void) | null>(null);
439
+ const duplicateCleanup = useRef<(() => void) | null>(null);
440
+ const handledSelectAllRequestRef = useRef(selectAllRequest);
441
+ const handledClearSelectionRequestRef = useRef(clearSelectionRequest);
50
442
  const [isDragging, setIsDragging] = useState(false);
443
+ const [isPanning, setIsPanning] = useState(false);
444
+ const [marquee, setMarquee] = useState<MarqueeRect | null>(null);
445
+ const [alignmentGuides, setAlignmentGuides] = useState<AlignmentGuide[]>([]);
446
+ const [duplicatePreview, setDuplicatePreview] =
447
+ useState<DuplicatePreview | null>(null);
448
+ const [transformBadge, setTransformBadge] = useState<TransformBadge | null>(
449
+ null,
450
+ );
451
+ const [dragCursor, setDragCursor] = useState<string | null>(null);
452
+ const suppressNextPick = useRef(false);
453
+ const feedbackTimerRef = useRef<number | null>(null);
454
+
455
+ useEffect(() => {
456
+ onGeometryChangeRef.current = onGeometryChange;
457
+ }, [onGeometryChange]);
458
+
459
+ useEffect(() => {
460
+ onGeometryCommitRef.current = onGeometryCommit;
461
+ }, [onGeometryCommit]);
462
+
463
+ useEffect(() => {
464
+ activePenPathRef.current = activePenPath;
465
+ }, [activePenPath]);
466
+
467
+ const updateFrameGeometry = useCallback(
468
+ (updater: (current: FrameGeometryById) => FrameGeometryById) => {
469
+ setFrameGeometry((current) => {
470
+ const next = updater(current);
471
+ frameGeometryRef.current = next;
472
+ onGeometryChangeRef.current?.(next);
473
+ return next;
474
+ });
475
+ },
476
+ [],
477
+ );
478
+
479
+ const updateSelectedIds = useCallback(
480
+ (updater: (current: string[]) => string[]) => {
481
+ setSelectedIds((current) => {
482
+ const next = dedupeIds(updater(current));
483
+ if (sameIds(current, next)) {
484
+ selectedIdsRef.current = current;
485
+ return current;
486
+ }
487
+ selectedIdsRef.current = next;
488
+ return next;
489
+ });
490
+ },
491
+ [],
492
+ );
493
+
494
+ const updateDraftPrimitives = useCallback(
495
+ (updater: (current: DraftPrimitive[]) => DraftPrimitive[]) => {
496
+ setDraftPrimitives((current) => {
497
+ const next = updater(current);
498
+ draftPrimitivesRef.current = next;
499
+ return next;
500
+ });
501
+ },
502
+ [],
503
+ );
504
+
505
+ const updateSelectedDraftIds = useCallback(
506
+ (updater: (current: string[]) => string[]) => {
507
+ setSelectedDraftIds((current) => {
508
+ const currentIds = new Set(
509
+ draftPrimitivesRef.current.map(({ id }) => id),
510
+ );
511
+ const next = dedupeIds(updater(current)).filter((id) =>
512
+ currentIds.has(id),
513
+ );
514
+ if (sameIds(current, next)) {
515
+ selectedDraftIdsRef.current = current;
516
+ return current;
517
+ }
518
+ selectedDraftIdsRef.current = next;
519
+ return next;
520
+ });
521
+ },
522
+ [],
523
+ );
524
+
525
+ useEffect(() => {
526
+ panRef.current = pan;
527
+ }, [pan]);
528
+
529
+ useEffect(() => {
530
+ zoomRef.current = canvasZoom;
531
+ }, [canvasZoom]);
532
+
533
+ useEffect(() => {
534
+ frameGeometryRef.current = frameGeometry;
535
+ }, [frameGeometry]);
536
+
537
+ useEffect(() => {
538
+ selectedIdsRef.current = selectedIds;
539
+ }, [selectedIds]);
540
+
541
+ useEffect(() => {
542
+ draftPrimitivesRef.current = draftPrimitives;
543
+ }, [draftPrimitives]);
544
+
545
+ useEffect(() => {
546
+ selectedDraftIdsRef.current = selectedDraftIds;
547
+ }, [selectedDraftIds]);
548
+
549
+ useEffect(() => {
550
+ setCanvasZoom(zoom);
551
+ zoomRef.current = zoom;
552
+ }, [zoom]);
553
+
554
+ useEffect(() => {
555
+ const currentIds = new Set(screens.map((screen) => screen.id));
556
+ updateFrameGeometry((current) => {
557
+ const next: FrameGeometryById = {};
558
+ let changed = Object.keys(current).some((id) => !currentIds.has(id));
559
+
560
+ screens.forEach((screen, index) => {
561
+ const existing = current[screen.id];
562
+ const persisted = geometryById?.[screen.id];
563
+ const resolved = {
564
+ ...getInitialFrameGeometry(index),
565
+ ...persisted,
566
+ } as FrameGeometry;
567
+ next[screen.id] = persisted ? resolved : (existing ?? resolved);
568
+ if (
569
+ !existing ||
570
+ (persisted && !sameFrameGeometry(existing, resolved))
571
+ ) {
572
+ changed = true;
573
+ }
574
+ });
575
+
576
+ return changed ? next : current;
577
+ });
578
+ updateSelectedIds((current) => {
579
+ const next = current.filter((id) => currentIds.has(id));
580
+ return next.length === current.length ? current : next;
581
+ });
582
+ }, [geometryById, screens, updateFrameGeometry, updateSelectedIds]);
583
+
584
+ useEffect(() => {
585
+ if (!activeId) return;
586
+ updateSelectedIds((current) =>
587
+ current.includes(activeId) ? current : [activeId],
588
+ );
589
+ }, [activeId, updateSelectedIds]);
590
+
591
+ useEffect(() => {
592
+ if (
593
+ selectAllRequest === undefined ||
594
+ selectAllRequest === handledSelectAllRequestRef.current
595
+ ) {
596
+ return;
597
+ }
598
+ handledSelectAllRequestRef.current = selectAllRequest;
599
+ updateSelectedDraftIds(() => []);
600
+ updateSelectedIds(() => screens.map((screen) => screen.id));
601
+ }, [screens, selectAllRequest, updateSelectedDraftIds, updateSelectedIds]);
602
+
603
+ useEffect(() => {
604
+ if (
605
+ clearSelectionRequest === undefined ||
606
+ clearSelectionRequest === handledClearSelectionRequestRef.current
607
+ ) {
608
+ return;
609
+ }
610
+ handledClearSelectionRequestRef.current = clearSelectionRequest;
611
+ updateSelectedDraftIds(() => []);
612
+ updateSelectedIds(() => []);
613
+ setMarquee(null);
614
+ setAlignmentGuides([]);
615
+ setTransformBadge(null);
616
+ }, [clearSelectionRequest, updateSelectedDraftIds, updateSelectedIds]);
51
617
 
52
618
  // Center the lineup on first mount so the user sees screens, not whitespace.
53
619
  useEffect(() => {
@@ -55,169 +621,3396 @@ export function MultiScreenCanvas({
55
621
  const rect = surfaceRef.current.getBoundingClientRect();
56
622
  const columns = Math.min(screens.length, 3);
57
623
  const rows = Math.ceil(screens.length / columns);
58
- const scale = zoom / 100;
624
+ const scale = zoomRef.current / 100;
59
625
  const totalWidth = columns * SCREEN_WIDTH + (columns - 1) * SCREEN_GAP;
60
626
  const totalHeight = rows * SCREEN_CARD_HEIGHT + (rows - 1) * SCREEN_GAP;
61
627
  const visualLeft = Math.max(24, (rect.width - totalWidth * scale) / 2);
62
628
  const visualTop = Math.max(24, (rect.height - totalHeight * scale) / 2);
63
- setPan({
629
+ const nextPan = {
64
630
  x: visualLeft - SURFACE_PADDING * scale,
65
631
  y: visualTop - SURFACE_PADDING * scale,
66
- });
632
+ };
633
+ panRef.current = nextPan;
634
+ setPan(nextPan);
67
635
  // Only on mount or when screen count changes, not on every pan update.
68
636
  // eslint-disable-next-line react-hooks/exhaustive-deps
69
- }, [screens.length, zoom]);
637
+ }, [screens.length]);
70
638
 
71
639
  useEffect(() => {
72
640
  return () => {
73
641
  dragCleanup.current?.();
642
+ duplicateCleanup.current?.();
643
+ if (feedbackTimerRef.current !== null) {
644
+ window.clearTimeout(feedbackTimerRef.current);
645
+ }
74
646
  };
75
647
  }, []);
76
648
 
77
- const handleMouseDown = useCallback(
649
+ const canvasPointFromClient = useCallback(
650
+ (clientX: number, clientY: number) => {
651
+ const rect = surfaceRef.current?.getBoundingClientRect();
652
+ if (!rect) return { x: 0, y: 0 };
653
+ return screenToCanvasPoint(
654
+ { x: clientX, y: clientY },
655
+ { ...panRef.current, zoom: zoomRef.current },
656
+ { x: rect.left, y: rect.top },
657
+ SURFACE_PADDING,
658
+ true,
659
+ );
660
+ },
661
+ [],
662
+ );
663
+
664
+ const getCanvasPoint = useCallback((clientX: number, clientY: number) => {
665
+ const rect = surfaceRef.current?.getBoundingClientRect();
666
+ if (!rect) return { x: 0, y: 0 };
667
+ return screenToCanvasPoint(
668
+ { x: clientX, y: clientY },
669
+ { ...panRef.current, zoom: zoomRef.current },
670
+ { x: rect.left, y: rect.top },
671
+ SURFACE_PADDING,
672
+ );
673
+ }, []);
674
+
675
+ const getCurrentFrameEntries = useCallback(
676
+ () => getFrameEntries(screens, frameGeometryRef.current),
677
+ [screens],
678
+ );
679
+
680
+ const getCurrentDraftEntries = useCallback(
681
+ () =>
682
+ draftPrimitivesRef.current.map((draft) => ({
683
+ id: draft.id,
684
+ geometry: draft.geometry,
685
+ })),
686
+ [],
687
+ );
688
+
689
+ const getCurrentCanvasEntries = useCallback(
690
+ () => [...getCurrentFrameEntries(), ...getCurrentDraftEntries()],
691
+ [getCurrentDraftEntries, getCurrentFrameEntries],
692
+ );
693
+
694
+ const getFrameAtClientPoint = useCallback(
695
+ (clientX: number, clientY: number) => {
696
+ const point = canvasPointFromClient(clientX, clientY);
697
+ return getCurrentFrameEntries()
698
+ .map((entry, index) => ({ ...entry, index }))
699
+ .filter((entry) =>
700
+ rectContainsPoint(getSelectableBounds(entry.geometry), point),
701
+ )
702
+ .sort(
703
+ (a, b) =>
704
+ (b.geometry.z ?? 0) - (a.geometry.z ?? 0) || b.index - a.index,
705
+ )[0]?.id;
706
+ },
707
+ [canvasPointFromClient, getCurrentFrameEntries],
708
+ );
709
+
710
+ const deleteSelectedItems = useCallback(() => {
711
+ const frameIds = selectedIdsRef.current.filter(
712
+ (id) => frameGeometryRef.current[id],
713
+ );
714
+ const draftIds = selectedDraftIdsRef.current.filter((id) =>
715
+ draftPrimitivesRef.current.some((draft) => draft.id === id),
716
+ );
717
+ if (frameIds.length === 0 && draftIds.length === 0) return false;
718
+
719
+ if (draftIds.length > 0) {
720
+ updateDraftPrimitives((current) =>
721
+ current.filter((draft) => !draftIds.includes(draft.id)),
722
+ );
723
+ updateSelectedDraftIds((current) =>
724
+ current.filter((id) => !draftIds.includes(id)),
725
+ );
726
+ }
727
+
728
+ if (frameIds.length > 0) {
729
+ const accepted = onDeleteSelection?.(frameIds);
730
+ if (accepted !== false && onDeleteSelection) {
731
+ const before = cloneFrameGeometryById(frameGeometryRef.current);
732
+ const after = cloneFrameGeometryById(before);
733
+ frameIds.forEach((id) => {
734
+ delete after[id];
735
+ });
736
+ updateFrameGeometry(() => after);
737
+ onGeometryCommitRef.current?.(before, after);
738
+ updateSelectedIds((current) =>
739
+ current.filter((id) => !frameIds.includes(id)),
740
+ );
741
+ }
742
+ }
743
+
744
+ setMarquee(null);
745
+ setAlignmentGuides([]);
746
+ setTransformBadge(null);
747
+ return true;
748
+ }, [
749
+ onDeleteSelection,
750
+ updateDraftPrimitives,
751
+ updateFrameGeometry,
752
+ updateSelectedDraftIds,
753
+ updateSelectedIds,
754
+ ]);
755
+
756
+ const installDragListeners = useCallback(
757
+ (
758
+ handleMouseMove: (ev: MouseEvent) => void,
759
+ handleMouseUp: (ev: MouseEvent) => void,
760
+ ) => {
761
+ dragCleanup.current?.();
762
+ let lastMouseEvent: MouseEvent | null = null;
763
+ const move = (ev: MouseEvent) => {
764
+ lastMouseEvent = ev;
765
+ ev.preventDefault();
766
+ handleMouseMove(ev);
767
+ };
768
+ const up = (ev: MouseEvent) => {
769
+ lastMouseEvent = ev;
770
+ ev.preventDefault();
771
+ handleMouseUp(ev);
772
+ };
773
+ const cleanupOnBlur = () =>
774
+ handleMouseUp(lastMouseEvent ?? new MouseEvent("mouseup"));
775
+ dragCleanup.current = () => {
776
+ window.removeEventListener("mousemove", move);
777
+ window.removeEventListener("mouseup", up);
778
+ window.removeEventListener("blur", cleanupOnBlur);
779
+ dragCleanup.current = null;
780
+ };
781
+ window.addEventListener("mousemove", move);
782
+ window.addEventListener("mouseup", up);
783
+ window.addEventListener("blur", cleanupOnBlur);
784
+ },
785
+ [],
786
+ );
787
+
788
+ const scheduleFeedbackClear = useCallback(() => {
789
+ if (feedbackTimerRef.current !== null) {
790
+ window.clearTimeout(feedbackTimerRef.current);
791
+ }
792
+ feedbackTimerRef.current = window.setTimeout(() => {
793
+ setAlignmentGuides([]);
794
+ setTransformBadge(null);
795
+ feedbackTimerRef.current = null;
796
+ }, 650);
797
+ }, []);
798
+
799
+ const showTransformFeedback = useCallback(
800
+ (text: string, clientX: number, clientY: number) => {
801
+ setTransformBadge({
802
+ text,
803
+ x: clientX + 12,
804
+ y: clientY + 12,
805
+ });
806
+ },
807
+ [],
808
+ );
809
+
810
+ const finishDrag = useCallback(() => {
811
+ if (feedbackTimerRef.current !== null) {
812
+ window.clearTimeout(feedbackTimerRef.current);
813
+ feedbackTimerRef.current = null;
814
+ }
815
+ dragState.current = null;
816
+ setIsDragging(false);
817
+ setIsPanning(false);
818
+ setMarquee(null);
819
+ setCreationPreview(null);
820
+ setAlignmentGuides([]);
821
+ setTransformBadge(null);
822
+ setDragCursor(null);
823
+ dragCleanup.current?.();
824
+ }, []);
825
+
826
+ const beginPan = useCallback(
827
+ (e: React.MouseEvent) => {
828
+ e.preventDefault();
829
+ e.stopPropagation();
830
+ dragState.current = {
831
+ type: "pan",
832
+ originClient: { x: e.clientX, y: e.clientY },
833
+ originPan: panRef.current,
834
+ };
835
+ setIsPanning(true);
836
+
837
+ const handleMouseMove = (ev: MouseEvent) => {
838
+ const state = dragState.current;
839
+ if (!state || state.type !== "pan") return;
840
+ const nextPan = {
841
+ x: state.originPan.x + ev.clientX - state.originClient.x,
842
+ y: state.originPan.y + ev.clientY - state.originClient.y,
843
+ };
844
+ panRef.current = nextPan;
845
+ setPan(nextPan);
846
+ };
847
+
848
+ installDragListeners(handleMouseMove, finishDrag);
849
+ },
850
+ [finishDrag, installDragListeners],
851
+ );
852
+
853
+ const beginMarquee = useCallback(
78
854
  (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
855
  e.preventDefault();
84
856
  e.stopPropagation();
85
- const dragOrigin = { x: e.clientX - pan.x, y: e.clientY - pan.y };
86
- dragging.current = dragOrigin;
857
+ const originCanvas = getCanvasPoint(e.clientX, e.clientY);
858
+ dragState.current = {
859
+ type: "marquee",
860
+ originClient: { x: e.clientX, y: e.clientY },
861
+ originCanvas,
862
+ baseSelectedIds: selectedIdsRef.current,
863
+ baseSelectedDraftIds: selectedDraftIdsRef.current,
864
+ additive: e.shiftKey,
865
+ hasMoved: false,
866
+ };
867
+ setMarquee({ ...originCanvas, width: 0, height: 0 });
868
+ if (!e.shiftKey) {
869
+ updateSelectedIds(() => []);
870
+ updateSelectedDraftIds(() => []);
871
+ }
87
872
  setIsDragging(true);
88
873
 
89
874
  const handleMouseMove = (ev: MouseEvent) => {
90
- if (!dragging.current) return;
91
- setPan({
92
- x: ev.clientX - dragOrigin.x,
93
- y: ev.clientY - dragOrigin.y,
94
- });
875
+ const state = dragState.current;
876
+ if (!state || state.type !== "marquee") return;
877
+ const nextPoint = getCanvasPoint(ev.clientX, ev.clientY);
878
+ const rect = normalizeRectFromPoints(state.originCanvas, nextPoint);
879
+ if (
880
+ !state.hasMoved &&
881
+ Math.hypot(
882
+ ev.clientX - state.originClient.x,
883
+ ev.clientY - state.originClient.y,
884
+ ) >= DRAG_THRESHOLD
885
+ ) {
886
+ state.hasMoved = true;
887
+ }
888
+ setMarquee(rect);
889
+
890
+ const hitIds = getCurrentFrameEntries()
891
+ .filter((entry) =>
892
+ rectIntersects(rect, getSelectableBounds(entry.geometry)),
893
+ )
894
+ .map((entry) => entry.id);
895
+ const hitDraftIds = getCurrentDraftEntries()
896
+ .filter((entry) =>
897
+ rectIntersects(rect, getSelectableBounds(entry.geometry)),
898
+ )
899
+ .map((entry) => entry.id);
900
+ updateSelectedIds(() =>
901
+ state.additive
902
+ ? dedupeIds([...state.baseSelectedIds, ...hitIds])
903
+ : hitIds,
904
+ );
905
+ updateSelectedDraftIds(() =>
906
+ state.additive
907
+ ? dedupeIds([...state.baseSelectedDraftIds, ...hitDraftIds])
908
+ : hitDraftIds,
909
+ );
95
910
  };
96
911
 
97
912
  const handleMouseUp = () => {
98
- dragging.current = null;
99
- setIsDragging(false);
100
- window.removeEventListener("mousemove", handleMouseMove);
101
- window.removeEventListener("mouseup", handleMouseUp);
102
- dragCleanup.current = null;
913
+ const state = dragState.current;
914
+ if (state?.type === "marquee" && !state.hasMoved && !state.additive) {
915
+ updateSelectedIds(() => []);
916
+ updateSelectedDraftIds(() => []);
917
+ }
918
+ finishDrag();
103
919
  };
104
920
 
105
- dragCleanup.current?.();
106
- dragCleanup.current = handleMouseUp;
107
- window.addEventListener("mousemove", handleMouseMove);
108
- window.addEventListener("mouseup", handleMouseUp);
921
+ installDragListeners(handleMouseMove, handleMouseUp);
109
922
  },
110
- [pan],
923
+ [
924
+ finishDrag,
925
+ getCanvasPoint,
926
+ getCurrentDraftEntries,
927
+ getCurrentFrameEntries,
928
+ installDragListeners,
929
+ updateSelectedDraftIds,
930
+ updateSelectedIds,
931
+ ],
111
932
  );
112
933
 
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
- />
934
+ const getTargetFrameForDraft = useCallback(
935
+ (draft: DraftPrimitive) =>
936
+ getCurrentFrameEntries()
937
+ .filter(({ geometry }) =>
938
+ rectContainsPoint(
939
+ {
940
+ left: geometry.x,
941
+ top: geometry.y,
942
+ right: geometry.x + geometry.width,
943
+ bottom: geometry.y + geometry.height,
944
+ },
945
+ getFrameCenter(draft.geometry),
946
+ ),
947
+ )
948
+ .sort((a, b) => (b.geometry.z ?? 0) - (a.geometry.z ?? 0))[0],
949
+ [getCurrentFrameEntries],
950
+ );
129
951
 
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>
952
+ const persistDraftPrimitive = useCallback(
953
+ (draft: DraftPrimitive) => {
954
+ const targetFrame = getTargetFrameForDraft(draft);
955
+ if (!targetFrame || !onCreatePrimitive) return null;
956
+ const targetScreen = screens.find(
957
+ (screen) => screen.id === targetFrame.id,
958
+ );
959
+ const targetMetadata = targetScreen
960
+ ? resolveScreenMetadata(
961
+ targetScreen,
962
+ metadataById?.[targetScreen.id],
963
+ getScreenMetadata?.(targetScreen),
964
+ )
965
+ : undefined;
966
+
967
+ const localPrimitive = draftPrimitiveToInsert(
968
+ draft,
969
+ targetFrame.geometry,
970
+ targetMetadata,
971
+ );
972
+ const persisted = onCreatePrimitive(targetFrame.id, localPrimitive);
973
+ return persisted ? targetFrame.id : null;
974
+ },
975
+ [
976
+ getScreenMetadata,
977
+ getTargetFrameForDraft,
978
+ metadataById,
979
+ onCreatePrimitive,
980
+ screens,
981
+ ],
159
982
  );
160
- }
161
983
 
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>
984
+ const commitDraftPrimitive = useCallback(
985
+ (nextDraft: DraftPrimitive) => {
986
+ const persistedFrameId = persistDraftPrimitive(nextDraft);
987
+ if (persistedFrameId) {
988
+ updateDraftPrimitives((current) =>
989
+ current.filter((draft) => draft.id !== nextDraft.id),
990
+ );
991
+ updateSelectedDraftIds(() => []);
992
+ updateSelectedIds(() => [persistedFrameId]);
993
+ return;
994
+ }
995
+
996
+ updateDraftPrimitives((current) => [...current, nextDraft]);
997
+ updateSelectedIds(() => []);
998
+ updateSelectedDraftIds(() => [nextDraft.id]);
999
+ },
1000
+ [
1001
+ persistDraftPrimitive,
1002
+ updateDraftPrimitives,
1003
+ updateSelectedDraftIds,
1004
+ updateSelectedIds,
1005
+ ],
1006
+ );
1007
+
1008
+ const clearActivePenPath = useCallback(() => {
1009
+ setActivePenPath(null);
1010
+ setPenGesturePreview(null);
1011
+ setPenPointer(null);
1012
+ setPenCloseHover(false);
1013
+ }, []);
1014
+
1015
+ const finishPenPath = useCallback(
1016
+ (path = activePenPathRef.current) => {
1017
+ if (!path || path.nodes.length < 2) {
1018
+ clearActivePenPath();
1019
+ return;
1020
+ }
1021
+
1022
+ commitDraftPrimitive(
1023
+ createPenDraftPrimitive(path, {
1024
+ stroke: toolProps?.stroke,
1025
+ strokeWidth: toolProps?.strokeWidth,
1026
+ }),
1027
+ );
1028
+ clearActivePenPath();
1029
+ onActiveToolChange?.("pen");
1030
+ },
1031
+ [clearActivePenPath, commitDraftPrimitive, onActiveToolChange, toolProps],
1032
+ );
1033
+
1034
+ const getPenAnchorPoint = useCallback(
1035
+ (
1036
+ clientX: number,
1037
+ clientY: number,
1038
+ shiftKey: boolean,
1039
+ path: PenPath | null,
1040
+ ) => {
1041
+ const rawPoint = getCanvasPoint(clientX, clientY);
1042
+ const lastAnchor = path?.nodes[path.nodes.length - 1]?.point;
1043
+ return shiftKey && lastAnchor
1044
+ ? constrainPointTo45Degrees(lastAnchor, rawPoint)
1045
+ : rawPoint;
1046
+ },
1047
+ [getCanvasPoint],
1048
+ );
1049
+
1050
+ const updatePenPointer = useCallback(
1051
+ (clientX: number, clientY: number, shiftKey: boolean) => {
1052
+ const path = activePenPathRef.current;
1053
+ if (!path || path.closed) {
1054
+ setPenPointer(null);
1055
+ setPenCloseHover(false);
1056
+ return;
1057
+ }
1058
+
1059
+ const rawPoint = getCanvasPoint(clientX, clientY);
1060
+ const closeHover = isPenCloseTarget(
1061
+ path,
1062
+ rawPoint,
1063
+ PEN_CLOSE_HIT_RADIUS_SCREEN_PX / (zoomRef.current / 100),
1064
+ );
1065
+ setPenCloseHover(closeHover);
1066
+ setPenPointer(
1067
+ closeHover
1068
+ ? path.nodes[0].point
1069
+ : getPenAnchorPoint(clientX, clientY, shiftKey, path),
1070
+ );
1071
+ },
1072
+ [getCanvasPoint, getPenAnchorPoint],
222
1073
  );
1074
+
1075
+ const beginPenNodeCreation = useCallback(
1076
+ (e: React.MouseEvent) => {
1077
+ if (e.button !== 0) return;
1078
+ e.preventDefault();
1079
+ e.stopPropagation();
1080
+ suppressNextPick.current = true;
1081
+
1082
+ const pathBefore = activePenPathRef.current?.closed
1083
+ ? null
1084
+ : activePenPathRef.current;
1085
+ const rawPoint = getCanvasPoint(e.clientX, e.clientY);
1086
+ if (
1087
+ pathBefore &&
1088
+ isPenCloseTarget(
1089
+ pathBefore,
1090
+ rawPoint,
1091
+ PEN_CLOSE_HIT_RADIUS_SCREEN_PX / (zoomRef.current / 100),
1092
+ )
1093
+ ) {
1094
+ finishPenPath(closePenPath(pathBefore));
1095
+ return;
1096
+ }
1097
+
1098
+ const anchor = getPenAnchorPoint(
1099
+ e.clientX,
1100
+ e.clientY,
1101
+ e.shiftKey,
1102
+ pathBefore,
1103
+ );
1104
+ const pathSnapshot = pathBefore ? clonePenPath(pathBefore) : null;
1105
+ dragState.current = {
1106
+ type: "pen-node",
1107
+ originClient: { x: e.clientX, y: e.clientY },
1108
+ anchor,
1109
+ pathBefore: pathSnapshot,
1110
+ hasMoved: false,
1111
+ };
1112
+ setPenGesturePreview(
1113
+ appendPenNode(pathSnapshot, createCornerNode(anchor)),
1114
+ );
1115
+ setPenPointer(null);
1116
+ setPenCloseHover(false);
1117
+ setIsDragging(true);
1118
+ setDragCursor("crosshair");
1119
+
1120
+ const handleMouseMove = (ev: MouseEvent) => {
1121
+ const state = dragState.current;
1122
+ if (!state || state.type !== "pen-node") return;
1123
+ if (
1124
+ !state.hasMoved &&
1125
+ Math.hypot(
1126
+ ev.clientX - state.originClient.x,
1127
+ ev.clientY - state.originClient.y,
1128
+ ) >= DRAG_THRESHOLD
1129
+ ) {
1130
+ state.hasMoved = true;
1131
+ }
1132
+
1133
+ const handlePoint = getCanvasPoint(ev.clientX, ev.clientY);
1134
+ const node = state.hasMoved
1135
+ ? createSmoothNode(
1136
+ state.anchor,
1137
+ ev.shiftKey
1138
+ ? constrainPointTo45Degrees(state.anchor, handlePoint)
1139
+ : handlePoint,
1140
+ )
1141
+ : createCornerNode(state.anchor);
1142
+ setPenGesturePreview(appendPenNode(state.pathBefore, node));
1143
+ };
1144
+
1145
+ const handleMouseUp = (ev: MouseEvent) => {
1146
+ const state = dragState.current;
1147
+ if (!state || state.type !== "pen-node") {
1148
+ finishDrag();
1149
+ return;
1150
+ }
1151
+
1152
+ const handlePoint = getCanvasPoint(ev.clientX, ev.clientY);
1153
+ const node: PenNode = state.hasMoved
1154
+ ? createSmoothNode(
1155
+ state.anchor,
1156
+ ev.shiftKey
1157
+ ? constrainPointTo45Degrees(state.anchor, handlePoint)
1158
+ : handlePoint,
1159
+ )
1160
+ : createCornerNode(state.anchor);
1161
+ setActivePenPath(appendPenNode(state.pathBefore, node));
1162
+ setPenGesturePreview(null);
1163
+ setPenPointer(null);
1164
+ setPenCloseHover(false);
1165
+ onActiveToolChange?.("pen");
1166
+ finishDrag();
1167
+ };
1168
+
1169
+ installDragListeners(handleMouseMove, handleMouseUp);
1170
+ },
1171
+ [
1172
+ finishDrag,
1173
+ finishPenPath,
1174
+ getCanvasPoint,
1175
+ getPenAnchorPoint,
1176
+ installDragListeners,
1177
+ onActiveToolChange,
1178
+ ],
1179
+ );
1180
+
1181
+ const beginDraftCreation = useCallback(
1182
+ (tool: DraftCreationTool, e: React.MouseEvent) => {
1183
+ if (e.button !== 0) return;
1184
+ e.preventDefault();
1185
+ e.stopPropagation();
1186
+
1187
+ const originCanvas = getCanvasPoint(e.clientX, e.clientY);
1188
+ const initialGeometry = getDraftGeometryForTool(
1189
+ tool,
1190
+ originCanvas,
1191
+ originCanvas,
1192
+ );
1193
+ const initialPoints =
1194
+ tool === "pen"
1195
+ ? [originCanvas]
1196
+ : tool === "line" || tool === "arrow"
1197
+ ? [
1198
+ originCanvas,
1199
+ { x: originCanvas.x + DRAFT_LINE_WIDTH, y: originCanvas.y },
1200
+ ]
1201
+ : undefined;
1202
+ dragState.current = {
1203
+ type: "draft-create",
1204
+ tool,
1205
+ originClient: { x: e.clientX, y: e.clientY },
1206
+ originCanvas,
1207
+ points: initialPoints ?? [],
1208
+ hasMoved: false,
1209
+ };
1210
+ setCreationPreview({
1211
+ tool,
1212
+ geometry: initialGeometry,
1213
+ points: initialPoints,
1214
+ });
1215
+ setIsDragging(true);
1216
+ setDragCursor(tool === "pen" ? "crosshair" : "crosshair");
1217
+
1218
+ const handleMouseMove = (ev: MouseEvent) => {
1219
+ const state = dragState.current;
1220
+ if (!state || state.type !== "draft-create") return;
1221
+ const nextCanvas = getCanvasPoint(ev.clientX, ev.clientY);
1222
+ if (
1223
+ !state.hasMoved &&
1224
+ Math.hypot(
1225
+ ev.clientX - state.originClient.x,
1226
+ ev.clientY - state.originClient.y,
1227
+ ) >= DRAG_THRESHOLD
1228
+ ) {
1229
+ state.hasMoved = true;
1230
+ }
1231
+
1232
+ if (state.tool === "pen") {
1233
+ const minDistance =
1234
+ PEN_SAMPLE_DISTANCE_SCREEN_PX / (zoomRef.current / 100);
1235
+ state.points = appendPolylinePoint(
1236
+ state.points,
1237
+ nextCanvas,
1238
+ minDistance,
1239
+ );
1240
+ setCreationPreview({
1241
+ tool,
1242
+ geometry: getPathGeometry(state.points),
1243
+ points: state.points,
1244
+ });
1245
+ return;
1246
+ }
1247
+
1248
+ setCreationPreview({
1249
+ tool,
1250
+ geometry: getDraftGeometryForTool(
1251
+ tool,
1252
+ state.originCanvas,
1253
+ nextCanvas,
1254
+ ),
1255
+ points:
1256
+ state.tool === "line" || state.tool === "arrow"
1257
+ ? [state.originCanvas, nextCanvas]
1258
+ : undefined,
1259
+ });
1260
+ };
1261
+
1262
+ const handleMouseUp = (ev: MouseEvent) => {
1263
+ const state = dragState.current;
1264
+ if (!state || state.type !== "draft-create") {
1265
+ finishDrag();
1266
+ return;
1267
+ }
1268
+
1269
+ let endCanvas = getCanvasPoint(ev.clientX, ev.clientY);
1270
+ const releaseMoved =
1271
+ state.hasMoved ||
1272
+ Math.hypot(
1273
+ ev.clientX - state.originClient.x,
1274
+ ev.clientY - state.originClient.y,
1275
+ ) >= DRAG_THRESHOLD;
1276
+ state.hasMoved = releaseMoved;
1277
+ let points = state.tool === "pen" ? state.points : undefined;
1278
+ if (state.tool === "pen") {
1279
+ points = appendPolylinePoint(state.points, endCanvas, 0);
1280
+ if (!releaseMoved || points.length < 2) {
1281
+ finishDrag();
1282
+ return;
1283
+ }
1284
+ endCanvas = points[points.length - 1] ?? endCanvas;
1285
+ }
1286
+ if (state.tool === "frame" && onCreateScreenFrame) {
1287
+ onCreateScreenFrame(
1288
+ getDraftGeometryForTool(state.tool, state.originCanvas, endCanvas),
1289
+ );
1290
+ if (activeTool === undefined) {
1291
+ setLocalActiveTool("move");
1292
+ }
1293
+ onActiveToolChange?.("move");
1294
+ finishDrag();
1295
+ return;
1296
+ }
1297
+ const nextDraft = createDraftPrimitive({
1298
+ tool: state.tool,
1299
+ start: state.originCanvas,
1300
+ end: endCanvas,
1301
+ points,
1302
+ moved: releaseMoved,
1303
+ toolProps,
1304
+ fallbackText: t("designEditor.tools.text"),
1305
+ });
1306
+ commitDraftPrimitive(nextDraft);
1307
+ if (activeTool === undefined) {
1308
+ setLocalActiveTool("move");
1309
+ }
1310
+ if (state.tool === "pen") {
1311
+ if (activeTool === undefined) {
1312
+ setLocalActiveTool("pen");
1313
+ }
1314
+ onActiveToolChange?.("pen");
1315
+ } else {
1316
+ onActiveToolChange?.("move");
1317
+ }
1318
+ finishDrag();
1319
+ };
1320
+
1321
+ installDragListeners(handleMouseMove, handleMouseUp);
1322
+ },
1323
+ [
1324
+ activeTool,
1325
+ commitDraftPrimitive,
1326
+ finishDrag,
1327
+ getCanvasPoint,
1328
+ installDragListeners,
1329
+ onActiveToolChange,
1330
+ onCreateScreenFrame,
1331
+ t,
1332
+ toolProps,
1333
+ ],
1334
+ );
1335
+
1336
+ const beginDraftDrag = useCallback(
1337
+ (id: string, e: React.MouseEvent) => {
1338
+ if (e.button !== 0 || e.shiftKey) return;
1339
+ e.preventDefault();
1340
+ e.stopPropagation();
1341
+
1342
+ const currentSelectedDraftIds = selectedDraftIdsRef.current;
1343
+ const targetIds = currentSelectedDraftIds.includes(id)
1344
+ ? currentSelectedDraftIds
1345
+ : [id];
1346
+ const originDrafts = Object.fromEntries(
1347
+ draftPrimitivesRef.current
1348
+ .filter((draft) => targetIds.includes(draft.id))
1349
+ .map((draft) => [draft.id, cloneDraftPrimitive(draft)]),
1350
+ ) as DraftPrimitiveById;
1351
+ if (!originDrafts[id]) return;
1352
+ updateSelectedIds(() => []);
1353
+ updateSelectedDraftIds((current) =>
1354
+ current.includes(id) ? current : [id],
1355
+ );
1356
+
1357
+ dragState.current = {
1358
+ type: "draft-move",
1359
+ originClient: { x: e.clientX, y: e.clientY },
1360
+ originDrafts,
1361
+ targetIds,
1362
+ primaryId: id,
1363
+ hasMoved: false,
1364
+ };
1365
+ setIsDragging(true);
1366
+ setDragCursor("grabbing");
1367
+
1368
+ const handleMouseMove = (ev: MouseEvent) => {
1369
+ const state = dragState.current;
1370
+ if (!state || state.type !== "draft-move") return;
1371
+ const scale = zoomRef.current / 100;
1372
+ const dx = (ev.clientX - state.originClient.x) / scale;
1373
+ const dy = (ev.clientY - state.originClient.y) / scale;
1374
+ if (
1375
+ !state.hasMoved &&
1376
+ Math.hypot(
1377
+ ev.clientX - state.originClient.x,
1378
+ ev.clientY - state.originClient.y,
1379
+ ) >= DRAG_THRESHOLD
1380
+ ) {
1381
+ state.hasMoved = true;
1382
+ }
1383
+
1384
+ const movingEntries = state.targetIds.map((targetId) => {
1385
+ const origin = state.originDrafts[targetId].geometry;
1386
+ return {
1387
+ id: targetId,
1388
+ geometry: {
1389
+ ...origin,
1390
+ x: origin.x + dx,
1391
+ y: origin.y + dy,
1392
+ },
1393
+ };
1394
+ });
1395
+ const stationaryEntries = getCurrentCanvasEntries().filter(
1396
+ (entry) => !state.targetIds.includes(entry.id),
1397
+ );
1398
+ const snap = computeMoveSnap(movingEntries, stationaryEntries, {
1399
+ thresholdScreenPx: DEFAULT_SNAP_THRESHOLD_SCREEN_PX,
1400
+ zoom: zoomRef.current,
1401
+ bypass: ev.metaKey || ev.ctrlKey,
1402
+ });
1403
+
1404
+ updateDraftPrimitives((current) =>
1405
+ current.map((draft) => {
1406
+ const origin = state.originDrafts[draft.id];
1407
+ if (!origin) return draft;
1408
+ return moveDraftPrimitive(origin, dx + snap.dx, dy + snap.dy);
1409
+ }),
1410
+ );
1411
+ setAlignmentGuides(snap.guides);
1412
+ const primary = state.originDrafts[state.primaryId].geometry;
1413
+ showTransformFeedback(
1414
+ `X ${Math.round(primary.x + dx + snap.dx)} Y ${Math.round(
1415
+ primary.y + dy + snap.dy,
1416
+ )}`,
1417
+ ev.clientX,
1418
+ ev.clientY,
1419
+ );
1420
+ };
1421
+
1422
+ const handleMouseUp = () => {
1423
+ const state = dragState.current;
1424
+ if (state?.type === "draft-move" && state.hasMoved) {
1425
+ const persisted: Array<{ draftId: string; frameId: string }> = [];
1426
+ draftPrimitivesRef.current.forEach((draft) => {
1427
+ if (!state.targetIds.includes(draft.id)) return;
1428
+ const frameId = persistDraftPrimitive(draft);
1429
+ if (frameId) persisted.push({ draftId: draft.id, frameId });
1430
+ });
1431
+
1432
+ if (persisted.length > 0) {
1433
+ const persistedDraftIds = new Set(
1434
+ persisted.map((entry) => entry.draftId),
1435
+ );
1436
+ const lastFrameId = persisted[persisted.length - 1]?.frameId;
1437
+ updateDraftPrimitives((current) =>
1438
+ current.filter((draft) => !persistedDraftIds.has(draft.id)),
1439
+ );
1440
+ updateSelectedDraftIds((current) =>
1441
+ current.filter((draftId) => !persistedDraftIds.has(draftId)),
1442
+ );
1443
+ if (lastFrameId) updateSelectedIds(() => [lastFrameId]);
1444
+ }
1445
+ }
1446
+ finishDrag();
1447
+ };
1448
+
1449
+ installDragListeners(handleMouseMove, handleMouseUp);
1450
+ },
1451
+ [
1452
+ finishDrag,
1453
+ getCurrentCanvasEntries,
1454
+ installDragListeners,
1455
+ persistDraftPrimitive,
1456
+ showTransformFeedback,
1457
+ updateDraftPrimitives,
1458
+ updateSelectedDraftIds,
1459
+ updateSelectedIds,
1460
+ ],
1461
+ );
1462
+
1463
+ const beginDraftResize = useCallback(
1464
+ (id: string, handle: ResizeHandle, e: React.MouseEvent) => {
1465
+ if (e.button !== 0) return;
1466
+ e.preventDefault();
1467
+ e.stopPropagation();
1468
+
1469
+ const currentSelectedDraftIds = selectedDraftIdsRef.current;
1470
+ const targetIds = currentSelectedDraftIds.includes(id)
1471
+ ? currentSelectedDraftIds
1472
+ : [id];
1473
+ const originDrafts = Object.fromEntries(
1474
+ draftPrimitivesRef.current
1475
+ .filter((draft) => targetIds.includes(draft.id))
1476
+ .map((draft) => [draft.id, cloneDraftPrimitive(draft)]),
1477
+ ) as DraftPrimitiveById;
1478
+ const originEntries = Object.values(originDrafts).map((draft) => ({
1479
+ id: draft.id,
1480
+ geometry: draft.geometry,
1481
+ }));
1482
+ const originBounds = getFrameGroupBounds(originEntries);
1483
+ if (!originBounds || !originDrafts[id]) return;
1484
+ updateSelectedIds(() => []);
1485
+ updateSelectedDraftIds((current) =>
1486
+ current.includes(id) ? current : [id],
1487
+ );
1488
+
1489
+ dragState.current = {
1490
+ type: "draft-resize",
1491
+ originClient: { x: e.clientX, y: e.clientY },
1492
+ originDrafts,
1493
+ originBounds: frameBoundsToGeometry(originBounds),
1494
+ targetIds,
1495
+ handle,
1496
+ hasMoved: false,
1497
+ };
1498
+ setIsDragging(true);
1499
+ setDragCursor(getResizeCursor(handle));
1500
+
1501
+ const handleMouseMove = (ev: MouseEvent) => {
1502
+ const state = dragState.current;
1503
+ if (!state || state.type !== "draft-resize") return;
1504
+ const scale = zoomRef.current / 100;
1505
+ const dx = (ev.clientX - state.originClient.x) / scale;
1506
+ const dy = (ev.clientY - state.originClient.y) / scale;
1507
+ if (
1508
+ !state.hasMoved &&
1509
+ Math.hypot(
1510
+ ev.clientX - state.originClient.x,
1511
+ ev.clientY - state.originClient.y,
1512
+ ) >= DRAG_THRESHOLD
1513
+ ) {
1514
+ state.hasMoved = true;
1515
+ }
1516
+
1517
+ const originEntries = state.targetIds.map((targetId) => ({
1518
+ id: targetId,
1519
+ geometry: state.originDrafts[targetId].geometry,
1520
+ }));
1521
+ const resized = resizeFrameGroupFromDelta(
1522
+ originEntries,
1523
+ state.originBounds,
1524
+ state.handle,
1525
+ dx,
1526
+ dy,
1527
+ {
1528
+ preserveAspectRatio: ev.shiftKey,
1529
+ resizeFromCenter: ev.altKey,
1530
+ minWidth: 8,
1531
+ minHeight: 8,
1532
+ },
1533
+ );
1534
+ const snap = computeResizeSnap(
1535
+ resized.bounds,
1536
+ getCurrentCanvasEntries().filter(
1537
+ (entry) => !state.targetIds.includes(entry.id),
1538
+ ),
1539
+ state.handle,
1540
+ {
1541
+ thresholdScreenPx: DEFAULT_SNAP_THRESHOLD_SCREEN_PX,
1542
+ zoom: zoomRef.current,
1543
+ bypass: ev.metaKey || ev.ctrlKey,
1544
+ },
1545
+ );
1546
+ const resizedEntries = resizeFrameGroupToBounds(
1547
+ originEntries,
1548
+ state.originBounds,
1549
+ snap.frame,
1550
+ );
1551
+ const resizedById = Object.fromEntries(
1552
+ resizedEntries.map((entry) => [entry.id, entry.geometry]),
1553
+ ) as FrameGeometryById;
1554
+
1555
+ updateDraftPrimitives((current) =>
1556
+ current.map((draft) => {
1557
+ const origin = state.originDrafts[draft.id];
1558
+ const geometry = resizedById[draft.id];
1559
+ if (!origin || !geometry) return draft;
1560
+ return applyDraftGeometry(origin, geometry);
1561
+ }),
1562
+ );
1563
+ setAlignmentGuides(snap.guides);
1564
+ showTransformFeedback(
1565
+ `${Math.round(snap.frame.width)} x ${Math.round(snap.frame.height)}`,
1566
+ ev.clientX,
1567
+ ev.clientY,
1568
+ );
1569
+ };
1570
+
1571
+ installDragListeners(handleMouseMove, finishDrag);
1572
+ },
1573
+ [
1574
+ finishDrag,
1575
+ getCurrentCanvasEntries,
1576
+ installDragListeners,
1577
+ showTransformFeedback,
1578
+ updateDraftPrimitives,
1579
+ updateSelectedDraftIds,
1580
+ updateSelectedIds,
1581
+ ],
1582
+ );
1583
+
1584
+ const beginDraftGroupResize = useCallback(
1585
+ (handle: ResizeHandle, e: React.MouseEvent) => {
1586
+ const firstSelectedId = selectedDraftIdsRef.current[0];
1587
+ if (!firstSelectedId) return;
1588
+ beginDraftResize(firstSelectedId, handle, e);
1589
+ },
1590
+ [beginDraftResize],
1591
+ );
1592
+
1593
+ const handleDraftClick = useCallback(
1594
+ (id: string, e: React.MouseEvent) => {
1595
+ e.stopPropagation();
1596
+ updateSelectedIds(() => []);
1597
+ updateSelectedDraftIds((current) => {
1598
+ if (e.shiftKey) {
1599
+ return current.includes(id)
1600
+ ? current.filter((selectedId) => selectedId !== id)
1601
+ : [...current, id];
1602
+ }
1603
+ return [id];
1604
+ });
1605
+ },
1606
+ [updateSelectedDraftIds, updateSelectedIds],
1607
+ );
1608
+
1609
+ const beginFrameDrag = useCallback(
1610
+ (id: string, e: React.MouseEvent) => {
1611
+ if (e.button !== 0 || e.shiftKey) return;
1612
+ e.preventDefault();
1613
+ e.stopPropagation();
1614
+
1615
+ const currentSelectedIds = selectedIdsRef.current;
1616
+ const targetIds = currentSelectedIds.includes(id)
1617
+ ? currentSelectedIds
1618
+ : [id];
1619
+ if (!currentSelectedIds.includes(id)) {
1620
+ updateSelectedIds(() => [id]);
1621
+ }
1622
+ updateSelectedDraftIds(() => []);
1623
+
1624
+ const entries = getCurrentFrameEntries();
1625
+ const originFrames = Object.fromEntries(
1626
+ entries
1627
+ .filter((entry) => targetIds.includes(entry.id))
1628
+ .map((entry) => [entry.id, entry.geometry]),
1629
+ ) as FrameGeometryById;
1630
+ if (!originFrames[id]) return;
1631
+
1632
+ dragState.current = {
1633
+ type: "move",
1634
+ originClient: { x: e.clientX, y: e.clientY },
1635
+ originFrames,
1636
+ targetIds,
1637
+ primaryId: id,
1638
+ hasMoved: false,
1639
+ };
1640
+ setIsDragging(true);
1641
+ setDragCursor("grabbing");
1642
+
1643
+ const handleMouseMove = (ev: MouseEvent) => {
1644
+ const state = dragState.current;
1645
+ if (!state || state.type !== "move") return;
1646
+ const scale = zoomRef.current / 100;
1647
+ const dx = (ev.clientX - state.originClient.x) / scale;
1648
+ const dy = (ev.clientY - state.originClient.y) / scale;
1649
+ if (
1650
+ !state.hasMoved &&
1651
+ Math.hypot(
1652
+ ev.clientX - state.originClient.x,
1653
+ ev.clientY - state.originClient.y,
1654
+ ) >= DRAG_THRESHOLD
1655
+ ) {
1656
+ state.hasMoved = true;
1657
+ }
1658
+
1659
+ const movingEntries = state.targetIds.map((targetId) => ({
1660
+ id: targetId,
1661
+ geometry: {
1662
+ ...state.originFrames[targetId],
1663
+ x: state.originFrames[targetId].x + dx,
1664
+ y: state.originFrames[targetId].y + dy,
1665
+ },
1666
+ }));
1667
+ const stationaryEntries = getCurrentFrameEntries().filter(
1668
+ (entry) => !state.targetIds.includes(entry.id),
1669
+ );
1670
+ const snap = computeMoveSnap(movingEntries, stationaryEntries, {
1671
+ thresholdScreenPx: DEFAULT_SNAP_THRESHOLD_SCREEN_PX,
1672
+ zoom: zoomRef.current,
1673
+ bypass: ev.metaKey || ev.ctrlKey,
1674
+ });
1675
+
1676
+ updateFrameGeometry((current) => {
1677
+ const next = { ...current };
1678
+ state.targetIds.forEach((targetId) => {
1679
+ const origin = state.originFrames[targetId];
1680
+ next[targetId] = {
1681
+ ...origin,
1682
+ x: origin.x + dx + snap.dx,
1683
+ y: origin.y + dy + snap.dy,
1684
+ };
1685
+ });
1686
+ return next;
1687
+ });
1688
+ setAlignmentGuides(snap.guides);
1689
+ const primary = state.originFrames[state.primaryId];
1690
+ showTransformFeedback(
1691
+ `X ${Math.round(primary.x + dx + snap.dx)} Y ${Math.round(
1692
+ primary.y + dy + snap.dy,
1693
+ )}`,
1694
+ ev.clientX,
1695
+ ev.clientY,
1696
+ );
1697
+ };
1698
+
1699
+ const handleMouseUp = () => {
1700
+ const state = dragState.current;
1701
+ if (state?.type === "move" && state.hasMoved) {
1702
+ const after = cloneFrameGeometryById(frameGeometryRef.current);
1703
+ onGeometryCommitRef.current?.(
1704
+ frameGeometryWithOverrides(after, state.originFrames),
1705
+ after,
1706
+ );
1707
+ suppressNextPick.current = true;
1708
+ }
1709
+ finishDrag();
1710
+ };
1711
+
1712
+ installDragListeners(handleMouseMove, handleMouseUp);
1713
+ },
1714
+ [
1715
+ finishDrag,
1716
+ getCurrentFrameEntries,
1717
+ installDragListeners,
1718
+ showTransformFeedback,
1719
+ updateFrameGeometry,
1720
+ updateSelectedDraftIds,
1721
+ updateSelectedIds,
1722
+ ],
1723
+ );
1724
+
1725
+ const beginResize = useCallback(
1726
+ (id: string, handle: ResizeHandle, e: React.MouseEvent) => {
1727
+ if (e.button !== 0) return;
1728
+ e.preventDefault();
1729
+ e.stopPropagation();
1730
+ suppressNextPick.current = true;
1731
+
1732
+ const currentSelectedIds = selectedIdsRef.current;
1733
+ const targetIds = currentSelectedIds.includes(id)
1734
+ ? currentSelectedIds
1735
+ : [id];
1736
+ const originEntries = getCurrentFrameEntries().filter((entry) =>
1737
+ targetIds.includes(entry.id),
1738
+ );
1739
+ const originBounds = getFrameGroupBounds(originEntries);
1740
+ if (!originBounds || originEntries.length === 0) return;
1741
+ updateSelectedIds((current) => (current.includes(id) ? current : [id]));
1742
+
1743
+ dragState.current = {
1744
+ type: "resize",
1745
+ originClient: { x: e.clientX, y: e.clientY },
1746
+ originFrames: Object.fromEntries(
1747
+ originEntries.map((entry) => [entry.id, entry.geometry]),
1748
+ ) as FrameGeometryById,
1749
+ originBounds: frameBoundsToGeometry(originBounds),
1750
+ targetIds: originEntries.map((entry) => entry.id),
1751
+ handle,
1752
+ hasMoved: false,
1753
+ };
1754
+ setIsDragging(true);
1755
+ setDragCursor(getResizeCursor(handle));
1756
+
1757
+ const handleMouseMove = (ev: MouseEvent) => {
1758
+ const state = dragState.current;
1759
+ if (!state || state.type !== "resize") return;
1760
+ const scale = zoomRef.current / 100;
1761
+ const dx = (ev.clientX - state.originClient.x) / scale;
1762
+ const dy = (ev.clientY - state.originClient.y) / scale;
1763
+ if (
1764
+ !state.hasMoved &&
1765
+ Math.hypot(
1766
+ ev.clientX - state.originClient.x,
1767
+ ev.clientY - state.originClient.y,
1768
+ ) >= DRAG_THRESHOLD
1769
+ ) {
1770
+ state.hasMoved = true;
1771
+ }
1772
+
1773
+ const originEntries = state.targetIds.map((targetId) => ({
1774
+ id: targetId,
1775
+ geometry: state.originFrames[targetId],
1776
+ }));
1777
+ const resized = resizeFrameGroupFromDelta(
1778
+ originEntries,
1779
+ state.originBounds,
1780
+ state.handle,
1781
+ dx,
1782
+ dy,
1783
+ {
1784
+ preserveAspectRatio: ev.shiftKey,
1785
+ resizeFromCenter: ev.altKey,
1786
+ },
1787
+ );
1788
+ const snap = computeResizeSnap(
1789
+ resized.bounds,
1790
+ getCurrentFrameEntries().filter(
1791
+ (entry) => !state.targetIds.includes(entry.id),
1792
+ ),
1793
+ state.handle,
1794
+ {
1795
+ thresholdScreenPx: DEFAULT_SNAP_THRESHOLD_SCREEN_PX,
1796
+ zoom: zoomRef.current,
1797
+ bypass: ev.metaKey || ev.ctrlKey,
1798
+ },
1799
+ );
1800
+ const resizedEntries = resizeFrameGroupToBounds(
1801
+ originEntries,
1802
+ state.originBounds,
1803
+ snap.frame,
1804
+ );
1805
+ updateFrameGeometry((current) => {
1806
+ const next = { ...current };
1807
+ resizedEntries.forEach((entry) => {
1808
+ next[entry.id] = {
1809
+ ...state.originFrames[entry.id],
1810
+ ...entry.geometry,
1811
+ };
1812
+ });
1813
+ return next;
1814
+ });
1815
+ setAlignmentGuides(snap.guides);
1816
+ showTransformFeedback(
1817
+ `${Math.round(snap.frame.width)} x ${Math.round(snap.frame.height)}`,
1818
+ ev.clientX,
1819
+ ev.clientY,
1820
+ );
1821
+ };
1822
+
1823
+ const handleMouseUp = () => {
1824
+ const state = dragState.current;
1825
+ if (state?.type === "resize" && state.hasMoved) {
1826
+ const after = cloneFrameGeometryById(frameGeometryRef.current);
1827
+ onGeometryCommitRef.current?.(
1828
+ frameGeometryWithOverrides(after, state.originFrames),
1829
+ after,
1830
+ );
1831
+ }
1832
+ finishDrag();
1833
+ };
1834
+
1835
+ installDragListeners(handleMouseMove, handleMouseUp);
1836
+ },
1837
+ [
1838
+ finishDrag,
1839
+ getCurrentFrameEntries,
1840
+ installDragListeners,
1841
+ showTransformFeedback,
1842
+ updateFrameGeometry,
1843
+ updateSelectedIds,
1844
+ ],
1845
+ );
1846
+
1847
+ const beginGroupResize = useCallback(
1848
+ (handle: ResizeHandle, e: React.MouseEvent) => {
1849
+ const firstSelectedId = selectedIdsRef.current[0];
1850
+ if (!firstSelectedId) return;
1851
+ beginResize(firstSelectedId, handle, e);
1852
+ },
1853
+ [beginResize],
1854
+ );
1855
+
1856
+ const beginRotate = useCallback(
1857
+ (id: string, e: React.MouseEvent) => {
1858
+ if (e.button !== 0) return;
1859
+ e.preventDefault();
1860
+ e.stopPropagation();
1861
+ suppressNextPick.current = true;
1862
+
1863
+ const originFrame = getCurrentFrameEntries().find(
1864
+ (entry) => entry.id === id,
1865
+ )?.geometry;
1866
+ if (!originFrame) return;
1867
+ updateSelectedIds((current) => (current.includes(id) ? current : [id]));
1868
+
1869
+ const pointer = getCanvasPoint(e.clientX, e.clientY);
1870
+ const center = getFrameCenter(originFrame);
1871
+ dragState.current = {
1872
+ type: "rotate",
1873
+ originClient: { x: e.clientX, y: e.clientY },
1874
+ originFrame,
1875
+ frameId: id,
1876
+ originPointerAngle: angleBetween(center, pointer),
1877
+ originRotation: originFrame.rotation ?? 0,
1878
+ hasMoved: false,
1879
+ };
1880
+ setIsDragging(true);
1881
+ setDragCursor("grabbing");
1882
+
1883
+ const handleMouseMove = (ev: MouseEvent) => {
1884
+ const state = dragState.current;
1885
+ if (!state || state.type !== "rotate") return;
1886
+ if (
1887
+ !state.hasMoved &&
1888
+ Math.hypot(
1889
+ ev.clientX - state.originClient.x,
1890
+ ev.clientY - state.originClient.y,
1891
+ ) >= DRAG_THRESHOLD
1892
+ ) {
1893
+ state.hasMoved = true;
1894
+ }
1895
+ const pointer = getCanvasPoint(ev.clientX, ev.clientY);
1896
+ const center = getFrameCenter(state.originFrame);
1897
+ const raw =
1898
+ state.originRotation +
1899
+ angleBetween(center, pointer) -
1900
+ state.originPointerAngle;
1901
+ const rotation = ev.shiftKey ? Math.round(raw / 15) * 15 : raw;
1902
+ updateFrameGeometry((current) => ({
1903
+ ...current,
1904
+ [state.frameId]: {
1905
+ ...state.originFrame,
1906
+ rotation: Math.round(rotation * 10) / 10,
1907
+ },
1908
+ }));
1909
+ showTransformFeedback(
1910
+ `${Math.round(rotation)}deg`,
1911
+ ev.clientX,
1912
+ ev.clientY,
1913
+ );
1914
+ };
1915
+
1916
+ const handleMouseUp = () => {
1917
+ const state = dragState.current;
1918
+ if (state?.type === "rotate" && state.hasMoved) {
1919
+ const after = cloneFrameGeometryById(frameGeometryRef.current);
1920
+ onGeometryCommitRef.current?.(
1921
+ frameGeometryWithOverrides(after, {
1922
+ [state.frameId]: state.originFrame,
1923
+ }),
1924
+ after,
1925
+ );
1926
+ }
1927
+ suppressNextPick.current = true;
1928
+ finishDrag();
1929
+ };
1930
+
1931
+ installDragListeners(handleMouseMove, handleMouseUp);
1932
+ },
1933
+ [
1934
+ finishDrag,
1935
+ getCanvasPoint,
1936
+ getCurrentFrameEntries,
1937
+ installDragListeners,
1938
+ showTransformFeedback,
1939
+ updateFrameGeometry,
1940
+ updateSelectedIds,
1941
+ ],
1942
+ );
1943
+
1944
+ const handleFrameClick = useCallback(
1945
+ (id: string, e: React.MouseEvent<HTMLElement>) => {
1946
+ e.stopPropagation();
1947
+ if (suppressNextPick.current) {
1948
+ suppressNextPick.current = false;
1949
+ return;
1950
+ }
1951
+
1952
+ if (e.shiftKey) {
1953
+ updateSelectedDraftIds(() => []);
1954
+ updateSelectedIds((current) =>
1955
+ current.includes(id)
1956
+ ? current.filter((selectedId) => selectedId !== id)
1957
+ : [...current, id],
1958
+ );
1959
+ return;
1960
+ }
1961
+
1962
+ updateSelectedDraftIds(() => []);
1963
+ updateSelectedIds(() => [id]);
1964
+ onPick(id);
1965
+ },
1966
+ [onPick, updateSelectedDraftIds, updateSelectedIds],
1967
+ );
1968
+
1969
+ const handleFrameDoubleClick = useCallback(
1970
+ (id: string, e: React.MouseEvent<HTMLElement>) => {
1971
+ e.preventDefault();
1972
+ e.stopPropagation();
1973
+ updateSelectedDraftIds(() => []);
1974
+ updateSelectedIds(() => [id]);
1975
+ onPick(id);
1976
+ onEdit?.(id);
1977
+ },
1978
+ [onEdit, onPick, updateSelectedDraftIds, updateSelectedIds],
1979
+ );
1980
+
1981
+ const beginDuplicateGesture = useCallback(
1982
+ (screen: ScreenFile, display: string, e: React.MouseEvent<HTMLElement>) => {
1983
+ if (e.button !== 0 || !e.altKey) return;
1984
+ e.preventDefault();
1985
+ e.stopPropagation();
1986
+ duplicateCleanup.current?.();
1987
+
1988
+ const surfaceRect = surfaceRef.current?.getBoundingClientRect();
1989
+ const origin = { x: e.clientX, y: e.clientY };
1990
+ const originCanvas = canvasPointFromClient(e.clientX, e.clientY);
1991
+ const sourceFrame = getCurrentFrameEntries().find(
1992
+ (entry) => entry.id === screen.id,
1993
+ );
1994
+ const pointerOffset = sourceFrame
1995
+ ? {
1996
+ x: originCanvas.x - sourceFrame.geometry.x,
1997
+ y: originCanvas.y - sourceFrame.geometry.y,
1998
+ }
1999
+ : { x: 0, y: 0 };
2000
+ const previewPoint = {
2001
+ x: surfaceRect ? e.clientX - surfaceRect.left + 16 : e.clientX,
2002
+ y: surfaceRect ? e.clientY - surfaceRect.top + 16 : e.clientY,
2003
+ };
2004
+
2005
+ setDuplicatePreview({
2006
+ display,
2007
+ x: previewPoint.x,
2008
+ y: previewPoint.y,
2009
+ canDuplicate: !!onDuplicate,
2010
+ moved: false,
2011
+ });
2012
+
2013
+ const handleMouseMove = (ev: MouseEvent) => {
2014
+ const dx = ev.clientX - origin.x;
2015
+ const dy = ev.clientY - origin.y;
2016
+ const moved = Math.hypot(dx, dy) >= DUPLICATE_DRAG_THRESHOLD;
2017
+ const rect = surfaceRef.current?.getBoundingClientRect();
2018
+ setDuplicatePreview({
2019
+ display,
2020
+ x: rect ? ev.clientX - rect.left + 16 : ev.clientX,
2021
+ y: rect ? ev.clientY - rect.top + 16 : ev.clientY,
2022
+ canDuplicate: !!onDuplicate,
2023
+ moved,
2024
+ });
2025
+ };
2026
+
2027
+ const cleanupDuplicateGesture = () => {
2028
+ setDuplicatePreview(null);
2029
+ window.removeEventListener("mousemove", handleMouseMove);
2030
+ window.removeEventListener("mouseup", handleMouseUp);
2031
+ duplicateCleanup.current = null;
2032
+ };
2033
+
2034
+ const handleMouseUp = (ev: MouseEvent) => {
2035
+ const moved =
2036
+ Math.hypot(ev.clientX - origin.x, ev.clientY - origin.y) >=
2037
+ DUPLICATE_DRAG_THRESHOLD;
2038
+ const mode = moved ? "alt-drag" : "alt-click";
2039
+
2040
+ if (onDuplicate) {
2041
+ const dropCanvasPosition = canvasPointFromClient(
2042
+ ev.clientX,
2043
+ ev.clientY,
2044
+ );
2045
+ const canvasPosition =
2046
+ moved || !sourceFrame
2047
+ ? {
2048
+ x: dropCanvasPosition.x - pointerOffset.x,
2049
+ y: dropCanvasPosition.y - pointerOffset.y,
2050
+ }
2051
+ : {
2052
+ x: sourceFrame.geometry.x + SCREEN_GAP / 2,
2053
+ y: sourceFrame.geometry.y + SCREEN_GAP / 2,
2054
+ };
2055
+ onDuplicate(screen.id, {
2056
+ mode,
2057
+ screen,
2058
+ canvasPosition,
2059
+ canvasOffset: pointerOffset,
2060
+ dropCanvasPosition,
2061
+ });
2062
+ } else if (!moved) {
2063
+ onPick(screen.id);
2064
+ }
2065
+
2066
+ cleanupDuplicateGesture();
2067
+ };
2068
+
2069
+ duplicateCleanup.current = cleanupDuplicateGesture;
2070
+ window.addEventListener("mousemove", handleMouseMove);
2071
+ window.addEventListener("mouseup", handleMouseUp);
2072
+ },
2073
+ [canvasPointFromClient, getCurrentFrameEntries, onDuplicate, onPick],
2074
+ );
2075
+
2076
+ const handleMouseDown = useCallback(
2077
+ (e: React.MouseEvent) => {
2078
+ const target = e.target as HTMLElement;
2079
+ const onFrame = !!target.closest("[data-frame-shell]");
2080
+ const tool = normalizeCanvasTool(activeTool ?? localActiveTool);
2081
+ if (e.button === 1) {
2082
+ beginPan(e);
2083
+ return;
2084
+ }
2085
+ if (e.button === 0 && tool === "hand") {
2086
+ beginPan(e);
2087
+ return;
2088
+ }
2089
+ if (e.button === 0 && tool === "pen") {
2090
+ beginPenNodeCreation(e);
2091
+ return;
2092
+ }
2093
+ const creationTool = getDraftCreationTool(tool);
2094
+ if (e.button === 0 && creationTool) {
2095
+ beginDraftCreation(creationTool, e);
2096
+ return;
2097
+ }
2098
+ if (e.button === 0 && !onFrame) {
2099
+ beginMarquee(e);
2100
+ }
2101
+ },
2102
+ [
2103
+ activeTool,
2104
+ beginDraftCreation,
2105
+ beginMarquee,
2106
+ beginPan,
2107
+ beginPenNodeCreation,
2108
+ localActiveTool,
2109
+ ],
2110
+ );
2111
+
2112
+ const handleMouseMove = useCallback(
2113
+ (e: React.MouseEvent) => {
2114
+ const tool = normalizeCanvasTool(activeTool ?? localActiveTool);
2115
+ if (tool !== "pen" || dragState.current?.type === "pen-node") return;
2116
+ updatePenPointer(e.clientX, e.clientY, e.shiftKey);
2117
+ },
2118
+ [activeTool, localActiveTool, updatePenPointer],
2119
+ );
2120
+
2121
+ const handleWheelEvent = useCallback(
2122
+ (event: WheelEvent) => {
2123
+ const rect = surfaceRef.current?.getBoundingClientRect();
2124
+ if (!rect) return;
2125
+ event.preventDefault();
2126
+ event.stopPropagation();
2127
+ const delta = getWheelDelta(event);
2128
+
2129
+ if (event.ctrlKey || event.metaKey) {
2130
+ const currentZoom = zoomRef.current;
2131
+ const zoomDeltaY = clamp(
2132
+ delta.y,
2133
+ -MAX_WHEEL_ZOOM_DELTA,
2134
+ MAX_WHEEL_ZOOM_DELTA,
2135
+ );
2136
+ const nextZoom = clamp(
2137
+ currentZoom * Math.exp(-zoomDeltaY * ZOOM_SENSITIVITY),
2138
+ MIN_ZOOM,
2139
+ MAX_ZOOM,
2140
+ );
2141
+ if (nextZoom === currentZoom) return;
2142
+
2143
+ const nextPan = getPanForZoomToCursor({
2144
+ pan: panRef.current,
2145
+ cursor: { x: event.clientX - rect.left, y: event.clientY - rect.top },
2146
+ oldZoom: currentZoom,
2147
+ nextZoom,
2148
+ });
2149
+ const zoomEditFrameId =
2150
+ zoomToEditThreshold !== undefined &&
2151
+ currentZoom < zoomToEditThreshold &&
2152
+ nextZoom >= zoomToEditThreshold
2153
+ ? getFrameAtClientPoint(event.clientX, event.clientY)
2154
+ : undefined;
2155
+
2156
+ zoomRef.current = nextZoom;
2157
+ panRef.current = nextPan;
2158
+ setCanvasZoom(nextZoom);
2159
+ setPan(nextPan);
2160
+ onZoomChange?.(nextZoom);
2161
+ if (zoomEditFrameId) {
2162
+ onZoomToEdit?.(zoomEditFrameId);
2163
+ }
2164
+ return;
2165
+ }
2166
+
2167
+ const deltaX = clamp(
2168
+ event.shiftKey && delta.x === 0 ? delta.y : delta.x,
2169
+ -MAX_WHEEL_PAN_DELTA,
2170
+ MAX_WHEEL_PAN_DELTA,
2171
+ );
2172
+ const deltaY = clamp(
2173
+ event.shiftKey && delta.x === 0 ? 0 : delta.y,
2174
+ -MAX_WHEEL_PAN_DELTA,
2175
+ MAX_WHEEL_PAN_DELTA,
2176
+ );
2177
+ const nextPan = {
2178
+ x: panRef.current.x - deltaX,
2179
+ y: panRef.current.y - deltaY,
2180
+ };
2181
+ panRef.current = nextPan;
2182
+ setPan(nextPan);
2183
+ },
2184
+ [getFrameAtClientPoint, onZoomChange, onZoomToEdit, zoomToEditThreshold],
2185
+ );
2186
+
2187
+ useEffect(() => {
2188
+ const surface = surfaceRef.current;
2189
+ if (!surface) return;
2190
+ surface.addEventListener("wheel", handleWheelEvent, {
2191
+ capture: true,
2192
+ passive: false,
2193
+ });
2194
+ return () => {
2195
+ surface.removeEventListener("wheel", handleWheelEvent, {
2196
+ capture: true,
2197
+ });
2198
+ };
2199
+ }, [handleWheelEvent]);
2200
+
2201
+ useEffect(() => {
2202
+ const handleKeyDown = (event: KeyboardEvent) => {
2203
+ if (activePenPathRef.current) return;
2204
+ if (!isArrowNudgeKey(event.key) || isEditableHotkeyTarget(event.target)) {
2205
+ return;
2206
+ }
2207
+
2208
+ const targetIds = selectedIdsRef.current.filter(
2209
+ (id) => frameGeometryRef.current[id],
2210
+ );
2211
+ const targetDraftIds = selectedDraftIdsRef.current.filter((id) =>
2212
+ draftPrimitivesRef.current.some((draft) => draft.id === id),
2213
+ );
2214
+ if (targetIds.length === 0 && targetDraftIds.length === 0) return;
2215
+
2216
+ event.preventDefault();
2217
+ event.stopPropagation();
2218
+
2219
+ const nudge = getNudgeDelta(event.key, {
2220
+ altKey: event.altKey,
2221
+ ctrlKey: event.ctrlKey,
2222
+ metaKey: event.metaKey,
2223
+ shiftKey: event.shiftKey,
2224
+ });
2225
+ const movingFrameEntries = targetIds.map((targetId) => {
2226
+ const origin = frameGeometryRef.current[targetId];
2227
+ return {
2228
+ id: targetId,
2229
+ geometry: {
2230
+ ...origin,
2231
+ x: origin.x + nudge.dx,
2232
+ y: origin.y + nudge.dy,
2233
+ },
2234
+ };
2235
+ });
2236
+ const movingDraftEntries = targetDraftIds
2237
+ .map((targetId) =>
2238
+ draftPrimitivesRef.current.find((draft) => draft.id === targetId),
2239
+ )
2240
+ .filter(isDraftPrimitive)
2241
+ .map((draft) => ({
2242
+ id: draft.id,
2243
+ geometry: {
2244
+ ...draft.geometry,
2245
+ x: draft.geometry.x + nudge.dx,
2246
+ y: draft.geometry.y + nudge.dy,
2247
+ },
2248
+ }));
2249
+ const movingEntries = [...movingFrameEntries, ...movingDraftEntries];
2250
+ const movingIds = [...targetIds, ...targetDraftIds];
2251
+ const stationaryEntries = getCurrentCanvasEntries().filter(
2252
+ (entry) => !movingIds.includes(entry.id),
2253
+ );
2254
+ const snap = computeMoveSnap(movingEntries, stationaryEntries, {
2255
+ thresholdScreenPx: DEFAULT_SNAP_THRESHOLD_SCREEN_PX,
2256
+ zoom: zoomRef.current,
2257
+ bypass: nudge.snap.bypass,
2258
+ });
2259
+
2260
+ if (targetIds.length > 0) {
2261
+ const before = cloneFrameGeometryById(frameGeometryRef.current);
2262
+ const next = { ...before };
2263
+ targetIds.forEach((targetId) => {
2264
+ const origin = before[targetId] ?? frameGeometryRef.current[targetId];
2265
+ next[targetId] = {
2266
+ ...origin,
2267
+ x: origin.x + nudge.dx + snap.dx,
2268
+ y: origin.y + nudge.dy + snap.dy,
2269
+ };
2270
+ });
2271
+ updateFrameGeometry(() => next);
2272
+ onGeometryCommitRef.current?.(before, cloneFrameGeometryById(next));
2273
+ }
2274
+ if (targetDraftIds.length > 0) {
2275
+ updateDraftPrimitives((current) =>
2276
+ current.map((draft) =>
2277
+ targetDraftIds.includes(draft.id)
2278
+ ? moveDraftPrimitive(
2279
+ draft,
2280
+ nudge.dx + snap.dx,
2281
+ nudge.dy + snap.dy,
2282
+ )
2283
+ : draft,
2284
+ ),
2285
+ );
2286
+ }
2287
+ setAlignmentGuides(snap.guides);
2288
+ scheduleFeedbackClear();
2289
+ };
2290
+
2291
+ window.addEventListener("keydown", handleKeyDown, true);
2292
+ return () => {
2293
+ window.removeEventListener("keydown", handleKeyDown, true);
2294
+ };
2295
+ }, [
2296
+ getCurrentCanvasEntries,
2297
+ scheduleFeedbackClear,
2298
+ updateDraftPrimitives,
2299
+ updateFrameGeometry,
2300
+ ]);
2301
+
2302
+ useEffect(() => {
2303
+ const handleKeyDown = (event: KeyboardEvent) => {
2304
+ if (activePenPathRef.current) return;
2305
+ if (
2306
+ (event.key !== "Delete" && event.key !== "Backspace") ||
2307
+ event.metaKey ||
2308
+ event.ctrlKey ||
2309
+ isEditableHotkeyTarget(event.target)
2310
+ ) {
2311
+ return;
2312
+ }
2313
+ if (!deleteSelectedItems()) return;
2314
+ event.preventDefault();
2315
+ event.stopPropagation();
2316
+ event.stopImmediatePropagation();
2317
+ };
2318
+
2319
+ window.addEventListener("keydown", handleKeyDown, true);
2320
+ return () => {
2321
+ window.removeEventListener("keydown", handleKeyDown, true);
2322
+ };
2323
+ }, [deleteSelectedItems]);
2324
+
2325
+ useEffect(() => {
2326
+ const handleKeyDown = (event: KeyboardEvent) => {
2327
+ const path = activePenPathRef.current;
2328
+ if (
2329
+ !path ||
2330
+ event.metaKey ||
2331
+ event.ctrlKey ||
2332
+ isEditableHotkeyTarget(event.target)
2333
+ ) {
2334
+ return;
2335
+ }
2336
+
2337
+ if (event.key === "Enter" || event.key === "Escape") {
2338
+ event.preventDefault();
2339
+ event.stopPropagation();
2340
+ event.stopImmediatePropagation();
2341
+ finishPenPath(path);
2342
+ return;
2343
+ }
2344
+
2345
+ if (event.key === "Delete" || event.key === "Backspace") {
2346
+ event.preventDefault();
2347
+ event.stopPropagation();
2348
+ event.stopImmediatePropagation();
2349
+ const remainingNodes = path.nodes.slice(0, -1);
2350
+ if (remainingNodes.length === 0) {
2351
+ clearActivePenPath();
2352
+ return;
2353
+ }
2354
+ setActivePenPath({ nodes: remainingNodes, closed: false });
2355
+ setPenPointer(null);
2356
+ setPenCloseHover(false);
2357
+ }
2358
+ };
2359
+
2360
+ window.addEventListener("keydown", handleKeyDown, true);
2361
+ return () => {
2362
+ window.removeEventListener("keydown", handleKeyDown, true);
2363
+ };
2364
+ }, [clearActivePenPath, finishPenPath]);
2365
+
2366
+ useEffect(() => {
2367
+ const tool = normalizeCanvasTool(activeTool ?? localActiveTool);
2368
+ if (tool !== "pen") clearActivePenPath();
2369
+ }, [activeTool, clearActivePenPath, localActiveTool]);
2370
+
2371
+ const scale = canvasZoom / 100;
2372
+ const chromeScale = scale > 0 ? 1 / scale : 1;
2373
+ const showPixelGrid = canvasZoom >= PIXEL_GRID_ZOOM;
2374
+ const effectiveTool = normalizeCanvasTool(activeTool ?? localActiveTool);
2375
+ const penActive = effectiveTool === "pen";
2376
+ const creationToolActive = Boolean(getDraftCreationTool(effectiveTool));
2377
+ const displayedPenPath = penGesturePreview
2378
+ ? penGesturePreview
2379
+ : activePenPath && penPointer && activePenPath.nodes.length > 0
2380
+ ? penCloseHover
2381
+ ? closePenPath(activePenPath)
2382
+ : appendPenNode(activePenPath, createCornerNode(penPointer))
2383
+ : activePenPath;
2384
+ const selectedIdSet = new Set(selectedIds);
2385
+ const selectedDraftIdSet = new Set(selectedDraftIds);
2386
+ const surfaceCursor = isPanning
2387
+ ? "grabbing"
2388
+ : dragCursor
2389
+ ? dragCursor
2390
+ : isDragging && marquee
2391
+ ? "crosshair"
2392
+ : penActive || getDraftCreationTool(effectiveTool)
2393
+ ? "crosshair"
2394
+ : effectiveTool === "hand"
2395
+ ? "grab"
2396
+ : "default";
2397
+ const canvasFrames = screens.map((screen, index) => ({
2398
+ screen,
2399
+ geometry: frameGeometry[screen.id] ?? getInitialFrameGeometry(index),
2400
+ }));
2401
+ const selectedFrameEntries = canvasFrames
2402
+ .filter(({ screen }) => selectedIdSet.has(screen.id))
2403
+ .map(({ screen, geometry }) => ({ id: screen.id, geometry }));
2404
+ const selectedGroupBounds =
2405
+ selectedFrameEntries.length > 1
2406
+ ? getFrameGroupBounds(selectedFrameEntries)
2407
+ : null;
2408
+ const hasGroupSelection = !!selectedGroupBounds;
2409
+ const selectedDraftEntries = draftPrimitives
2410
+ .filter((draft) => selectedDraftIdSet.has(draft.id))
2411
+ .map((draft) => ({ id: draft.id, geometry: draft.geometry }));
2412
+ const selectedDraftGroupBounds =
2413
+ selectedDraftEntries.length > 1
2414
+ ? getFrameGroupBounds(selectedDraftEntries)
2415
+ : null;
2416
+ return (
2417
+ <div
2418
+ ref={surfaceRef}
2419
+ className="relative h-full w-full select-none overflow-hidden bg-background"
2420
+ onMouseDown={handleMouseDown}
2421
+ onMouseMove={handleMouseMove}
2422
+ style={{ cursor: surfaceCursor, touchAction: "none" }}
2423
+ >
2424
+ {showPixelGrid ? (
2425
+ <div
2426
+ className="pointer-events-none absolute inset-0 opacity-60"
2427
+ style={{
2428
+ backgroundImage:
2429
+ "linear-gradient(to right, hsl(var(--border)) 1px, transparent 1px), linear-gradient(to bottom, hsl(var(--border)) 1px, transparent 1px)",
2430
+ backgroundPosition: `${pan.x}px ${pan.y}px`,
2431
+ backgroundSize: `${scale}px ${scale}px`,
2432
+ }}
2433
+ />
2434
+ ) : null}
2435
+
2436
+ <div
2437
+ className="pointer-events-none absolute"
2438
+ style={{
2439
+ left: pan.x,
2440
+ top: pan.y,
2441
+ transform: `scale(${scale})`,
2442
+ transformOrigin: "top left",
2443
+ }}
2444
+ >
2445
+ {canvasFrames.map(({ screen, geometry }) => {
2446
+ const metadata = resolveScreenMetadata(
2447
+ screen,
2448
+ metadataById?.[screen.id],
2449
+ getScreenMetadata?.(screen),
2450
+ );
2451
+ return (
2452
+ <Screen
2453
+ key={screen.id}
2454
+ screen={screen}
2455
+ metadata={metadata}
2456
+ geometry={geometry}
2457
+ screenContent={renderScreenContent?.(screen, metadata, geometry)}
2458
+ isActive={screen.id === activeId}
2459
+ isSelected={selectedIdSet.has(screen.id)}
2460
+ groupSelected={hasGroupSelection}
2461
+ handlesEnabled={!hasGroupSelection}
2462
+ penActive={penActive}
2463
+ creationToolActive={creationToolActive}
2464
+ chromeScale={chromeScale}
2465
+ onPick={handleFrameClick}
2466
+ onEdit={handleFrameDoubleClick}
2467
+ onStartFrameDrag={beginFrameDrag}
2468
+ onStartResize={beginResize}
2469
+ onStartRotate={beginRotate}
2470
+ onStartDuplicateGesture={beginDuplicateGesture}
2471
+ />
2472
+ );
2473
+ })}
2474
+
2475
+ {draftPrimitives.map((draft) => (
2476
+ <DraftPrimitiveLayer
2477
+ key={draft.id}
2478
+ draft={draft}
2479
+ isSelected={selectedDraftIdSet.has(draft.id)}
2480
+ groupSelected={Boolean(selectedDraftGroupBounds)}
2481
+ penActive={penActive}
2482
+ chromeScale={chromeScale}
2483
+ onClick={handleDraftClick}
2484
+ onStartDrag={beginDraftDrag}
2485
+ onStartResize={beginDraftResize}
2486
+ />
2487
+ ))}
2488
+
2489
+ {creationPreview ? (
2490
+ <DraftPrimitiveLayer
2491
+ draft={previewDraftPrimitive(creationPreview)}
2492
+ isSelected
2493
+ preview
2494
+ groupSelected={false}
2495
+ penActive={penActive}
2496
+ chromeScale={chromeScale}
2497
+ onClick={() => {}}
2498
+ onStartDrag={() => {}}
2499
+ onStartResize={() => {}}
2500
+ />
2501
+ ) : null}
2502
+
2503
+ {displayedPenPath ? (
2504
+ <PenPathOverlay path={displayedPenPath} closeHover={penCloseHover} />
2505
+ ) : null}
2506
+
2507
+ {selectedGroupBounds ? (
2508
+ <GroupSelectionBox
2509
+ bounds={selectedGroupBounds}
2510
+ chromeScale={chromeScale}
2511
+ onStartResize={beginGroupResize}
2512
+ />
2513
+ ) : null}
2514
+
2515
+ {selectedDraftGroupBounds ? (
2516
+ <GroupSelectionBox
2517
+ bounds={selectedDraftGroupBounds}
2518
+ chromeScale={chromeScale}
2519
+ onStartResize={beginDraftGroupResize}
2520
+ />
2521
+ ) : null}
2522
+
2523
+ {alignmentGuides.map((guide, index) => (
2524
+ <span
2525
+ key={`${guide.orientation}-${guide.position}-${index}`}
2526
+ className="pointer-events-none absolute z-30 bg-destructive/90"
2527
+ style={
2528
+ guide.orientation === "vertical"
2529
+ ? {
2530
+ left: SURFACE_PADDING + guide.position,
2531
+ top: SURFACE_PADDING + guide.start,
2532
+ width: 1,
2533
+ height: Math.max(1, guide.end - guide.start),
2534
+ }
2535
+ : {
2536
+ left: SURFACE_PADDING + guide.start,
2537
+ top: SURFACE_PADDING + guide.position,
2538
+ width: Math.max(1, guide.end - guide.start),
2539
+ height: 1,
2540
+ }
2541
+ }
2542
+ />
2543
+ ))}
2544
+
2545
+ {marquee ? (
2546
+ <span
2547
+ className="pointer-events-none absolute z-40 border border-[var(--design-editor-accent-color)] bg-[var(--design-editor-selection-color)]"
2548
+ style={{
2549
+ left: SURFACE_PADDING + marquee.x,
2550
+ top: SURFACE_PADDING + marquee.y,
2551
+ width: marquee.width,
2552
+ height: marquee.height,
2553
+ }}
2554
+ />
2555
+ ) : null}
2556
+ </div>
2557
+
2558
+ {duplicatePreview ? (
2559
+ <div
2560
+ className={cn(
2561
+ "pointer-events-none absolute z-20 rounded-lg border bg-background/90 shadow-2xl backdrop-blur-sm transition-colors",
2562
+ duplicatePreview.canDuplicate
2563
+ ? "border-primary/80 ring-4 ring-primary/15"
2564
+ : "border-dashed border-muted-foreground/45",
2565
+ )}
2566
+ style={{
2567
+ left: duplicatePreview.x,
2568
+ top: duplicatePreview.y,
2569
+ width: SCREEN_WIDTH * Math.min(scale, 1),
2570
+ height: SCREEN_HEIGHT * Math.min(scale, 1),
2571
+ maxWidth: SCREEN_WIDTH,
2572
+ maxHeight: SCREEN_HEIGHT,
2573
+ }}
2574
+ >
2575
+ <div className="flex h-full w-full items-start justify-between rounded-lg bg-muted/20 p-2">
2576
+ <span className="max-w-[190px] truncate text-[11px] font-medium text-foreground">
2577
+ {duplicatePreview.display}
2578
+ </span>
2579
+ <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">
2580
+ <IconCopy className="h-3 w-3" />
2581
+ {duplicatePreview.canDuplicate
2582
+ ? duplicatePreview.moved
2583
+ ? t("multiScreenCanvas.fork")
2584
+ : t("multiScreenCanvas.duplicate")
2585
+ : t("multiScreenCanvas.preview")}
2586
+ </span>
2587
+ </div>
2588
+ </div>
2589
+ ) : null}
2590
+
2591
+ {transformBadge ? (
2592
+ <div
2593
+ 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"
2594
+ style={{ left: transformBadge.x, top: transformBadge.y }}
2595
+ >
2596
+ {transformBadge.text}
2597
+ </div>
2598
+ ) : null}
2599
+ </div>
2600
+ );
2601
+ }
2602
+
2603
+ function DraftPrimitiveLayer({
2604
+ draft,
2605
+ isSelected,
2606
+ groupSelected,
2607
+ penActive,
2608
+ chromeScale,
2609
+ preview = false,
2610
+ onClick,
2611
+ onStartDrag,
2612
+ onStartResize,
2613
+ }: {
2614
+ draft: DraftPrimitive;
2615
+ isSelected: boolean;
2616
+ groupSelected: boolean;
2617
+ penActive: boolean;
2618
+ chromeScale: number;
2619
+ preview?: boolean;
2620
+ onClick: (id: string, e: React.MouseEvent) => void;
2621
+ onStartDrag: (id: string, e: React.MouseEvent) => void;
2622
+ onStartResize: (
2623
+ id: string,
2624
+ handle: ResizeHandle,
2625
+ e: React.MouseEvent,
2626
+ ) => void;
2627
+ }) {
2628
+ const { geometry } = draft;
2629
+ const selected = isSelected && !groupSelected;
2630
+ return (
2631
+ <button
2632
+ data-frame-shell
2633
+ type="button"
2634
+ className={cn(
2635
+ "group/artboard pointer-events-auto absolute block overflow-visible text-left outline-none",
2636
+ preview || penActive ? "cursor-crosshair" : "cursor-move",
2637
+ )}
2638
+ style={{
2639
+ left: SURFACE_PADDING + geometry.x,
2640
+ top: SURFACE_PADDING + geometry.y,
2641
+ width: geometry.width,
2642
+ height: geometry.height,
2643
+ zIndex: geometry.z ?? 40,
2644
+ transform: geometry.rotation
2645
+ ? `rotate(${geometry.rotation}deg)`
2646
+ : undefined,
2647
+ }}
2648
+ onClick={(event) => {
2649
+ if (penActive) return;
2650
+ if (!preview) onClick(draft.id, event);
2651
+ }}
2652
+ onMouseDown={(event) => {
2653
+ if (penActive) return;
2654
+ if (!preview) onStartDrag(draft.id, event);
2655
+ }}
2656
+ >
2657
+ <DraftPrimitiveContent draft={draft} preview={preview} />
2658
+ <span
2659
+ className={cn(
2660
+ "pointer-events-none absolute rounded-sm border transition-opacity",
2661
+ selected
2662
+ ? "border-[var(--design-editor-accent-color)] opacity-100"
2663
+ : "border-[var(--design-editor-accent-color)] opacity-0 group-hover/artboard:opacity-100",
2664
+ )}
2665
+ style={{
2666
+ inset: -5 * chromeScale,
2667
+ borderWidth: 1.5 * chromeScale,
2668
+ }}
2669
+ />
2670
+ <ResizeHandles
2671
+ active={selected || preview}
2672
+ enabled={!penActive && ((isSelected && !groupSelected) || preview)}
2673
+ showRotate={false}
2674
+ chromeScale={chromeScale}
2675
+ onStartResize={(handle, event) =>
2676
+ onStartResize(draft.id, handle, event)
2677
+ }
2678
+ onStartRotate={() => {}}
2679
+ />
2680
+ </button>
2681
+ );
2682
+ }
2683
+
2684
+ function DraftPrimitiveContent({
2685
+ draft,
2686
+ preview,
2687
+ }: {
2688
+ draft: DraftPrimitive;
2689
+ preview: boolean;
2690
+ }) {
2691
+ const muted = preview ? "opacity-70" : "";
2692
+ if (
2693
+ draft.kind === "path" ||
2694
+ draft.kind === "line" ||
2695
+ draft.kind === "arrow"
2696
+ ) {
2697
+ const markerId = `arrow-${draft.id.replace(/[^a-zA-Z0-9_-]/g, "")}`;
2698
+ const pathData =
2699
+ draft.pathData ??
2700
+ (draft.penPath
2701
+ ? serializePenPath(draft.penPath)
2702
+ : pointsToPath(draft.points ?? []));
2703
+ return (
2704
+ <svg
2705
+ className={cn("block size-full overflow-visible", muted)}
2706
+ viewBox={`${draft.geometry.x} ${draft.geometry.y} ${draft.geometry.width} ${draft.geometry.height}`}
2707
+ >
2708
+ {draft.kind === "arrow" ? (
2709
+ <defs>
2710
+ <marker
2711
+ id={markerId}
2712
+ markerWidth="10"
2713
+ markerHeight="10"
2714
+ refX="8"
2715
+ refY="5"
2716
+ orient="auto"
2717
+ markerUnits="strokeWidth"
2718
+ >
2719
+ <path d="M 0 0 L 10 5 L 0 10 z" fill="currentColor" />
2720
+ </marker>
2721
+ </defs>
2722
+ ) : null}
2723
+ <path
2724
+ d={pathData}
2725
+ fill="none"
2726
+ stroke={draft.stroke ?? "hsl(var(--primary))"}
2727
+ strokeLinecap="round"
2728
+ strokeLinejoin="round"
2729
+ strokeWidth={draft.strokeWidth ?? 3}
2730
+ markerEnd={draft.kind === "arrow" ? `url(#${markerId})` : undefined}
2731
+ />
2732
+ </svg>
2733
+ );
2734
+ }
2735
+
2736
+ if (draft.kind === "text") {
2737
+ return (
2738
+ <div
2739
+ className={cn(
2740
+ "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",
2741
+ muted,
2742
+ )}
2743
+ >
2744
+ <span className="truncate">{draft.text}</span>
2745
+ </div>
2746
+ );
2747
+ }
2748
+
2749
+ if (draft.kind === "frame") {
2750
+ return (
2751
+ <div
2752
+ className={cn(
2753
+ "size-full rounded-sm border-2 border-dashed border-primary/70 bg-primary/5",
2754
+ muted,
2755
+ )}
2756
+ />
2757
+ );
2758
+ }
2759
+
2760
+ if (draft.kind === "ellipse") {
2761
+ return (
2762
+ <div
2763
+ className={cn("size-full rounded-full border", muted)}
2764
+ style={{
2765
+ background: draft.fill ?? "hsl(var(--primary) / 0.12)",
2766
+ borderColor: draft.stroke ?? "hsl(var(--primary) / 0.7)",
2767
+ borderWidth: draft.strokeWidth ?? 1,
2768
+ }}
2769
+ />
2770
+ );
2771
+ }
2772
+
2773
+ if (draft.kind === "polygon" || draft.kind === "star") {
2774
+ return (
2775
+ <svg
2776
+ className={cn("block size-full overflow-visible", muted)}
2777
+ viewBox={`0 0 ${Math.max(1, draft.geometry.width)} ${Math.max(
2778
+ 1,
2779
+ draft.geometry.height,
2780
+ )}`}
2781
+ >
2782
+ <polygon
2783
+ points={polygonPointsForBox(
2784
+ draft.kind,
2785
+ draft.geometry.width,
2786
+ draft.geometry.height,
2787
+ )}
2788
+ fill={draft.fill ?? "hsl(var(--primary) / 0.12)"}
2789
+ stroke={draft.stroke ?? "hsl(var(--primary))"}
2790
+ strokeLinejoin="round"
2791
+ strokeWidth={draft.strokeWidth ?? 1.5}
2792
+ />
2793
+ </svg>
2794
+ );
2795
+ }
2796
+
2797
+ return (
2798
+ <div
2799
+ className={cn("size-full rounded-sm border", muted)}
2800
+ style={{
2801
+ background: draft.fill ?? "hsl(var(--primary) / 0.12)",
2802
+ borderColor: draft.stroke ?? "hsl(var(--primary) / 0.7)",
2803
+ borderWidth: draft.strokeWidth ?? 1,
2804
+ }}
2805
+ />
2806
+ );
2807
+ }
2808
+
2809
+ function PenPathOverlay({
2810
+ path,
2811
+ closeHover,
2812
+ }: {
2813
+ path: PenPath;
2814
+ closeHover: boolean;
2815
+ }) {
2816
+ const geometry = getPenPathGeometry(path);
2817
+ const pathData = serializePenPath(path);
2818
+ return (
2819
+ <div
2820
+ data-pen-path-overlay
2821
+ className="pointer-events-none absolute z-[90]"
2822
+ style={{
2823
+ left: SURFACE_PADDING + geometry.x,
2824
+ top: SURFACE_PADDING + geometry.y,
2825
+ width: geometry.width,
2826
+ height: geometry.height,
2827
+ }}
2828
+ >
2829
+ <svg
2830
+ className="absolute inset-0 size-full overflow-visible"
2831
+ viewBox={`${geometry.x} ${geometry.y} ${geometry.width} ${geometry.height}`}
2832
+ >
2833
+ {path.nodes.map((node, index) => (
2834
+ <g key={`handles-${index}`}>
2835
+ {node.handleIn ? (
2836
+ <line
2837
+ x1={node.point.x}
2838
+ y1={node.point.y}
2839
+ x2={node.handleIn.x}
2840
+ y2={node.handleIn.y}
2841
+ stroke="var(--design-editor-accent-color)"
2842
+ strokeDasharray="3 3"
2843
+ strokeWidth={1}
2844
+ />
2845
+ ) : null}
2846
+ {node.handleOut ? (
2847
+ <line
2848
+ x1={node.point.x}
2849
+ y1={node.point.y}
2850
+ x2={node.handleOut.x}
2851
+ y2={node.handleOut.y}
2852
+ stroke="var(--design-editor-accent-color)"
2853
+ strokeDasharray="3 3"
2854
+ strokeWidth={1}
2855
+ />
2856
+ ) : null}
2857
+ </g>
2858
+ ))}
2859
+ <path
2860
+ d={pathData}
2861
+ fill="none"
2862
+ stroke="rgba(255,255,255,0.95)"
2863
+ strokeLinecap="round"
2864
+ strokeLinejoin="round"
2865
+ strokeWidth={5}
2866
+ />
2867
+ <path
2868
+ d={pathData}
2869
+ fill="none"
2870
+ stroke="var(--design-editor-accent-color)"
2871
+ strokeLinecap="round"
2872
+ strokeLinejoin="round"
2873
+ strokeWidth={2}
2874
+ />
2875
+ </svg>
2876
+ {path.nodes.map((node, index) => (
2877
+ <span
2878
+ key={`anchor-${index}`}
2879
+ data-pen-anchor
2880
+ className={cn(
2881
+ "absolute size-2 rounded-[2px] border shadow-sm",
2882
+ index === 0 && closeHover
2883
+ ? "scale-125 border-[var(--design-editor-accent-color)] bg-[var(--design-editor-accent-color)] ring-4 ring-[var(--design-editor-selection-color)]"
2884
+ : "border-[var(--design-editor-accent-color)] bg-[var(--design-editor-accent-contrast-color)]",
2885
+ )}
2886
+ style={{
2887
+ left: node.point.x - geometry.x - 4,
2888
+ top: node.point.y - geometry.y - 4,
2889
+ }}
2890
+ />
2891
+ ))}
2892
+ {path.nodes.flatMap((node, index) =>
2893
+ [node.handleIn, node.handleOut]
2894
+ .filter(isPoint)
2895
+ .map((handle, handleIndex) => (
2896
+ <span
2897
+ key={`handle-${index}-${handleIndex}`}
2898
+ data-pen-handle
2899
+ className="absolute size-1.5 rounded-full border border-[var(--design-editor-accent-color)] bg-background shadow-sm"
2900
+ style={{
2901
+ left: handle.x - geometry.x - 3,
2902
+ top: handle.y - geometry.y - 3,
2903
+ }}
2904
+ />
2905
+ )),
2906
+ )}
2907
+ </div>
2908
+ );
2909
+ }
2910
+
2911
+ function isPoint(point: Point | undefined): point is Point {
2912
+ return !!point;
2913
+ }
2914
+
2915
+ function Screen({
2916
+ screen,
2917
+ metadata,
2918
+ geometry,
2919
+ isActive,
2920
+ isSelected,
2921
+ groupSelected,
2922
+ handlesEnabled,
2923
+ penActive,
2924
+ creationToolActive,
2925
+ chromeScale,
2926
+ onPick,
2927
+ onEdit,
2928
+ onStartFrameDrag,
2929
+ onStartResize,
2930
+ onStartRotate,
2931
+ onStartDuplicateGesture,
2932
+ screenContent,
2933
+ }: {
2934
+ screen: ScreenFile;
2935
+ metadata: ResolvedScreenMetadata;
2936
+ geometry: FrameGeometry;
2937
+ isActive: boolean;
2938
+ isSelected: boolean;
2939
+ groupSelected: boolean;
2940
+ handlesEnabled: boolean;
2941
+ penActive: boolean;
2942
+ creationToolActive: boolean;
2943
+ chromeScale: number;
2944
+ screenContent?: ReactNode;
2945
+ onPick: (id: string, e: React.MouseEvent<HTMLElement>) => void;
2946
+ onEdit: (id: string, e: React.MouseEvent<HTMLElement>) => void;
2947
+ onStartFrameDrag: (id: string, e: React.MouseEvent) => void;
2948
+ onStartResize: (
2949
+ id: string,
2950
+ handle: ResizeHandle,
2951
+ e: React.MouseEvent,
2952
+ ) => void;
2953
+ onStartRotate: (id: string, e: React.MouseEvent) => void;
2954
+ onStartDuplicateGesture: (
2955
+ screen: ScreenFile,
2956
+ display: string,
2957
+ e: React.MouseEvent<HTMLElement>,
2958
+ ) => void;
2959
+ }) {
2960
+ const t = useT();
2961
+ const display = metadata.title ?? prettyScreenName(screen.filename);
2962
+ const previewUrl = metadata.previewUrl ?? getPreviewUrl(screen.content);
2963
+ const suppressNextClick = useRef(false);
2964
+ const emphasized = isActive || isSelected;
2965
+ const selectionOutlined = isSelected && !groupSelected;
2966
+ const showHoverOutline =
2967
+ !creationToolActive && (!isSelected || !groupSelected);
2968
+
2969
+ return (
2970
+ <div
2971
+ data-frame-shell
2972
+ className="group/frame pointer-events-auto absolute"
2973
+ style={{
2974
+ left: SURFACE_PADDING + geometry.x,
2975
+ top: SURFACE_PADDING + geometry.y - FRAME_LABEL_HEIGHT,
2976
+ width: geometry.width,
2977
+ transform: geometry.rotation
2978
+ ? `rotate(${geometry.rotation}deg)`
2979
+ : undefined,
2980
+ transformOrigin: `${geometry.width / 2}px ${FRAME_LABEL_HEIGHT + geometry.height / 2}px`,
2981
+ zIndex: geometry.z,
2982
+ }}
2983
+ >
2984
+ <div
2985
+ className="flex h-7 w-full cursor-default items-center justify-between gap-2 px-1"
2986
+ onMouseDown={(e) => {
2987
+ if (e.button !== 0) return;
2988
+ if (penActive || creationToolActive) return;
2989
+ if (e.shiftKey) {
2990
+ e.stopPropagation();
2991
+ return;
2992
+ }
2993
+ onStartFrameDrag(screen.id, e);
2994
+ }}
2995
+ >
2996
+ <div className="flex min-w-0 items-center gap-1.5">
2997
+ <span
2998
+ className={cn(
2999
+ "h-1.5 w-1.5 shrink-0 rounded-full",
3000
+ emphasized ? "bg-primary" : "bg-muted-foreground/40",
3001
+ )}
3002
+ />
3003
+ <span
3004
+ className={cn(
3005
+ "truncate text-[11px] font-medium",
3006
+ emphasized ? "text-foreground" : "text-muted-foreground",
3007
+ )}
3008
+ title={screen.filename}
3009
+ >
3010
+ {display}
3011
+ </span>
3012
+ <span className="hidden shrink-0 text-[10px] tabular-nums text-muted-foreground/70 sm:inline">
3013
+ {metadata.width} x {metadata.height}
3014
+ </span>
3015
+ </div>
3016
+ <button
3017
+ type="button"
3018
+ className={cn(
3019
+ "flex h-5 max-w-[46%] shrink-0 items-center gap-1 overflow-hidden rounded-md border border-border bg-background/95 px-1.5 text-[10px] font-medium text-foreground opacity-0 shadow-sm transition-opacity",
3020
+ "hover:bg-accent hover:text-accent-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
3021
+ "group-hover/frame:opacity-100 group-focus-within/frame:opacity-100",
3022
+ emphasized && "opacity-100",
3023
+ )}
3024
+ aria-label={t("multiScreenCanvas.fullView")}
3025
+ title={t("multiScreenCanvas.fullView")}
3026
+ onClick={(event) => onEdit(screen.id, event)}
3027
+ onMouseDown={(event) => {
3028
+ event.preventDefault();
3029
+ event.stopPropagation();
3030
+ }}
3031
+ >
3032
+ <IconMaximize className="size-3 shrink-0" />
3033
+ <span className="truncate">{t("multiScreenCanvas.fullView")}</span>
3034
+ </button>
3035
+ </div>
3036
+ <div
3037
+ data-screen-card
3038
+ role="button"
3039
+ tabIndex={0}
3040
+ onClick={(e) => {
3041
+ if (isInteractiveScreenContentTarget(e.target)) return;
3042
+ e.stopPropagation();
3043
+ if (suppressNextClick.current) {
3044
+ suppressNextClick.current = false;
3045
+ return;
3046
+ }
3047
+ if (e.detail > 1) return;
3048
+ onPick(screen.id, e);
3049
+ }}
3050
+ onDoubleClick={(e) => {
3051
+ if (isInteractiveScreenContentTarget(e.target)) return;
3052
+ e.preventDefault();
3053
+ e.stopPropagation();
3054
+ }}
3055
+ onMouseDown={(e) => {
3056
+ if (creationToolActive) return;
3057
+ if (isInteractiveScreenContentTarget(e.target)) return;
3058
+ if (e.detail > 1) {
3059
+ e.stopPropagation();
3060
+ return;
3061
+ }
3062
+ if (penActive) return;
3063
+ if (e.altKey && e.button === 0) {
3064
+ suppressNextClick.current = true;
3065
+ onStartDuplicateGesture(screen, display, e);
3066
+ return;
3067
+ }
3068
+ if (e.button === 0) {
3069
+ if (e.shiftKey) {
3070
+ e.stopPropagation();
3071
+ return;
3072
+ }
3073
+ onStartFrameDrag(screen.id, e);
3074
+ }
3075
+ }}
3076
+ className={cn(
3077
+ "group/artboard relative block overflow-visible rounded-lg bg-background text-left outline-none transition-colors",
3078
+ "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
3079
+ emphasized
3080
+ ? "text-foreground"
3081
+ : "text-muted-foreground hover:text-foreground",
3082
+ )}
3083
+ style={{
3084
+ width: geometry.width,
3085
+ height: geometry.height,
3086
+ cursor:
3087
+ penActive || creationToolActive
3088
+ ? "crosshair"
3089
+ : isSelected
3090
+ ? "move"
3091
+ : "pointer",
3092
+ touchAction: "none",
3093
+ }}
3094
+ >
3095
+ <span
3096
+ className={cn(
3097
+ "pointer-events-none absolute border transition-opacity",
3098
+ selectionOutlined
3099
+ ? "border-[var(--design-editor-accent-color)] opacity-100"
3100
+ : showHoverOutline
3101
+ ? "border-[var(--design-editor-accent-color)] opacity-0 group-hover/artboard:opacity-100"
3102
+ : "border-transparent opacity-0",
3103
+ )}
3104
+ style={{
3105
+ inset: -5 * chromeScale,
3106
+ borderRadius: 13 * chromeScale,
3107
+ borderWidth: 1.5 * chromeScale,
3108
+ }}
3109
+ />
3110
+ <span
3111
+ className={cn(
3112
+ "relative block h-full w-full overflow-hidden rounded-lg border bg-white shadow-2xl transition-colors",
3113
+ "border-border group-hover/artboard:border-muted-foreground/60",
3114
+ )}
3115
+ style={{ pointerEvents: penActive ? "none" : undefined }}
3116
+ >
3117
+ {screenContent ?? (
3118
+ <iframe
3119
+ src={previewUrl}
3120
+ srcDoc={previewUrl ? undefined : screen.content}
3121
+ sandbox="allow-scripts"
3122
+ loading="lazy"
3123
+ className="pointer-events-none border-0"
3124
+ style={{
3125
+ width: metadata.width,
3126
+ height: metadata.height,
3127
+ transform: `scale(${geometry.width / metadata.width}, ${
3128
+ geometry.height / metadata.height
3129
+ })`,
3130
+ transformOrigin: "top left",
3131
+ }}
3132
+ title={screen.filename}
3133
+ />
3134
+ )}
3135
+ {creationToolActive ? (
3136
+ <span
3137
+ className="absolute inset-0 z-20 cursor-crosshair"
3138
+ aria-hidden="true"
3139
+ />
3140
+ ) : null}
3141
+ <span className="pointer-events-none absolute inset-0 rounded-[7px] border border-black/5" />
3142
+ </span>
3143
+ <ResizeHandles
3144
+ active={selectionOutlined}
3145
+ enabled={!penActive && !creationToolActive && handlesEnabled}
3146
+ showRotate={false}
3147
+ chromeScale={chromeScale}
3148
+ onStartResize={(handle, e) => onStartResize(screen.id, handle, e)}
3149
+ onStartRotate={(e) => onStartRotate(screen.id, e)}
3150
+ />
3151
+ </div>
3152
+ </div>
3153
+ );
3154
+ }
3155
+
3156
+ function GroupSelectionBox({
3157
+ bounds,
3158
+ chromeScale,
3159
+ onStartResize,
3160
+ }: {
3161
+ bounds: NonNullable<ReturnType<typeof getFrameGroupBounds>>;
3162
+ chromeScale: number;
3163
+ onStartResize: (handle: ResizeHandle, e: React.MouseEvent) => void;
3164
+ }) {
3165
+ return (
3166
+ <div
3167
+ data-frame-shell
3168
+ className="pointer-events-none absolute z-30 border border-[var(--design-editor-accent-color)]"
3169
+ style={{
3170
+ left: SURFACE_PADDING + bounds.left,
3171
+ top: SURFACE_PADDING + bounds.top,
3172
+ width: bounds.width,
3173
+ height: bounds.height,
3174
+ borderRadius: 13 * chromeScale,
3175
+ borderWidth: 1.5 * chromeScale,
3176
+ }}
3177
+ >
3178
+ <ResizeHandles
3179
+ active
3180
+ enabled
3181
+ showRotate={false}
3182
+ chromeScale={chromeScale}
3183
+ onStartResize={onStartResize}
3184
+ onStartRotate={() => {}}
3185
+ />
3186
+ </div>
3187
+ );
3188
+ }
3189
+
3190
+ function ResizeHandles({
3191
+ active,
3192
+ enabled,
3193
+ showRotate = true,
3194
+ chromeScale = 1,
3195
+ onStartResize,
3196
+ onStartRotate,
3197
+ }: {
3198
+ active: boolean;
3199
+ enabled: boolean;
3200
+ showRotate?: boolean;
3201
+ chromeScale?: number;
3202
+ onStartResize: (handle: ResizeHandle, e: React.MouseEvent) => void;
3203
+ onStartRotate: (e: React.MouseEvent) => void;
3204
+ }) {
3205
+ if (!enabled) return null;
3206
+
3207
+ const visibleHandleClass = cn(
3208
+ "pointer-events-auto absolute z-20 rounded-[2px] border border-[var(--design-editor-accent-color)] bg-[var(--design-editor-accent-contrast-color)] shadow transition-opacity",
3209
+ active
3210
+ ? "opacity-100"
3211
+ : "opacity-0 group-hover/artboard:opacity-100 group-focus-visible/artboard:opacity-100",
3212
+ );
3213
+ const edgeHandleClass =
3214
+ "pointer-events-auto absolute z-10 bg-transparent opacity-0";
3215
+
3216
+ return (
3217
+ <>
3218
+ {EDGE_RESIZE_HANDLE_CONFIGS.map((config) => (
3219
+ <span
3220
+ key={config.handle}
3221
+ data-resize-handle
3222
+ className={edgeHandleClass}
3223
+ style={edgeHandleStyle(config.handle, config.cursor, chromeScale)}
3224
+ onMouseDown={(e) => onStartResize(config.handle, e)}
3225
+ />
3226
+ ))}
3227
+ {CORNER_RESIZE_HANDLE_CONFIGS.map((config) => (
3228
+ <span
3229
+ key={config.handle}
3230
+ data-resize-handle
3231
+ className={visibleHandleClass}
3232
+ style={cornerHandleStyle(config.handle, config.cursor, chromeScale)}
3233
+ onMouseDown={(e) => onStartResize(config.handle, e)}
3234
+ />
3235
+ ))}
3236
+ {showRotate
3237
+ ? ROTATE_HANDLE_CONFIGS.map((config) => (
3238
+ <span
3239
+ key={config.corner}
3240
+ data-rotate-handle
3241
+ className={cn(
3242
+ "pointer-events-auto absolute z-10 size-5 rounded-full transition-opacity active:cursor-grabbing",
3243
+ active
3244
+ ? "opacity-100"
3245
+ : "opacity-0 group-hover/artboard:opacity-100 group-focus-visible/artboard:opacity-100",
3246
+ )}
3247
+ style={rotateHandleStyle(config.corner, chromeScale)}
3248
+ onMouseDown={onStartRotate}
3249
+ />
3250
+ ))
3251
+ : null}
3252
+ </>
3253
+ );
3254
+ }
3255
+
3256
+ const CORNER_RESIZE_HANDLE_CONFIGS: Array<{
3257
+ handle: ResizeHandle;
3258
+ cursor: string;
3259
+ }> = [
3260
+ { handle: "nw", cursor: "nwse-resize" },
3261
+ { handle: "ne", cursor: "nesw-resize" },
3262
+ { handle: "se", cursor: "nwse-resize" },
3263
+ { handle: "sw", cursor: "nesw-resize" },
3264
+ ];
3265
+
3266
+ const EDGE_RESIZE_HANDLE_CONFIGS: Array<{
3267
+ handle: ResizeHandle;
3268
+ cursor: string;
3269
+ }> = [
3270
+ { handle: "n", cursor: "ns-resize" },
3271
+ { handle: "e", cursor: "ew-resize" },
3272
+ { handle: "s", cursor: "ns-resize" },
3273
+ { handle: "w", cursor: "ew-resize" },
3274
+ ];
3275
+
3276
+ const ALL_RESIZE_HANDLE_CONFIGS = [
3277
+ ...CORNER_RESIZE_HANDLE_CONFIGS,
3278
+ ...EDGE_RESIZE_HANDLE_CONFIGS,
3279
+ ];
3280
+
3281
+ const ROTATE_HANDLE_CONFIGS: Array<{
3282
+ corner: string;
3283
+ }> = [{ corner: "nw" }, { corner: "ne" }, { corner: "se" }, { corner: "sw" }];
3284
+
3285
+ function edgeHandleStyle(
3286
+ handle: ResizeHandle,
3287
+ cursor: string,
3288
+ chromeScale: number,
3289
+ ): CSSProperties {
3290
+ const size = 14 * chromeScale;
3291
+ const offset = -size / 2;
3292
+ if (handle === "n" || handle === "s") {
3293
+ return {
3294
+ cursor,
3295
+ left: 0,
3296
+ right: 0,
3297
+ height: size,
3298
+ [handle === "n" ? "top" : "bottom"]: offset,
3299
+ };
3300
+ }
3301
+ return {
3302
+ cursor,
3303
+ top: 0,
3304
+ bottom: 0,
3305
+ width: size,
3306
+ [handle === "w" ? "left" : "right"]: offset,
3307
+ };
3308
+ }
3309
+
3310
+ function cornerHandleStyle(
3311
+ handle: ResizeHandle,
3312
+ cursor: string,
3313
+ chromeScale: number,
3314
+ ): CSSProperties {
3315
+ const size = 10 * chromeScale;
3316
+ const offset = -size / 2;
3317
+ return {
3318
+ cursor,
3319
+ width: size,
3320
+ height: size,
3321
+ borderWidth: Math.max(1, 1.25 * chromeScale),
3322
+ ...(handle.includes("n") ? { top: offset } : { bottom: offset }),
3323
+ ...(handle.includes("w") ? { left: offset } : { right: offset }),
3324
+ };
3325
+ }
3326
+
3327
+ function rotateHandleStyle(corner: string, chromeScale: number): CSSProperties {
3328
+ const size = 28 * chromeScale;
3329
+ const offset = -34 * chromeScale;
3330
+ return {
3331
+ cursor: "grab",
3332
+ width: size,
3333
+ height: size,
3334
+ ...(corner.includes("n") ? { top: offset } : { bottom: offset }),
3335
+ ...(corner.includes("w") ? { left: offset } : { right: offset }),
3336
+ };
3337
+ }
3338
+
3339
+ interface FrameEntry {
3340
+ id: string;
3341
+ geometry: FrameGeometry;
3342
+ }
3343
+
3344
+ interface BoundsRect {
3345
+ left: number;
3346
+ top: number;
3347
+ right: number;
3348
+ bottom: number;
3349
+ }
3350
+
3351
+ function getInitialFrameGeometry(index: number): FrameGeometry {
3352
+ const column = index % 3;
3353
+ const row = Math.floor(index / 3);
3354
+ return {
3355
+ x: column * (SCREEN_WIDTH + SCREEN_GAP),
3356
+ y: row * (SCREEN_CARD_HEIGHT + SCREEN_GAP),
3357
+ width: SCREEN_WIDTH,
3358
+ height: SCREEN_HEIGHT,
3359
+ };
3360
+ }
3361
+
3362
+ function getFrameEntries(
3363
+ screens: ScreenFile[],
3364
+ geometryById: FrameGeometryById,
3365
+ ): FrameEntry[] {
3366
+ return screens.map((screen, index) => ({
3367
+ id: screen.id,
3368
+ geometry: geometryById[screen.id] ?? getInitialFrameGeometry(index),
3369
+ }));
3370
+ }
3371
+
3372
+ function dedupeIds(ids: string[]) {
3373
+ return [...new Set(ids)];
3374
+ }
3375
+
3376
+ function sameIds(a: string[], b: string[]) {
3377
+ return a.length === b.length && a.every((id, index) => id === b[index]);
3378
+ }
3379
+
3380
+ function isArrowNudgeKey(key: string): key is ArrowNudgeKey {
3381
+ return (
3382
+ key === "ArrowUp" ||
3383
+ key === "ArrowRight" ||
3384
+ key === "ArrowDown" ||
3385
+ key === "ArrowLeft"
3386
+ );
3387
+ }
3388
+
3389
+ function isEditableHotkeyTarget(target: EventTarget | null) {
3390
+ if (!target || typeof Element === "undefined") return false;
3391
+ if (!(target instanceof Element)) return false;
3392
+ const editable = target.closest(
3393
+ [
3394
+ "input",
3395
+ "textarea",
3396
+ "select",
3397
+ "[contenteditable]",
3398
+ '[role="textbox"]',
3399
+ '[data-hotkeys-scope="text"]',
3400
+ ].join(","),
3401
+ );
3402
+ if (!editable) return false;
3403
+ if (editable instanceof HTMLElement && editable.isContentEditable) {
3404
+ return true;
3405
+ }
3406
+ const tagName = editable.tagName.toLowerCase();
3407
+ return tagName === "input" || tagName === "textarea" || tagName === "select";
3408
+ }
3409
+
3410
+ function isInteractiveScreenContentTarget(target: EventTarget | null) {
3411
+ return (
3412
+ target instanceof Element &&
3413
+ Boolean(
3414
+ target.closest(
3415
+ ".design-canvas-iframe-wrapper,[data-design-preview-iframe]",
3416
+ ),
3417
+ )
3418
+ );
3419
+ }
3420
+
3421
+ function frameBoundsToGeometry(bounds: {
3422
+ left: number;
3423
+ top: number;
3424
+ width: number;
3425
+ height: number;
3426
+ }): FrameGeometry {
3427
+ return {
3428
+ x: bounds.left,
3429
+ y: bounds.top,
3430
+ width: bounds.width,
3431
+ height: bounds.height,
3432
+ };
3433
+ }
3434
+
3435
+ function getResizeCursor(handle: ResizeHandle) {
3436
+ return (
3437
+ ALL_RESIZE_HANDLE_CONFIGS.find((config) => config.handle === handle)
3438
+ ?.cursor ?? "default"
3439
+ );
3440
+ }
3441
+
3442
+ function normalizeRectFromPoints(start: Point, end: Point): MarqueeRect {
3443
+ const x = Math.min(start.x, end.x);
3444
+ const y = Math.min(start.y, end.y);
3445
+ return {
3446
+ x,
3447
+ y,
3448
+ width: Math.abs(end.x - start.x),
3449
+ height: Math.abs(end.y - start.y),
3450
+ };
3451
+ }
3452
+
3453
+ function rectIntersects(rect: MarqueeRect, bounds: BoundsRect) {
3454
+ const right = rect.x + rect.width;
3455
+ const bottom = rect.y + rect.height;
3456
+ return (
3457
+ rect.x <= bounds.right &&
3458
+ right >= bounds.left &&
3459
+ rect.y <= bounds.bottom &&
3460
+ bottom >= bounds.top
3461
+ );
3462
+ }
3463
+
3464
+ function rectContainsPoint(bounds: BoundsRect, point: Point) {
3465
+ return (
3466
+ point.x >= bounds.left &&
3467
+ point.x <= bounds.right &&
3468
+ point.y >= bounds.top &&
3469
+ point.y <= bounds.bottom
3470
+ );
3471
+ }
3472
+
3473
+ function sameFrameGeometry(a: FrameGeometry, b: FrameGeometry) {
3474
+ return (
3475
+ a.x === b.x &&
3476
+ a.y === b.y &&
3477
+ a.width === b.width &&
3478
+ a.height === b.height &&
3479
+ (a.rotation ?? 0) === (b.rotation ?? 0) &&
3480
+ (a.z ?? 0) === (b.z ?? 0)
3481
+ );
3482
+ }
3483
+
3484
+ function cloneFrameGeometryById(
3485
+ geometryById: FrameGeometryById,
3486
+ ): FrameGeometryById {
3487
+ return Object.fromEntries(
3488
+ Object.entries(geometryById).map(([id, geometry]) => [id, { ...geometry }]),
3489
+ );
3490
+ }
3491
+
3492
+ function frameGeometryWithOverrides(
3493
+ base: FrameGeometryById,
3494
+ overrides: FrameGeometryById,
3495
+ ): FrameGeometryById {
3496
+ const next = cloneFrameGeometryById(base);
3497
+ Object.entries(overrides).forEach(([id, geometry]) => {
3498
+ next[id] = { ...geometry };
3499
+ });
3500
+ return next;
3501
+ }
3502
+
3503
+ function isDraftPrimitive(
3504
+ value: DraftPrimitive | undefined,
3505
+ ): value is DraftPrimitive {
3506
+ return Boolean(value);
3507
+ }
3508
+
3509
+ function getDraftGeometryForTool(
3510
+ tool: DraftCreationTool,
3511
+ start: Point,
3512
+ end: Point,
3513
+ ): FrameGeometry {
3514
+ if (tool === "pen" || tool === "line" || tool === "arrow") {
3515
+ return getPathGeometry([start, end]);
3516
+ }
3517
+ const options =
3518
+ tool === "frame"
3519
+ ? {
3520
+ minWidth: 24,
3521
+ minHeight: 24,
3522
+ defaultWidth: DRAFT_FRAME_WIDTH,
3523
+ defaultHeight: DRAFT_FRAME_HEIGHT,
3524
+ }
3525
+ : tool === "text"
3526
+ ? {
3527
+ minWidth: 24,
3528
+ minHeight: 18,
3529
+ defaultWidth: DRAFT_TEXT_WIDTH,
3530
+ defaultHeight: DRAFT_TEXT_HEIGHT,
3531
+ }
3532
+ : {
3533
+ minWidth: 8,
3534
+ minHeight: 8,
3535
+ defaultWidth: DRAFT_RECT_WIDTH,
3536
+ defaultHeight: DRAFT_RECT_HEIGHT,
3537
+ };
3538
+ return getDraftGeometryFromPoints(start, end, options);
3539
+ }
3540
+
3541
+ function getPathGeometry(points: readonly Point[]): FrameGeometry {
3542
+ if (points.length === 0) {
3543
+ return {
3544
+ x: 0,
3545
+ y: 0,
3546
+ width: DRAFT_PATH_MIN_SIZE,
3547
+ height: DRAFT_PATH_MIN_SIZE,
3548
+ };
3549
+ }
3550
+ const xs = points.map((point) => point.x);
3551
+ const ys = points.map((point) => point.y);
3552
+ const left = Math.min(...xs);
3553
+ const top = Math.min(...ys);
3554
+ const right = Math.max(...xs);
3555
+ const bottom = Math.max(...ys);
3556
+ return {
3557
+ x: left,
3558
+ y: top,
3559
+ width: Math.max(DRAFT_PATH_MIN_SIZE, right - left),
3560
+ height: Math.max(DRAFT_PATH_MIN_SIZE, bottom - top),
3561
+ };
3562
+ }
3563
+
3564
+ function createDraftPrimitive({
3565
+ tool,
3566
+ start,
3567
+ end,
3568
+ points,
3569
+ moved,
3570
+ toolProps,
3571
+ fallbackText,
3572
+ }: DraftPrimitiveInput): DraftPrimitive {
3573
+ const id = createDraftId(tool);
3574
+ const geometry = moved
3575
+ ? getDraftGeometryForTool(tool, start, end)
3576
+ : getDraftGeometryForTool(tool, start, start);
3577
+ if (tool === "pen") {
3578
+ const pathPoints =
3579
+ points && points.length > 1
3580
+ ? points
3581
+ : [
3582
+ start,
3583
+ { x: start.x + 64, y: start.y + 24 },
3584
+ { x: start.x + 128, y: start.y },
3585
+ ];
3586
+ return createPenDraftPrimitive(
3587
+ {
3588
+ nodes: pathPoints.map(createCornerNode),
3589
+ closed: false,
3590
+ },
3591
+ {
3592
+ id,
3593
+ stroke: toolProps?.stroke,
3594
+ strokeWidth: toolProps?.strokeWidth,
3595
+ },
3596
+ );
3597
+ }
3598
+ if (tool === "text") {
3599
+ return {
3600
+ id,
3601
+ kind: "text",
3602
+ geometry,
3603
+ text: toolProps?.text ?? fallbackText,
3604
+ fill: toolProps?.fill,
3605
+ stroke: toolProps?.stroke,
3606
+ autoSize: !moved,
3607
+ };
3608
+ }
3609
+ if (tool === "line" || tool === "arrow") {
3610
+ const pathPoints = moved
3611
+ ? [start, end]
3612
+ : [start, { x: start.x + DRAFT_LINE_WIDTH, y: start.y }];
3613
+ return {
3614
+ id,
3615
+ kind: tool,
3616
+ geometry: getPathGeometry(pathPoints),
3617
+ points: pathPoints,
3618
+ stroke: toolProps?.stroke,
3619
+ strokeWidth: toolProps?.strokeWidth ?? 3,
3620
+ };
3621
+ }
3622
+ return {
3623
+ id,
3624
+ kind:
3625
+ tool === "frame"
3626
+ ? "frame"
3627
+ : tool === "ellipse" || tool === "polygon" || tool === "star"
3628
+ ? tool
3629
+ : "rectangle",
3630
+ geometry,
3631
+ fill: toolProps?.fill,
3632
+ stroke: toolProps?.stroke,
3633
+ strokeWidth: toolProps?.strokeWidth,
3634
+ };
3635
+ }
3636
+
3637
+ function createPenDraftPrimitive(
3638
+ path: PenPath,
3639
+ {
3640
+ id = createDraftId("pen"),
3641
+ stroke,
3642
+ strokeWidth,
3643
+ }: { id?: string; stroke?: string; strokeWidth?: number } = {},
3644
+ ): DraftPrimitive {
3645
+ const penPath = clonePenPath(path);
3646
+ return {
3647
+ id,
3648
+ kind: "path",
3649
+ geometry: getPenPathGeometry(penPath),
3650
+ penPath,
3651
+ pathData: serializePenPath(penPath),
3652
+ stroke,
3653
+ strokeWidth: strokeWidth ?? 3,
3654
+ };
3655
+ }
3656
+
3657
+ function createDraftId(tool: DraftCreationTool) {
3658
+ return `draft-${tool}-${Date.now()}-${Math.random()
3659
+ .toString(36)
3660
+ .slice(2, 8)}`;
3661
+ }
3662
+
3663
+ function cloneDraftPrimitive(draft: DraftPrimitive): DraftPrimitive {
3664
+ return {
3665
+ ...draft,
3666
+ geometry: { ...draft.geometry },
3667
+ points: draft.points?.map((point) => ({ ...point })),
3668
+ penPath: draft.penPath ? clonePenPath(draft.penPath) : undefined,
3669
+ };
3670
+ }
3671
+
3672
+ function draftPrimitiveToInsert(
3673
+ draft: DraftPrimitive,
3674
+ frameGeometry: FrameGeometry,
3675
+ metadata?: ResolvedScreenMetadata,
3676
+ ): CanvasPrimitiveInsert {
3677
+ const scaleX =
3678
+ metadata && frameGeometry.width > 0
3679
+ ? metadata.width / frameGeometry.width
3680
+ : 1;
3681
+ const scaleY =
3682
+ metadata && frameGeometry.height > 0
3683
+ ? metadata.height / frameGeometry.height
3684
+ : 1;
3685
+ const toLocalPoint = (point: Point) => ({
3686
+ x: Math.round((point.x - frameGeometry.x) * scaleX),
3687
+ y: Math.round((point.y - frameGeometry.y) * scaleY),
3688
+ });
3689
+ const localGeometry = {
3690
+ ...draft.geometry,
3691
+ x: Math.round((draft.geometry.x - frameGeometry.x) * scaleX),
3692
+ y: Math.round((draft.geometry.y - frameGeometry.y) * scaleY),
3693
+ width: Math.max(1, Math.round(draft.geometry.width * scaleX)),
3694
+ height: Math.max(1, Math.round(draft.geometry.height * scaleY)),
3695
+ };
3696
+ const scaledPenPath = draft.penPath
3697
+ ? scalePenPathToGeometry(draft.penPath, frameGeometry, {
3698
+ x: 0,
3699
+ y: 0,
3700
+ width: metadata?.width ?? frameGeometry.width,
3701
+ height: metadata?.height ?? frameGeometry.height,
3702
+ })
3703
+ : undefined;
3704
+ return {
3705
+ kind: draft.kind,
3706
+ geometry: localGeometry,
3707
+ points: draft.points?.map(toLocalPoint),
3708
+ pathData: scaledPenPath ? serializePenPath(scaledPenPath) : undefined,
3709
+ text: draft.text,
3710
+ fill: draft.fill,
3711
+ stroke: draft.stroke,
3712
+ strokeWidth: draft.strokeWidth,
3713
+ autoSize: draft.autoSize,
3714
+ };
3715
+ }
3716
+
3717
+ function moveDraftPrimitive(
3718
+ draft: DraftPrimitive,
3719
+ dx: number,
3720
+ dy: number,
3721
+ ): DraftPrimitive {
3722
+ const movedPenPath = draft.penPath
3723
+ ? translatePenPath(draft.penPath, dx, dy)
3724
+ : undefined;
3725
+ return {
3726
+ ...draft,
3727
+ geometry: {
3728
+ ...draft.geometry,
3729
+ x: draft.geometry.x + dx,
3730
+ y: draft.geometry.y + dy,
3731
+ },
3732
+ points: draft.points?.map((point) => ({
3733
+ x: point.x + dx,
3734
+ y: point.y + dy,
3735
+ })),
3736
+ penPath: movedPenPath,
3737
+ pathData: movedPenPath ? serializePenPath(movedPenPath) : draft.pathData,
3738
+ };
3739
+ }
3740
+
3741
+ function applyDraftGeometry(
3742
+ draft: DraftPrimitive,
3743
+ geometry: FrameGeometry,
3744
+ ): DraftPrimitive {
3745
+ const origin = draft.geometry;
3746
+ const scaleX = geometry.width / Math.max(1, origin.width);
3747
+ const scaleY = geometry.height / Math.max(1, origin.height);
3748
+ const scaledPenPath = draft.penPath
3749
+ ? scalePenPathToGeometry(draft.penPath, origin, geometry)
3750
+ : undefined;
3751
+ return {
3752
+ ...draft,
3753
+ geometry,
3754
+ points: draft.points?.map((point) => ({
3755
+ x: geometry.x + (point.x - origin.x) * scaleX,
3756
+ y: geometry.y + (point.y - origin.y) * scaleY,
3757
+ })),
3758
+ penPath: scaledPenPath,
3759
+ pathData: scaledPenPath ? serializePenPath(scaledPenPath) : draft.pathData,
3760
+ };
3761
+ }
3762
+
3763
+ function normalizeCanvasTool(
3764
+ tool: MultiScreenCanvasTool,
3765
+ ): MultiScreenCanvasTool {
3766
+ return tool === "rectangle" ? "rect" : tool;
3767
+ }
3768
+
3769
+ function getDraftCreationTool(
3770
+ tool: MultiScreenCanvasTool,
3771
+ ): DraftCreationTool | null {
3772
+ if (
3773
+ tool === "frame" ||
3774
+ tool === "rect" ||
3775
+ tool === "line" ||
3776
+ tool === "arrow" ||
3777
+ tool === "ellipse" ||
3778
+ tool === "polygon" ||
3779
+ tool === "star" ||
3780
+ tool === "text" ||
3781
+ tool === "pen"
3782
+ ) {
3783
+ return tool;
3784
+ }
3785
+ return null;
3786
+ }
3787
+
3788
+ function polygonPointsForBox(
3789
+ kind: "polygon" | "star",
3790
+ width: number,
3791
+ height: number,
3792
+ ) {
3793
+ const safeWidth = Math.max(1, width);
3794
+ const safeHeight = Math.max(1, height);
3795
+ const cx = safeWidth / 2;
3796
+ const cy = safeHeight / 2;
3797
+ const radius = Math.max(1, Math.min(safeWidth, safeHeight) / 2);
3798
+ const points: Point[] = [];
3799
+
3800
+ if (kind === "polygon") {
3801
+ for (let index = 0; index < 3; index += 1) {
3802
+ const angle = -Math.PI / 2 + (index * Math.PI * 2) / 3;
3803
+ points.push({
3804
+ x: cx + Math.cos(angle) * radius,
3805
+ y: cy + Math.sin(angle) * radius,
3806
+ });
3807
+ }
3808
+ } else {
3809
+ for (let index = 0; index < 10; index += 1) {
3810
+ const angle = -Math.PI / 2 + (index * Math.PI) / 5;
3811
+ const r = index % 2 === 0 ? radius : radius * 0.45;
3812
+ points.push({
3813
+ x: cx + Math.cos(angle) * r,
3814
+ y: cy + Math.sin(angle) * r,
3815
+ });
3816
+ }
3817
+ }
3818
+
3819
+ return points
3820
+ .map((point) => `${roundCoord(point.x)},${roundCoord(point.y)}`)
3821
+ .join(" ");
3822
+ }
3823
+
3824
+ function pointsToPath(points: readonly Point[]) {
3825
+ if (points.length === 0) return "";
3826
+ const [first, ...rest] = points;
3827
+ return [
3828
+ `M ${roundCoord(first.x)} ${roundCoord(first.y)}`,
3829
+ ...rest.map((point) => `L ${roundCoord(point.x)} ${roundCoord(point.y)}`),
3830
+ ].join(" ");
3831
+ }
3832
+
3833
+ function roundCoord(value: number) {
3834
+ return Math.round(value * 10) / 10;
3835
+ }
3836
+
3837
+ function previewDraftPrimitive(preview: DraftCreationPreview): DraftPrimitive {
3838
+ return {
3839
+ id: "draft-preview",
3840
+ kind:
3841
+ preview.tool === "pen"
3842
+ ? "path"
3843
+ : preview.tool === "frame"
3844
+ ? "frame"
3845
+ : preview.tool === "text"
3846
+ ? "text"
3847
+ : preview.tool === "line" ||
3848
+ preview.tool === "arrow" ||
3849
+ preview.tool === "ellipse" ||
3850
+ preview.tool === "polygon" ||
3851
+ preview.tool === "star"
3852
+ ? preview.tool
3853
+ : "rectangle",
3854
+ geometry: preview.geometry,
3855
+ points:
3856
+ preview.points ??
3857
+ (preview.tool === "line" || preview.tool === "arrow"
3858
+ ? [
3859
+ {
3860
+ x: preview.geometry.x,
3861
+ y: preview.geometry.y + preview.geometry.height / 2,
3862
+ },
3863
+ {
3864
+ x: preview.geometry.x + preview.geometry.width,
3865
+ y: preview.geometry.y + preview.geometry.height / 2,
3866
+ },
3867
+ ]
3868
+ : undefined),
3869
+ text: "Text", // i18n-ignore preview-only canvas placeholder
3870
+ };
3871
+ }
3872
+
3873
+ function getFrameCenter(frame: FrameGeometry): Point {
3874
+ return {
3875
+ x: frame.x + frame.width / 2,
3876
+ y: frame.y + frame.height / 2,
3877
+ };
3878
+ }
3879
+
3880
+ function angleBetween(center: Point, point: Point) {
3881
+ return (Math.atan2(point.y - center.y, point.x - center.x) * 180) / Math.PI;
3882
+ }
3883
+
3884
+ function getSelectableBounds(geometry: FrameGeometry): BoundsRect {
3885
+ return {
3886
+ left: geometry.x,
3887
+ top: geometry.y - FRAME_LABEL_HEIGHT,
3888
+ right: geometry.x + geometry.width,
3889
+ bottom: geometry.y + geometry.height,
3890
+ };
3891
+ }
3892
+
3893
+ function getWheelDelta(event: WheelEvent) {
3894
+ const multiplier =
3895
+ event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 800 : 1;
3896
+ return {
3897
+ x: event.deltaX * multiplier,
3898
+ y: event.deltaY * multiplier,
3899
+ };
3900
+ }
3901
+
3902
+ function clamp(value: number, min: number, max: number) {
3903
+ return Math.min(max, Math.max(min, value));
3904
+ }
3905
+
3906
+ function resolveScreenMetadata(
3907
+ screen: ScreenFile,
3908
+ keyedMetadata?: ScreenMetadata,
3909
+ getterMetadata?: ScreenMetadata,
3910
+ ): ResolvedScreenMetadata {
3911
+ const metadata = { ...screen, ...keyedMetadata, ...getterMetadata };
3912
+ const previewUrl =
3913
+ metadata.url ??
3914
+ metadata.previewUrl ??
3915
+ screen.previewUrl ??
3916
+ getPreviewUrl(screen.content);
3917
+ const width = metadata.width && metadata.width > 0 ? metadata.width : 1280;
3918
+ const height =
3919
+ metadata.height && metadata.height > 0 ? metadata.height : 2560;
3920
+ return {
3921
+ source:
3922
+ normalizeSource(metadata.sourceType ?? metadata.source) ??
3923
+ deriveSource(screen, previewUrl),
3924
+ previewState:
3925
+ normalizePreviewState(
3926
+ metadata.lod ?? metadata.previewState ?? metadata.status,
3927
+ ) ?? derivePreviewState(screen, previewUrl),
3928
+ title: metadata.title,
3929
+ width,
3930
+ height,
3931
+ previewUrl,
3932
+ };
3933
+ }
3934
+
3935
+ function normalizeSource(value?: string): ScreenSourceType | undefined {
3936
+ const normalized = value?.toLowerCase();
3937
+ if (!normalized) return undefined;
3938
+ if (normalized === "local" || normalized === "localhost") return "localhost";
3939
+ if (normalized === "fusion" || normalized === "remote-fusion")
3940
+ return "fusion";
3941
+ if (normalized === "inline" || normalized === "code") return "inline";
3942
+ return undefined;
3943
+ }
3944
+
3945
+ function normalizePreviewState(value?: string): ScreenPreviewState | undefined {
3946
+ const normalized = value?.toLowerCase();
3947
+ if (!normalized) return undefined;
3948
+ if (normalized === "live") return "live";
3949
+ if (normalized === "snapshot" || normalized === "cached") return "snapshot";
3950
+ if (normalized === "preview" || normalized === "draft") return "preview";
3951
+ return undefined;
3952
+ }
3953
+
3954
+ function deriveSource(
3955
+ screen: ScreenFile,
3956
+ previewUrl?: string,
3957
+ ): ScreenSourceType {
3958
+ const haystack =
3959
+ `${screen.filename} ${screen.content.slice(0, 4000)}`.toLowerCase();
3960
+ const url = getUrl(previewUrl ?? screen.content);
3961
+
3962
+ if (
3963
+ url?.hostname === "localhost" ||
3964
+ url?.hostname === "127.0.0.1" ||
3965
+ url?.hostname.endsWith(".local") ||
3966
+ haystack.includes("localhost") ||
3967
+ haystack.includes("127.0.0.1")
3968
+ ) {
3969
+ return "localhost";
3970
+ }
3971
+
3972
+ if (haystack.includes("fusion") || url?.hostname.includes("fusion")) {
3973
+ return "fusion";
3974
+ }
3975
+
3976
+ return "inline";
3977
+ }
3978
+
3979
+ function derivePreviewState(
3980
+ screen: ScreenFile,
3981
+ previewUrl?: string,
3982
+ ): ScreenPreviewState {
3983
+ const haystack =
3984
+ `${screen.filename} ${screen.content.slice(0, 4000)}`.toLowerCase();
3985
+
3986
+ if (
3987
+ haystack.includes("snapshot") ||
3988
+ haystack.includes("screenshot") ||
3989
+ haystack.includes("cached") ||
3990
+ haystack.includes("data:image/")
3991
+ ) {
3992
+ return "snapshot";
3993
+ }
3994
+
3995
+ if (previewUrl || deriveSource(screen, previewUrl) !== "inline") {
3996
+ return "live";
3997
+ }
3998
+
3999
+ return "preview";
4000
+ }
4001
+
4002
+ function getPreviewUrl(content: string) {
4003
+ return getUrl(content.trim())?.toString();
4004
+ }
4005
+
4006
+ function getUrl(value: string | undefined) {
4007
+ if (!value) return undefined;
4008
+ try {
4009
+ const url = new URL(value);
4010
+ return url.protocol === "http:" || url.protocol === "https:"
4011
+ ? url
4012
+ : undefined;
4013
+ } catch {
4014
+ return undefined;
4015
+ }
223
4016
  }