@agent-native/core 0.92.7 → 0.92.8

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 (289) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +12 -0
  3. package/corpus/core/docs/content/a2a-protocol.mdx +12 -0
  4. package/corpus/core/docs/content/locales/ar-SA/a2a-protocol.mdx +12 -0
  5. package/corpus/core/docs/content/locales/ar-SA/pr-visual-recap.mdx +3 -3
  6. package/corpus/core/docs/content/locales/ar-SA/pure-agent-apps.mdx +4 -0
  7. package/corpus/core/docs/content/locales/de-DE/a2a-protocol.mdx +12 -0
  8. package/corpus/core/docs/content/locales/de-DE/pr-visual-recap.mdx +3 -3
  9. package/corpus/core/docs/content/locales/de-DE/pure-agent-apps.mdx +4 -0
  10. package/corpus/core/docs/content/locales/es-ES/a2a-protocol.mdx +12 -0
  11. package/corpus/core/docs/content/locales/es-ES/pr-visual-recap.mdx +3 -3
  12. package/corpus/core/docs/content/locales/es-ES/pure-agent-apps.mdx +4 -0
  13. package/corpus/core/docs/content/locales/fr-FR/a2a-protocol.mdx +12 -0
  14. package/corpus/core/docs/content/locales/fr-FR/pr-visual-recap.mdx +3 -3
  15. package/corpus/core/docs/content/locales/fr-FR/pure-agent-apps.mdx +4 -0
  16. package/corpus/core/docs/content/locales/hi-IN/a2a-protocol.mdx +12 -0
  17. package/corpus/core/docs/content/locales/hi-IN/pr-visual-recap.mdx +3 -3
  18. package/corpus/core/docs/content/locales/hi-IN/pure-agent-apps.mdx +4 -0
  19. package/corpus/core/docs/content/locales/ja-JP/a2a-protocol.mdx +12 -0
  20. package/corpus/core/docs/content/locales/ja-JP/pr-visual-recap.mdx +3 -3
  21. package/corpus/core/docs/content/locales/ja-JP/pure-agent-apps.mdx +4 -0
  22. package/corpus/core/docs/content/locales/ko-KR/a2a-protocol.mdx +12 -0
  23. package/corpus/core/docs/content/locales/ko-KR/pr-visual-recap.mdx +3 -3
  24. package/corpus/core/docs/content/locales/ko-KR/pure-agent-apps.mdx +4 -0
  25. package/corpus/core/docs/content/locales/pt-BR/a2a-protocol.mdx +12 -0
  26. package/corpus/core/docs/content/locales/pt-BR/pr-visual-recap.mdx +3 -3
  27. package/corpus/core/docs/content/locales/pt-BR/pure-agent-apps.mdx +4 -0
  28. package/corpus/core/docs/content/locales/zh-CN/a2a-protocol.mdx +12 -0
  29. package/corpus/core/docs/content/locales/zh-CN/pr-visual-recap.mdx +3 -3
  30. package/corpus/core/docs/content/locales/zh-CN/pure-agent-apps.mdx +4 -0
  31. package/corpus/core/docs/content/locales/zh-TW/a2a-protocol.mdx +12 -0
  32. package/corpus/core/docs/content/locales/zh-TW/pr-visual-recap.mdx +3 -3
  33. package/corpus/core/docs/content/locales/zh-TW/pure-agent-apps.mdx +4 -0
  34. package/corpus/core/docs/content/pr-visual-recap.mdx +3 -3
  35. package/corpus/core/docs/content/pure-agent-apps.mdx +22 -0
  36. package/corpus/core/package.json +1 -1
  37. package/corpus/core/src/agent/engine/builtin.ts +1 -1
  38. package/corpus/core/src/agent/model-config.ts +30 -30
  39. package/corpus/core/src/agent/production-agent.ts +129 -7
  40. package/corpus/core/src/cli/design-connect.ts +438 -83
  41. package/corpus/core/src/cli/recap.ts +2 -2
  42. package/corpus/core/src/client/AgentPanel.tsx +8 -1
  43. package/corpus/core/src/client/agent-chat-adapter.ts +12 -7
  44. package/corpus/core/src/client/composer/TiptapComposer.tsx +1 -1
  45. package/corpus/core/src/client/index.ts +4 -0
  46. package/corpus/core/src/client/org/TeamPage.tsx +41 -26
  47. package/corpus/core/src/client/use-action.ts +113 -1
  48. package/corpus/core/src/db/client.ts +12 -2
  49. package/corpus/core/src/db/create-get-db.ts +4 -1
  50. package/corpus/core/src/scripts/agent-engines/manage-agent-engine.ts +1 -1
  51. package/corpus/core/src/scripts/agent-engines/set-agent-engine.ts +1 -1
  52. package/corpus/core/src/server/transcribe-voice.ts +1 -1
  53. package/corpus/core/src/usage/store.ts +13 -4
  54. package/corpus/core/src/vite/client.ts +38 -1
  55. package/corpus/templates/analytics/AGENTS.md +3 -0
  56. package/corpus/templates/analytics/actions/get-data-program.ts +78 -0
  57. package/corpus/templates/analytics/actions/list-data-programs.ts +49 -0
  58. package/corpus/templates/analytics/app/global.css +11 -0
  59. package/corpus/templates/analytics/app/hooks/use-dashboard-chat-context.ts +163 -17
  60. package/corpus/templates/analytics/app/i18n/zh-TW.ts +2 -0
  61. package/corpus/templates/analytics/app/i18n-data.ts +20 -0
  62. package/corpus/templates/analytics/app/pages/adhoc/explorer-dashboard/ChartCard.tsx +96 -3
  63. package/corpus/templates/analytics/app/pages/adhoc/explorer-dashboard/index.tsx +3 -1
  64. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/SqlChartCard.tsx +61 -4
  65. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +40 -2
  66. package/corpus/templates/analytics/changelog/2026-07-09-analytics-no-longer-forgets-successful-data-queries-when-a-l.md +6 -0
  67. package/corpus/templates/analytics/changelog/2026-07-09-dashboard-charts-keep-consistent-spacing-when-they-stack-ver.md +6 -0
  68. package/corpus/templates/analytics/changelog/2026-07-09-dashboard-charts-tables-and-extensions-can-be-selected-and-d.md +6 -0
  69. package/corpus/templates/analytics/changelog/2026-07-09-long-running-analytics-requests-now-recover-automatically-wh.md +6 -0
  70. package/corpus/templates/analytics/server/db/index.ts +6 -0
  71. package/corpus/templates/clips/desktop/src/hooks/useMeetingTranscription.ts +1 -7
  72. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +102 -15
  73. package/corpus/templates/content/app/hooks/use-content-database.ts +52 -0
  74. package/corpus/templates/content/app/hooks/use-document-properties.ts +50 -6
  75. package/corpus/templates/content/app/i18n/zh-TW.ts +2 -0
  76. package/corpus/templates/content/app/i18n-data.ts +28 -0
  77. package/corpus/templates/content/changelog/2026-07-09-notion-conflict-warnings-no-longer-flash-during-normal-synce.md +6 -0
  78. package/corpus/templates/content/changelog/2026-07-09-property-menus-and-delete-confirmations-now-open-visibly-and.md +6 -0
  79. package/corpus/templates/content/server/lib/notion-sync.ts +130 -28
  80. package/corpus/templates/design/actions/add-breakpoint.ts +48 -68
  81. package/corpus/templates/design/actions/add-localhost-screens.ts +496 -61
  82. package/corpus/templates/design/actions/apply-component-prop-edit.ts +2 -18
  83. package/corpus/templates/design/actions/apply-design-token-edit.ts +51 -82
  84. package/corpus/templates/design/actions/apply-tweaks.ts +63 -41
  85. package/corpus/templates/design/actions/capture-design-state.ts +2 -2
  86. package/corpus/templates/design/actions/connect-localhost.ts +58 -12
  87. package/corpus/templates/design/actions/create-component.ts +2 -18
  88. package/corpus/templates/design/actions/create-design-branch.ts +38 -18
  89. package/corpus/templates/design/actions/create-fusion-app.ts +15 -11
  90. package/corpus/templates/design/actions/delete-file.ts +19 -34
  91. package/corpus/templates/design/actions/deploy-design-preview.ts +59 -27
  92. package/corpus/templates/design/actions/deploy-fusion-app.ts +22 -15
  93. package/corpus/templates/design/actions/export-pdf.ts +2 -1
  94. package/corpus/templates/design/actions/export-zip.ts +9 -3
  95. package/corpus/templates/design/actions/generate-design.ts +107 -70
  96. package/corpus/templates/design/actions/get-component-details.ts +2 -18
  97. package/corpus/templates/design/actions/get-design-branch-diff.ts +2 -3
  98. package/corpus/templates/design/actions/get-design-surface-index.ts +2 -18
  99. package/corpus/templates/design/actions/get-design.ts +2 -1
  100. package/corpus/templates/design/actions/grant-localhost-write-consent.ts +7 -1
  101. package/corpus/templates/design/actions/import-design-tokens.ts +114 -76
  102. package/corpus/templates/design/actions/index-components.ts +2 -18
  103. package/corpus/templates/design/actions/list-design-source-capabilities.ts +23 -28
  104. package/corpus/templates/design/actions/list-local-files.ts +9 -2
  105. package/corpus/templates/design/actions/migrate-board-objects-to-file.ts +82 -98
  106. package/corpus/templates/design/actions/open-component-source.ts +2 -18
  107. package/corpus/templates/design/actions/open-visual-edit.ts +19 -1
  108. package/corpus/templates/design/actions/present-design-variants.ts +70 -70
  109. package/corpus/templates/design/actions/preview-component-prop-edit.ts +4 -14
  110. package/corpus/templates/design/actions/read-local-file.ts +9 -2
  111. package/corpus/templates/design/actions/remove-breakpoint.ts +40 -61
  112. package/corpus/templates/design/actions/rename-screen.ts +425 -0
  113. package/corpus/templates/design/actions/request-localhost-write-consent.ts +12 -2
  114. package/corpus/templates/design/actions/revoke-localhost-write-consent.ts +9 -2
  115. package/corpus/templates/design/actions/sync-fusion-app.ts +56 -39
  116. package/corpus/templates/design/actions/update-design.ts +376 -61
  117. package/corpus/templates/design/actions/update-file.ts +455 -188
  118. package/corpus/templates/design/actions/write-local-file.ts +10 -2
  119. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +685 -608
  120. package/corpus/templates/design/app/components/design/LayersPanel.tsx +43 -15
  121. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +576 -2015
  122. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +62 -14
  123. package/corpus/templates/design/app/components/design/bridge/embedded-wheel.bridge.ts +263 -25
  124. package/corpus/templates/design/app/components/design/design-canvas/annotation-submit.ts +34 -0
  125. package/corpus/templates/design/app/components/design/design-canvas/coordinate-transforms.ts +40 -0
  126. package/corpus/templates/design/app/components/design/design-canvas/creation.ts +47 -0
  127. package/corpus/templates/design/app/components/design/design-canvas/element-payload.ts +27 -0
  128. package/corpus/templates/design/app/components/design/design-canvas/embedded-frame.ts +62 -0
  129. package/corpus/templates/design/app/components/design/design-canvas/external-preview.ts +137 -0
  130. package/corpus/templates/design/app/components/design/design-canvas/file-drop.ts +10 -0
  131. package/corpus/templates/design/app/components/design/design-canvas/hit-test.ts +23 -0
  132. package/corpus/templates/design/app/components/design/design-canvas/iframe-events.ts +23 -0
  133. package/corpus/templates/design/app/components/design/design-canvas/iframe-pan.ts +177 -0
  134. package/corpus/templates/design/app/components/design/design-canvas/motion-types.ts +7 -0
  135. package/corpus/templates/design/app/components/design/design-canvas/pending-text-edit.ts +62 -0
  136. package/corpus/templates/design/app/components/design/index.ts +2 -2
  137. package/corpus/templates/design/app/components/design/multi-screen/canvas-tools.ts +100 -0
  138. package/corpus/templates/design/app/components/design/multi-screen/coordinate-transforms.ts +81 -0
  139. package/corpus/templates/design/app/components/design/multi-screen/cross-screen-drop.ts +23 -13
  140. package/corpus/templates/design/app/components/design/multi-screen/culling.ts +238 -19
  141. package/corpus/templates/design/app/components/design/multi-screen/draft-primitives.ts +424 -0
  142. package/corpus/templates/design/app/components/design/multi-screen/frame-geometry.ts +341 -0
  143. package/corpus/templates/design/app/components/design/multi-screen/iframe-targeting.ts +52 -0
  144. package/corpus/templates/design/app/components/design/multi-screen/overview-layout.ts +62 -0
  145. package/corpus/templates/design/app/components/design/multi-screen/primitive-drop-target.ts +283 -0
  146. package/corpus/templates/design/app/components/design/multi-screen/screen-content-cache.ts +272 -0
  147. package/corpus/templates/design/app/components/design/multi-screen/types.ts +5 -1
  148. package/corpus/templates/design/app/components/design/multi-screen/wheel-gesture-state.ts +12 -0
  149. package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +325 -84
  150. package/corpus/templates/design/app/hooks/use-navigation-state.ts +15 -1
  151. package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +119 -47
  152. package/corpus/templates/design/app/i18n/zh-TW.ts +9 -0
  153. package/corpus/templates/design/app/i18n-data.ts +223 -0
  154. package/corpus/templates/design/app/lib/design-save-outbox.ts +379 -0
  155. package/corpus/templates/design/app/lib/png-clipboard.ts +113 -0
  156. package/corpus/templates/design/app/pages/design-editor/canvas-primitives.ts +229 -0
  157. package/corpus/templates/design/app/pages/design-editor/code-layer-state.ts +918 -0
  158. package/corpus/templates/design/app/pages/design-editor/collab-sync.ts +43 -0
  159. package/corpus/templates/design/app/pages/design-editor/cross-screen-text-color.ts +336 -0
  160. package/corpus/templates/design/app/pages/design-editor/data-operations.ts +270 -0
  161. package/corpus/templates/design/app/pages/design-editor/dom-guards.ts +24 -0
  162. package/corpus/templates/design/app/pages/design-editor/dom-utils.ts +11 -0
  163. package/corpus/templates/design/app/pages/design-editor/editor-session.ts +20 -0
  164. package/corpus/templates/design/app/pages/design-editor/editor-state.ts +408 -0
  165. package/corpus/templates/design/app/pages/design-editor/export-capture.ts +115 -0
  166. package/corpus/templates/design/app/pages/design-editor/geometry-persistence.ts +93 -0
  167. package/corpus/templates/design/app/pages/design-editor/history.ts +169 -0
  168. package/corpus/templates/design/app/pages/design-editor/layout-operations.ts +185 -0
  169. package/corpus/templates/design/app/pages/design-editor/motion-state.ts +309 -0
  170. package/corpus/templates/design/app/pages/design-editor/overview-camera.ts +438 -0
  171. package/corpus/templates/design/app/pages/design-editor/pending-edits.ts +510 -0
  172. package/corpus/templates/design/app/pages/design-editor/portable-style.ts +115 -0
  173. package/corpus/templates/design/app/pages/design-editor/selection-state.ts +337 -0
  174. package/corpus/templates/design/app/pages/design-editor/style-utils.ts +5 -0
  175. package/corpus/templates/design/app/pages/design-editor/tool-state.ts +92 -0
  176. package/corpus/templates/design/app/pages/design-editor/tweak-save.ts +87 -0
  177. package/corpus/templates/design/app/pages/design-editor/types.ts +36 -0
  178. package/corpus/templates/design/app/routes/design.$id.tsx +11 -1
  179. package/corpus/templates/design/changelog/2026-07-09-design-edits-canvas-positions-tools-and-tab-context-now-pers.md +6 -0
  180. package/corpus/templates/design/changelog/2026-07-09-figma-style-shortcuts-drawing-annotations-and-cross-screen-d.md +6 -0
  181. package/corpus/templates/design/changelog/2026-07-09-hand-and-scale-tools-now-keep-their-active-toolbar-identity-.md +6 -0
  182. package/corpus/templates/design/changelog/2026-07-09-large-multi-screen-designs-now-stay-responsive-during-long-c.md +6 -0
  183. package/corpus/templates/design/changelog/2026-07-09-layer-drags-now-land-exactly-where-the-insertion-indicator-s.md +6 -0
  184. package/corpus/templates/design/server/db/index.ts +2 -52
  185. package/corpus/templates/design/server/db/schema.ts +16 -0
  186. package/corpus/templates/design/server/lib/design-data-access.ts +70 -0
  187. package/corpus/templates/design/server/lib/design-data-mutation.ts +249 -0
  188. package/corpus/templates/design/server/lib/fusion-screens.ts +89 -76
  189. package/corpus/templates/design/server/lib/import-design-files.ts +76 -35
  190. package/corpus/templates/design/server/lib/verify-write-grant.ts +7 -2
  191. package/corpus/templates/design/server/plugins/db.ts +19 -0
  192. package/corpus/templates/design/server/source-workspace.ts +12 -13
  193. package/corpus/templates/design/shared/resolve-tweaks.ts +15 -0
  194. package/corpus/templates/design/shared/screen-rename.ts +42 -0
  195. package/corpus/templates/design/shared/source-mode.ts +84 -4
  196. package/dist/agent/engine/builder-engine.d.ts +1 -1
  197. package/dist/agent/engine/builder-engine.d.ts.map +1 -1
  198. package/dist/agent/engine/builtin.js +1 -1
  199. package/dist/agent/engine/builtin.js.map +1 -1
  200. package/dist/agent/model-config.d.ts +9 -9
  201. package/dist/agent/model-config.d.ts.map +1 -1
  202. package/dist/agent/model-config.js +30 -30
  203. package/dist/agent/model-config.js.map +1 -1
  204. package/dist/agent/production-agent.d.ts +60 -1
  205. package/dist/agent/production-agent.d.ts.map +1 -1
  206. package/dist/agent/production-agent.js +106 -7
  207. package/dist/agent/production-agent.js.map +1 -1
  208. package/dist/cli/design-connect.d.ts +30 -1
  209. package/dist/cli/design-connect.d.ts.map +1 -1
  210. package/dist/cli/design-connect.js +359 -75
  211. package/dist/cli/design-connect.js.map +1 -1
  212. package/dist/cli/recap.js +2 -2
  213. package/dist/cli/recap.js.map +1 -1
  214. package/dist/client/AgentPanel.d.ts.map +1 -1
  215. package/dist/client/AgentPanel.js +6 -1
  216. package/dist/client/AgentPanel.js.map +1 -1
  217. package/dist/client/agent-chat-adapter.d.ts.map +1 -1
  218. package/dist/client/agent-chat-adapter.js +10 -5
  219. package/dist/client/agent-chat-adapter.js.map +1 -1
  220. package/dist/client/composer/TiptapComposer.js +1 -1
  221. package/dist/client/composer/TiptapComposer.js.map +1 -1
  222. package/dist/client/index.d.ts +1 -1
  223. package/dist/client/index.d.ts.map +1 -1
  224. package/dist/client/index.js +1 -1
  225. package/dist/client/index.js.map +1 -1
  226. package/dist/client/org/TeamPage.d.ts.map +1 -1
  227. package/dist/client/org/TeamPage.js +6 -5
  228. package/dist/client/org/TeamPage.js.map +1 -1
  229. package/dist/client/use-action.d.ts +32 -0
  230. package/dist/client/use-action.d.ts.map +1 -1
  231. package/dist/client/use-action.js +68 -1
  232. package/dist/client/use-action.js.map +1 -1
  233. package/dist/collab/struct-routes.d.ts +1 -1
  234. package/dist/db/client.d.ts +3 -0
  235. package/dist/db/client.d.ts.map +1 -1
  236. package/dist/db/client.js +7 -2
  237. package/dist/db/client.js.map +1 -1
  238. package/dist/db/create-get-db.d.ts.map +1 -1
  239. package/dist/db/create-get-db.js +2 -2
  240. package/dist/db/create-get-db.js.map +1 -1
  241. package/dist/notifications/routes.d.ts +3 -3
  242. package/dist/resources/handlers.d.ts +2 -2
  243. package/dist/scripts/agent-engines/manage-agent-engine.js +1 -1
  244. package/dist/scripts/agent-engines/manage-agent-engine.js.map +1 -1
  245. package/dist/scripts/agent-engines/set-agent-engine.js +1 -1
  246. package/dist/scripts/agent-engines/set-agent-engine.js.map +1 -1
  247. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  248. package/dist/server/transcribe-voice.js +1 -1
  249. package/dist/server/transcribe-voice.js.map +1 -1
  250. package/dist/usage/store.d.ts.map +1 -1
  251. package/dist/usage/store.js +13 -4
  252. package/dist/usage/store.js.map +1 -1
  253. package/dist/vite/client.d.ts.map +1 -1
  254. package/dist/vite/client.js +36 -1
  255. package/dist/vite/client.js.map +1 -1
  256. package/docs/content/a2a-protocol.mdx +12 -0
  257. package/docs/content/locales/ar-SA/a2a-protocol.mdx +12 -0
  258. package/docs/content/locales/ar-SA/pr-visual-recap.mdx +3 -3
  259. package/docs/content/locales/ar-SA/pure-agent-apps.mdx +4 -0
  260. package/docs/content/locales/de-DE/a2a-protocol.mdx +12 -0
  261. package/docs/content/locales/de-DE/pr-visual-recap.mdx +3 -3
  262. package/docs/content/locales/de-DE/pure-agent-apps.mdx +4 -0
  263. package/docs/content/locales/es-ES/a2a-protocol.mdx +12 -0
  264. package/docs/content/locales/es-ES/pr-visual-recap.mdx +3 -3
  265. package/docs/content/locales/es-ES/pure-agent-apps.mdx +4 -0
  266. package/docs/content/locales/fr-FR/a2a-protocol.mdx +12 -0
  267. package/docs/content/locales/fr-FR/pr-visual-recap.mdx +3 -3
  268. package/docs/content/locales/fr-FR/pure-agent-apps.mdx +4 -0
  269. package/docs/content/locales/hi-IN/a2a-protocol.mdx +12 -0
  270. package/docs/content/locales/hi-IN/pr-visual-recap.mdx +3 -3
  271. package/docs/content/locales/hi-IN/pure-agent-apps.mdx +4 -0
  272. package/docs/content/locales/ja-JP/a2a-protocol.mdx +12 -0
  273. package/docs/content/locales/ja-JP/pr-visual-recap.mdx +3 -3
  274. package/docs/content/locales/ja-JP/pure-agent-apps.mdx +4 -0
  275. package/docs/content/locales/ko-KR/a2a-protocol.mdx +12 -0
  276. package/docs/content/locales/ko-KR/pr-visual-recap.mdx +3 -3
  277. package/docs/content/locales/ko-KR/pure-agent-apps.mdx +4 -0
  278. package/docs/content/locales/pt-BR/a2a-protocol.mdx +12 -0
  279. package/docs/content/locales/pt-BR/pr-visual-recap.mdx +3 -3
  280. package/docs/content/locales/pt-BR/pure-agent-apps.mdx +4 -0
  281. package/docs/content/locales/zh-CN/a2a-protocol.mdx +12 -0
  282. package/docs/content/locales/zh-CN/pr-visual-recap.mdx +3 -3
  283. package/docs/content/locales/zh-CN/pure-agent-apps.mdx +4 -0
  284. package/docs/content/locales/zh-TW/a2a-protocol.mdx +12 -0
  285. package/docs/content/locales/zh-TW/pr-visual-recap.mdx +3 -3
  286. package/docs/content/locales/zh-TW/pure-agent-apps.mdx +4 -0
  287. package/docs/content/pr-visual-recap.mdx +3 -3
  288. package/docs/content/pure-agent-apps.mdx +22 -0
  289. package/package.json +2 -2
@@ -1,9 +1,5 @@
1
1
  import { useT } from "@agent-native/core/client";
2
2
  import {
3
- DEFAULT_ASSIGNED_REGION_GAP,
4
- DEFAULT_ASSIGNED_REGION_HEIGHT,
5
- DEFAULT_ASSIGNED_REGION_MAX_COLUMNS,
6
- DEFAULT_ASSIGNED_REGION_WIDTH,
7
3
  DEFAULT_CANVAS_MAX_ZOOM,
8
4
  DEFAULT_CANVAS_MIN_ZOOM,
9
5
  DEFAULT_SNAP_THRESHOLD_SCREEN_PX,
@@ -11,16 +7,12 @@ import {
11
7
  computeEqualGapGuides,
12
8
  computeMoveSnap,
13
9
  computeResizeSnap,
14
- type DistanceGuideBand,
15
10
  type EqualGapGuide,
16
- type FrameBounds,
17
11
  getCameraForBounds,
18
- getDraftGeometryFromPoints,
19
12
  getFrameGroupBounds,
20
13
  getNudgeDelta,
21
14
  getPanForZoomToCursor,
22
15
  getResizeCursorForHandle,
23
- getRotatedFrameAABB,
24
16
  resizeFrameGroupFromDelta,
25
17
  resizeFrameGroupToBounds,
26
18
  resizeRotatedFrameFromDelta,
@@ -42,7 +34,6 @@ import {
42
34
  isPenCloseTarget,
43
35
  movePenAnchor,
44
36
  movePenHandle,
45
- scalePenPathToGeometry,
46
37
  serializePenPath,
47
38
  setPenNodeType,
48
39
  snapPenAnchorPoint,
@@ -62,6 +53,7 @@ import {
62
53
  useState,
63
54
  useCallback,
64
55
  useEffect,
56
+ useLayoutEffect,
65
57
  useMemo,
66
58
  type CSSProperties,
67
59
  type PointerEvent as ReactPointerEvent,
@@ -85,121 +77,54 @@ import {
85
77
  DEFAULT_LINE_STROKE,
86
78
  DEFAULT_LINE_STROKE_WIDTH_PX,
87
79
  } from "./canvas-primitive-style";
88
- import {
89
- DesignCanvas,
90
- LIGHTWEIGHT_HIT_TEST_BRIDGE_SCRIPT,
91
- appendHitTestResponder,
92
- type IframeContextMenuPayload,
93
- type IframeFigmaClipboardPastePayload,
94
- type IframeHotkeyPayload,
95
- } from "./DesignCanvas";
80
+ import { appendHitTestResponder } from "./design-canvas/hit-test";
81
+ import { DesignCanvas } from "./DesignCanvas";
96
82
  import {
97
83
  gradientToCss,
98
84
  parseGradientCss,
99
85
  type GradientStopValue,
100
86
  } from "./inspector/GradientEditor";
101
- import {
102
- DEVICE_FRAME_VIEWPORTS,
103
- type DeviceFrameType,
104
- type ElementInfo,
105
- type ElementSelectionIntent,
106
- type PortableStyleSnapshot,
107
- } from "./types";
108
-
109
- // Re-export so consumers of MultiScreenCanvas can use the same script without
110
- // importing DesignCanvas directly.
111
- export { LIGHTWEIGHT_HIT_TEST_BRIDGE_SCRIPT };
112
-
113
87
  import type {
114
88
  AltHoverMeasurement,
115
89
  AltHoverMeasurementLine,
116
90
  CanvasLayerMarqueeCandidate,
117
- CanvasLayerMarqueeSelection,
118
- CanvasPrimitiveInsert,
119
- CanvasToolProps,
120
91
  CrossScreenDragElementRect,
121
- CrossScreenDropAxis,
122
92
  CrossScreenDropGuide,
123
- CrossScreenDropMode,
124
- CrossScreenDropPlacement,
125
- CrossScreenHitTestAnchorRect,
126
93
  CrossScreenHitTestResult,
127
94
  DraftCreationPreview,
128
95
  DraftCreationTool,
129
- DraftCreateDragState,
130
96
  DraftGeometryModifiers,
131
- DraftMoveDragState,
132
97
  DraftPrimitive,
133
98
  DraftPrimitiveById,
134
- DraftPrimitiveInput,
135
- DraftPrimitiveKind,
136
- DraftResizeDragState,
137
99
  DragState,
138
100
  DuplicatePreview,
139
- DuplicateRequest,
140
101
  FrameGeometry,
141
102
  FrameGeometryById,
142
103
  GradientEditOverlayTarget,
143
- GradientLinePoint,
144
- GroupRotateDragState,
145
104
  MarqueeDragState,
146
105
  MarqueeRect,
147
- MoveDragState,
148
106
  MultiScreenCanvasProps,
149
107
  MultiScreenCanvasTool,
150
- PanDragState,
151
108
  PendingWheelGesture,
152
- PenNodeDragState,
153
109
  PersistedDraftPrimitive,
154
110
  Point,
155
- ResizeDragState,
156
111
  ResizeHandle,
157
112
  ResolvedScreenMetadata,
158
- RotateDragState,
159
113
  ScreenContentCacheEntry,
160
114
  ScreenFile,
161
- ScreenMetadata,
162
- ScreenPreviewState,
163
- ScreenSourceType,
164
115
  TransformBadge,
165
- VectorEditAnchorDragState,
166
- VectorEditHandleDragState,
167
- VectorEditOverlayState,
168
- } from "./multi-screen/types";
169
- // Re-export so external consumers of MultiScreenCanvas (tests, other
170
- // components) can keep importing these from this module directly.
171
- export type {
172
- AltHoverMeasurement,
173
- AltHoverMeasurementLine,
174
- CanvasLayerMarqueeSelection,
175
- CanvasPrimitiveInsert,
176
- CrossScreenDropAxis,
177
- CrossScreenDropGuide,
178
- CrossScreenDropMode,
179
- CrossScreenDropPlacement,
180
- CrossScreenHitTestAnchorRect,
181
- CrossScreenHitTestResult,
182
- DraftPrimitive,
183
- DraftPrimitiveKind,
184
- DuplicateRequest,
185
- FrameGeometry,
186
- GradientEditOverlayTarget,
187
- GradientLinePoint,
188
- MultiScreenCanvasTool,
189
- Point,
190
116
  VectorEditOverlayState,
191
117
  } from "./multi-screen/types";
118
+ import { type ElementInfo, type PortableStyleSnapshot } from "./types";
192
119
 
193
120
  /**
194
121
  * design-editor overview canvas. Renders every file in the design as a movable,
195
122
  * resizable frame inside an infinite, pannable surface.
196
123
  */
197
- export const OVERVIEW_FRAME_WIDTH = 320;
198
124
  const SCREEN_WIDTH = OVERVIEW_FRAME_WIDTH;
199
125
  const SCREEN_HEIGHT = 640;
200
126
  const SCREEN_CARD_HEIGHT = SCREEN_HEIGHT + 26;
201
127
  const SCREEN_GAP = 56;
202
- export const SURFACE_PADDING = 240;
203
128
  const DUPLICATE_DRAG_THRESHOLD = 6;
204
129
  const DRAG_THRESHOLD = 3;
205
130
  /** How close two gaps must be (in screen px, converted to canvas px at the
@@ -223,6 +148,10 @@ const TRANSFORM_BADGE_MAX_WIDTH = 180;
223
148
  // (frame z-order is a small per-design integer) while staying well under the
224
149
  // reserved resize-handle stacking range (999_999+).
225
150
  const TOP_SCREEN_Z_BOOST = 100_000;
151
+ const EMPTY_SELECTED_LAYER_SELECTOR_GROUPS_BY_SCREEN: Record<
152
+ string,
153
+ string[][]
154
+ > = {};
226
155
  // Shared with canvas-math.ts (DEFAULT_CANVAS_MIN_ZOOM/DEFAULT_CANVAS_MAX_ZOOM)
227
156
  // so this surface's zoom clamp lives in one place instead of being
228
157
  // redeclared locally and drifting from the shared constant.
@@ -232,6 +161,24 @@ const ZOOM_SENSITIVITY = 0.01;
232
161
  const MAX_WHEEL_ZOOM_DELTA = 120;
233
162
  const MAX_WHEEL_PAN_DELTA = 140;
234
163
  const PIXEL_GRID_ZOOM = 800;
164
+ import {
165
+ BOARD_SURFACE_BACKGROUND,
166
+ getBoardContentKey,
167
+ getBoardContentLayerSignature,
168
+ getBoardSurfaceRenderContent,
169
+ hasBoardSurfaceContent,
170
+ } from "./multi-screen/board-surface-html";
171
+ import {
172
+ getDraftCreationTool,
173
+ isDirectScreenHoverTarget,
174
+ isOsFileDrag,
175
+ normalizeCanvasTool,
176
+ shouldBeginCanvasPan,
177
+ shouldBoardSurfaceCapturePointerEvents,
178
+ shouldClearSelectionOnEmptyCanvasClick,
179
+ shouldShowBreakpointMenuAffordance,
180
+ shouldShowFrameFullViewButton,
181
+ } from "./multi-screen/canvas-tools";
235
182
  import {
236
183
  CHROME_SETTLE_MS,
237
184
  getChromeBorderTransition,
@@ -243,229 +190,37 @@ import {
243
190
  getCornerHandleGeometry,
244
191
  getEdgeHandleHitGeometry,
245
192
  } from "./multi-screen/handle-hit-zones";
246
-
247
- export function isDirectScreenHoverTarget(
248
- target: EventTarget | null,
249
- currentTarget: HTMLElement,
250
- ) {
251
- if (target === currentTarget) return true;
252
- const element =
253
- target && typeof (target as Element).closest === "function"
254
- ? (target as Element)
255
- : null;
256
- return !!element && !element.closest("[data-screen-content]");
257
- }
258
-
259
- /**
260
- * True when a drag event is carrying native OS files (e.g. dragged in from
261
- * Finder/Explorer) rather than an internal HTML5 DOM drag (element reorder,
262
- * the extensions-panel native-asset drag, etc). Internal drags set
263
- * `dataTransfer.types` to things like "text/plain" and never include
264
- * "Files"; a real OS file drag always includes the "Files" type — per spec
265
- * this is reliably present during dragenter/dragover even before drop (when
266
- * `dataTransfer.files` itself is empty for security reasons), so this is the
267
- * correct check to gate preventDefault()/highlight logic on, not
268
- * `files.length` (which is only populated at drop time).
269
- */
270
- export function isOsFileDrag(event: {
271
- dataTransfer: { types: readonly string[] | DOMStringList } | null;
272
- }): boolean {
273
- const types = event.dataTransfer?.types;
274
- if (!types) return false;
275
- for (let i = 0; i < types.length; i += 1) {
276
- if (types[i] === "Files") return true;
277
- }
278
- return false;
279
- }
280
-
281
- // PERF9-WHEEL: module-scoped "a wheel/pinch camera gesture is in flight"
282
- // flag, set by markWheelGestureActive on the first applied flush and cleared
283
- // by commitView once the gesture settles (or on canvas unmount). Lives at
284
- // module scope (not React state) because its consumers are event handlers —
285
- // DesignEditor's hover callbacks — that must read the CURRENT value without
286
- // any render round-trip: flipping React state for this at gesture start
287
- // re-rendered every Screen and measurably stalled the first wheel ticks.
288
- //
289
- // Single-canvas-instance assumption: this flag is process-wide, not scoped
290
- // to a particular MultiScreenCanvas mount. The editor only ever renders one
291
- // MultiScreenCanvas at a time, so that's fine in practice — but if a second
292
- // instance were ever mounted concurrently (e.g. a future side-by-side or
293
- // picture-in-picture view), both would read/write the SAME flag and could
294
- // stomp each other's gesture state. The unmount cleanup effect below clears
295
- // it UNCONDITIONALLY (no ownership/identity check), so an unmounting second
296
- // instance would also incorrectly clear a still-active gesture that belongs
297
- // to the other, still-mounted instance. Revisit this if that assumption ever
298
- // changes.
299
- let wheelCameraGestureActive = false;
300
-
301
- /** True while a MultiScreenCanvas wheel/pinch camera gesture is in flight
302
- * (first applied wheel flush → settled debounced commit). DesignEditor's
303
- * hover handlers consult this to skip hover setState mid-gesture: muting
304
- * the content layers at gesture start flips the element under the cursor,
305
- * firing a hover-clear that would otherwise cost a full-tree React render
306
- * exactly when the pan needs the main thread most. Hover state simply goes
307
- * stale for the gesture's duration (same contract as a space-drag pan) and
308
- * recomputes from the next real pointer event after settle. */
309
- export function isWheelCameraGestureActive(): boolean {
310
- return wheelCameraGestureActive;
311
- }
312
-
313
- export function shouldBoardSurfaceCapturePointerEvents(args: {
314
- tool: MultiScreenCanvasTool | string;
315
- gestureActive?: boolean;
316
- }) {
317
- if (args.gestureActive) return false;
318
- const tool = normalizeCanvasTool(args.tool as MultiScreenCanvasTool);
319
- return (
320
- !getDraftCreationTool(tool) &&
321
- tool !== "hand" &&
322
- tool !== "comment" &&
323
- tool !== "draw"
324
- );
325
- }
326
-
327
- export function shouldShowFrameFullViewButton(args: {
328
- emphasized: boolean;
329
- showFullView?: boolean;
330
- childHoverActive?: boolean;
331
- }) {
332
- return args.emphasized || !!args.showFullView || !!args.childHoverActive;
333
- }
334
-
335
- /** How long an armed duplicate-commit suppression stays valid while the
336
- * created file(s) round-trip through the server before appearing in
337
- * `screens`. Generous vs. the usual create-file latency, but short enough
338
- * that an armed flag left behind by a failed mutation can't swallow a much
339
- * later, unrelated screen-count recenter. */
340
- export const LINEUP_RECENTER_SUPPRESS_MAX_AGE_MS = 10_000;
341
-
342
- /** Armed by a duplicate commit (alt-drag drop or Cmd+D) so the
343
- * lineup-recenter effect can recognise — and skip — exactly the screen-count
344
- * transition(s) that duplicate is about to produce. */
345
- export interface LineupRecenterDuplicateArm {
346
- atMs: number;
347
- /** `screens.length` at the moment the duplicate was dispatched. */
348
- fromCount: number;
349
- /** How many duplicates were dispatched (alt-drag: 1; Cmd+D: one per
350
- * selected frame — they land one server round-trip at a time). */
351
- addedCount: number;
352
- }
353
-
354
- /**
355
- * Whether the "center the lineup when the screen footprint changes" effect
356
- * must SKIP its camera move for this run. A duplicate (alt-drag drop OR
357
- * Cmd+D) places the clone deliberately relative to content the user is
358
- * already looking at — Figma keeps the camera still on that commit, but the
359
- * recenter effect (keyed on `screens.length`) would otherwise pan/zoom the
360
- * whole board the moment each new screen lands, making every untouched
361
- * sibling visibly shift. The duplicate commit arms `armed` with a timestamp,
362
- * the pre-duplicate screen count, and how many clones were dispatched; the
363
- * effect suppresses only transitions inside `(fromCount, fromCount +
364
- * addedCount]`, only while fresh, and never for device-frame-preview-driven
365
- * runs — so initial-load fit, explicit Zoom-to-fit (cameraCommand, a
366
- * different path entirely), toolbar add-screen recenters, and device-frame
367
- * changes all keep their behavior. Pure/exported for unit tests.
368
- */
369
- export function shouldSuppressLineupRecenter(args: {
370
- armed: LineupRecenterDuplicateArm | null;
371
- nowMs: number;
372
- screenCount: number;
373
- deviceFrameChanged: boolean;
374
- maxAgeMs?: number;
375
- }): boolean {
376
- if (!args.armed) return false;
377
- if (args.deviceFrameChanged) return false;
378
- const age = args.nowMs - args.armed.atMs;
379
- if (age < 0 || age > (args.maxAgeMs ?? LINEUP_RECENTER_SUPPRESS_MAX_AGE_MS)) {
380
- return false;
381
- }
382
- return (
383
- args.screenCount > args.armed.fromCount && // i18n-ignore — pure boolean expression, guard misreads the parenthesized return in this .tsx as JSX text
384
- args.screenCount <= args.armed.fromCount + args.armed.addedCount
385
- );
386
- }
387
-
388
- /**
389
- * True when this lineup-recenter run was caused purely by the screen count
390
- * SHRINKING (delete, or undo of a duplicate/add). Nothing new needs to
391
- * become reachable when screens disappear, and Figma keeps the camera still
392
- * on delete/undo — so the recenter effect skips these runs entirely (undo
393
- * after a duplicate previously yanked the camera a second time).
394
- * Device-frame-preview changes still recenter regardless of count direction.
395
- * Pure/exported for unit tests.
396
- */
397
- export function isLineupShrinkOnlyChange(args: {
398
- previousCount: number | null;
399
- screenCount: number;
400
- deviceFrameChanged: boolean;
401
- }): boolean {
402
- return (
403
- !args.deviceFrameChanged &&
404
- args.previousCount !== null &&
405
- args.screenCount < args.previousCount
406
- );
407
- }
408
-
409
- /**
410
- * Item 8b — whether an overview breakpoint frame's "…" menu affordance
411
- * (Change width / Remove) should render: gated by edit permission AND at
412
- * least one of the two callbacks being wired, then shown only for the
413
- * active frame or while its own menu is already open — same
414
- * one-visible-at-a-time rule BreakpointDeviceControl's own per-segment menu
415
- * uses, so idle frames stay visually clean. Pure/exported for unit tests.
416
- */
417
- export function shouldShowBreakpointMenuAffordance(args: {
418
- canEdit: boolean;
419
- hasRemoveOrChangeWidth: boolean;
420
- isActive: boolean;
421
- menuOpen: boolean;
422
- }): boolean {
423
- return (
424
- args.canEdit &&
425
- args.hasRemoveOrChangeWidth &&
426
- (args.isActive || args.menuOpen)
427
- );
428
- }
429
-
430
- export function getBoardSurfaceLayerStyle(args: {
431
- geometry: FrameGeometry;
432
- interactive: boolean;
433
- }): CSSProperties {
434
- return {
435
- position: "absolute",
436
- left: SURFACE_PADDING + args.geometry.x,
437
- top: SURFACE_PADDING + args.geometry.y,
438
- width: args.geometry.width,
439
- height: args.geometry.height,
440
- overflow: "hidden",
441
- pointerEvents: args.interactive ? "auto" : "none",
442
- background: "transparent",
443
- zIndex: 0,
444
- };
445
- }
446
-
447
193
  import {
448
- BOARD_SURFACE_BACKGROUND,
449
- getBoardContentKey,
450
- getBoardContentLayerSignature,
451
- getBoardSurfaceRenderContent,
452
- hasBoardSurfaceContent,
453
- hashString,
454
- } from "./multi-screen/board-surface-html";
194
+ findCanvasIframeForScreen,
195
+ getActiveScreenIframeId,
196
+ getBreakpointIframeId,
197
+ isBreakpointSelectionTarget,
198
+ } from "./multi-screen/iframe-targeting";
199
+ import {
200
+ getBoardSurfaceLayerStyle,
201
+ isLineupShrinkOnlyChange,
202
+ OVERVIEW_FRAME_WIDTH,
203
+ shouldSuppressLineupRecenter,
204
+ SURFACE_PADDING,
205
+ type LineupRecenterDuplicateArm,
206
+ } from "./multi-screen/overview-layout";
207
+ import {
208
+ getCachedScreenContentNode,
209
+ getPreviewUrl,
210
+ pruneResolvedMetadataCache,
211
+ pruneScreenContentCache,
212
+ resolveScreenMetadata,
213
+ resolveScreenMetadataCached,
214
+ sameResolvedMetadata,
215
+ type ResolvedMetadataCacheEntry,
216
+ } from "./multi-screen/screen-content-cache";
217
+ import { setWheelCameraGestureActive } from "./multi-screen/wheel-gesture-state";
455
218
 
456
- const DRAFT_FRAME_WIDTH = 320;
457
- const DRAFT_FRAME_HEIGHT = 640;
458
219
  // Figma parity: a plain click (no drag) with the rectangle or ellipse tool
459
220
  // places a 100x100 shape. Both tools share this default via the "else"
460
221
  // branch of getDraftGeometryForTool below — drag-to-size behavior is
461
222
  // unaffected since getDraftGeometryFromPoints only falls back to these when
462
223
  // the pointer hasn't moved.
463
- const DRAFT_RECT_WIDTH = 100;
464
- const DRAFT_RECT_HEIGHT = 100;
465
- const DRAFT_TEXT_WIDTH = 180;
466
- const DRAFT_TEXT_HEIGHT = 48;
467
- const DRAFT_LINE_WIDTH = 160;
468
- const DRAFT_PATH_MIN_SIZE = 12;
469
224
  const PEN_CLOSE_HIT_RADIUS_SCREEN_PX = 10;
470
225
  /** Screen-space hit radius for vector-edit anchor/handle pointer targets,
471
226
  * independent of PEN_CLOSE_HIT_RADIUS_SCREEN_PX (that one gates the pen
@@ -478,11 +233,14 @@ const VECTOR_EDIT_HIT_RADIUS_SCREEN_PX = 8;
478
233
  import {
479
234
  alignmentGuidesEqual,
480
235
  altHoverMeasurementEqual,
481
- altHoverMeasurementLineEqual,
482
236
  computeAltHoverMeasurement,
483
- distanceGuideBandEqual,
484
237
  equalGapGuidesEqual,
485
238
  } from "./multi-screen/alt-hover-measurement";
239
+ import {
240
+ boardPointToScreenLocalPoint,
241
+ screenLocalPointToBoardPoint,
242
+ screenLocalRectToBoardGeometry,
243
+ } from "./multi-screen/coordinate-transforms";
486
244
  import {
487
245
  getCrossScreenDropGuideForHitTest,
488
246
  getCrossScreenDropGuideStyle,
@@ -495,14 +253,46 @@ import {
495
253
  } from "./multi-screen/cross-screen-drop";
496
254
  import {
497
255
  clampFrameGeometryToViewport,
498
- computeScreenCullTier,
256
+ computeBoundedScreenCullState,
257
+ getScreenContentCullState,
499
258
  getOverscannedViewportCanvasBounds,
500
- isFrameWithinOverscannedViewport,
501
- OVERVIEW_CULLING_ENABLED,
502
- OVERVIEW_CULLING_OVERSCAN_FACTOR,
503
- type OverscannedViewportBounds,
504
259
  type ScreenCullTier,
505
260
  } from "./multi-screen/culling";
261
+ import {
262
+ applyDraftGeometry,
263
+ cloneDraftPrimitive,
264
+ createDraftPrimitive,
265
+ createPenDraftPrimitive,
266
+ DRAFT_LINE_WIDTH,
267
+ draftPrimitiveToInsert,
268
+ getDraftGeometryForTool,
269
+ getDraftPreviewGeometryForTool,
270
+ isDraftPrimitive,
271
+ moveDraftPrimitive,
272
+ pointsToPath,
273
+ polygonPointsForBox,
274
+ previewDraftPrimitive,
275
+ shapeClosingHandles,
276
+ } from "./multi-screen/draft-primitives";
277
+ import {
278
+ angleBetween,
279
+ cloneFrameGeometryById,
280
+ findTopFrameEntryAtPoint,
281
+ frameGeometryWithOverrides,
282
+ frameStyleLeftTop,
283
+ getBreakpointFrameGeometry,
284
+ getFrameCenter,
285
+ getInitialFrameGeometry,
286
+ getLayerSelectableBounds,
287
+ getOutsideFrameDraftFallback,
288
+ getPreviewDeviceFrameGeometry,
289
+ getScreenPreviewViewport,
290
+ getSelectableBounds,
291
+ rectContainsPoint,
292
+ resolveFrameGeometrySync,
293
+ rotatePointAroundCenter,
294
+ sameFrameGeometry,
295
+ } from "./multi-screen/frame-geometry";
506
296
  import {
507
297
  angleFromDraggedEndpoint,
508
298
  gradientLineEndpoints,
@@ -510,44 +300,13 @@ import {
510
300
  screenPxToCanvasPx,
511
301
  stopPercentFromDraggedPoint,
512
302
  } from "./multi-screen/gradient-overlay-geometry";
513
- import type { AlignmentGuide, CanvasFrameEntry } from "./multi-screen/types";
514
303
  import {
515
- vectorEditCanvasToLocalPoint,
516
- vectorEditLocalToCanvasPoint,
517
- } from "./multi-screen/vector-edit-geometry";
518
-
519
- // Re-export so external consumers of MultiScreenCanvas (tests, other
520
- // components) can keep importing these from this module directly.
521
- export {
522
- alignmentGuidesEqual,
523
- altHoverMeasurementEqual,
524
- altHoverMeasurementLineEqual,
525
- angleFromDraggedEndpoint,
526
- clampFrameGeometryToViewport,
527
- computeAltHoverMeasurement,
528
- computeScreenCullTier,
529
- distanceGuideBandEqual,
530
- equalGapGuidesEqual,
531
- getBoardContentKey,
532
- getBoardContentLayerSignature,
533
- getBoardSurfaceRenderContent,
534
- getChromeBorderTransition,
535
- getCrossScreenDropGuideForHitTest,
536
- getCrossScreenDropGuideStyle,
537
- getOverscannedViewportCanvasBounds,
538
- getSelectionBoxTransition,
539
- hasBoardSurfaceContent,
540
- gradientLineEndpoints,
541
- gradientStopPoints,
542
- isFrameWithinOverscannedViewport,
543
- OVERVIEW_CULLING_ENABLED,
544
- OVERVIEW_CULLING_OVERSCAN_FACTOR,
545
- screenPxToCanvasPx,
546
- stopPercentFromDraggedPoint,
547
- vectorEditCanvasToLocalPoint,
548
- vectorEditLocalToCanvasPoint,
549
- };
550
- export type { OverscannedViewportBounds, ScreenCullTier };
304
+ getPrimitiveDropTargetForPoint,
305
+ resolveNodeScreenId,
306
+ type PrimitiveDropTarget,
307
+ } from "./multi-screen/primitive-drop-target";
308
+ import type { AlignmentGuide, CanvasFrameEntry } from "./multi-screen/types";
309
+ import { vectorEditCanvasToLocalPoint } from "./multi-screen/vector-edit-geometry";
551
310
 
552
311
  export const MultiScreenCanvas = memo(function MultiScreenCanvas({
553
312
  screens,
@@ -587,7 +346,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
587
346
  onEditBreakpoint,
588
347
  onSelectionChange,
589
348
  onLayerMarqueeSelectionChange,
590
- selectedLayerSelectorGroupsByScreen = {},
349
+ selectedLayerSelectorGroupsByScreen = EMPTY_SELECTED_LAYER_SELECTOR_GROUPS_BY_SCREEN,
591
350
  onCrossScreenElementDrop,
592
351
  boardFileId,
593
352
  boardFileContent,
@@ -780,8 +539,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
780
539
  }
781
540
  const [crossScreenGhost, setCrossScreenGhost] =
782
541
  useState<CrossScreenDragGhost | null>(null);
783
- const [crossScreenTarget, setCrossScreenTarget] =
784
- useState<CrossScreenDragTarget | null>(null);
542
+ const [, setCrossScreenTarget] = useState<CrossScreenDragTarget | null>(null);
785
543
  // OS file drag-over highlight (Figma parity §1): id of the screen frame
786
544
  // currently under a native OS file drag, or "" for "over the board/empty
787
545
  // canvas" (no frame). null means no drag is in progress. rAF-throttled in
@@ -867,17 +625,31 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
867
625
  const chromeSettleTimerRef = useRef<number | null>(null);
868
626
  const [chromeSettling, setChromeSettling] = useState(false);
869
627
  const previousPreviewDeviceFrameRef = useRef(previewDeviceFrame);
870
- // Overview viewport culling (PF22): every screen id that has ever
871
- // intersected the overscanned viewport this session. Once a screen id is
872
- // added it is never removed a screen that becomes visible mounts real
873
- // content and stays mounted (as a hidden-but-live iframe when later culled
874
- // again), never regressing to the lightweight placeholder. A plain mutable
875
- // Set (not state) is intentional: membership is read-and-written together
876
- // with the rest of the per-render visibility computation below (see
877
- // screenCullTierById), not something that should itself trigger a
878
- // re-render — the geometry/pan/zoom changes that make a screen newly
879
- // visible already do that.
628
+ // Overview viewport culling (PF22 + bounded live-context follow-up): track
629
+ // both the screens that have rendered at least once and the smaller subset
630
+ // whose browsing contexts are currently mounted. Offscreen contexts are
631
+ // retained only while they fit the LRU budget; once evicted, the screen's
632
+ // lightweight cached React node remains available for a direct revisit.
880
633
  const hasBeenVisibleScreenIdsRef = useRef<Set<string>>(new Set());
634
+ const liveScreenIdsRef = useRef<Set<string>>(new Set());
635
+ const lastVisibleEpochByScreenIdRef = useRef<Map<string, number>>(new Map());
636
+ const cullAccessEpochRef = useRef(0);
637
+ useEffect(() => {
638
+ const liveScreenIds = new Set(screens.map((screen) => screen.id));
639
+ for (const id of hasBeenVisibleScreenIdsRef.current) {
640
+ if (!liveScreenIds.has(id)) {
641
+ hasBeenVisibleScreenIdsRef.current.delete(id);
642
+ }
643
+ }
644
+ for (const id of liveScreenIdsRef.current) {
645
+ if (!liveScreenIds.has(id)) liveScreenIdsRef.current.delete(id);
646
+ }
647
+ for (const id of lastVisibleEpochByScreenIdRef.current.keys()) {
648
+ if (!liveScreenIds.has(id)) {
649
+ lastVisibleEpochByScreenIdRef.current.delete(id);
650
+ }
651
+ }
652
+ }, [screens]);
881
653
 
882
654
  // Track the pannable surface's own on-screen size for culling's viewport
883
655
  // bounds (getOverscannedViewportCanvasBounds). Only the surface's *size*
@@ -885,7 +657,12 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
885
657
  // from pan/zoom separately — a plain ResizeObserver on the fixed-position
886
658
  // surface element is sufficient and avoids reading getBoundingClientRect on
887
659
  // every render.
888
- useEffect(() => {
660
+ // Measure before paint. A passive effect lets the initial {0,0} viewport
661
+ // commit paint every on-screen frame as a placeholder, then swaps those
662
+ // placeholders for live iframes one frame later — a visible cold-open flash.
663
+ // The synchronous layout measurement keeps the first painted overview on
664
+ // the correct culling tier while ResizeObserver owns later size changes.
665
+ useLayoutEffect(() => {
889
666
  const surface = surfaceRef.current;
890
667
  if (!surface) return;
891
668
  const updateSize = (width: number, height: number) => {
@@ -965,6 +742,12 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
965
742
  const screenContentCacheRef = useRef<Map<string, ScreenContentCacheEntry>>(
966
743
  new Map(),
967
744
  );
745
+ useEffect(() => {
746
+ pruneScreenContentCache(
747
+ screenContentCacheRef.current,
748
+ new Set(screens.map((screen) => screen.id)),
749
+ );
750
+ }, [screens]);
968
751
 
969
752
  useEffect(() => {
970
753
  onGeometryChangeRef.current = onGeometryChange;
@@ -1457,7 +1240,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
1457
1240
  // Clears unconditionally (single-canvas-instance assumption — see the
1458
1241
  // module-scope doc comment on wheelCameraGestureActive above).
1459
1242
  wheelGestureActiveRef.current = false;
1460
- wheelCameraGestureActive = false;
1243
+ setWheelCameraGestureActive(false);
1461
1244
  wheelGestureMutedElementsRef.current = null;
1462
1245
  };
1463
1246
  }, []);
@@ -1517,8 +1300,20 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
1517
1300
  );
1518
1301
 
1519
1302
  const getFrameEntryAtPoint = useCallback(
1520
- (point: Point) => findTopFrameEntryAtPoint(getCurrentFrameEntries(), point),
1521
- [getCurrentFrameEntries],
1303
+ (point: Point) =>
1304
+ findTopFrameEntryAtPoint(getCurrentFrameEntries(), point, {
1305
+ // Screen wrappers give this same id a large z-index boost. Geometry
1306
+ // hit testing must mirror it or drops/draws on overlapping frames can
1307
+ // persist into a visually obscured sibling.
1308
+ foregroundId:
1309
+ selectedIdsRef.current.find(
1310
+ (id) => frameGeometryRef.current[id] !== undefined,
1311
+ ) ??
1312
+ (activeId && frameGeometryRef.current[activeId]
1313
+ ? activeId
1314
+ : screensRef.current[0]?.id),
1315
+ }),
1316
+ [activeId, getCurrentFrameEntries],
1522
1317
  );
1523
1318
 
1524
1319
  // Mirrors getFrameEntryAtPoint above, but hit-tests draft primitives
@@ -1691,6 +1486,11 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
1691
1486
  clearCrossScreenDropGuide();
1692
1487
  crossScreenTargetRef.current = null;
1693
1488
  crossScreenDragMsgRef.current = null;
1489
+ // A cancelled/blurred drag must not donate its last board coordinate to
1490
+ // the next gesture. In particular, a second drag can emit start -> end
1491
+ // without an out-of-iframe move; retaining the previous point would make
1492
+ // that no-op gesture drop at the prior drag's location.
1493
+ crossScreenLastBoardPointRef.current = null;
1694
1494
  // Drop the timeout-fallback cache so a future, unrelated drag session
1695
1495
  // never shows a guide left over from this one.
1696
1496
  crossScreenLastHitResultRef.current.clear();
@@ -1735,10 +1535,11 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
1735
1535
  if (!targetContentWindow) return Promise.resolve({});
1736
1536
  const { width: targetViewportWidth, height: targetViewportHeight } =
1737
1537
  getTargetViewportMetadata(targetScreen, targetIframe);
1738
- const scaleX = targetViewportWidth / Math.max(1, targetGeometry.width);
1739
- const scaleY = targetViewportHeight / Math.max(1, targetGeometry.height);
1740
- const localX = (boardPoint.x - targetGeometry.x) * scaleX;
1741
- const localY = (boardPoint.y - targetGeometry.y) * scaleY;
1538
+ const localPoint = boardPointToScreenLocalPoint(
1539
+ boardPoint,
1540
+ targetGeometry,
1541
+ { width: targetViewportWidth, height: targetViewportHeight },
1542
+ );
1742
1543
 
1743
1544
  const correlationId = `hit-${Date.now()}-${Math.random()
1744
1545
  .toString(36)
@@ -1807,8 +1608,8 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
1807
1608
  {
1808
1609
  type: "agent-native:hit-test",
1809
1610
  correlationId,
1810
- x: localX,
1811
- y: localY,
1611
+ x: localPoint.x,
1612
+ y: localPoint.y,
1812
1613
  preview: options.preview === true,
1813
1614
  },
1814
1615
  "*",
@@ -1834,14 +1635,10 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
1834
1635
  );
1835
1636
  const { width: targetViewportWidth, height: targetViewportHeight } =
1836
1637
  getTargetViewportMetadata(targetScreen, targetIframe);
1837
- const scaleX =
1838
- targetViewportWidth / Math.max(1, candidate.geometry.width);
1839
- const scaleY =
1840
- targetViewportHeight / Math.max(1, candidate.geometry.height);
1841
- return {
1842
- x: (boardPoint.x - candidate.geometry.x) * scaleX,
1843
- y: (boardPoint.y - candidate.geometry.y) * scaleY,
1844
- };
1638
+ return boardPointToScreenLocalPoint(boardPoint, candidate.geometry, {
1639
+ width: targetViewportWidth,
1640
+ height: targetViewportHeight,
1641
+ });
1845
1642
  };
1846
1643
 
1847
1644
  const requestCrossScreenDropGuide = (
@@ -2265,10 +2062,13 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
2265
2062
  clearCrossScreenDrag();
2266
2063
  return;
2267
2064
  }
2268
- const scaleX = sourceGeometry.width / Math.max(1, viewportW);
2269
- const scaleY = sourceGeometry.height / Math.max(1, viewportH);
2270
- boardX = sourceGeometry.x + iframeX * scaleX;
2271
- boardY = sourceGeometry.y + iframeY * scaleY;
2065
+ const boardPoint = screenLocalPointToBoardPoint(
2066
+ { x: iframeX, y: iframeY },
2067
+ sourceGeometry,
2068
+ { width: viewportW, height: viewportH },
2069
+ );
2070
+ boardX = boardPoint.x;
2071
+ boardY = boardPoint.y;
2272
2072
  }
2273
2073
  const boardPoint = { x: boardX, y: boardY };
2274
2074
  updateCrossScreenTargetFromBoardPoint(boardPoint, sourceScreenId);
@@ -2440,8 +2240,10 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
2440
2240
  const iframeId = targetScreen
2441
2241
  ? getActiveScreenIframeId(targetScreen)
2442
2242
  : screenId;
2443
- const targetIframe = surfaceRef.current?.querySelector<HTMLIFrameElement>(
2444
- `[data-screen-iframe-id="${CSS.escape(iframeId)}"]`,
2243
+ const targetIframe = findCanvasIframeForScreen(
2244
+ surfaceRef.current,
2245
+ iframeId,
2246
+ boardFileId,
2445
2247
  );
2446
2248
  const targetContentWindow = targetIframe?.contentWindow;
2447
2249
  if (!targetContentWindow) return Promise.resolve([]);
@@ -2490,7 +2292,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
2490
2292
  );
2491
2293
  });
2492
2294
  },
2493
- [],
2295
+ [boardFileId],
2494
2296
  );
2495
2297
 
2496
2298
  /** Collects marquee-selectable layer candidates. Each screen requires an
@@ -2518,18 +2320,20 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
2518
2320
  const metadata = getResolvedMetadata(screen);
2519
2321
  const viewportWidth = iframe?.clientWidth || metadata.width;
2520
2322
  const viewportHeight = iframe?.clientHeight || metadata.height;
2521
- const scaleX = entry.geometry.width / Math.max(1, viewportWidth);
2522
- const scaleY = entry.geometry.height / Math.max(1, viewportHeight);
2523
2323
  const infos = await requestSelectableElementInfos(entry.id);
2524
2324
  return infos.map((info) => ({
2525
2325
  screenId: entry.id,
2526
2326
  info,
2527
- geometry: {
2528
- x: entry.geometry.x + info.boundingRect.x * scaleX,
2529
- y: entry.geometry.y + info.boundingRect.y * scaleY,
2530
- width: info.boundingRect.width * scaleX,
2531
- height: info.boundingRect.height * scaleY,
2532
- },
2327
+ geometry: screenLocalRectToBoardGeometry(
2328
+ {
2329
+ left: info.boundingRect.x,
2330
+ top: info.boundingRect.y,
2331
+ width: info.boundingRect.width,
2332
+ height: info.boundingRect.height,
2333
+ },
2334
+ entry.geometry,
2335
+ { width: viewportWidth, height: viewportHeight },
2336
+ ),
2533
2337
  frameGeometry: entry.geometry,
2534
2338
  }));
2535
2339
  }),
@@ -2543,12 +2347,19 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
2543
2347
  return infos.map((info) => ({
2544
2348
  screenId: boardFileId,
2545
2349
  info,
2546
- geometry: {
2547
- x: boardFrameGeometry.x + info.boundingRect.x,
2548
- y: boardFrameGeometry.y + info.boundingRect.y,
2549
- width: info.boundingRect.width,
2550
- height: info.boundingRect.height,
2551
- },
2350
+ geometry: screenLocalRectToBoardGeometry(
2351
+ {
2352
+ left: info.boundingRect.x,
2353
+ top: info.boundingRect.y,
2354
+ width: info.boundingRect.width,
2355
+ height: info.boundingRect.height,
2356
+ },
2357
+ boardFrameGeometry,
2358
+ {
2359
+ width: boardFrameGeometry.width,
2360
+ height: boardFrameGeometry.height,
2361
+ },
2362
+ ),
2552
2363
  frameGeometry: boardFrameGeometry,
2553
2364
  }));
2554
2365
  })()
@@ -2667,10 +2478,31 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
2667
2478
  identityCoordinateScreenIds: boardFileId
2668
2479
  ? new Set([boardFileId])
2669
2480
  : undefined,
2481
+ // The board iframe is rendered before screen frames and is therefore
2482
+ // always behind them, even though it is appended to the hit-test
2483
+ // input above. Never let an overlapping board container steal a drop
2484
+ // from the visible screen under the pointer.
2485
+ backgroundScreenIds: boardFileId ? new Set([boardFileId]) : undefined,
2486
+ // Screen wrappers give the selected/active frame a paint-order boost.
2487
+ // Mirror that ordering in geometry hit testing so the highlighted
2488
+ // container is the one the user can actually see.
2489
+ foregroundScreenId:
2490
+ selectedIdsRef.current.find(
2491
+ (id) => frameGeometryRef.current[id] !== undefined,
2492
+ ) ??
2493
+ (activeId && frameGeometryRef.current[activeId]
2494
+ ? activeId
2495
+ : screensRef.current[0]?.id),
2670
2496
  },
2671
2497
  );
2672
2498
  },
2673
- [boardFileContent, boardFileId, boardFrameGeometry, getResolvedMetadata],
2499
+ [
2500
+ activeId,
2501
+ boardFileContent,
2502
+ boardFileId,
2503
+ boardFrameGeometry,
2504
+ getResolvedMetadata,
2505
+ ],
2674
2506
  );
2675
2507
 
2676
2508
  const resolvePrimitiveScreenId = useCallback(
@@ -2718,6 +2550,60 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
2718
2550
  let cancelled = false;
2719
2551
  const state = dragState.current;
2720
2552
 
2553
+ const restoreImperativeMoveDom = (
2554
+ originById: Record<string, FrameGeometry | DraftPrimitive>,
2555
+ targetIds: string[],
2556
+ kind: "frame" | "draft",
2557
+ ) => {
2558
+ const originGeometries: FrameGeometry[] = [];
2559
+ targetIds.forEach((targetId) => {
2560
+ const origin = originById[targetId];
2561
+ const geometry =
2562
+ origin && "geometry" in origin ? origin.geometry : origin;
2563
+ if (!geometry) return;
2564
+ originGeometries.push(geometry);
2565
+ const selector =
2566
+ kind === "frame"
2567
+ ? `[data-frame-id="${CSS.escape(targetId)}"]`
2568
+ : `[data-draft-id="${CSS.escape(targetId)}"]`;
2569
+ const element =
2570
+ surfaceRef.current?.querySelector<HTMLElement>(selector);
2571
+ if (!element) return;
2572
+ const labelHeight =
2573
+ kind === "frame"
2574
+ ? FRAME_LABEL_HEIGHT * chromeScaleFromZoom(zoomRef.current)
2575
+ : 0;
2576
+ const position = frameStyleLeftTop(geometry, labelHeight);
2577
+ element.style.left = `${position.left}px`;
2578
+ element.style.top = `${position.top}px`;
2579
+ });
2580
+
2581
+ const selectionBox = surfaceRef.current?.querySelector<HTMLElement>(
2582
+ "[data-frame-selection-box]",
2583
+ );
2584
+ if (!selectionBox || originGeometries.length === 0) return;
2585
+ const bounds =
2586
+ originGeometries.length === 1
2587
+ ? originGeometries[0]
2588
+ : (() => {
2589
+ const group = getFrameGroupBounds(
2590
+ originGeometries.map((geometry) => ({ id: "", geometry })),
2591
+ );
2592
+ return group
2593
+ ? {
2594
+ x: group.left,
2595
+ y: group.top,
2596
+ width: group.width,
2597
+ height: group.height,
2598
+ }
2599
+ : null;
2600
+ })();
2601
+ if (!bounds) return;
2602
+ const position = frameStyleLeftTop(bounds);
2603
+ selectionBox.style.left = `${position.left}px`;
2604
+ selectionBox.style.top = `${position.top}px`;
2605
+ };
2606
+
2721
2607
  if (state) {
2722
2608
  cancelled = true;
2723
2609
  if (
@@ -2725,6 +2611,17 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
2725
2611
  state.type === "resize" ||
2726
2612
  state.type === "group-rotate"
2727
2613
  ) {
2614
+ if (state.type === "move") {
2615
+ // Move drags mutate left/top directly while React deliberately keeps
2616
+ // its pre-drag props. A state rollback to those same values does not
2617
+ // make React rewrite the externally-mutated styles, so Escape must
2618
+ // restore the frame + selection-box DOM positions explicitly.
2619
+ restoreImperativeMoveDom(
2620
+ state.originFrames,
2621
+ state.targetIds,
2622
+ "frame",
2623
+ );
2624
+ }
2728
2625
  updateFrameGeometry((current) =>
2729
2626
  frameGeometryWithOverrides(current, state.originFrames),
2730
2627
  );
@@ -2734,12 +2631,29 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
2734
2631
  [state.frameId]: { ...state.originFrame },
2735
2632
  }));
2736
2633
  } else if (state.type === "draft-move" || state.type === "draft-resize") {
2634
+ if (state.type === "draft-move") {
2635
+ restoreImperativeMoveDom(
2636
+ state.originDrafts,
2637
+ state.targetIds,
2638
+ "draft",
2639
+ );
2640
+ }
2737
2641
  updateDraftPrimitives((current) =>
2738
2642
  current.map((draft) => {
2739
2643
  const origin = state.originDrafts[draft.id];
2740
2644
  return origin ? cloneDraftPrimitive(origin) : draft;
2741
2645
  }),
2742
2646
  );
2647
+ } else if (state.type === "pan") {
2648
+ // Mouse/space-pan follows the same imperative DOM path as wheel zoom:
2649
+ // React's `pan` state intentionally stays stale during the gesture.
2650
+ // Restore both sources of truth on Escape so the world does not remain
2651
+ // visually displaced until a later render (or get re-committed by the
2652
+ // already-scheduled settle timer).
2653
+ panRef.current = { ...state.originPan };
2654
+ setPan(panRef.current);
2655
+ applyViewToDomRef.current();
2656
+ recomputePenPointerForViewChangeRef.current();
2743
2657
  } else if (state.type === "marquee") {
2744
2658
  updateSelectedIds(() => state.baseSelectedIds);
2745
2659
  updateSelectedDraftIds(() => state.baseSelectedDraftIds);
@@ -2856,6 +2770,15 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
2856
2770
  const originCanvas = getCanvasPoint(e.clientX, e.clientY);
2857
2771
  let latestRect = normalizeRectFromPoints(originCanvas, originCanvas);
2858
2772
  let layerCandidates: CanvasLayerMarqueeCandidate[] = [];
2773
+ const marqueeState: MarqueeDragState = {
2774
+ type: "marquee",
2775
+ originClient: { x: e.clientX, y: e.clientY },
2776
+ originCanvas,
2777
+ baseSelectedIds: selectedIdsRef.current,
2778
+ baseSelectedDraftIds: selectedDraftIdsRef.current,
2779
+ additive: e.shiftKey,
2780
+ hasMoved: false,
2781
+ };
2859
2782
  // PF20: collecting selectable layer info for every screen on the board
2860
2783
  // requires one async postMessage round-trip per iframe. Doing that
2861
2784
  // eagerly for the whole board on marquee mousedown is wasted work for
@@ -2868,15 +2791,18 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
2868
2791
  const collectingScreenIds = new Set<string>();
2869
2792
  const reportLayerSelection = (rect: MarqueeRect) => {
2870
2793
  const state = dragState.current;
2871
- if (!state || state.type !== "marquee") return;
2872
- const chromeScale = chromeScaleFromZoom(zoomRef.current);
2794
+ // Async selectable-rect replies from a previous marquee can arrive
2795
+ // after a new marquee has already begun. Type alone is insufficient —
2796
+ // both gestures are `marquee`; require this exact gesture object so a
2797
+ // stale reply cannot flash/replace the new gesture's layer selection.
2798
+ if (state !== marqueeState) return;
2873
2799
  const selection = layerCandidates
2874
2800
  .filter((candidate) =>
2875
2801
  rotatedRectIntersects(
2876
2802
  rect,
2877
- getSelectableBounds(candidate.geometry, chromeScale),
2878
- getFrameCenter(candidate.frameGeometry),
2879
- candidate.frameGeometry.rotation ?? 0,
2803
+ getLayerSelectableBounds(candidate.geometry),
2804
+ getFrameCenter(candidate.geometry),
2805
+ candidate.geometry.rotation ?? 0,
2880
2806
  ),
2881
2807
  )
2882
2808
  .map((candidate) => ({
@@ -2912,20 +2838,12 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
2912
2838
  collectingScreenIds.delete(id);
2913
2839
  collectedScreenIds.add(id);
2914
2840
  });
2915
- if (dragState.current?.type !== "marquee") return;
2841
+ if (dragState.current !== marqueeState) return;
2916
2842
  layerCandidates = [...layerCandidates, ...candidates];
2917
2843
  reportLayerSelection(latestRect);
2918
2844
  });
2919
2845
  };
2920
- dragState.current = {
2921
- type: "marquee",
2922
- originClient: { x: e.clientX, y: e.clientY },
2923
- originCanvas,
2924
- baseSelectedIds: selectedIdsRef.current,
2925
- baseSelectedDraftIds: selectedDraftIdsRef.current,
2926
- additive: e.shiftKey,
2927
- hasMoved: false,
2928
- };
2846
+ dragState.current = marqueeState;
2929
2847
  setMarquee({ ...originCanvas, width: 0, height: 0 });
2930
2848
  if (!e.shiftKey) {
2931
2849
  updateSelectedIds(() => []);
@@ -3041,22 +2959,11 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
3041
2959
  const draftCenter = getFrameCenter(draft.geometry);
3042
2960
 
3043
2961
  // Primary: find the frame whose bounds contain the draft's center point.
3044
- const containing = entries
3045
- .filter(({ geometry }) => {
3046
- const bounds = {
3047
- left: geometry.x,
3048
- top: geometry.y,
3049
- right: geometry.x + geometry.width,
3050
- bottom: geometry.y + geometry.height,
3051
- };
3052
- const local = rotatePointAroundCenter(
3053
- draftCenter,
3054
- getFrameCenter(geometry),
3055
- geometry.rotation ?? 0,
3056
- );
3057
- return rectContainsPoint(bounds, local);
3058
- })
3059
- .sort((a, b) => (b.geometry.z ?? 0) - (a.geometry.z ?? 0))[0];
2962
+ // Reuse the same z + DOM-order resolver used by OS file drops. The old
2963
+ // z-only stable sort chose the *first* frame on ties, while the browser
2964
+ // paints the later sibling on top — so shapes could visibly land in one
2965
+ // screen but persist into the obscured screen underneath it.
2966
+ const containing = getFrameEntryAtPoint(draftCenter);
3060
2967
 
3061
2968
  if (containing) return containing;
3062
2969
 
@@ -3064,7 +2971,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
3064
2971
  hasBoardDrawHandler: Boolean(onBoardDrawPrimitiveRef.current),
3065
2972
  });
3066
2973
  },
3067
- [getCurrentFrameEntries],
2974
+ [getCurrentFrameEntries, getFrameEntryAtPoint],
3068
2975
  );
3069
2976
 
3070
2977
  const persistDraftPrimitive = useCallback(
@@ -3156,9 +3063,18 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
3156
3063
  );
3157
3064
  updateSelectedDraftIds(() => []);
3158
3065
  updateSelectedIds(() => []);
3159
- onPrimitiveCreated?.(persisted.frameId, persisted.nodeId, {
3160
- nextTool: options?.nextTool,
3161
- });
3066
+ // Board persistence is owned by onBoardDrawPrimitive; DesignEditor's
3067
+ // board handler already selects/activates the new node before returning.
3068
+ // Calling the generic callback again caused a duplicate selection/edit
3069
+ // transition. Ordinary screen inserts still need the callback.
3070
+ if (
3071
+ persisted.frameId !== boardFileId &&
3072
+ persisted.frameId !== "__board__"
3073
+ ) {
3074
+ onPrimitiveCreated?.(persisted.frameId, persisted.nodeId, {
3075
+ nextTool: options?.nextTool,
3076
+ });
3077
+ }
3162
3078
  return;
3163
3079
  }
3164
3080
 
@@ -3168,6 +3084,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
3168
3084
  },
3169
3085
  [
3170
3086
  persistDraftPrimitive,
3087
+ boardFileId,
3171
3088
  onPrimitiveCreated,
3172
3089
  updateDraftPrimitives,
3173
3090
  updateSelectedDraftIds,
@@ -3959,6 +3876,12 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
3959
3876
  state.hasMoved = true;
3960
3877
  }
3961
3878
 
3879
+ // Match frame drags: pointer jitter below the drag threshold is still
3880
+ // a click. Draft moves use direct DOM mutation, so applying even a
3881
+ // 1px delta here leaves a phantom visual nudge that React does not know
3882
+ // it needs to overwrite on mouseup.
3883
+ if (!state.hasMoved) return;
3884
+
3962
3885
  // Shift held mid-move (not at mousedown — that path is shift-click
3963
3886
  // multi-select and never reaches here, see the guard in
3964
3887
  // beginDraftDrag above) locks movement to a single axis, matching
@@ -4111,8 +4034,25 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
4111
4034
  placement: "inside",
4112
4035
  });
4113
4036
  });
4114
- const lastNodeId = persisted[persisted.length - 1]?.nodeId;
4115
- if (lastNodeId) updateSelectedIds(() => [lastNodeId]);
4037
+ const lastPersisted = persisted[persisted.length - 1];
4038
+ if (lastPersisted) {
4039
+ updateSelectedIds(() =>
4040
+ screensRef.current.some(
4041
+ (screen) => screen.id === lastPersisted.frameId,
4042
+ )
4043
+ ? [lastPersisted.frameId]
4044
+ : [],
4045
+ );
4046
+ if (
4047
+ lastPersisted.frameId !== boardFileId &&
4048
+ lastPersisted.frameId !== "__board__"
4049
+ ) {
4050
+ onPrimitiveCreated?.(
4051
+ lastPersisted.frameId,
4052
+ lastPersisted.nodeId,
4053
+ );
4054
+ }
4055
+ }
4116
4056
  }
4117
4057
  } else {
4118
4058
  // Normal drop: persist into whichever screen contains the draft.
@@ -4143,8 +4083,25 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
4143
4083
  updateSelectedDraftIds((current) =>
4144
4084
  current.filter((draftId) => !persistedDraftIds.has(draftId)),
4145
4085
  );
4146
- const lastNodeId = persisted[persisted.length - 1]?.nodeId;
4147
- if (lastNodeId) updateSelectedIds(() => [lastNodeId]);
4086
+ const lastPersisted = persisted[persisted.length - 1];
4087
+ if (lastPersisted) {
4088
+ updateSelectedIds(() =>
4089
+ screensRef.current.some(
4090
+ (screen) => screen.id === lastPersisted.frameId,
4091
+ )
4092
+ ? [lastPersisted.frameId]
4093
+ : [],
4094
+ );
4095
+ if (
4096
+ lastPersisted.frameId !== boardFileId &&
4097
+ lastPersisted.frameId !== "__board__"
4098
+ ) {
4099
+ onPrimitiveCreated?.(
4100
+ lastPersisted.frameId,
4101
+ lastPersisted.nodeId,
4102
+ );
4103
+ }
4104
+ }
4148
4105
  }
4149
4106
  }
4150
4107
  }
@@ -4159,6 +4116,8 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
4159
4116
  getCanvasPoint,
4160
4117
  getCurrentCanvasEntries,
4161
4118
  installDragListeners,
4119
+ boardFileId,
4120
+ onPrimitiveCreated,
4162
4121
  persistDraftPrimitive,
4163
4122
  updateDraftPrimitives,
4164
4123
  updateDraftPrimitivesRefOnly,
@@ -4222,6 +4181,10 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
4222
4181
  state.hasMoved = true;
4223
4182
  }
4224
4183
 
4184
+ // A resize handle click (or normal 1-2px hand jitter) must not resize
4185
+ // the draft. Frame resize already enforces this same threshold.
4186
+ if (!state.hasMoved) return;
4187
+
4225
4188
  const originEntries = state.targetIds.map((targetId) => ({
4226
4189
  id: targetId,
4227
4190
  geometry: state.originDrafts[targetId].geometry,
@@ -5305,6 +5268,13 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
5305
5268
  const target = e.target as HTMLElement;
5306
5269
  const onFrame = !!target.closest("[data-frame-shell]");
5307
5270
  const tool = normalizeCanvasTool(activeTool ?? localActiveTool);
5271
+ // Middle-button and hand-tool pan are global canvas gestures, including
5272
+ // while vector editing. The old ordering returned early from vectorEdit
5273
+ // for non-left clicks, silently disabling Figma's middle-mouse pan.
5274
+ if (shouldBeginCanvasPan({ button: e.button, tool })) {
5275
+ beginPan(e);
5276
+ return;
5277
+ }
5308
5278
  if (vectorEdit) {
5309
5279
  if (e.button !== 0) return;
5310
5280
  e.preventDefault();
@@ -5351,14 +5321,6 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
5351
5321
  vectorEdit.onExit();
5352
5322
  return;
5353
5323
  }
5354
- if (e.button === 1) {
5355
- beginPan(e);
5356
- return;
5357
- }
5358
- if (e.button === 0 && tool === "hand") {
5359
- beginPan(e);
5360
- return;
5361
- }
5362
5324
  if (e.button === 0 && tool === "pen") {
5363
5325
  beginPenNodeCreation(e);
5364
5326
  return;
@@ -5432,9 +5394,15 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
5432
5394
  ? hoveredDraft
5433
5395
  : null,
5434
5396
  ].filter((entry): entry is NonNullable<typeof entry> => !!entry);
5435
- const hovered = hoveredCandidates.sort(
5436
- (a, b) => (b.geometry.z ?? 0) - (a.geometry.z ?? 0),
5437
- )[0];
5397
+ const hovered = hoveredCandidates
5398
+ .map((entry, index) => ({ entry, index }))
5399
+ .sort(
5400
+ (a, b) =>
5401
+ (b.entry.geometry.z ?? 0) - (a.entry.geometry.z ?? 0) ||
5402
+ // Drafts render after screen frames inside the world container, so
5403
+ // they win an equal-z overlap just as they do visually.
5404
+ b.index - a.index,
5405
+ )[0]?.entry;
5438
5406
 
5439
5407
  if (!hovered) {
5440
5408
  setAltHoverMeasurement(null);
@@ -5529,7 +5497,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
5529
5497
  // only rewrites style properties it believes changed).
5530
5498
  if (wheelGestureActiveRef.current) {
5531
5499
  wheelGestureActiveRef.current = false;
5532
- wheelCameraGestureActive = false;
5500
+ setWheelCameraGestureActive(false);
5533
5501
  const muted = wheelGestureMutedElementsRef.current;
5534
5502
  wheelGestureMutedElementsRef.current = null;
5535
5503
  if (muted) {
@@ -5609,7 +5577,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
5609
5577
  const markWheelGestureActive = useCallback(() => {
5610
5578
  if (wheelGestureActiveRef.current) return;
5611
5579
  wheelGestureActiveRef.current = true;
5612
- wheelCameraGestureActive = true;
5580
+ setWheelCameraGestureActive(true);
5613
5581
  const surface = surfaceRef.current;
5614
5582
  if (!surface) return;
5615
5583
  // Mute the same layers a drag's canvasGestureActive gates via props:
@@ -6150,9 +6118,18 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
6150
6118
  ? closePenPath(activePenPath)
6151
6119
  : appendPenNode(activePenPath, createCornerNode(penPointer))
6152
6120
  : activePenPath;
6153
- const selectedIdSet = new Set(selectedIds);
6154
- const fullViewIdSet = new Set(fullViewScreenIds ?? []);
6155
- const selectedDraftIdSet = new Set(selectedDraftIds);
6121
+ // These sets feed culling and every screen/draft render. Keeping their
6122
+ // identities stable avoids invalidating screenCullTierById (and therefore
6123
+ // the full screen-content cache walk) on unrelated hover/chrome renders.
6124
+ const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]);
6125
+ const fullViewIdSet = useMemo(
6126
+ () => new Set(fullViewScreenIds ?? []),
6127
+ [fullViewScreenIds],
6128
+ );
6129
+ const selectedDraftIdSet = useMemo(
6130
+ () => new Set(selectedDraftIds),
6131
+ [selectedDraftIds],
6132
+ );
6156
6133
  const surfaceCursor = isPanning
6157
6134
  ? "grabbing"
6158
6135
  : dragCursor
@@ -6213,45 +6190,77 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
6213
6190
  for (const id of cache.keys()) {
6214
6191
  if (!nextIds.has(id)) cache.delete(id);
6215
6192
  }
6193
+ pruneResolvedMetadataCache(resolvedMetadataCacheRef.current, nextIds);
6216
6194
  return next;
6217
6195
  }, [frameGeometry, getResolvedMetadata, screens]);
6218
- // Overview viewport culling (PF22): resolve each screen's cull tier from
6219
- // canvasFrames' geometry against the overscanned viewport computed from
6220
- // *committed* pan/canvasZoom + the measured surface size never the
6221
- // imperative per-gesture zoomRef/panRef (see the culling helpers' module
6222
- // doc for why). activeId and the current selection are always "visible"
6223
- // regardless of position (mission requirement — matches Figma never
6224
- // culling the object you're actively editing/have selected).
6225
- //
6226
- // hasBeenVisibleScreenIdsRef is intentionally mutated inside this useMemo,
6227
- // mirroring the established canvasFrameEntryCacheRef/screenContentCacheRef
6228
- // pattern in this file: a plain ref-backed cache read-and-written together
6229
- // with the memoized computation it backs, not React state, since "a screen
6230
- // just became visible" shouldn't itself force an extra render beyond the
6231
- // one already happening from the pan/zoom/selection change that made it
6232
- // visible.
6196
+ // Interaction-protected screens are never eligible for LRU eviction. Active
6197
+ // screen/frame selection are the primary signals; layer selection, native
6198
+ // file-drop targeting, gradient editing, and in-flight frame transforms are
6199
+ // included too so an editor gesture cannot lose its iframe mid-operation.
6200
+ const protectedLiveScreenIds = useMemo(() => {
6201
+ const protectedIds = new Set(selectedIdSet);
6202
+ if (activeId) protectedIds.add(activeId);
6203
+ if (fileDragOverFrameId) protectedIds.add(fileDragOverFrameId);
6204
+ if (gradientEditTarget) {
6205
+ protectedIds.add(gradientEditTarget.frameOrDraftId);
6206
+ }
6207
+ for (const [screenId, selectorGroups] of Object.entries(
6208
+ selectedLayerSelectorGroupsByScreen,
6209
+ )) {
6210
+ if (selectorGroups?.length > 0) protectedIds.add(screenId);
6211
+ }
6212
+ if (isDragging) {
6213
+ const activeDrag = dragState.current;
6214
+ if (activeDrag?.type === "move" || activeDrag?.type === "resize") {
6215
+ activeDrag.targetIds.forEach((id) => protectedIds.add(id));
6216
+ } else if (activeDrag?.type === "rotate") {
6217
+ protectedIds.add(activeDrag.frameId);
6218
+ } else if (activeDrag?.type === "group-rotate") {
6219
+ activeDrag.targetIds.forEach((id) => protectedIds.add(id));
6220
+ }
6221
+ }
6222
+ const crossScreenTargetId = crossScreenTargetRef.current?.id;
6223
+ if (crossScreenGhost && crossScreenTargetId) {
6224
+ protectedIds.add(crossScreenTargetId);
6225
+ }
6226
+ return protectedIds;
6227
+ }, [
6228
+ activeId,
6229
+ crossScreenGhost,
6230
+ fileDragOverFrameId,
6231
+ gradientEditTarget,
6232
+ isDragging,
6233
+ selectedIdSet,
6234
+ selectedLayerSelectorGroupsByScreen,
6235
+ ]);
6236
+
6237
+ // Resolve a bounded live-context allocation from the committed camera. The
6238
+ // overscan still makes imminent pan destinations live early; unlike the old
6239
+ // one-way hasBeenVisible Set, however, the LRU pool cannot grow forever.
6233
6240
  const screenCullTierById = useMemo(() => {
6234
6241
  const viewport = getOverscannedViewportCanvasBounds(
6235
6242
  surfaceSize,
6236
6243
  pan,
6237
6244
  canvasZoom,
6238
6245
  );
6239
- const hasBeenVisible = hasBeenVisibleScreenIdsRef.current;
6240
- const next = new Map<string, ScreenCullTier>();
6241
- canvasFrames.forEach(({ screen, geometry }) => {
6242
- const alwaysVisible =
6243
- screen.id === activeId || selectedIdSet.has(screen.id);
6244
- const tier = computeScreenCullTier({
6246
+ const next = computeBoundedScreenCullState({
6247
+ candidates: canvasFrames.map(({ screen, geometry }) => ({
6248
+ id: screen.id,
6245
6249
  geometry,
6246
- viewport,
6247
- alwaysVisible,
6248
- hasBeenVisible: hasBeenVisible.has(screen.id),
6249
- });
6250
- if (tier === "visible") hasBeenVisible.add(screen.id);
6251
- next.set(screen.id, tier);
6250
+ iframeCount: 1 + (screen.breakpointWidths?.length ?? 0),
6251
+ })),
6252
+ viewport,
6253
+ protectedScreenIds: protectedLiveScreenIds,
6254
+ previousLiveScreenIds: liveScreenIdsRef.current,
6255
+ everVisibleScreenIds: hasBeenVisibleScreenIdsRef.current,
6256
+ lastVisibleEpochByScreenId: lastVisibleEpochByScreenIdRef.current,
6257
+ accessEpoch: ++cullAccessEpochRef.current,
6252
6258
  });
6253
- return next;
6254
- }, [activeId, canvasFrames, canvasZoom, pan, selectedIdSet, surfaceSize]);
6259
+ liveScreenIdsRef.current = next.liveScreenIds;
6260
+ hasBeenVisibleScreenIdsRef.current = next.everVisibleScreenIds;
6261
+ lastVisibleEpochByScreenIdRef.current = next.lastVisibleEpochByScreenId;
6262
+ return next.tierByScreenId;
6263
+ }, [canvasFrames, canvasZoom, pan, protectedLiveScreenIds, surfaceSize]);
6255
6264
  const topScreenId = useMemo(
6256
6265
  () =>
6257
6266
  selectedIds.find((id) =>
@@ -6300,7 +6309,6 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
6300
6309
  const screenContentById = useMemo(() => {
6301
6310
  if (!renderScreenContent) return new Map<string, ReactNode>();
6302
6311
  const cache = screenContentCacheRef.current;
6303
- const nextIds = new Set<string>();
6304
6312
  const next = new Map<string, ReactNode>();
6305
6313
  canvasFrames.forEach(({ screen, metadata, geometry }) => {
6306
6314
  // Overview viewport culling (PF22): a screen that has never been
@@ -6308,20 +6316,11 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
6308
6316
  // entirely instead of mounting the real (DesignEditor-provided)
6309
6317
  // content node and hiding it — for boards with 100+ screens this is
6310
6318
  // the actual cost renderScreenContent exists to avoid paying (it
6311
- // mounts a live DesignCanvas/iframe per screen). Once a screen has
6312
- // ever been visible ("visible" or "culled" tier) it keeps getting a
6313
- // real content node forever after, per the "never unmount a live
6314
- // iframe" rule — see Screen's contentVisibility handling below for how
6315
- // a "culled" tier screen's mounted iframe is cheaply hidden instead.
6319
+ // mounts a live DesignCanvas/iframe per screen). Evicted screens skip
6320
+ // mounting too, but their cache entry remains so a revisit can reuse the
6321
+ // already-created React node without regenerating screen content.
6316
6322
  const tier = screenCullTierById.get(screen.id) ?? "visible";
6317
- if (tier === "placeholder") {
6318
- // Also drop any stale cache entry so a screen that regresses to
6319
- // never-rendered (shouldn't normally happen, but keeps the cache
6320
- // honest) doesn't resurrect a stale node later.
6321
- cache.delete(screen.id);
6322
- return;
6323
- }
6324
- nextIds.add(screen.id);
6323
+ if (tier === "placeholder" || tier === "evicted") return;
6325
6324
  next.set(
6326
6325
  screen.id,
6327
6326
  getCachedScreenContentNode(
@@ -6333,9 +6332,10 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
6333
6332
  ),
6334
6333
  );
6335
6334
  });
6336
- for (const id of cache.keys()) {
6337
- if (!nextIds.has(id)) cache.delete(id);
6338
- }
6335
+ pruneScreenContentCache(
6336
+ cache,
6337
+ new Set(canvasFrames.map(({ screen }) => screen.id)),
6338
+ );
6339
6339
  return next;
6340
6340
  }, [canvasFrames, renderScreenContent, screenCullTierById]);
6341
6341
  // PF19: filters/maps over canvasFrames + a getFrameGroupBounds pass — cheap
@@ -6917,6 +6917,9 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
6917
6917
  (SURFACE_PADDING + primitiveDropTarget.boardRect.y) * scale,
6918
6918
  width: Math.max(1, primitiveDropTarget.boardRect.width * scale),
6919
6919
  height: Math.max(1, primitiveDropTarget.boardRect.height * scale),
6920
+ transform: primitiveDropTarget.boardRect.rotation
6921
+ ? `rotate(${primitiveDropTarget.boardRect.rotation}deg)`
6922
+ : undefined,
6920
6923
  // Match the in-screen inside-guide style: 2px accent border + 14%
6921
6924
  // accent fill. Uses the same CSS variable as the DesignCanvas guide.
6922
6925
  border: "2px solid var(--design-editor-accent-color)",
@@ -7798,63 +7801,6 @@ function nextBreakpointWidth(existing: number[]): number | undefined {
7798
7801
  * (which always returns the first DOM match) can't silently collide two
7799
7802
  * different iframes onto the same id.
7800
7803
  */
7801
- export function getPrimaryIframeId(screenId: string): string {
7802
- return screenId;
7803
- }
7804
-
7805
- /** The `data-screen-iframe-id` value for one specific breakpoint sub-frame. */
7806
- export function getBreakpointIframeId(
7807
- screenId: string,
7808
- widthPx: number,
7809
- ): string {
7810
- return `${screenId}::bp-${widthPx}`;
7811
- }
7812
-
7813
- /**
7814
- * BP-DEEP v2 item 3 — whether the selection chrome for a selected screen
7815
- * belongs on one of its BREAKPOINT sub-frames instead of the base frame:
7816
- * true when the screen's active breakpoint width is set AND still exists in
7817
- * its breakpoint set (a stale width from a removed breakpoint falls back to
7818
- * the base frame carrying selection as usual). While true, the base frame
7819
- * suppresses its own selected chrome (label emphasis + SelectionBox corner
7820
- * handles) so exactly ONE frame — the active edit target — reads as
7821
- * selected. Pure/exported for unit tests.
7822
- */
7823
- export function isBreakpointSelectionTarget(screen: {
7824
- breakpointWidths?: number[];
7825
- activeBreakpointWidth?: number;
7826
- }): boolean {
7827
- return (
7828
- screen.activeBreakpointWidth != null &&
7829
- (screen.breakpointWidths ?? []).includes(screen.activeBreakpointWidth)
7830
- );
7831
- }
7832
-
7833
- /**
7834
- * Resolves which iframe DOM id actually represents `screen` right now for
7835
- * hit-test / drag / wheel bridge lookups: the currently active breakpoint
7836
- * sub-frame if one is selected, otherwise the primary iframe. Every
7837
- * `querySelector('[data-screen-iframe-id="…"]')` call site that resolves a
7838
- * screen id to a live iframe must go through this so an active breakpoint
7839
- * scope actually targets the frame the user is looking at, instead of
7840
- * always silently resolving to the primary frame regardless of which one is
7841
- * selected.
7842
- */
7843
- export function getActiveScreenIframeId(screen: {
7844
- id: string;
7845
- activeBreakpointWidth?: number;
7846
- breakpointWidths?: number[];
7847
- }): string {
7848
- const activeWidth = screen.activeBreakpointWidth;
7849
- if (
7850
- activeWidth !== undefined &&
7851
- screen.breakpointWidths?.includes(activeWidth)
7852
- ) {
7853
- return getBreakpointIframeId(screen.id, activeWidth);
7854
- }
7855
- return getPrimaryIframeId(screen.id);
7856
- }
7857
-
7858
7804
  interface ScreenProps {
7859
7805
  screen: ScreenFile;
7860
7806
  metadata: ResolvedScreenMetadata;
@@ -7879,9 +7825,9 @@ interface ScreenProps {
7879
7825
  * "visible": render screenContent normally. "culled": screenContent (if
7880
7826
  * any) stays mounted but is hidden from paint (visibility/
7881
7827
  * content-visibility, never display:none/unmount — iframes lose all
7882
- * internal state on unmount). "placeholder": no screenContent was even
7883
- * computed for this screen (see screenContentById) render a
7884
- * lightweight chrome-only placeholder instead of an iframe. */
7828
+ * internal state on unmount). "evicted" and "placeholder": no mounted
7829
+ * browsing context; render lightweight chrome while the cached content
7830
+ * descriptor is retained for an evicted screen's revisit. */
7885
7831
  cullTier: ScreenCullTier;
7886
7832
  onPick: (id: string, e: React.MouseEvent<HTMLElement>) => void;
7887
7833
  onEdit: (id: string, e: React.MouseEvent<HTMLElement>) => void;
@@ -7965,10 +7911,11 @@ const Screen = memo(function Screen({
7965
7911
  // display:none (can drop layout/scroll state on some engines) and NOT
7966
7912
  // will-change (a prior perf attempt using will-change on this same overview
7967
7913
  // caused permanent blur by pinning a low-res compositor layer — see the
7968
- // world-transform comment near applyViewToDom). "placeholder" tier never
7969
- // had content computed for it at all (see screenContentById), so it always
7970
- // renders the chrome-only placeholder below regardless of this flag.
7971
- const isCulled = cullTier === "culled";
7914
+ // world-transform comment near applyViewToDom). "placeholder" and
7915
+ // "evicted" tiers have no mounted browsing context, so they render the
7916
+ // chrome-only placeholder below regardless of this flag.
7917
+ const { shouldMount: shouldMountContent, isHidden: isCulled } =
7918
+ getScreenContentCullState(cullTier);
7972
7919
  const [directlyHovered, setDirectlyHovered] = useState(false);
7973
7920
  const frameDirectlyHovered =
7974
7921
  (directlyHovered || isDirectlyHovered) &&
@@ -8268,14 +8215,12 @@ const Screen = memo(function Screen({
8268
8215
  contentVisibility: isCulled ? "hidden" : undefined,
8269
8216
  }}
8270
8217
  >
8271
- {cullTier === "placeholder" ? (
8272
- // Tier A (PF22): this screen has never been visible this
8273
- // session, so no iframe/DesignCanvas was ever mounted for it
8274
- // (see screenContentById) render inert chrome-only filler that
8275
- // keeps the same wrapper geometry as real content so selection,
8276
- // drag, marquee, and measurement math (all geometry-based, see
8277
- // computeScreenCullTier's callers) stay unaffected. Deliberately
8278
- // no iframe here at all, not even a hidden one.
8218
+ {!shouldMountContent ? (
8219
+ // Tier A/evicted (PF22 + bounded follow-up): no live browsing
8220
+ // context is mounted. Render inert chrome-only filler that keeps
8221
+ // the same wrapper geometry as real content so selection, drag,
8222
+ // marquee, and measurement math stay unaffected. The content
8223
+ // descriptor remains cached for direct revisit restoration.
8279
8224
  <div
8280
8225
  data-screen-placeholder
8281
8226
  aria-hidden="true"
@@ -8292,7 +8237,10 @@ const Screen = memo(function Screen({
8292
8237
  src={previewUrl}
8293
8238
  srcDoc={previewUrl ? undefined : srcdocWithHitTest}
8294
8239
  sandbox="allow-scripts"
8295
- loading="lazy"
8240
+ // Visible includes the generous overscan band, so eager load
8241
+ // here prewarms the document before it crosses the raw
8242
+ // viewport edge. Warm hidden iframes are already loaded.
8243
+ loading={cullTier === "visible" ? "eager" : "lazy"}
8296
8244
  className="pointer-events-none border-0"
8297
8245
  style={{
8298
8246
  width: previewViewport.viewportWidth,
@@ -8392,6 +8340,7 @@ const Screen = memo(function Screen({
8392
8340
  isScreenSelected={isSelected}
8393
8341
  penActive={penActive}
8394
8342
  creationToolActive={creationToolActive}
8343
+ cullTier={cullTier}
8395
8344
  chromeScale={chromeScale}
8396
8345
  chromeSettling={chromeSettling}
8397
8346
  onPick={onPick}
@@ -8501,34 +8450,6 @@ const BREAKPOINT_FRAME_GAP = 24;
8501
8450
  * frame box height is therefore derived from the breakpoint's OWN natural
8502
8451
  * size, never forced to equal the primary frame's height.
8503
8452
  */
8504
- export function getBreakpointFrameGeometry(args: {
8505
- widthPx: number;
8506
- naturalAspect: number;
8507
- primaryScale: number;
8508
- }): {
8509
- /** On-canvas frame box width, in canvas px. */
8510
- frameWidth: number;
8511
- /** On-canvas frame box height, in canvas px — the iframe's own natural
8512
- * aspect at `widthPx`, uniformly scaled; NOT forced to the primary's
8513
- * height. */
8514
- frameHeight: number;
8515
- /** The iframe's own intrinsic (pre-scale) height at `widthPx`. */
8516
- naturalHeight: number;
8517
- /** Uniform scale factor applied to BOTH iframe axes (never distorts). */
8518
- scale: number;
8519
- } {
8520
- const scale =
8521
- Number.isFinite(args.primaryScale) && args.primaryScale > 0
8522
- ? args.primaryScale
8523
- : 1;
8524
- const naturalHeight = Math.round(
8525
- args.widthPx * Math.max(0.01, args.naturalAspect),
8526
- );
8527
- const frameWidth = Math.round(args.widthPx * scale);
8528
- const frameHeight = Math.round(naturalHeight * scale);
8529
- return { frameWidth, frameHeight, naturalHeight, scale };
8530
- }
8531
-
8532
8453
  function BreakpointPreviewRow({
8533
8454
  screen,
8534
8455
  primaryGeometry,
@@ -8540,6 +8461,7 @@ function BreakpointPreviewRow({
8540
8461
  isScreenSelected,
8541
8462
  penActive,
8542
8463
  creationToolActive,
8464
+ cullTier,
8543
8465
  chromeScale,
8544
8466
  chromeSettling,
8545
8467
  onPick,
@@ -8578,6 +8500,10 @@ function BreakpointPreviewRow({
8578
8500
  isScreenSelected: boolean;
8579
8501
  penActive: boolean;
8580
8502
  creationToolActive: boolean;
8503
+ /** Uses the owning screen's exact culling lifecycle: never-seen/evicted
8504
+ * previews stay iframe-free, warm offscreen previews remain mounted but
8505
+ * hidden, and visible previews render normally. */
8506
+ cullTier: ScreenCullTier;
8581
8507
  chromeScale: number;
8582
8508
  chromeSettling: boolean;
8583
8509
  /** BP-DEEP item 5 — click-to-target: picking a breakpoint frame picks its
@@ -8620,6 +8546,8 @@ function BreakpointPreviewRow({
8620
8546
  // draft value of its width input.
8621
8547
  const [menuOpenForWidth, setMenuOpenForWidth] = useState<number | null>(null);
8622
8548
  const [widthDraft, setWidthDraft] = useState("");
8549
+ const { shouldMount: shouldMountContent, isHidden: isCulled } =
8550
+ getScreenContentCullState(cullTier);
8623
8551
 
8624
8552
  return (
8625
8553
  <>
@@ -8905,45 +8833,62 @@ function BreakpointPreviewRow({
8905
8833
  ) : null}
8906
8834
  <span
8907
8835
  data-screen-content
8836
+ data-cull-tier={cullTier}
8908
8837
  className="relative block h-full w-full overflow-hidden rounded-[inherit] bg-white shadow-2xl ring-1 ring-inset ring-border"
8838
+ style={{
8839
+ visibility: isCulled ? "hidden" : undefined,
8840
+ contentVisibility: isCulled ? "hidden" : undefined,
8841
+ }}
8909
8842
  >
8910
- <iframe
8911
- // Distinct id per breakpoint sub-frame — the primary iframe
8912
- // above uses the bare screen id, so without a suffix here
8913
- // every breakpoint sub-frame's iframe would share the exact
8914
- // same [data-screen-iframe-id] as the primary, and
8915
- // querySelector (which returns only the first DOM match)
8916
- // would always resolve hit-test/drag/wheel bridge lookups to
8917
- // the primary frame regardless of which breakpoint is
8918
- // active. getActiveScreenIframeId resolves the correct one
8919
- // to query at lookup time.
8920
- data-screen-iframe-id={getBreakpointIframeId(
8921
- screen.id,
8922
- widthPx,
8923
- )}
8924
- src={previewUrl}
8925
- srcDoc={previewUrl ? undefined : srcdocWithHitTest}
8926
- sandbox="allow-scripts"
8927
- loading="lazy"
8928
- className="pointer-events-none border-0"
8929
- style={{
8930
- width: widthPx,
8931
- height: naturalHeight,
8932
- // BP-DEEP item 3a: a SINGLE uniform factor on both axes —
8933
- // the iframe lays out at its own real width (so CSS media
8934
- // queries recompute exactly like a real narrower
8935
- // viewport), then that already-correct box is scaled down
8936
- // uniformly to fit the canvas, never stretched
8937
- // non-uniformly to match the primary frame's height.
8938
- transform: scale === 1 ? undefined : `scale(${scale})`,
8939
- transformOrigin: "top left",
8940
- backgroundColor: "white",
8941
- colorScheme: "light",
8942
- backfaceVisibility: "hidden",
8943
- }}
8944
- title={`${screen.filename} ${breakpointLabel(widthPx)}`}
8945
- />
8946
- {creationToolActive || penActive ? (
8843
+ {!shouldMountContent ? (
8844
+ <div
8845
+ data-breakpoint-placeholder
8846
+ aria-hidden="true"
8847
+ className="flex h-full w-full items-center justify-center bg-muted/40 text-muted-foreground"
8848
+ >
8849
+ <span className="max-w-[80%] truncate px-2 text-center !text-[11px] font-medium">
8850
+ {breakpointLabel(widthPx)}
8851
+ </span>
8852
+ </div>
8853
+ ) : (
8854
+ <iframe
8855
+ // Distinct id per breakpoint sub-frame — the primary iframe
8856
+ // above uses the bare screen id, so without a suffix here
8857
+ // every breakpoint sub-frame's iframe would share the exact
8858
+ // same [data-screen-iframe-id] as the primary, and
8859
+ // querySelector (which returns only the first DOM match)
8860
+ // would always resolve hit-test/drag/wheel bridge lookups to
8861
+ // the primary frame regardless of which breakpoint is
8862
+ // active. getActiveScreenIframeId resolves the correct one
8863
+ // to query at lookup time.
8864
+ data-screen-iframe-id={getBreakpointIframeId(
8865
+ screen.id,
8866
+ widthPx,
8867
+ )}
8868
+ src={previewUrl}
8869
+ srcDoc={previewUrl ? undefined : srcdocWithHitTest}
8870
+ sandbox="allow-scripts"
8871
+ loading={cullTier === "visible" ? "eager" : "lazy"}
8872
+ className="pointer-events-none border-0"
8873
+ style={{
8874
+ width: widthPx,
8875
+ height: naturalHeight,
8876
+ // BP-DEEP item 3a: a SINGLE uniform factor on both axes —
8877
+ // the iframe lays out at its own real width (so CSS media
8878
+ // queries recompute exactly like a real narrower
8879
+ // viewport), then that already-correct box is scaled down
8880
+ // uniformly to fit the canvas, never stretched
8881
+ // non-uniformly to match the primary frame's height.
8882
+ transform: scale === 1 ? undefined : `scale(${scale})`,
8883
+ transformOrigin: "top left",
8884
+ backgroundColor: "white",
8885
+ colorScheme: "light",
8886
+ backfaceVisibility: "hidden",
8887
+ }}
8888
+ title={`${screen.filename} — ${breakpointLabel(widthPx)}`}
8889
+ />
8890
+ )}
8891
+ {shouldMountContent && (creationToolActive || penActive) ? (
8947
8892
  <span className="absolute inset-0 z-20 cursor-crosshair" />
8948
8893
  ) : null}
8949
8894
  </span>
@@ -9430,178 +9375,6 @@ function rotateHandleStyle(
9430
9375
  };
9431
9376
  }
9432
9377
 
9433
- interface FrameEntry {
9434
- id: string;
9435
- geometry: FrameGeometry;
9436
- }
9437
-
9438
- interface BoundsRect {
9439
- left: number;
9440
- top: number;
9441
- right: number;
9442
- bottom: number;
9443
- }
9444
-
9445
- export interface ScreenViewportSize {
9446
- width: number;
9447
- height: number;
9448
- }
9449
-
9450
- export function getInitialFrameGeometry(
9451
- index: number,
9452
- metadata?: ScreenViewportSize,
9453
- ): FrameGeometry {
9454
- // Seed default frames with the actual screen dimensions and the 3-column grid
9455
- // the overview centering math (which uses SCREEN_WIDTH/SCREEN_GAP) expects, so
9456
- // a design without persisted geometry opens centered. (The larger
9457
- // assigned-region grid is only for the agent's generation planning, not the
9458
- // editor's default placement.)
9459
- const column = index % 3;
9460
- const row = Math.floor(index / 3);
9461
- const height = getOverviewFrameHeight(SCREEN_WIDTH, metadata);
9462
- return {
9463
- x: column * (SCREEN_WIDTH + SCREEN_GAP),
9464
- y: row * (height + FRAME_LABEL_HEIGHT + SCREEN_GAP),
9465
- width: SCREEN_WIDTH,
9466
- height,
9467
- };
9468
- }
9469
-
9470
- function getOverviewFrameHeight(width: number, metadata?: ScreenViewportSize) {
9471
- const sourceWidth =
9472
- metadata?.width && metadata.width > 0 ? metadata.width : 1280;
9473
- const sourceHeight =
9474
- metadata?.height && metadata.height > 0 ? metadata.height : 2560;
9475
- return Math.max(80, Math.round((width * sourceHeight) / sourceWidth));
9476
- }
9477
-
9478
- /**
9479
- * B5-9: resolves the `screens`/`geometryById` prop-sync effect's per-screen
9480
- * frame geometry AND decides whether the result is worth notifying the
9481
- * parent about (`onGeometryChange`).
9482
- *
9483
- * A brand-new screen (duplicate or frame-tool create) appears in `screens`
9484
- * as soon as its file row exists, but its REAL geometry (correct width/
9485
- * height copied from the source, or drawn dimensions) is written to the
9486
- * server by the caller (e.g. DesignEditor's handleDuplicateScreen) and only
9487
- * round-trips back into `geometryById` a beat later. In that gap, treating
9488
- * "no existing local entry yet" as a real geometry change and notifying the
9489
- * parent with the disposable `getInitialFrameGeometry()` fallback (hardcoded
9490
- * SCREEN_WIDTH/height) is wrong: `onGeometryChange` and the caller's own
9491
- * save share one debounced timer, so this fallback-driven notify can fire
9492
- * AFTER and silently overwrite the caller's correct pending write —
9493
- * permanently persisting the wrong (narrower, default-positioned) geometry.
9494
- *
9495
- * So: `shouldNotifyParent` is true only when a screen's PERSISTED geometry
9496
- * actually differs from what's locally known (a real external change worth
9497
- * syncing back — e.g. a resize/move that arrived through the prop), or when
9498
- * a screen was removed. A screen adopting its local-only fallback for the
9499
- * first time (`existing` undefined, `persisted` undefined) sets `changed`
9500
- * so the render-local state still updates, but must NOT set
9501
- * `shouldNotifyParent` — the caller should apply that case via a ref-only
9502
- * update instead of the notifying path.
9503
- *
9504
- * Exported for unit testing.
9505
- */
9506
- export function resolveFrameGeometrySync(args: {
9507
- screens: ReadonlyArray<{ id: string; metadata?: ScreenViewportSize }>;
9508
- currentGeometryById: FrameGeometryById;
9509
- persistedGeometryById:
9510
- | Record<string, Partial<FrameGeometry> | undefined>
9511
- | undefined;
9512
- }): {
9513
- next: FrameGeometryById;
9514
- changed: boolean;
9515
- shouldNotifyParent: boolean;
9516
- } {
9517
- const { screens, currentGeometryById, persistedGeometryById } = args;
9518
- const currentIds = new Set(screens.map((screen) => screen.id));
9519
- let shouldNotifyParent = Object.keys(currentGeometryById).some(
9520
- (id) => !currentIds.has(id),
9521
- );
9522
-
9523
- const next: FrameGeometryById = {};
9524
- let changed = shouldNotifyParent;
9525
-
9526
- screens.forEach((screen, index) => {
9527
- const existing = currentGeometryById[screen.id];
9528
- const persisted = persistedGeometryById?.[screen.id];
9529
- const resolved = {
9530
- ...getInitialFrameGeometry(index, screen.metadata),
9531
- ...persisted,
9532
- } as FrameGeometry;
9533
- next[screen.id] = persisted ? resolved : (existing ?? resolved);
9534
- if (!existing) {
9535
- changed = true;
9536
- }
9537
- if (persisted && !sameFrameGeometry(existing ?? resolved, resolved)) {
9538
- changed = true;
9539
- shouldNotifyParent = true;
9540
- }
9541
- });
9542
-
9543
- return { next, changed, shouldNotifyParent };
9544
- }
9545
-
9546
- export function getPreviewDeviceFrameGeometry({
9547
- currentGeometry,
9548
- metadata,
9549
- previewDeviceFrame,
9550
- }: {
9551
- currentGeometry: FrameGeometry;
9552
- metadata?: { width: number; height: number };
9553
- previewDeviceFrame: DeviceFrameType;
9554
- }): FrameGeometry {
9555
- if (previewDeviceFrame === "none") {
9556
- return {
9557
- ...currentGeometry,
9558
- height: getOverviewFrameHeight(currentGeometry.width, metadata),
9559
- };
9560
- }
9561
-
9562
- const viewport = DEVICE_FRAME_VIEWPORTS[previewDeviceFrame];
9563
- return {
9564
- ...currentGeometry,
9565
- width: Math.max(1, Math.round(metadata?.width ?? viewport.width)),
9566
- height: Math.max(1, Math.round(metadata?.height ?? viewport.height)),
9567
- };
9568
- }
9569
-
9570
- function getScreenPreviewViewport(
9571
- metadata: ScreenViewportSize,
9572
- geometry: ScreenViewportSize,
9573
- ) {
9574
- const metadataWidth = Math.max(1, Math.round(metadata.width));
9575
- const metadataHeight = Math.max(1, Math.round(metadata.height));
9576
- const geometryWidth = Math.max(1, Math.round(geometry.width));
9577
- const geometryHeight = Math.max(1, Math.round(geometry.height));
9578
- const metadataAspect = metadataWidth / metadataHeight;
9579
- const geometryAspect = geometryWidth / geometryHeight;
9580
- const aspectMatches = Math.abs(metadataAspect - geometryAspect) < 0.005;
9581
-
9582
- if (aspectMatches) {
9583
- return {
9584
- viewportWidth: metadataWidth,
9585
- viewportHeight: metadataHeight,
9586
- displayWidth: metadataWidth,
9587
- displayHeight: metadataHeight,
9588
- scale:
9589
- Math.abs(metadataWidth - geometryWidth) < 0.5 &&
9590
- Math.abs(metadataHeight - geometryHeight) < 0.5
9591
- ? 1
9592
- : geometryWidth / metadataWidth,
9593
- };
9594
- }
9595
-
9596
- return {
9597
- viewportWidth: geometryWidth,
9598
- viewportHeight: geometryHeight,
9599
- displayWidth: geometryWidth,
9600
- displayHeight: geometryHeight,
9601
- scale: 1,
9602
- };
9603
- }
9604
-
9605
9378
  function dedupeIds(ids: string[]) {
9606
9379
  return [...new Set(ids)];
9607
9380
  }
@@ -9637,13 +9410,6 @@ function xorMarqueeSelection(baseIds: string[], hitIds: string[]) {
9637
9410
  * already reported its own hit-tested selection on every mousemove tick, and
9638
9411
  * a shift-click on empty space (`additive`) is a no-op that preserves the
9639
9412
  * existing selection, matching Figma/Cursor's overview-canvas convention. */
9640
- export function shouldClearSelectionOnEmptyCanvasClick(gesture: {
9641
- hasMoved: boolean;
9642
- additive: boolean;
9643
- }): boolean {
9644
- return !gesture.hasMoved && !gesture.additive;
9645
- }
9646
-
9647
9413
  function sameIds(a: string[], b: string[]) {
9648
9414
  return a.length === b.length && a.every((id, index) => id === b[index]);
9649
9415
  }
@@ -9743,624 +9509,6 @@ function normalizeRectFromPoints(start: Point, end: Point): MarqueeRect {
9743
9509
  };
9744
9510
  }
9745
9511
 
9746
- function rectContainsPoint(bounds: BoundsRect, point: Point) {
9747
- return (
9748
- point.x >= bounds.left &&
9749
- point.x <= bounds.right &&
9750
- point.y >= bounds.top &&
9751
- point.y <= bounds.bottom
9752
- );
9753
- }
9754
-
9755
- /** Rotate `point` into the local (unrotated) space of a frame whose center is
9756
- * `center` and whose CSS transform is `rotate(degrees deg)`. Passing the
9757
- * inverse rotation maps world-space coordinates into the frame's local space
9758
- * so unrotated bounds tests remain correct. */
9759
- function rotatePointAroundCenter(
9760
- point: Point,
9761
- center: Point,
9762
- degrees: number,
9763
- ): Point {
9764
- if (!degrees) return point;
9765
- const rad = (-degrees * Math.PI) / 180; // inverse rotation
9766
- const dx = point.x - center.x;
9767
- const dy = point.y - center.y;
9768
- const cos = Math.cos(rad);
9769
- const sin = Math.sin(rad);
9770
- return {
9771
- x: center.x + dx * cos - dy * sin,
9772
- y: center.y + dx * sin + dy * cos,
9773
- };
9774
- }
9775
-
9776
- /**
9777
- * Pure drop-target resolution: finds the topmost frame entry (by z, then DOM
9778
- * index) whose (possibly rotated) bounds contain `point`, in canvas-world
9779
- * coordinates. Factored out of `getFrameEntryAtPoint`'s inline
9780
- * filter/sort so OS file drag-and-drop (Figma parity §1) can be unit tested
9781
- * without mounting the component — `getFrameEntryAtPoint` itself is just this
9782
- * function applied to `getCurrentFrameEntries()`.
9783
- */
9784
- export function findTopFrameEntryAtPoint<
9785
- T extends { id: string; geometry: FrameGeometry },
9786
- >(entries: readonly T[], point: Point): T | undefined {
9787
- return entries
9788
- .map((entry, index) => ({ entry, index }))
9789
- .filter(({ entry }) => {
9790
- const bounds = {
9791
- left: entry.geometry.x,
9792
- top: entry.geometry.y,
9793
- right: entry.geometry.x + entry.geometry.width,
9794
- bottom: entry.geometry.y + entry.geometry.height,
9795
- };
9796
- const local = rotatePointAroundCenter(
9797
- point,
9798
- getFrameCenter(entry.geometry),
9799
- entry.geometry.rotation ?? 0,
9800
- );
9801
- return rectContainsPoint(bounds, local);
9802
- })
9803
- .sort(
9804
- (a, b) =>
9805
- (b.entry.geometry.z ?? 0) - (a.entry.geometry.z ?? 0) ||
9806
- b.index - a.index,
9807
- )[0]?.entry;
9808
- }
9809
-
9810
- /** Decides where a drawn primitive lands when its center falls outside every
9811
- * screen frame (findTopFrameEntryAtPoint returned undefined) — used by
9812
- * getTargetFrameForDraft's fallback branch. Exported for direct unit
9813
- * testing, mirroring getCachedScreenContentNode.
9814
- *
9815
- * A draft drawn outside every frame belongs on the board (floating on the
9816
- * infinite canvas surface), not absorbed into a screen it was never drawn
9817
- * inside — regardless of how many screens exist. Returning undefined here
9818
- * routes the caller to onBoardDrawPrimitive.
9819
- *
9820
- * Only when there is genuinely no board to route to (no handler registered)
9821
- * do we fall back to absorbing into the nearest frame, so a drawn shape
9822
- * still persists somewhere rather than becoming a lost draft primitive.
9823
- * This previously special-cased "exactly one screen" as if a single-screen
9824
- * board had no meaningful "outside" — stale from before the board-file
9825
- * surface existed, and it broke drawing on empty canvas space whenever a
9826
- * design happened to have only one screen. */
9827
- export function getOutsideFrameDraftFallback<T>(
9828
- entries: readonly T[],
9829
- options: { hasBoardDrawHandler: boolean },
9830
- ): T | undefined {
9831
- if (entries.length === 0) return undefined;
9832
- if (options.hasBoardDrawHandler) return undefined;
9833
- return entries[0];
9834
- }
9835
-
9836
- function sameFrameGeometry(a: FrameGeometry, b: FrameGeometry) {
9837
- return (
9838
- a.x === b.x &&
9839
- a.y === b.y &&
9840
- a.width === b.width &&
9841
- a.height === b.height &&
9842
- (a.rotation ?? 0) === (b.rotation ?? 0) &&
9843
- (a.z ?? 0) === (b.z ?? 0)
9844
- );
9845
- }
9846
-
9847
- function sameResolvedMetadata(
9848
- a: ResolvedScreenMetadata,
9849
- b: ResolvedScreenMetadata,
9850
- ) {
9851
- return (
9852
- a.source === b.source &&
9853
- a.previewState === b.previewState &&
9854
- a.title === b.title &&
9855
- a.width === b.width &&
9856
- a.height === b.height &&
9857
- a.previewUrl === b.previewUrl
9858
- );
9859
- }
9860
-
9861
- function cloneFrameGeometryById(
9862
- geometryById: FrameGeometryById,
9863
- ): FrameGeometryById {
9864
- return Object.fromEntries(
9865
- Object.entries(geometryById).map(([id, geometry]) => [id, { ...geometry }]),
9866
- );
9867
- }
9868
-
9869
- function frameGeometryWithOverrides(
9870
- base: FrameGeometryById,
9871
- overrides: FrameGeometryById,
9872
- ): FrameGeometryById {
9873
- const next = cloneFrameGeometryById(base);
9874
- Object.entries(overrides).forEach(([id, geometry]) => {
9875
- next[id] = { ...geometry };
9876
- });
9877
- return next;
9878
- }
9879
-
9880
- function isDraftPrimitive(
9881
- value: DraftPrimitive | undefined,
9882
- ): value is DraftPrimitive {
9883
- return Boolean(value);
9884
- }
9885
-
9886
- export function getDraftPreviewGeometryForTool(
9887
- tool: DraftCreationTool,
9888
- start: Point,
9889
- end: Point,
9890
- hasMoved: boolean,
9891
- modifiers?: DraftGeometryModifiers,
9892
- ): FrameGeometry {
9893
- // Note: "pen" is a valid DraftCreationTool value for board-surface/cursor
9894
- // purposes (see getDraftCreationTool), but the pen tool never actually
9895
- // drives this creation-drag geometry path — beginPenNodeCreation handles
9896
- // pen gestures entirely separately via activePenPath/PenNodeDragState.
9897
- if (tool === "line" || tool === "arrow") {
9898
- return getDraftGeometryForTool(tool, start, end, modifiers);
9899
- }
9900
-
9901
- if (!hasMoved) {
9902
- return { x: start.x, y: start.y, width: 0, height: 0 };
9903
- }
9904
-
9905
- return getDraftGeometryForTool(tool, start, end, modifiers);
9906
- }
9907
-
9908
- function getDraftGeometryForTool(
9909
- tool: DraftCreationTool,
9910
- start: Point,
9911
- end: Point,
9912
- modifiers?: DraftGeometryModifiers,
9913
- ): FrameGeometry {
9914
- if (tool === "line" || tool === "arrow") {
9915
- // Shift constrains the line/arrow to 45deg increments, reusing the same
9916
- // helper the pen tool already uses for constrained segments.
9917
- const effectiveEnd = modifiers?.shiftKey
9918
- ? constrainPointTo45Degrees(start, end)
9919
- : end;
9920
- return getPathGeometry([start, effectiveEnd]);
9921
- }
9922
- const options =
9923
- tool === "frame"
9924
- ? {
9925
- minWidth: 24,
9926
- minHeight: 24,
9927
- defaultWidth: DRAFT_FRAME_WIDTH,
9928
- defaultHeight: DRAFT_FRAME_HEIGHT,
9929
- }
9930
- : tool === "text"
9931
- ? {
9932
- minWidth: 24,
9933
- minHeight: 18,
9934
- defaultWidth: DRAFT_TEXT_WIDTH,
9935
- defaultHeight: DRAFT_TEXT_HEIGHT,
9936
- }
9937
- : {
9938
- minWidth: 8,
9939
- minHeight: 8,
9940
- defaultWidth: DRAFT_RECT_WIDTH,
9941
- defaultHeight: DRAFT_RECT_HEIGHT,
9942
- };
9943
- return getDraftGeometryFromPoints(start, end, {
9944
- ...options,
9945
- square: modifiers?.shiftKey,
9946
- fromCenter: modifiers?.altKey,
9947
- });
9948
- }
9949
-
9950
- function getPathGeometry(points: readonly Point[]): FrameGeometry {
9951
- if (points.length === 0) {
9952
- return {
9953
- x: 0,
9954
- y: 0,
9955
- width: DRAFT_PATH_MIN_SIZE,
9956
- height: DRAFT_PATH_MIN_SIZE,
9957
- };
9958
- }
9959
- const xs = points.map((point) => point.x);
9960
- const ys = points.map((point) => point.y);
9961
- const left = Math.min(...xs);
9962
- const top = Math.min(...ys);
9963
- const right = Math.max(...xs);
9964
- const bottom = Math.max(...ys);
9965
- return {
9966
- x: left,
9967
- y: top,
9968
- width: Math.max(DRAFT_PATH_MIN_SIZE, right - left),
9969
- height: Math.max(DRAFT_PATH_MIN_SIZE, bottom - top),
9970
- };
9971
- }
9972
-
9973
- function createDraftPrimitive({
9974
- tool,
9975
- start,
9976
- end,
9977
- moved,
9978
- toolProps,
9979
- modifiers,
9980
- }: DraftPrimitiveInput): DraftPrimitive {
9981
- const id = createDraftId(tool);
9982
- const geometry = moved
9983
- ? getDraftGeometryForTool(tool, start, end, modifiers)
9984
- : getDraftGeometryForTool(tool, start, start, modifiers);
9985
- // Note: pen never reaches createDraftPrimitive — beginPenNodeCreation
9986
- // commits pen paths directly via createPenDraftPrimitive (see
9987
- // finishPenPath). The old fallback here (a hardcoded 3-point zigzag
9988
- // placeholder path used when no freehand samples existed) was unreachable
9989
- // dead code and has been removed.
9990
- if (tool === "text") {
9991
- return {
9992
- id,
9993
- kind: "text",
9994
- geometry,
9995
- text: toolProps?.text ?? "",
9996
- fill: toolProps?.fill,
9997
- stroke: toolProps?.stroke,
9998
- autoSize: !moved,
9999
- };
10000
- }
10001
- if (tool === "line" || tool === "arrow") {
10002
- const effectiveEnd = modifiers?.shiftKey
10003
- ? constrainPointTo45Degrees(start, end)
10004
- : end;
10005
- const pathPoints = moved
10006
- ? [start, effectiveEnd]
10007
- : [start, { x: start.x + DRAFT_LINE_WIDTH, y: start.y }];
10008
- return {
10009
- id,
10010
- kind: tool,
10011
- geometry: getPathGeometry(pathPoints),
10012
- points: pathPoints,
10013
- stroke: toolProps?.stroke,
10014
- // Figma parity: default line/arrow stroke width is 1px, not 3px. See
10015
- // DEFAULT_LINE_STROKE_WIDTH_PX in canvas-primitive-style.ts — the same
10016
- // token every other draw/commit call site uses.
10017
- strokeWidth: toolProps?.strokeWidth ?? DEFAULT_LINE_STROKE_WIDTH_PX,
10018
- };
10019
- }
10020
- return {
10021
- id,
10022
- kind:
10023
- tool === "frame"
10024
- ? "frame"
10025
- : tool === "ellipse" || tool === "polygon" || tool === "star"
10026
- ? tool
10027
- : "rectangle",
10028
- geometry,
10029
- fill: toolProps?.fill,
10030
- stroke: toolProps?.stroke,
10031
- strokeWidth: toolProps?.strokeWidth,
10032
- };
10033
- }
10034
-
10035
- /**
10036
- * Builds the closed preview/commit path for a click-or-drag close gesture
10037
- * (P6). Closing on the first anchor is deferred to mouseup so a drag can
10038
- * shape the closing segment's curve: dragging sets the first anchor's
10039
- * handleIn (mirrored across that anchor, like every other smooth-node
10040
- * handle) so the curve eases into the start point instead of always
10041
- * closing with a hard straight/corner segment.
10042
- */
10043
- function shapeClosingHandles(
10044
- pathBefore: PenPath,
10045
- dragPoint: Point | null,
10046
- ): PenPath {
10047
- const closed = closePenPath(pathBefore);
10048
- if (!dragPoint || closed.nodes.length === 0) return closed;
10049
-
10050
- const first = closed.nodes[0];
10051
- const handleIn = {
10052
- x: first.point.x - (dragPoint.x - first.point.x),
10053
- y: first.point.y - (dragPoint.y - first.point.y),
10054
- };
10055
- const nodes = closed.nodes.slice();
10056
- nodes[0] = { ...first, handleIn };
10057
- return { nodes, closed: true };
10058
- }
10059
-
10060
- function createPenDraftPrimitive(
10061
- path: PenPath,
10062
- {
10063
- id = createDraftId("pen"),
10064
- stroke,
10065
- strokeWidth,
10066
- }: { id?: string; stroke?: string; strokeWidth?: number } = {},
10067
- ): DraftPrimitive {
10068
- const penPath = clonePenPath(path);
10069
- return {
10070
- id,
10071
- kind: "path",
10072
- geometry: getPenPathGeometry(penPath),
10073
- penPath,
10074
- pathData: serializePenPath(penPath),
10075
- stroke,
10076
- // Figma parity: default pen-path stroke width is 1px, not 3px.
10077
- strokeWidth: strokeWidth ?? DEFAULT_LINE_STROKE_WIDTH_PX,
10078
- };
10079
- }
10080
-
10081
- function createDraftId(tool: DraftCreationTool) {
10082
- return `draft-${tool}-${Date.now()}-${Math.random()
10083
- .toString(36)
10084
- .slice(2, 8)}`;
10085
- }
10086
-
10087
- function cloneDraftPrimitive(draft: DraftPrimitive): DraftPrimitive {
10088
- return {
10089
- ...draft,
10090
- geometry: { ...draft.geometry },
10091
- points: draft.points?.map((point) => ({ ...point })),
10092
- penPath: draft.penPath ? clonePenPath(draft.penPath) : undefined,
10093
- };
10094
- }
10095
-
10096
- function draftPrimitiveToInsert(
10097
- draft: DraftPrimitive,
10098
- frameGeometry: FrameGeometry,
10099
- metadata?: ResolvedScreenMetadata,
10100
- ): CanvasPrimitiveInsert {
10101
- const scaleX =
10102
- metadata && frameGeometry.width > 0
10103
- ? metadata.width / frameGeometry.width
10104
- : 1;
10105
- const scaleY =
10106
- metadata && frameGeometry.height > 0
10107
- ? metadata.height / frameGeometry.height
10108
- : 1;
10109
- const toLocalPoint = (point: Point) => ({
10110
- x: Math.round((point.x - frameGeometry.x) * scaleX),
10111
- y: Math.round((point.y - frameGeometry.y) * scaleY),
10112
- });
10113
- const scaledPenPath = draft.penPath
10114
- ? scalePenPathToGeometry(draft.penPath, frameGeometry, {
10115
- x: 0,
10116
- y: 0,
10117
- width: metadata?.width ?? frameGeometry.width,
10118
- height: metadata?.height ?? frameGeometry.height,
10119
- })
10120
- : undefined;
10121
- const localGeometry = scaledPenPath
10122
- ? getPenPathGeometry(scaledPenPath)
10123
- : {
10124
- ...draft.geometry,
10125
- x: Math.round((draft.geometry.x - frameGeometry.x) * scaleX),
10126
- y: Math.round((draft.geometry.y - frameGeometry.y) * scaleY),
10127
- width: Math.max(1, Math.round(draft.geometry.width * scaleX)),
10128
- height: Math.max(1, Math.round(draft.geometry.height * scaleY)),
10129
- };
10130
- return {
10131
- kind: draft.kind,
10132
- nodeId: draft.id,
10133
- geometry: localGeometry,
10134
- points: draft.points?.map(toLocalPoint),
10135
- pathData: scaledPenPath ? serializePenPath(scaledPenPath) : undefined,
10136
- text: draft.text,
10137
- fill: draft.fill,
10138
- stroke: draft.stroke,
10139
- strokeWidth: draft.strokeWidth,
10140
- autoSize: draft.autoSize,
10141
- };
10142
- }
10143
-
10144
- function moveDraftPrimitive(
10145
- draft: DraftPrimitive,
10146
- dx: number,
10147
- dy: number,
10148
- ): DraftPrimitive {
10149
- const movedPenPath = draft.penPath
10150
- ? translatePenPath(draft.penPath, dx, dy)
10151
- : undefined;
10152
- return {
10153
- ...draft,
10154
- geometry: {
10155
- ...draft.geometry,
10156
- x: draft.geometry.x + dx,
10157
- y: draft.geometry.y + dy,
10158
- },
10159
- points: draft.points?.map((point) => ({
10160
- x: point.x + dx,
10161
- y: point.y + dy,
10162
- })),
10163
- penPath: movedPenPath,
10164
- pathData: movedPenPath ? serializePenPath(movedPenPath) : draft.pathData,
10165
- };
10166
- }
10167
-
10168
- /**
10169
- * K-scale (Figma "Scale" tool) parity for draft primitives: when `scaleK` is
10170
- * true, `strokeWidth` grows/shrinks proportionally with the resize's uniform
10171
- * scale factor, matching Figma's Scale tool multiplying stroke weight (and,
10172
- * for text layers, font size — see the note below) along with the box. A
10173
- * *normal* resize (`scaleK` false/omitted, the default) only ever changes
10174
- * `geometry`, exactly as before this parity change.
10175
- *
10176
- * Uses `Math.max(scaleX, scaleY)` as the single scalar rather than scaling
10177
- * each axis independently: a stroke or font size has no "horizontal" vs
10178
- * "vertical" component to split across, so a non-uniform drag (K-scale
10179
- * doesn't itself force uniform scaling the way Shift does — see
10180
- * beginDraftResize's `preserveAspectRatio: ev.shiftKey` option, which is
10181
- * independent of the K tool) still needs one scalar. Figma's own Scale tool
10182
- * always drags with aspect ratio implicitly locked in practice; taking the
10183
- * larger of the two axis factors keeps the stroke/text growth from ever
10184
- * silently lagging behind the more-scaled axis.
10185
- *
10186
- * Text drafts (`DraftPrimitive` kind "text") have no explicit numeric
10187
- * font-size field today — text sizing on this canvas is implicit (CSS
10188
- * `font-size: 16px` baked into canvas-primitive-style.ts's `text` case, not a
10189
- * per-draft property), so there is nothing here to scale for text yet. See
10190
- * the report for the precise follow-up needed (promoting font-size to a
10191
- * first-class DraftPrimitive field) before K-scale can affect text size.
10192
- */
10193
- export function applyDraftGeometry(
10194
- draft: DraftPrimitive,
10195
- geometry: FrameGeometry,
10196
- scaleK?: boolean,
10197
- ): DraftPrimitive {
10198
- const origin = draft.geometry;
10199
- const scaleX = geometry.width / Math.max(1, origin.width);
10200
- const scaleY = geometry.height / Math.max(1, origin.height);
10201
- const scaledPenPath = draft.penPath
10202
- ? scalePenPathToGeometry(draft.penPath, origin, geometry)
10203
- : undefined;
10204
- const strokeScaleFactor = Math.max(scaleX, scaleY);
10205
- const scaledStrokeWidth =
10206
- scaleK && draft.strokeWidth !== undefined
10207
- ? Math.max(
10208
- 0,
10209
- Math.round(draft.strokeWidth * strokeScaleFactor * 100) / 100,
10210
- )
10211
- : draft.strokeWidth;
10212
- return {
10213
- ...draft,
10214
- geometry,
10215
- points: draft.points?.map((point) => ({
10216
- x: geometry.x + (point.x - origin.x) * scaleX,
10217
- y: geometry.y + (point.y - origin.y) * scaleY,
10218
- })),
10219
- penPath: scaledPenPath,
10220
- pathData: scaledPenPath ? serializePenPath(scaledPenPath) : draft.pathData,
10221
- strokeWidth: scaledStrokeWidth,
10222
- };
10223
- }
10224
-
10225
- function normalizeCanvasTool(
10226
- tool: MultiScreenCanvasTool,
10227
- ): MultiScreenCanvasTool {
10228
- return tool === "rectangle" ? "rect" : tool;
10229
- }
10230
-
10231
- function getDraftCreationTool(
10232
- tool: MultiScreenCanvasTool,
10233
- ): DraftCreationTool | null {
10234
- if (
10235
- tool === "frame" ||
10236
- tool === "rect" ||
10237
- tool === "line" ||
10238
- tool === "arrow" ||
10239
- tool === "ellipse" ||
10240
- tool === "polygon" ||
10241
- tool === "star" ||
10242
- tool === "text" ||
10243
- tool === "pen"
10244
- ) {
10245
- return tool;
10246
- }
10247
- return null;
10248
- }
10249
-
10250
- function polygonPointsForBox(
10251
- kind: "polygon" | "star",
10252
- width: number,
10253
- height: number,
10254
- ) {
10255
- const safeWidth = Math.max(1, width);
10256
- const safeHeight = Math.max(1, height);
10257
- const cx = safeWidth / 2;
10258
- const cy = safeHeight / 2;
10259
- const radius = Math.max(1, Math.min(safeWidth, safeHeight) / 2);
10260
- const points: Point[] = [];
10261
-
10262
- if (kind === "polygon") {
10263
- for (let index = 0; index < 3; index += 1) {
10264
- const angle = -Math.PI / 2 + (index * Math.PI * 2) / 3;
10265
- points.push({
10266
- x: cx + Math.cos(angle) * radius,
10267
- y: cy + Math.sin(angle) * radius,
10268
- });
10269
- }
10270
- } else {
10271
- for (let index = 0; index < 10; index += 1) {
10272
- const angle = -Math.PI / 2 + (index * Math.PI) / 5;
10273
- const r = index % 2 === 0 ? radius : radius * 0.45;
10274
- points.push({
10275
- x: cx + Math.cos(angle) * r,
10276
- y: cy + Math.sin(angle) * r,
10277
- });
10278
- }
10279
- }
10280
-
10281
- return points
10282
- .map((point) => `${roundCoord(point.x)},${roundCoord(point.y)}`)
10283
- .join(" ");
10284
- }
10285
-
10286
- function pointsToPath(points: readonly Point[]) {
10287
- if (points.length === 0) return "";
10288
- const [first, ...rest] = points;
10289
- return [
10290
- `M ${roundCoord(first.x)} ${roundCoord(first.y)}`,
10291
- ...rest.map((point) => `L ${roundCoord(point.x)} ${roundCoord(point.y)}`),
10292
- ].join(" ");
10293
- }
10294
-
10295
- function roundCoord(value: number) {
10296
- return Math.round(value * 10) / 10;
10297
- }
10298
-
10299
- function previewDraftPrimitive(preview: DraftCreationPreview): DraftPrimitive {
10300
- return {
10301
- id: "draft-preview",
10302
- kind:
10303
- preview.tool === "pen"
10304
- ? "path"
10305
- : preview.tool === "frame"
10306
- ? "frame"
10307
- : preview.tool === "text"
10308
- ? "text"
10309
- : preview.tool === "line" ||
10310
- preview.tool === "arrow" ||
10311
- preview.tool === "ellipse" ||
10312
- preview.tool === "polygon" ||
10313
- preview.tool === "star"
10314
- ? preview.tool
10315
- : "rectangle",
10316
- geometry: preview.geometry,
10317
- points:
10318
- preview.points ??
10319
- (preview.tool === "line" || preview.tool === "arrow"
10320
- ? [
10321
- {
10322
- x: preview.geometry.x,
10323
- y: preview.geometry.y + preview.geometry.height / 2,
10324
- },
10325
- {
10326
- x: preview.geometry.x + preview.geometry.width,
10327
- y: preview.geometry.y + preview.geometry.height / 2,
10328
- },
10329
- ]
10330
- : undefined),
10331
- text: "Text", // i18n-ignore preview-only canvas placeholder
10332
- };
10333
- }
10334
-
10335
- function getFrameCenter(frame: FrameGeometry): Point {
10336
- return {
10337
- x: frame.x + frame.width / 2,
10338
- y: frame.y + frame.height / 2,
10339
- };
10340
- }
10341
-
10342
- function angleBetween(center: Point, point: Point) {
10343
- return (Math.atan2(point.y - center.y, point.x - center.x) * 180) / Math.PI;
10344
- }
10345
-
10346
- function getSelectableBounds(
10347
- geometry: FrameGeometry,
10348
- chromeScale = 1,
10349
- ): BoundsRect {
10350
- return {
10351
- left: geometry.x,
10352
- // The rendered frame-label band is FRAME_LABEL_HEIGHT * chromeScale (see
10353
- // frameLabelHeight in the Screen renderer) — chromeScale grows as you
10354
- // zoom out (chromeScale = 1 / scale) so the label stays a constant
10355
- // on-screen size. Marquee/layer hit-testing must match that rendered
10356
- // band, not a fixed canvas-space height, or the clickable label area
10357
- // drifts out of sync with what's drawn at any zoom other than 100%.
10358
- top: geometry.y - FRAME_LABEL_HEIGHT * chromeScale,
10359
- right: geometry.x + geometry.width,
10360
- bottom: geometry.y + geometry.height,
10361
- };
10362
- }
10363
-
10364
9512
  function getWheelDeltaFromValues(
10365
9513
  deltaX: number,
10366
9514
  deltaY: number,
@@ -10377,263 +9525,6 @@ function clamp(value: number, min: number, max: number) {
10377
9525
  return Math.min(max, Math.max(min, value));
10378
9526
  }
10379
9527
 
10380
- interface ResolvedMetadataCacheEntry {
10381
- screen: ScreenFile;
10382
- keyedMetadata?: ScreenMetadata;
10383
- getterMetadata?: ScreenMetadata;
10384
- previewDeviceFrame: DeviceFrameType;
10385
- result: ResolvedScreenMetadata;
10386
- }
10387
-
10388
- /** Shallow-compares the small flat metadata records resolveScreenMetadata
10389
- * reads. `getScreenMetadata?.(screen)` callers are not required to return a
10390
- * referentially-stable object (DesignEditor's own implementations typically
10391
- * build a fresh literal per call), so an identity check alone would never
10392
- * hit. A field-by-field compare over ScreenMetadata's small fixed key set
10393
- * is cheap and correct. */
10394
- function sameScreenMetadataInput(
10395
- a: ScreenMetadata | undefined,
10396
- b: ScreenMetadata | undefined,
10397
- ) {
10398
- if (a === b) return true;
10399
- if (!a || !b) return false;
10400
- return (
10401
- a.source === b.source &&
10402
- a.sourceType === b.sourceType &&
10403
- a.lod === b.lod &&
10404
- a.previewState === b.previewState &&
10405
- a.title === b.title &&
10406
- a.width === b.width &&
10407
- a.height === b.height &&
10408
- a.url === b.url &&
10409
- a.previewUrl === b.previewUrl &&
10410
- a.bridgeUrl === b.bridgeUrl
10411
- );
10412
- }
10413
-
10414
- /** Test-only escape hatch, mirrors __clearPrimitiveParseCachesForTests. The
10415
- * cache itself is a per-MultiScreenCanvas-instance ref (see
10416
- * resolvedMetadataCacheRef), so production code never needs to reset it. */
10417
- export function __clearResolvedMetadataCacheForTests(
10418
- cache: Map<string, ResolvedMetadataCacheEntry>,
10419
- ) {
10420
- cache.clear();
10421
- }
10422
-
10423
- /** Per-screen memoized resolveScreenMetadata (PF20). resolveScreenMetadata
10424
- * does a bounded string scan (deriveSource/derivePreviewState, up to 4000
10425
- * chars) plus URL parsing every time it's called — work that is wasted when
10426
- * called again for a screen whose `screen`/`keyedMetadata`/`getterMetadata`/
10427
- * `previewDeviceFrame` inputs are unchanged from the previous call (e.g. a
10428
- * sibling screen's drag/resize triggering a canvasFrames rebuild). Cache is
10429
- * keyed by screen.id — one entry per screen, invalidated only when that
10430
- * screen's own inputs change — so it stays correct even though
10431
- * resolveScreenMetadata itself does not depend on geometry at all. */
10432
- export function resolveScreenMetadataCached(
10433
- cache: Map<string, ResolvedMetadataCacheEntry>,
10434
- screen: ScreenFile,
10435
- keyedMetadata: ScreenMetadata | undefined,
10436
- getterMetadata: ScreenMetadata | undefined,
10437
- previewDeviceFrame: DeviceFrameType,
10438
- ): ResolvedScreenMetadata {
10439
- const cached = cache.get(screen.id);
10440
- if (
10441
- cached &&
10442
- cached.screen === screen &&
10443
- cached.previewDeviceFrame === previewDeviceFrame &&
10444
- sameScreenMetadataInput(cached.keyedMetadata, keyedMetadata) &&
10445
- sameScreenMetadataInput(cached.getterMetadata, getterMetadata)
10446
- ) {
10447
- return cached.result;
10448
- }
10449
- const result = resolveScreenMetadata(
10450
- screen,
10451
- keyedMetadata,
10452
- getterMetadata,
10453
- previewDeviceFrame,
10454
- );
10455
- cache.set(screen.id, {
10456
- screen,
10457
- keyedMetadata,
10458
- getterMetadata,
10459
- previewDeviceFrame,
10460
- result,
10461
- });
10462
- return result;
10463
- }
10464
-
10465
- /** Pure per-screen content-node cache lookup backing the screenContentById
10466
- * useMemo (PF21). Returns the previously rendered node when this screen's
10467
- * own inputs are unchanged — `screen` identity, `renderScreenContent`
10468
- * identity, `metadata` by value, and the ROUNDED geometry width/height
10469
- * (matching DesignEditor's getEmbeddedFrame rounding). geometry.x/y are
10470
- * deliberately excluded from the key: position is applied by the Screen
10471
- * wrapper's left/top/transform, never by the content node, so a move-drag
10472
- * must reuse the cached node. Exported for direct unit testing, mirroring
10473
- * parsePrimitivesFromScreen. */
10474
- export function getCachedScreenContentNode(
10475
- cache: Map<string, ScreenContentCacheEntry>, // i18n-ignore scanner false positive on TS generics, not a string
10476
- screen: ScreenFile,
10477
- metadata: ResolvedScreenMetadata,
10478
- geometry: FrameGeometry,
10479
- renderScreenContent: NonNullable<
10480
- MultiScreenCanvasProps["renderScreenContent"]
10481
- >,
10482
- ): ReactNode {
10483
- const width = Math.max(1, Math.round(geometry.width));
10484
- const height = Math.max(1, Math.round(geometry.height));
10485
- const prior = cache.get(screen.id);
10486
- if (
10487
- prior &&
10488
- prior.screen === screen &&
10489
- prior.renderScreenContent === renderScreenContent &&
10490
- sameResolvedMetadata(prior.metadata, metadata) &&
10491
- prior.width === width &&
10492
- prior.height === height
10493
- ) {
10494
- return prior.contentNode;
10495
- }
10496
- const contentNode = renderScreenContent(screen, metadata, geometry);
10497
- cache.set(screen.id, {
10498
- screen,
10499
- metadata,
10500
- width,
10501
- height,
10502
- renderScreenContent,
10503
- contentNode,
10504
- });
10505
- return contentNode;
10506
- }
10507
-
10508
- function resolveScreenMetadata(
10509
- screen: ScreenFile,
10510
- keyedMetadata?: ScreenMetadata,
10511
- getterMetadata?: ScreenMetadata,
10512
- previewDeviceFrame: DeviceFrameType = "none",
10513
- ): ResolvedScreenMetadata {
10514
- // Normalize content to a string up front: a screen whose content has not yet
10515
- // loaded (or is otherwise not a plain string) must not crash the overview
10516
- // render via the content.trim()/slice() helpers below.
10517
- const safeScreen: ScreenFile =
10518
- typeof screen.content === "string" ? screen : { ...screen, content: "" };
10519
- const metadata = { ...safeScreen, ...keyedMetadata, ...getterMetadata };
10520
- const previewUrl =
10521
- metadata.url ??
10522
- metadata.previewUrl ??
10523
- safeScreen.previewUrl ??
10524
- getPreviewUrl(safeScreen.content);
10525
- const deviceViewport =
10526
- previewDeviceFrame === "none"
10527
- ? undefined
10528
- : DEVICE_FRAME_VIEWPORTS[previewDeviceFrame];
10529
- const width =
10530
- deviceViewport?.width ??
10531
- (metadata.width && metadata.width > 0 ? metadata.width : 1280);
10532
- const height =
10533
- deviceViewport?.height ??
10534
- (metadata.height && metadata.height > 0 ? metadata.height : 2560);
10535
- return {
10536
- source:
10537
- normalizeSource(metadata.sourceType ?? metadata.source) ??
10538
- deriveSource(safeScreen, previewUrl),
10539
- previewState:
10540
- normalizePreviewState(
10541
- metadata.lod ?? metadata.previewState ?? metadata.status,
10542
- ) ?? derivePreviewState(safeScreen, previewUrl),
10543
- title: metadata.title,
10544
- width,
10545
- height,
10546
- previewUrl,
10547
- };
10548
- }
10549
-
10550
- function normalizeSource(value?: string): ScreenSourceType | undefined {
10551
- const normalized = value?.toLowerCase();
10552
- if (!normalized) return undefined;
10553
- if (normalized === "local" || normalized === "localhost") return "localhost";
10554
- if (normalized === "fusion" || normalized === "remote-fusion")
10555
- return "fusion";
10556
- if (normalized === "inline" || normalized === "code") return "inline";
10557
- return undefined;
10558
- }
10559
-
10560
- function normalizePreviewState(value?: string): ScreenPreviewState | undefined {
10561
- const normalized = value?.toLowerCase();
10562
- if (!normalized) return undefined;
10563
- if (normalized === "live") return "live";
10564
- if (normalized === "snapshot" || normalized === "cached") return "snapshot";
10565
- if (normalized === "preview" || normalized === "draft") return "preview";
10566
- return undefined;
10567
- }
10568
-
10569
- function deriveSource(
10570
- screen: ScreenFile,
10571
- previewUrl?: string,
10572
- ): ScreenSourceType {
10573
- const haystack =
10574
- `${screen.filename} ${screen.content.slice(0, 4000)}`.toLowerCase();
10575
- const url = getUrl(previewUrl ?? screen.content);
10576
-
10577
- if (
10578
- url?.hostname === "localhost" ||
10579
- url?.hostname === "127.0.0.1" ||
10580
- url?.hostname.endsWith(".local") ||
10581
- haystack.includes("localhost") ||
10582
- haystack.includes("127.0.0.1")
10583
- ) {
10584
- return "localhost";
10585
- }
10586
-
10587
- if (haystack.includes("fusion") || url?.hostname.includes("fusion")) {
10588
- return "fusion";
10589
- }
10590
-
10591
- return "inline";
10592
- }
10593
-
10594
- function derivePreviewState(
10595
- screen: ScreenFile,
10596
- previewUrl?: string,
10597
- ): ScreenPreviewState {
10598
- const haystack =
10599
- `${screen.filename} ${screen.content.slice(0, 4000)}`.toLowerCase();
10600
-
10601
- if (
10602
- haystack.includes("snapshot") ||
10603
- haystack.includes("screenshot") ||
10604
- haystack.includes("cached") ||
10605
- haystack.includes("data:image/")
10606
- ) {
10607
- return "snapshot";
10608
- }
10609
-
10610
- if (previewUrl || deriveSource(screen, previewUrl) !== "inline") {
10611
- return "live";
10612
- }
10613
-
10614
- return "preview";
10615
- }
10616
-
10617
- function getPreviewUrl(content: string) {
10618
- // Tolerate non-string content (e.g. a screen whose content has not loaded):
10619
- // a missing URL is correct here, a crash is not.
10620
- return getUrl(
10621
- typeof content === "string" ? content.trim() : undefined,
10622
- )?.toString();
10623
- }
10624
-
10625
- function getUrl(value: string | undefined) {
10626
- if (!value) return undefined;
10627
- try {
10628
- const url = new URL(value);
10629
- return url.protocol === "http:" || url.protocol === "https:"
10630
- ? url
10631
- : undefined;
10632
- } catch {
10633
- return undefined;
10634
- }
10635
- }
10636
-
10637
9528
  function clampNumber(value: number, min: number, max: number) {
10638
9529
  return Math.min(max, Math.max(min, value));
10639
9530
  }
@@ -10643,333 +9534,3 @@ function clampNumber(value: number, min: number, max: number) {
10643
9534
  // ---------------------------------------------------------------------------
10644
9535
 
10645
9536
  /** A committed container primitive that can accept a dropped primitive. */
10646
- export interface PrimitiveDropTarget {
10647
- /** The node's data-agent-native-node-id value. */
10648
- nodeId: string;
10649
- /** The screen frame id (ScreenFile.id) that owns this primitive. */
10650
- screenId: string;
10651
- /** The primitive's bounding rect in board/canvas space. */
10652
- boardRect: FrameGeometry;
10653
- }
10654
-
10655
- /**
10656
- * Parsed representation of a committed primitive found in a screen's HTML.
10657
- * Geometry is in screen-local CSS pixels (as written by appendCanvasPrimitiveToHtml).
10658
- */
10659
- export interface ParsedScreenPrimitive {
10660
- nodeId: string;
10661
- screenId: string;
10662
- /** Position relative to screen body (CSS px). */
10663
- localLeft: number;
10664
- localTop: number;
10665
- localWidth: number;
10666
- localHeight: number;
10667
- isContainer: boolean;
10668
- }
10669
-
10670
- /** Simple LRU-style cache to avoid re-parsing the same screen HTML on every
10671
- * mousemove frame. Keyed by `screenId:contentLength:hash` so that ANY edit
10672
- * (including changes deep in the middle of a large HTML document) invalidates
10673
- * the entry. A length:hash pair eliminates the collision zone that existed
10674
- * when only prefix48+suffix48 were used — edits in `content[48..len-49]` would
10675
- * produce the same key even though the content differed. */
10676
- export const primitiveParseCache = new Map<string, ParsedScreenPrimitive[]>();
10677
- const PRIMITIVE_PARSE_CACHE_MAX = 64;
10678
-
10679
- /** First-level identity cache: most callers (e.g. drag/marquee mousemove
10680
- * handlers) call parsePrimitivesFromScreen repeatedly per frame against the
10681
- * *same* ScreenFile object — `screen.content` hasn't changed at all between
10682
- * calls. Hashing the full content string on every call to build the
10683
- * second-level cache key is wasted work in that case (PF17). Keep one most-
10684
- * recent `{content, result}` pair per screen id and skip straight past the
10685
- * hash when the content reference is unchanged. Only falls through to the
10686
- * hash-keyed Map (and a real re-parse) when content has actually changed. */
10687
- const primitiveParseIdentityCache = new Map<
10688
- string,
10689
- { content: string; result: ParsedScreenPrimitive[] }
10690
- >();
10691
-
10692
- /** Test-only escape hatch: clears both cache levels. Production code never
10693
- * needs to call this — the identity cache self-invalidates whenever
10694
- * `screen.content` changes, and the hash-keyed cache is bounded/LRU'd. */
10695
- export function __clearPrimitiveParseCachesForTests() {
10696
- primitiveParseCache.clear();
10697
- primitiveParseIdentityCache.clear();
10698
- }
10699
-
10700
- export function parsePrimitivesFromScreen(
10701
- screen: ScreenFile,
10702
- ): ParsedScreenPrimitive[] {
10703
- const identityEntry = primitiveParseIdentityCache.get(screen.id);
10704
- if (identityEntry && identityEntry.content === screen.content) {
10705
- return identityEntry.result;
10706
- }
10707
-
10708
- const cacheKey = `${screen.id}:${screen.content.length}:${hashString(screen.content)}`;
10709
- const cached = primitiveParseCache.get(cacheKey);
10710
- if (cached) {
10711
- primitiveParseIdentityCache.set(screen.id, {
10712
- content: screen.content,
10713
- result: cached,
10714
- });
10715
- return cached;
10716
- }
10717
-
10718
- const result: ParsedScreenPrimitive[] = [];
10719
- if (typeof DOMParser === "undefined" || !screen.content) {
10720
- return result;
10721
- }
10722
-
10723
- try {
10724
- const doc = new DOMParser().parseFromString(screen.content, "text/html");
10725
- const nodes = doc.querySelectorAll("[data-agent-native-node-id]");
10726
- nodes.forEach((el) => {
10727
- const nodeId = el.getAttribute("data-agent-native-node-id");
10728
- if (!nodeId) return;
10729
-
10730
- const htmlEl = el as HTMLElement;
10731
- const style = htmlEl.style;
10732
- const tag = el.tagName.toLowerCase();
10733
- const primitiveKind = (
10734
- el.getAttribute("data-an-primitive") || ""
10735
- ).toLowerCase();
10736
-
10737
- // Only block elements with explicit absolute positioning are considered
10738
- // positioned primitives drawn by appendCanvasPrimitiveToHtml.
10739
- if (style.position !== "absolute") return;
10740
-
10741
- const left = parseFloat(style.left) || 0;
10742
- const top = parseFloat(style.top) || 0;
10743
- const width = parseFloat(style.width) || 0;
10744
- const height = parseFloat(style.height) || 0;
10745
-
10746
- // Validity: must have non-zero size
10747
- if (width <= 0 || height <= 0) return;
10748
-
10749
- // Only explicit layout primitives and canvas-created rectangles accept
10750
- // drops. Avoid treating every absolute div as a container, because imported
10751
- // app markup can contain structural wrappers that should stay inert here.
10752
- const isDiv = tag === "div";
10753
- const isEllipse =
10754
- style.borderRadius === "50%" ||
10755
- style.borderRadius === "50% 50% 50% 50%";
10756
- const isTextAutoSize = style.display === "inline-block";
10757
- const isAutoLayout =
10758
- style.display === "flex" ||
10759
- style.display === "inline-flex" ||
10760
- style.display === "grid" ||
10761
- style.display === "inline-grid";
10762
- const isCanvasRectangle =
10763
- isDiv && (primitiveKind === "rectangle" || primitiveKind === "rect");
10764
- const isContainer =
10765
- isDiv &&
10766
- !isEllipse &&
10767
- !isTextAutoSize &&
10768
- (isAutoLayout || isCanvasRectangle);
10769
-
10770
- result.push({
10771
- nodeId,
10772
- screenId: screen.id,
10773
- localLeft: left,
10774
- localTop: top,
10775
- localWidth: width,
10776
- localHeight: height,
10777
- isContainer,
10778
- });
10779
- });
10780
- } catch {
10781
- // Silently ignore parse errors
10782
- }
10783
-
10784
- if (primitiveParseCache.size >= PRIMITIVE_PARSE_CACHE_MAX) {
10785
- // Evict the oldest entry
10786
- const firstKey = primitiveParseCache.keys().next().value;
10787
- if (firstKey !== undefined) primitiveParseCache.delete(firstKey);
10788
- }
10789
- primitiveParseCache.set(cacheKey, result);
10790
- primitiveParseIdentityCache.set(screen.id, {
10791
- content: screen.content,
10792
- result,
10793
- });
10794
- return result;
10795
- }
10796
-
10797
- /**
10798
- * Surface-space `left`/`top` for a dragged frame's wrapper element, matching
10799
- * the inline style Screen/DraftPrimitiveLayer compute from `geometry`
10800
- * (`SURFACE_PADDING + geometry.x` / `SURFACE_PADDING + geometry.y -
10801
- * labelHeight`). Used by beginFrameDrag/beginDraftDrag (PERF9 — see their
10802
- * mousemove comments) to mutate a dragged node's DOM style directly every
10803
- * rAF tick instead of committing React state, so the pan/zoom path's
10804
- * "imperative transform now, commit state once on release" discipline
10805
- * (applyViewToDom/scheduleViewCommit) also applies to object drags. Screens
10806
- * pass their scaled label-row height; draft primitives (no label row) omit
10807
- * it (default 0).
10808
- */
10809
- export function frameStyleLeftTop(
10810
- geometry: { x: number; y: number },
10811
- labelHeight = 0,
10812
- ): { left: number; top: number } {
10813
- return {
10814
- left: SURFACE_PADDING + geometry.x,
10815
- top: SURFACE_PADDING + geometry.y - labelHeight,
10816
- };
10817
- }
10818
-
10819
- /**
10820
- * Convert a screen-local primitive rect to board/canvas coordinates.
10821
- *
10822
- * appendCanvasPrimitiveToHtml stores positions in screen-local CSS pixels
10823
- * scaled from the board draft geometry:
10824
- * localX = (boardX - frame.x) * (metadata.width / frame.width)
10825
- * Inverting:
10826
- * boardX = frame.x + localX * (frame.width / metadata.width)
10827
- */
10828
- export function primitiveLocalToBoardRect(
10829
- localLeft: number,
10830
- localTop: number,
10831
- localWidth: number,
10832
- localHeight: number,
10833
- frameGeometry: FrameGeometry,
10834
- metadata: { width: number; height: number },
10835
- ): FrameGeometry {
10836
- const scaleX = frameGeometry.width / Math.max(1, metadata.width);
10837
- const scaleY = frameGeometry.height / Math.max(1, metadata.height);
10838
- return {
10839
- x: frameGeometry.x + localLeft * scaleX,
10840
- y: frameGeometry.y + localTop * scaleY,
10841
- width: Math.max(1, localWidth * scaleX),
10842
- height: Math.max(1, localHeight * scaleY),
10843
- };
10844
- }
10845
-
10846
- /**
10847
- * Find the topmost committed container primitive at `point` (canvas coords),
10848
- * excluding `draggedNodeId` and any of its descendants.
10849
- *
10850
- * Descendants are detected geometrically: a primitive whose board rect is
10851
- * fully enclosed by the dragged node's board rect is treated as a descendant
10852
- * and excluded. This avoids a circular parent-child relationship on drop.
10853
- *
10854
- * Returns null if no valid target found.
10855
- */
10856
- export function getPrimitiveDropTargetForPoint(
10857
- point: Point,
10858
- draggedNodeId: string | null,
10859
- screens: ScreenFile[],
10860
- frameGeometryById: FrameGeometryById,
10861
- getMetadata: (screen: ScreenFile) => { width: number; height: number },
10862
- options: { identityCoordinateScreenIds?: ReadonlySet<string> } = {},
10863
- ): PrimitiveDropTarget | null {
10864
- const toBoardRect = (
10865
- prim: ParsedScreenPrimitive,
10866
- frameGeometry: FrameGeometry,
10867
- metadata: { width: number; height: number },
10868
- ): FrameGeometry => {
10869
- if (options.identityCoordinateScreenIds?.has(prim.screenId)) {
10870
- return {
10871
- x: prim.localLeft,
10872
- y: prim.localTop,
10873
- width: Math.max(1, prim.localWidth),
10874
- height: Math.max(1, prim.localHeight),
10875
- };
10876
- }
10877
- return primitiveLocalToBoardRect(
10878
- prim.localLeft,
10879
- prim.localTop,
10880
- prim.localWidth,
10881
- prim.localHeight,
10882
- frameGeometry,
10883
- metadata,
10884
- );
10885
- };
10886
-
10887
- // Pre-compute the dragged node's board rect so we can exclude its descendants.
10888
- let draggedBoardRect: FrameGeometry | null = null;
10889
- if (draggedNodeId) {
10890
- outer: for (const screen of screens) {
10891
- const frameGeometry = frameGeometryById[screen.id];
10892
- if (!frameGeometry) continue;
10893
- const metadata = getMetadata(screen);
10894
- const primitives = parsePrimitivesFromScreen(screen);
10895
- for (const prim of primitives) {
10896
- if (prim.nodeId === draggedNodeId) {
10897
- draggedBoardRect = toBoardRect(prim, frameGeometry, metadata);
10898
- break outer;
10899
- }
10900
- }
10901
- }
10902
- }
10903
-
10904
- let best: PrimitiveDropTarget | null = null;
10905
-
10906
- for (const screen of screens) {
10907
- const frameGeometry = frameGeometryById[screen.id];
10908
- if (!frameGeometry) continue;
10909
-
10910
- const frameBounds = {
10911
- left: frameGeometry.x,
10912
- top: frameGeometry.y,
10913
- right: frameGeometry.x + frameGeometry.width,
10914
- bottom: frameGeometry.y + frameGeometry.height,
10915
- };
10916
- if (!rectContainsPoint(frameBounds, point)) {
10917
- continue;
10918
- }
10919
-
10920
- const metadata = getMetadata(screen);
10921
- const primitives = parsePrimitivesFromScreen(screen);
10922
-
10923
- for (const prim of primitives) {
10924
- if (!prim.isContainer) continue;
10925
- if (draggedNodeId && prim.nodeId === draggedNodeId) continue;
10926
-
10927
- const boardRect = toBoardRect(prim, frameGeometry, metadata);
10928
-
10929
- // Exclude geometric descendants: a primitive whose board rect is fully
10930
- // contained within the dragged node's board rect is a child/descendant
10931
- // and cannot be a valid reparent target (would create a cycle).
10932
- if (
10933
- draggedBoardRect &&
10934
- boardRect.x >= draggedBoardRect.x &&
10935
- boardRect.y >= draggedBoardRect.y &&
10936
- boardRect.x + boardRect.width <=
10937
- draggedBoardRect.x + draggedBoardRect.width &&
10938
- boardRect.y + boardRect.height <=
10939
- draggedBoardRect.y + draggedBoardRect.height
10940
- ) {
10941
- continue;
10942
- }
10943
-
10944
- if (
10945
- point.x >= boardRect.x &&
10946
- point.x <= boardRect.x + boardRect.width &&
10947
- point.y >= boardRect.y &&
10948
- point.y <= boardRect.y + boardRect.height
10949
- ) {
10950
- // Later in the DOM = higher paint order = topmost visually.
10951
- // We take the last match within each screen (DOM order).
10952
- best = { nodeId: prim.nodeId, screenId: screen.id, boardRect };
10953
- }
10954
- }
10955
- }
10956
-
10957
- return best;
10958
- }
10959
-
10960
- /**
10961
- * Resolve which screen (ScreenFile.id) owns a committed primitive nodeId by
10962
- * scanning all screen HTML for the given data-agent-native-node-id value.
10963
- */
10964
- export function resolveNodeScreenId(
10965
- nodeId: string,
10966
- screens: ScreenFile[],
10967
- ): string | null {
10968
- for (const screen of screens) {
10969
- const primitives = parsePrimitivesFromScreen(screen);
10970
- if (primitives.some((p) => p.nodeId === nodeId)) {
10971
- return screen.id;
10972
- }
10973
- }
10974
- return null;
10975
- }