@dfosco/storyboard 0.5.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (592) hide show
  1. package/commandpalette.config.json +152 -0
  2. package/dist/storyboard-ui.css +1 -0
  3. package/dist/storyboard-ui.js +21328 -0
  4. package/dist/storyboard-ui.js.map +1 -0
  5. package/dist/tailwind.css +2 -0
  6. package/dist/tiny-canvas.css +1 -0
  7. package/dist/tiny-canvas.js +389 -0
  8. package/package.json +121 -0
  9. package/paste.config.json +67 -0
  10. package/scaffold/AGENTS.md +432 -0
  11. package/scaffold/agents/prompt-agent.agent.md +181 -0
  12. package/scaffold/agents/terminal-agent.agent.md +351 -0
  13. package/scaffold/codex/config.toml +246 -0
  14. package/scaffold/deploy.yml +103 -0
  15. package/scaffold/githooks/pre-push +114 -0
  16. package/scaffold/gitignore +64 -0
  17. package/scaffold/manifest.json +56 -0
  18. package/scaffold/preview.yml +181 -0
  19. package/scaffold/scripts/link.sh +26 -0
  20. package/scaffold/scripts/unlink.sh +10 -0
  21. package/scaffold/skills/agent-browser/SKILL.md +260 -0
  22. package/scaffold/skills/canvas/SKILL.md +364 -0
  23. package/scaffold/skills/create/SKILL.md +501 -0
  24. package/scaffold/skills/ship/SKILL.md +237 -0
  25. package/scaffold/skills/storyboard/SKILL.md +360 -0
  26. package/scaffold/skills/update-storyboard/SKILL.md +16 -0
  27. package/scaffold/skills/update-storyboard/update-storyboard-packages.sh +26 -0
  28. package/scaffold/skills/vitest/GENERATION.md +5 -0
  29. package/scaffold/skills/vitest/SKILL.md +52 -0
  30. package/scaffold/skills/vitest/references/advanced-environments.md +264 -0
  31. package/scaffold/skills/vitest/references/advanced-projects.md +300 -0
  32. package/scaffold/skills/vitest/references/advanced-type-testing.md +237 -0
  33. package/scaffold/skills/vitest/references/advanced-vi.md +249 -0
  34. package/scaffold/skills/vitest/references/core-cli.md +166 -0
  35. package/scaffold/skills/vitest/references/core-config.md +174 -0
  36. package/scaffold/skills/vitest/references/core-describe.md +193 -0
  37. package/scaffold/skills/vitest/references/core-expect.md +219 -0
  38. package/scaffold/skills/vitest/references/core-hooks.md +244 -0
  39. package/scaffold/skills/vitest/references/core-test-api.md +233 -0
  40. package/scaffold/skills/vitest/references/features-concurrency.md +250 -0
  41. package/scaffold/skills/vitest/references/features-context.md +238 -0
  42. package/scaffold/skills/vitest/references/features-coverage.md +207 -0
  43. package/scaffold/skills/vitest/references/features-filtering.md +211 -0
  44. package/scaffold/skills/vitest/references/features-mocking.md +265 -0
  45. package/scaffold/skills/vitest/references/features-snapshots.md +207 -0
  46. package/scaffold/skills/worktree/SKILL.md +93 -0
  47. package/scaffold/storyboard.config.json +44 -0
  48. package/src/canvas/Canvas.jsx +78 -0
  49. package/src/canvas/Draggable.jsx +235 -0
  50. package/src/canvas/index.d.ts +41 -0
  51. package/src/canvas/index.js +6 -0
  52. package/src/canvas/style.css +118 -0
  53. package/src/canvas/useResetCanvas.js +17 -0
  54. package/src/canvas/utils.js +136 -0
  55. package/src/core/assets/fonts/IoskeleyMono-Bold.woff2 +0 -0
  56. package/src/core/assets/fonts/IoskeleyMono-Italic.woff2 +0 -0
  57. package/src/core/assets/fonts/IoskeleyMono-Medium.woff2 +0 -0
  58. package/src/core/assets/fonts/IoskeleyMono-Regular.woff2 +0 -0
  59. package/src/core/assets/fonts/IoskeleyMono-SemiBold.woff2 +0 -0
  60. package/src/core/autosync/server.js +714 -0
  61. package/src/core/autosync/server.test.js +158 -0
  62. package/src/core/canvas/__tests__/agent-integration.test.js +596 -0
  63. package/src/core/canvas/__tests__/helpers/browser.js +95 -0
  64. package/src/core/canvas/__tests__/helpers/canvas-api.js +129 -0
  65. package/src/core/canvas/__tests__/helpers/perf.js +118 -0
  66. package/src/core/canvas/__tests__/helpers/setup.js +176 -0
  67. package/src/core/canvas/__tests__/helpers/tmux.js +130 -0
  68. package/src/core/canvas/__tests__/helpers/transcript.js +132 -0
  69. package/src/core/canvas/__tests__/terminal-integration.test.js +177 -0
  70. package/src/core/canvas/collision.js +292 -0
  71. package/src/core/canvas/collision.test.js +371 -0
  72. package/src/core/canvas/compact.js +83 -0
  73. package/src/core/canvas/deriveCanvasId.test.js +40 -0
  74. package/src/core/canvas/githubEmbeds.js +527 -0
  75. package/src/core/canvas/githubEmbeds.test.js +302 -0
  76. package/src/core/canvas/hot-pool.js +766 -0
  77. package/src/core/canvas/identity.js +107 -0
  78. package/src/core/canvas/identity.test.js +100 -0
  79. package/src/core/canvas/materializer.js +259 -0
  80. package/src/core/canvas/materializer.test.js +356 -0
  81. package/src/core/canvas/selectedWidgets.js +270 -0
  82. package/src/core/canvas/selectedWidgets.test.js +321 -0
  83. package/src/core/canvas/server.js +3134 -0
  84. package/src/core/canvas/server.test.js +379 -0
  85. package/src/core/canvas/terminal-config.js +330 -0
  86. package/src/core/canvas/terminal-registry.js +465 -0
  87. package/src/core/canvas/terminal-server.js +1436 -0
  88. package/src/core/canvas/writeGuard.js +53 -0
  89. package/src/core/cli/agent.js +85 -0
  90. package/src/core/cli/branch.js +386 -0
  91. package/src/core/cli/canvasAdd.js +241 -0
  92. package/src/core/cli/canvasBatch.js +98 -0
  93. package/src/core/cli/canvasBounds.js +160 -0
  94. package/src/core/cli/canvasRead.js +236 -0
  95. package/src/core/cli/canvasUpdate.js +179 -0
  96. package/src/core/cli/code.js +67 -0
  97. package/src/core/cli/compact.js +62 -0
  98. package/src/core/cli/create.js +674 -0
  99. package/src/core/cli/dev-helpers.js +53 -0
  100. package/src/core/cli/dev-helpers.test.js +53 -0
  101. package/src/core/cli/dev.js +430 -0
  102. package/src/core/cli/exit.js +38 -0
  103. package/src/core/cli/flags.js +174 -0
  104. package/src/core/cli/flags.test.js +155 -0
  105. package/src/core/cli/index.js +233 -0
  106. package/src/core/cli/intro.js +37 -0
  107. package/src/core/cli/proxy.js +319 -0
  108. package/src/core/cli/proxy.test.js +63 -0
  109. package/src/core/cli/schemas.js +223 -0
  110. package/src/core/cli/server.js +192 -0
  111. package/src/core/cli/serverUrl.js +61 -0
  112. package/src/core/cli/sessions.js +459 -0
  113. package/src/core/cli/setup.js +404 -0
  114. package/src/core/cli/terminal-commands.js +287 -0
  115. package/src/core/cli/terminal-messaging.js +231 -0
  116. package/src/core/cli/terminal-welcome.js +515 -0
  117. package/src/core/cli/updateVersion.js +124 -0
  118. package/src/core/comments/api.js +284 -0
  119. package/src/core/comments/api.test.js +282 -0
  120. package/src/core/comments/auth.js +151 -0
  121. package/src/core/comments/auth.test.js +167 -0
  122. package/src/core/comments/commentCache.js +109 -0
  123. package/src/core/comments/commentCache.test.js +48 -0
  124. package/src/core/comments/commentDrafts.js +68 -0
  125. package/src/core/comments/commentMode.js +63 -0
  126. package/src/core/comments/commentMode.test.js +90 -0
  127. package/src/core/comments/config.js +47 -0
  128. package/src/core/comments/config.test.js +77 -0
  129. package/src/core/comments/graphql.js +65 -0
  130. package/src/core/comments/graphql.test.js +95 -0
  131. package/src/core/comments/index.js +42 -0
  132. package/src/core/comments/metadata.js +52 -0
  133. package/src/core/comments/metadata.test.js +110 -0
  134. package/src/core/comments/queries.js +245 -0
  135. package/src/core/comments/ui/AuthModal.jsx +114 -0
  136. package/src/core/comments/ui/CommentOverlay.js +52 -0
  137. package/src/core/comments/ui/CommentWindow.jsx +329 -0
  138. package/src/core/comments/ui/CommentsDrawer.jsx +102 -0
  139. package/src/core/comments/ui/Composer.jsx +64 -0
  140. package/src/core/comments/ui/authModal.js +66 -0
  141. package/src/core/comments/ui/authModal.test.js +76 -0
  142. package/src/core/comments/ui/comment-cursor-dark.svg +1 -0
  143. package/src/core/comments/ui/comment-cursor.svg +1 -0
  144. package/src/core/comments/ui/comment-layout.css +142 -0
  145. package/src/core/comments/ui/commentWindow.js +121 -0
  146. package/src/core/comments/ui/comments.css +242 -0
  147. package/src/core/comments/ui/commentsDrawer.js +84 -0
  148. package/src/core/comments/ui/composer.js +136 -0
  149. package/src/core/comments/ui/index.js +14 -0
  150. package/src/core/comments/ui/mount.js +687 -0
  151. package/src/core/comments/ui/mount.test.js +336 -0
  152. package/src/core/data/dotPath.js +53 -0
  153. package/src/core/data/dotPath.test.js +114 -0
  154. package/src/core/data/loader.js +409 -0
  155. package/src/core/data/loader.test.js +599 -0
  156. package/src/core/data/viewfinder.js +363 -0
  157. package/src/core/data/viewfinder.test.js +456 -0
  158. package/src/core/devtools/devtools-consumer.js +28 -0
  159. package/src/core/devtools/devtools.js +144 -0
  160. package/src/core/devtools/devtools.test.js +75 -0
  161. package/src/core/devtools/sceneDebug.js +112 -0
  162. package/src/core/devtools/sceneDebug.test.js +141 -0
  163. package/src/core/index.js +124 -0
  164. package/src/core/inspector/fiberWalker.js +239 -0
  165. package/src/core/inspector/highlighter.js +275 -0
  166. package/src/core/inspector/mouseMode.js +259 -0
  167. package/src/core/lib/components/ui/alert/alert-action.jsx +11 -0
  168. package/src/core/lib/components/ui/alert/alert-description.jsx +11 -0
  169. package/src/core/lib/components/ui/alert/alert-title.jsx +11 -0
  170. package/src/core/lib/components/ui/alert/alert.jsx +25 -0
  171. package/src/core/lib/components/ui/alert/index.js +17 -0
  172. package/src/core/lib/components/ui/avatar/avatar-badge.jsx +22 -0
  173. package/src/core/lib/components/ui/avatar/avatar-fallback.jsx +18 -0
  174. package/src/core/lib/components/ui/avatar/avatar-group-count.jsx +19 -0
  175. package/src/core/lib/components/ui/avatar/avatar-group.jsx +19 -0
  176. package/src/core/lib/components/ui/avatar/avatar-image.jsx +15 -0
  177. package/src/core/lib/components/ui/avatar/avatar.jsx +19 -0
  178. package/src/core/lib/components/ui/avatar/index.js +22 -0
  179. package/src/core/lib/components/ui/badge/badge.jsx +31 -0
  180. package/src/core/lib/components/ui/badge/index.js +2 -0
  181. package/src/core/lib/components/ui/button/button.jsx +100 -0
  182. package/src/core/lib/components/ui/button/index.js +12 -0
  183. package/src/core/lib/components/ui/card/card-action.jsx +11 -0
  184. package/src/core/lib/components/ui/card/card-content.jsx +11 -0
  185. package/src/core/lib/components/ui/card/card-description.jsx +11 -0
  186. package/src/core/lib/components/ui/card/card-footer.jsx +11 -0
  187. package/src/core/lib/components/ui/card/card-header.jsx +19 -0
  188. package/src/core/lib/components/ui/card/card-title.jsx +11 -0
  189. package/src/core/lib/components/ui/card/card.jsx +17 -0
  190. package/src/core/lib/components/ui/card/index.js +25 -0
  191. package/src/core/lib/components/ui/checkbox/checkbox.jsx +29 -0
  192. package/src/core/lib/components/ui/checkbox/index.js +6 -0
  193. package/src/core/lib/components/ui/collapsible/collapsible-content.jsx +7 -0
  194. package/src/core/lib/components/ui/collapsible/collapsible-trigger.jsx +7 -0
  195. package/src/core/lib/components/ui/collapsible/collapsible.jsx +7 -0
  196. package/src/core/lib/components/ui/collapsible/index.js +13 -0
  197. package/src/core/lib/components/ui/dialog/dialog-close.jsx +7 -0
  198. package/src/core/lib/components/ui/dialog/dialog-content.jsx +34 -0
  199. package/src/core/lib/components/ui/dialog/dialog-description.jsx +15 -0
  200. package/src/core/lib/components/ui/dialog/dialog-footer.jsx +23 -0
  201. package/src/core/lib/components/ui/dialog/dialog-header.jsx +11 -0
  202. package/src/core/lib/components/ui/dialog/dialog-overlay.jsx +15 -0
  203. package/src/core/lib/components/ui/dialog/dialog-portal.jsx +4 -0
  204. package/src/core/lib/components/ui/dialog/dialog-title.jsx +15 -0
  205. package/src/core/lib/components/ui/dialog/dialog-trigger.jsx +7 -0
  206. package/src/core/lib/components/ui/dialog/dialog.jsx +4 -0
  207. package/src/core/lib/components/ui/dialog/index.js +34 -0
  208. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.jsx +8 -0
  209. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.jsx +30 -0
  210. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-content.jsx +22 -0
  211. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.jsx +16 -0
  212. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-group.jsx +7 -0
  213. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-item.jsx +20 -0
  214. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-label.jsx +17 -0
  215. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-portal.jsx +4 -0
  216. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.jsx +7 -0
  217. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.jsx +29 -0
  218. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-separator.jsx +15 -0
  219. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.jsx +16 -0
  220. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.jsx +15 -0
  221. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.jsx +23 -0
  222. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-sub.jsx +4 -0
  223. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-trigger.jsx +7 -0
  224. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu.jsx +4 -0
  225. package/src/core/lib/components/ui/dropdown-menu/index.js +54 -0
  226. package/src/core/lib/components/ui/input/index.js +7 -0
  227. package/src/core/lib/components/ui/input/input.jsx +19 -0
  228. package/src/core/lib/components/ui/label/index.js +7 -0
  229. package/src/core/lib/components/ui/label/label.jsx +19 -0
  230. package/src/core/lib/components/ui/panel/index.js +24 -0
  231. package/src/core/lib/components/ui/panel/panel-body.jsx +11 -0
  232. package/src/core/lib/components/ui/panel/panel-close.jsx +16 -0
  233. package/src/core/lib/components/ui/panel/panel-content.jsx +29 -0
  234. package/src/core/lib/components/ui/panel/panel-footer.jsx +11 -0
  235. package/src/core/lib/components/ui/panel/panel-header.jsx +11 -0
  236. package/src/core/lib/components/ui/panel/panel-title.jsx +12 -0
  237. package/src/core/lib/components/ui/panel/panel.jsx +4 -0
  238. package/src/core/lib/components/ui/popover/index.js +28 -0
  239. package/src/core/lib/components/ui/popover/popover-close.jsx +7 -0
  240. package/src/core/lib/components/ui/popover/popover-content.jsx +22 -0
  241. package/src/core/lib/components/ui/popover/popover-description.jsx +11 -0
  242. package/src/core/lib/components/ui/popover/popover-header.jsx +11 -0
  243. package/src/core/lib/components/ui/popover/popover-portal.jsx +4 -0
  244. package/src/core/lib/components/ui/popover/popover-title.jsx +11 -0
  245. package/src/core/lib/components/ui/popover/popover-trigger.jsx +8 -0
  246. package/src/core/lib/components/ui/popover/popover.jsx +4 -0
  247. package/src/core/lib/components/ui/searchable-list.jsx +160 -0
  248. package/src/core/lib/components/ui/select/index.js +37 -0
  249. package/src/core/lib/components/ui/select/select-content.jsx +30 -0
  250. package/src/core/lib/components/ui/select/select-group-heading.jsx +17 -0
  251. package/src/core/lib/components/ui/select/select-group.jsx +15 -0
  252. package/src/core/lib/components/ui/select/select-item.jsx +26 -0
  253. package/src/core/lib/components/ui/select/select-label.jsx +11 -0
  254. package/src/core/lib/components/ui/select/select-portal.jsx +4 -0
  255. package/src/core/lib/components/ui/select/select-scroll-down-button.jsx +18 -0
  256. package/src/core/lib/components/ui/select/select-scroll-up-button.jsx +18 -0
  257. package/src/core/lib/components/ui/select/select-separator.jsx +15 -0
  258. package/src/core/lib/components/ui/select/select-trigger.jsx +25 -0
  259. package/src/core/lib/components/ui/select/select.jsx +4 -0
  260. package/src/core/lib/components/ui/separator/index.js +7 -0
  261. package/src/core/lib/components/ui/separator/separator.jsx +22 -0
  262. package/src/core/lib/components/ui/sheet/index.js +34 -0
  263. package/src/core/lib/components/ui/sheet/sheet-close.jsx +7 -0
  264. package/src/core/lib/components/ui/sheet/sheet-content.jsx +35 -0
  265. package/src/core/lib/components/ui/sheet/sheet-description.jsx +15 -0
  266. package/src/core/lib/components/ui/sheet/sheet-footer.jsx +11 -0
  267. package/src/core/lib/components/ui/sheet/sheet-header.jsx +11 -0
  268. package/src/core/lib/components/ui/sheet/sheet-overlay.jsx +15 -0
  269. package/src/core/lib/components/ui/sheet/sheet-portal.jsx +4 -0
  270. package/src/core/lib/components/ui/sheet/sheet-title.jsx +15 -0
  271. package/src/core/lib/components/ui/sheet/sheet-trigger.jsx +7 -0
  272. package/src/core/lib/components/ui/sheet/sheet.jsx +4 -0
  273. package/src/core/lib/components/ui/textarea/index.js +7 -0
  274. package/src/core/lib/components/ui/textarea/textarea.jsx +18 -0
  275. package/src/core/lib/components/ui/toggle/index.js +8 -0
  276. package/src/core/lib/components/ui/toggle/toggle.jsx +36 -0
  277. package/src/core/lib/components/ui/toggle-group/index.js +10 -0
  278. package/src/core/lib/components/ui/toggle-group/toggle-group-item.jsx +29 -0
  279. package/src/core/lib/components/ui/toggle-group/toggle-group.jsx +43 -0
  280. package/src/core/lib/components/ui/tooltip/index.js +3 -0
  281. package/src/core/lib/components/ui/tooltip/tooltip-content.jsx +21 -0
  282. package/src/core/lib/components/ui/tooltip/tooltip-trigger.jsx +23 -0
  283. package/src/core/lib/components/ui/tooltip/tooltip.jsx +11 -0
  284. package/src/core/lib/components/ui/trigger-button/index.js +6 -0
  285. package/src/core/lib/components/ui/trigger-button/trigger-button.css +38 -0
  286. package/src/core/lib/components/ui/trigger-button/trigger-button.jsx +63 -0
  287. package/src/core/lib/utils/index.js +6 -0
  288. package/src/core/logger/devLogger.js +238 -0
  289. package/src/core/logger/devLogger.test.js +193 -0
  290. package/src/core/modes/modes.css +98 -0
  291. package/src/core/modes/modes.js +492 -0
  292. package/src/core/modes/modes.test.js +562 -0
  293. package/src/core/mountStoryboardCore.js +478 -0
  294. package/src/core/rename-watcher/config.json +23 -0
  295. package/src/core/rename-watcher/watcher.js +531 -0
  296. package/src/core/scaffold.js +100 -0
  297. package/src/core/server/index.js +391 -0
  298. package/src/core/session/bodyClasses.js +128 -0
  299. package/src/core/session/bodyClasses.test.js +192 -0
  300. package/src/core/session/hashSubscribe.js +19 -0
  301. package/src/core/session/hashSubscribe.test.js +62 -0
  302. package/src/core/session/hideMode.js +424 -0
  303. package/src/core/session/hideMode.test.js +268 -0
  304. package/src/core/session/interceptHideParams.js +35 -0
  305. package/src/core/session/interceptHideParams.test.js +90 -0
  306. package/src/core/session/localStorage.js +134 -0
  307. package/src/core/session/localStorage.test.js +148 -0
  308. package/src/core/session/session.js +76 -0
  309. package/src/core/session/session.test.js +91 -0
  310. package/src/core/stores/canvasConfig.js +134 -0
  311. package/src/core/stores/canvasConfig.test.js +120 -0
  312. package/src/core/stores/commandActions.js +284 -0
  313. package/src/core/stores/commandPaletteConfig.js +31 -0
  314. package/src/core/stores/configSchema.js +232 -0
  315. package/src/core/stores/configSchema.test.js +72 -0
  316. package/src/core/stores/configStore.js +161 -0
  317. package/src/core/stores/customerModeConfig.js +30 -0
  318. package/src/core/stores/featureFlags.js +127 -0
  319. package/src/core/stores/paletteProviders.js +360 -0
  320. package/src/core/stores/paletteProviders.test.js +186 -0
  321. package/src/core/stores/plugins.js +40 -0
  322. package/src/core/stores/plugins.test.js +68 -0
  323. package/src/core/stores/recentArtifacts.js +68 -0
  324. package/src/core/stores/recentArtifacts.test.js +71 -0
  325. package/src/core/stores/sidePanelStore.ts +143 -0
  326. package/src/core/stores/themeStore.ts +291 -0
  327. package/src/core/stores/toolRegistry.js +227 -0
  328. package/src/core/stores/toolStateStore.js +183 -0
  329. package/src/core/stores/toolStateStore.test.js +220 -0
  330. package/src/core/stores/toolbarConfigStore.js +165 -0
  331. package/src/core/stores/uiConfig.js +64 -0
  332. package/src/core/stores/uiConfig.test.js +63 -0
  333. package/src/core/styles/tailwind.css +204 -0
  334. package/src/core/tools/handlers/autosync.js +12 -0
  335. package/src/core/tools/handlers/canvasAddWidget.js +11 -0
  336. package/src/core/tools/handlers/canvasAgents.js +20 -0
  337. package/src/core/tools/handlers/canvasToolbar.js +56 -0
  338. package/src/core/tools/handlers/commandPalette.js +9 -0
  339. package/src/core/tools/handlers/comments.js +16 -0
  340. package/src/core/tools/handlers/create.js +39 -0
  341. package/src/core/tools/handlers/devtools.js +122 -0
  342. package/src/core/tools/handlers/devtools.test.js +87 -0
  343. package/src/core/tools/handlers/featureFlags.js +21 -0
  344. package/src/core/tools/handlers/flows.js +68 -0
  345. package/src/core/tools/handlers/hideChrome.js +9 -0
  346. package/src/core/tools/handlers/hideToolbars.js +25 -0
  347. package/src/core/tools/handlers/inspector.js +19 -0
  348. package/src/core/tools/handlers/paletteTheme.js +35 -0
  349. package/src/core/tools/handlers/theme.js +9 -0
  350. package/src/core/tools/registry.js +26 -0
  351. package/src/core/tools/surfaces/canvasToolbar.js +10 -0
  352. package/src/core/tools/surfaces/commandList.js +10 -0
  353. package/src/core/tools/surfaces/mainToolbar.js +11 -0
  354. package/src/core/tools/surfaces/registry.js +19 -0
  355. package/src/core/ui/ActionMenuButton.jsx +114 -0
  356. package/src/core/ui/AutosyncMenuButton.css +67 -0
  357. package/src/core/ui/AutosyncMenuButton.jsx +242 -0
  358. package/src/core/ui/BranchSelect.jsx +29 -0
  359. package/src/core/ui/BranchSelect.module.css +30 -0
  360. package/src/core/ui/CanvasAgentsMenu.jsx +89 -0
  361. package/src/core/ui/CanvasCreateMenu.jsx +611 -0
  362. package/src/core/ui/CanvasSnap.css +27 -0
  363. package/src/core/ui/CanvasSnap.jsx +51 -0
  364. package/src/core/ui/CanvasUndoRedo.css +36 -0
  365. package/src/core/ui/CanvasUndoRedo.jsx +62 -0
  366. package/src/core/ui/CanvasZoomControl.css +53 -0
  367. package/src/core/ui/CanvasZoomControl.jsx +49 -0
  368. package/src/core/ui/CanvasZoomToFit.css +18 -0
  369. package/src/core/ui/CanvasZoomToFit.jsx +26 -0
  370. package/src/core/ui/CommandMenu.css +8 -0
  371. package/src/core/ui/CommandMenu.jsx +287 -0
  372. package/src/core/ui/CommandPalette.jsx +35 -0
  373. package/src/core/ui/CommandPaletteTrigger.jsx +25 -0
  374. package/src/core/ui/CommentsMenuButton.jsx +40 -0
  375. package/src/core/ui/CoreUIBar.css +47 -0
  376. package/src/core/ui/CoreUIBar.jsx +905 -0
  377. package/src/core/ui/CreateMenuButton.jsx +117 -0
  378. package/src/core/ui/HideChromeTrigger.jsx +48 -0
  379. package/src/core/ui/Icon.jsx +279 -0
  380. package/src/core/ui/InspectorPanel.css +109 -0
  381. package/src/core/ui/InspectorPanel.jsx +632 -0
  382. package/src/core/ui/PwaInstallBanner.css +42 -0
  383. package/src/core/ui/PwaInstallBanner.jsx +124 -0
  384. package/src/core/ui/SidePanel.jsx +261 -0
  385. package/src/core/ui/ThemeMenuButton.jsx +139 -0
  386. package/src/core/ui/core-ui-colors.css +129 -0
  387. package/src/core/ui/design-modes.ts +7 -0
  388. package/src/core/ui/sidepanel.css +301 -0
  389. package/src/core/ui/viewfinder.ts +7 -0
  390. package/src/core/ui-entry.js +30 -0
  391. package/src/core/utils/fuzzySearch.js +117 -0
  392. package/src/core/utils/fuzzySearch.test.js +119 -0
  393. package/src/core/utils/mobileViewport.js +57 -0
  394. package/src/core/utils/mobileViewport.test.js +68 -0
  395. package/src/core/utils/prodMode.js +38 -0
  396. package/src/core/utils/smoothCorners.js +20 -0
  397. package/src/core/vite/docs-handler.js +155 -0
  398. package/src/core/vite/server-plugin.js +797 -0
  399. package/src/core/workshop/features/createCanvas/CreateCanvasForm.jsx +260 -0
  400. package/src/core/workshop/features/createCanvas/index.js +14 -0
  401. package/src/core/workshop/features/createFlow/CreateFlowForm.jsx +334 -0
  402. package/src/core/workshop/features/createFlow/index.js +19 -0
  403. package/src/core/workshop/features/createFlow/server.js +663 -0
  404. package/src/core/workshop/features/createPage/CreatePageForm.jsx +304 -0
  405. package/src/core/workshop/features/createPage/index.js +11 -0
  406. package/src/core/workshop/features/createPrototype/CreatePrototypeForm.jsx +289 -0
  407. package/src/core/workshop/features/createPrototype/index.js +19 -0
  408. package/src/core/workshop/features/createPrototype/server.js +433 -0
  409. package/src/core/workshop/features/createStory/CreateStoryForm.jsx +208 -0
  410. package/src/core/workshop/features/createStory/index.js +14 -0
  411. package/src/core/workshop/features/registry-server.js +22 -0
  412. package/src/core/workshop/features/registry.js +28 -0
  413. package/src/core/workshop/features/templateIndex.js +155 -0
  414. package/src/core/workshop/ui/WorkshopPanel.jsx +98 -0
  415. package/src/core/workshop/ui/mount.ts +6 -0
  416. package/src/core/worktree/port.js +268 -0
  417. package/src/core/worktree/port.test.js +222 -0
  418. package/src/core/worktree/serverRegistry.js +120 -0
  419. package/src/internals/AuthModal/AuthModal.jsx +132 -0
  420. package/src/internals/AuthModal/AuthModal.module.css +221 -0
  421. package/src/internals/BranchBar/BranchBar.jsx +87 -0
  422. package/src/internals/BranchBar/BranchBar.module.css +247 -0
  423. package/src/internals/BranchBar/useBranches.js +93 -0
  424. package/src/internals/BranchBar/useBranches.test.js +68 -0
  425. package/src/internals/CommandPalette/CommandPalette.jsx +1361 -0
  426. package/src/internals/CommandPalette/CreateDialog.jsx +219 -0
  427. package/src/internals/CommandPalette/command-palette.css +180 -0
  428. package/src/internals/FlowError.module.css +30 -0
  429. package/src/internals/Icon.jsx +279 -0
  430. package/src/internals/StoryboardContext.js +3 -0
  431. package/src/internals/Viewfinder.jsx +1479 -0
  432. package/src/internals/Viewfinder.module.css +1540 -0
  433. package/src/internals/Workspace.jsx +7 -0
  434. package/src/internals/__mocks__/virtual-storyboard-data-index.js +4 -0
  435. package/src/internals/canvas/CanvasControls.jsx +112 -0
  436. package/src/internals/canvas/CanvasControls.module.css +135 -0
  437. package/src/internals/canvas/CanvasPage.bridge.test.jsx +387 -0
  438. package/src/internals/canvas/CanvasPage.dragdrop.test.jsx +350 -0
  439. package/src/internals/canvas/CanvasPage.jsx +3092 -0
  440. package/src/internals/canvas/CanvasPage.module.css +187 -0
  441. package/src/internals/canvas/CanvasPage.multiselect.test.jsx +358 -0
  442. package/src/internals/canvas/CanvasToolbar.jsx +73 -0
  443. package/src/internals/canvas/CanvasToolbar.module.css +92 -0
  444. package/src/internals/canvas/ComponentErrorBoundary.jsx +50 -0
  445. package/src/internals/canvas/ConnectorLayer.jsx +208 -0
  446. package/src/internals/canvas/ConnectorLayer.module.css +129 -0
  447. package/src/internals/canvas/MarqueeOverlay.jsx +20 -0
  448. package/src/internals/canvas/PageSelector.jsx +587 -0
  449. package/src/internals/canvas/PageSelector.module.css +261 -0
  450. package/src/internals/canvas/PageSelector.test.jsx +113 -0
  451. package/src/internals/canvas/WebGLContextPool.jsx +292 -0
  452. package/src/internals/canvas/WebGLContextPool.test.jsx +165 -0
  453. package/src/internals/canvas/canvasApi.js +164 -0
  454. package/src/internals/canvas/canvasReloadGuard.js +37 -0
  455. package/src/internals/canvas/canvasReloadGuard.test.js +27 -0
  456. package/src/internals/canvas/canvasTheme.js +118 -0
  457. package/src/internals/canvas/componentIsolate.jsx +165 -0
  458. package/src/internals/canvas/componentSetIsolate.jsx +257 -0
  459. package/src/internals/canvas/computeCanvasBounds.test.js +121 -0
  460. package/src/internals/canvas/connectorGeometry.js +132 -0
  461. package/src/internals/canvas/hotPoolDevLogs.js +25 -0
  462. package/src/internals/canvas/textSelection.js +10 -0
  463. package/src/internals/canvas/textSelection.test.js +26 -0
  464. package/src/internals/canvas/useCanvas.js +126 -0
  465. package/src/internals/canvas/useCanvas.test.js +26 -0
  466. package/src/internals/canvas/useMarqueeSelect.js +213 -0
  467. package/src/internals/canvas/useMarqueeSelect.test.js +78 -0
  468. package/src/internals/canvas/useUndoRedo.js +86 -0
  469. package/src/internals/canvas/useUndoRedo.test.js +231 -0
  470. package/src/internals/canvas/widgets/CodePenEmbed.jsx +293 -0
  471. package/src/internals/canvas/widgets/CodePenEmbed.module.css +161 -0
  472. package/src/internals/canvas/widgets/ComponentSetWidget.jsx +2 -0
  473. package/src/internals/canvas/widgets/ComponentSetWidget.module.css +89 -0
  474. package/src/internals/canvas/widgets/ComponentWidget.jsx +14 -0
  475. package/src/internals/canvas/widgets/ComponentWidget.module.css +0 -0
  476. package/src/internals/canvas/widgets/CropOverlay.jsx +179 -0
  477. package/src/internals/canvas/widgets/CropOverlay.module.css +154 -0
  478. package/src/internals/canvas/widgets/ExpandedPane.jsx +474 -0
  479. package/src/internals/canvas/widgets/ExpandedPane.module.css +179 -0
  480. package/src/internals/canvas/widgets/ExpandedPane.test.jsx +240 -0
  481. package/src/internals/canvas/widgets/ExpandedPaneTopBar.jsx +111 -0
  482. package/src/internals/canvas/widgets/ExpandedPaneTopBar.module.css +59 -0
  483. package/src/internals/canvas/widgets/ExpandedPaneTopBar.test.jsx +45 -0
  484. package/src/internals/canvas/widgets/FigmaEmbed.jsx +296 -0
  485. package/src/internals/canvas/widgets/FigmaEmbed.module.css +222 -0
  486. package/src/internals/canvas/widgets/FrozenTerminalOverlay.jsx +151 -0
  487. package/src/internals/canvas/widgets/FrozenTerminalOverlay.module.css +83 -0
  488. package/src/internals/canvas/widgets/ImageWidget.jsx +287 -0
  489. package/src/internals/canvas/widgets/ImageWidget.module.css +81 -0
  490. package/src/internals/canvas/widgets/LinkPreview.jsx +439 -0
  491. package/src/internals/canvas/widgets/LinkPreview.module.css +585 -0
  492. package/src/internals/canvas/widgets/LinkPreview.test.jsx +193 -0
  493. package/src/internals/canvas/widgets/MarkdownBlock.jsx +354 -0
  494. package/src/internals/canvas/widgets/MarkdownBlock.module.css +377 -0
  495. package/src/internals/canvas/widgets/MarkdownBlock.test.jsx +92 -0
  496. package/src/internals/canvas/widgets/PromptWidget.jsx +428 -0
  497. package/src/internals/canvas/widgets/PromptWidget.module.css +273 -0
  498. package/src/internals/canvas/widgets/PrototypeEmbed.jsx +463 -0
  499. package/src/internals/canvas/widgets/PrototypeEmbed.module.css +579 -0
  500. package/src/internals/canvas/widgets/PrototypeEmbed.test.jsx +10 -0
  501. package/src/internals/canvas/widgets/ResizeHandle.jsx +67 -0
  502. package/src/internals/canvas/widgets/ResizeHandle.module.css +29 -0
  503. package/src/internals/canvas/widgets/StickyNote.jsx +92 -0
  504. package/src/internals/canvas/widgets/StickyNote.module.css +70 -0
  505. package/src/internals/canvas/widgets/StickyNote.test.jsx +116 -0
  506. package/src/internals/canvas/widgets/StorySetWidget.jsx +208 -0
  507. package/src/internals/canvas/widgets/StorySetWidget.module.css +89 -0
  508. package/src/internals/canvas/widgets/StoryWidget.jsx +334 -0
  509. package/src/internals/canvas/widgets/StoryWidget.module.css +211 -0
  510. package/src/internals/canvas/widgets/TerminalReadWidget.jsx +146 -0
  511. package/src/internals/canvas/widgets/TerminalReadWidget.module.css +94 -0
  512. package/src/internals/canvas/widgets/TerminalWidget.jsx +704 -0
  513. package/src/internals/canvas/widgets/TerminalWidget.module.css +444 -0
  514. package/src/internals/canvas/widgets/TilesWidget.jsx +300 -0
  515. package/src/internals/canvas/widgets/TilesWidget.module.css +133 -0
  516. package/src/internals/canvas/widgets/WidgetChrome.jsx +580 -0
  517. package/src/internals/canvas/widgets/WidgetChrome.module.css +421 -0
  518. package/src/internals/canvas/widgets/WidgetWrapper.jsx +15 -0
  519. package/src/internals/canvas/widgets/WidgetWrapper.module.css +25 -0
  520. package/src/internals/canvas/widgets/codepenUrl.js +75 -0
  521. package/src/internals/canvas/widgets/codepenUrl.test.js +76 -0
  522. package/src/internals/canvas/widgets/embedInteraction.test.jsx +173 -0
  523. package/src/internals/canvas/widgets/embedOverlay.module.css +35 -0
  524. package/src/internals/canvas/widgets/embedTheme.js +148 -0
  525. package/src/internals/canvas/widgets/expandUtils.js +559 -0
  526. package/src/internals/canvas/widgets/expandUtils.test.js +155 -0
  527. package/src/internals/canvas/widgets/figmaUrl.js +118 -0
  528. package/src/internals/canvas/widgets/figmaUrl.test.js +139 -0
  529. package/src/internals/canvas/widgets/githubUrl.js +82 -0
  530. package/src/internals/canvas/widgets/githubUrl.test.js +74 -0
  531. package/src/internals/canvas/widgets/iframeDevLogs.js +49 -0
  532. package/src/internals/canvas/widgets/iframeDevLogs.test.jsx +81 -0
  533. package/src/internals/canvas/widgets/index.js +42 -0
  534. package/src/internals/canvas/widgets/pasteRules.js +295 -0
  535. package/src/internals/canvas/widgets/pasteRules.test.js +474 -0
  536. package/src/internals/canvas/widgets/snapshotDisplay.test.jsx +211 -0
  537. package/src/internals/canvas/widgets/tilePool.js +23 -0
  538. package/src/internals/canvas/widgets/tiles/diagonal-bl.png +0 -0
  539. package/src/internals/canvas/widgets/tiles/diagonal-br.png +0 -0
  540. package/src/internals/canvas/widgets/tiles/diagonal-tl.png +0 -0
  541. package/src/internals/canvas/widgets/tiles/leaf.png +0 -0
  542. package/src/internals/canvas/widgets/tiles/quarter-tl.png +0 -0
  543. package/src/internals/canvas/widgets/tiles/quarter-tr.png +0 -0
  544. package/src/internals/canvas/widgets/tiles/solid-a.png +0 -0
  545. package/src/internals/canvas/widgets/tiles/solid-b.png +0 -0
  546. package/src/internals/canvas/widgets/widgetConfig.js +291 -0
  547. package/src/internals/canvas/widgets/widgetConfig.test.js +68 -0
  548. package/src/internals/canvas/widgets/widgetIcons.jsx +190 -0
  549. package/src/internals/canvas/widgets/widgetProps.js +133 -0
  550. package/src/internals/context/FormContext.js +13 -0
  551. package/src/internals/context/FormContext.test.js +48 -0
  552. package/src/internals/context.jsx +481 -0
  553. package/src/internals/context.test.jsx +296 -0
  554. package/src/internals/hashPreserver.js +73 -0
  555. package/src/internals/hashPreserver.test.js +107 -0
  556. package/src/internals/hooks/useConfig.js +14 -0
  557. package/src/internals/hooks/useFeatureFlag.js +14 -0
  558. package/src/internals/hooks/useFlows.js +50 -0
  559. package/src/internals/hooks/useFlows.test.js +134 -0
  560. package/src/internals/hooks/useHideMode.js +31 -0
  561. package/src/internals/hooks/useHideMode.test.js +43 -0
  562. package/src/internals/hooks/useLocalStorage.js +57 -0
  563. package/src/internals/hooks/useLocalStorage.test.js +75 -0
  564. package/src/internals/hooks/useMode.js +43 -0
  565. package/src/internals/hooks/useObject.js +101 -0
  566. package/src/internals/hooks/useObject.test.js +74 -0
  567. package/src/internals/hooks/useOverride.js +84 -0
  568. package/src/internals/hooks/useOverride.test.js +71 -0
  569. package/src/internals/hooks/usePrototypeReloadGuard.js +64 -0
  570. package/src/internals/hooks/useRecord.js +158 -0
  571. package/src/internals/hooks/useRecord.test.js +221 -0
  572. package/src/internals/hooks/useScene.js +38 -0
  573. package/src/internals/hooks/useScene.test.js +66 -0
  574. package/src/internals/hooks/useSceneData.js +108 -0
  575. package/src/internals/hooks/useSceneData.test.js +136 -0
  576. package/src/internals/hooks/useSession.js +4 -0
  577. package/src/internals/hooks/useSession.test.js +8 -0
  578. package/src/internals/hooks/useThemeState.js +61 -0
  579. package/src/internals/hooks/useThemeState.test.js +66 -0
  580. package/src/internals/hooks/useUndoRedo.js +28 -0
  581. package/src/internals/hooks/useUndoRedo.test.js +64 -0
  582. package/src/internals/index.js +58 -0
  583. package/src/internals/story/ComponentSetPage.jsx +198 -0
  584. package/src/internals/story/ComponentSetPage.module.css +129 -0
  585. package/src/internals/story/StoryPage.jsx +147 -0
  586. package/src/internals/story/StoryPage.module.css +18 -0
  587. package/src/internals/test-utils.js +45 -0
  588. package/src/internals/vite/data-plugin.js +1508 -0
  589. package/src/internals/vite/data-plugin.test.js +1223 -0
  590. package/src/test-utils.js +44 -0
  591. package/toolbar.config.json +271 -0
  592. package/widgets.config.json +1537 -0
@@ -0,0 +1,3092 @@
1
+ import { createElement, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
+ import { Canvas } from '../../canvas/index.js'
3
+ import '../../canvas/style.css'
4
+ import { useCanvas } from './useCanvas.js'
5
+ import { shouldPreventCanvasTextSelection } from './textSelection.js'
6
+ import { getCanvasThemeVars, getCanvasPrimerAttrs } from './canvasTheme.js'
7
+ import { getWidgetComponent } from './widgets/index.js'
8
+ import { schemas, getDefaults } from './widgets/widgetProps.js'
9
+ import { getFeatures, isResizable, isExpandable, getAnchorState, canAcceptConnection, isSplitScreenCapable } from './widgets/widgetConfig.js'
10
+ import { createPasteContext, resolvePaste } from './widgets/pasteRules.js'
11
+ import { getPasteRules } from '../../core/index.js'
12
+ import { isTerminalResizable, getTerminalDimensions } from '../../core/index.js'
13
+ import { getFlag } from '../../core/index.js'
14
+ import { getCanvasZoom } from '../../core/index.js'
15
+ import { registerSmoothCorners } from '../../core/utils/smoothCorners.js'
16
+ import { registerHotPoolDevLogs } from './hotPoolDevLogs.js'
17
+ import { isGitHubEmbedUrl } from './widgets/githubUrl.js'
18
+ import { WebGLContextPoolProvider, usePoolVisibilityUpdater } from './WebGLContextPool.jsx'
19
+
20
+ import WidgetChrome from './widgets/WidgetChrome.jsx'
21
+ import ComponentWidget from './widgets/ComponentWidget.jsx'
22
+ import useUndoRedo from './useUndoRedo.js'
23
+ import useMarqueeSelect from './useMarqueeSelect.js'
24
+ import MarqueeOverlay from './MarqueeOverlay.jsx'
25
+ import {
26
+ addWidget as addWidgetApi,
27
+ checkGitHubCliAvailable,
28
+ duplicateImage,
29
+ fetchGitHubEmbed,
30
+ getCanvas as getCanvasApi,
31
+ removeWidget as removeWidgetApi,
32
+ updateCanvas,
33
+ updateFolderMeta,
34
+ uploadImage,
35
+ addConnector as addConnectorApi,
36
+ removeConnector as removeConnectorApi,
37
+ updateConnector as updateConnectorApi,
38
+ batchOperations,
39
+ } from './canvasApi.js'
40
+ import PageSelector from './PageSelector.jsx'
41
+ import Icon from '../Icon.jsx'
42
+ import { stories as storyIndex } from 'virtual:storyboard-data-index'
43
+ import styles from './CanvasPage.module.css'
44
+ import ConnectorLayer from './ConnectorLayer.jsx'
45
+
46
+ /** Canvas zoom limits — read from storyboard.config.json via canvasConfig. */
47
+ function zoomLimits() {
48
+ const z = getCanvasZoom()
49
+ return { ZOOM_MIN: z.min, ZOOM_MAX: z.max, ZOOM_STEP: z.step }
50
+ }
51
+
52
+ /** Saved viewport state older than this is considered stale — zoom-to-fit instead. */
53
+ const VIEWPORT_TTL_MS = 15 * 60 * 1000
54
+
55
+ const CANVAS_BRIDGE_STATE_KEY = '__storyboardCanvasBridgeState'
56
+ const GH_INSTALL_URL = 'https://github.com/cli/cli'
57
+
58
+ registerSmoothCorners()
59
+ registerHotPoolDevLogs()
60
+
61
+ // Build a reverse map from story route paths → { storyId, route }
62
+ const storyRouteIndex = new Map()
63
+ for (const [storyId, data] of Object.entries(storyIndex || {})) {
64
+ if (data?._route) {
65
+ storyRouteIndex.set(data._route.replace(/\/+$/, ''), storyId)
66
+ }
67
+ }
68
+
69
+ function getToolbarColorMode(theme) {
70
+ return String(theme || 'light').startsWith('dark') ? 'dark' : 'light'
71
+ }
72
+
73
+ function resolveCanvasThemeFromStorage() {
74
+ if (typeof localStorage === 'undefined') return 'light'
75
+ let sync = { prototype: true, toolbar: false, codeBoxes: true, canvas: true }
76
+ try {
77
+ const rawSync = localStorage.getItem('sb-theme-sync')
78
+ if (rawSync) sync = { ...sync, ...JSON.parse(rawSync) }
79
+ } catch {
80
+ // Ignore malformed sync settings
81
+ }
82
+
83
+ if (!sync.canvas) return 'light'
84
+
85
+ const attrTheme = document.documentElement.getAttribute('data-sb-canvas-theme')
86
+ if (attrTheme) return attrTheme
87
+
88
+ const stored = localStorage.getItem('sb-color-scheme') || 'system'
89
+ if (stored !== 'system') return stored
90
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
91
+ }
92
+
93
+ /**
94
+ * Get the copyable URL for a widget based on its type.
95
+ * Returns the most relevant URL/path for the widget content.
96
+ */
97
+ // eslint-disable-next-line no-unused-vars
98
+ function getWidgetCopyableUrl(widget) {
99
+ const { type, props = {} } = widget
100
+ const base = (typeof import.meta !== 'undefined' && import.meta.env?.BASE_URL) || '/'
101
+ switch (type) {
102
+ case 'prototype':
103
+ // Prototype src is a path like "/MyPrototype" - make it a full URL
104
+ return props.src ? `${window.location.origin}${base.replace(/\/$/, '')}${props.src}` : ''
105
+ case 'figma-embed':
106
+ return props.url || ''
107
+ case 'link-preview':
108
+ return props.url || ''
109
+ case 'image':
110
+ // Return the served image URL
111
+ return props.src ? `${window.location.origin}${base.replace(/\/$/, '')}/_storyboard/canvas/images/${props.src}` : ''
112
+ case 'sticky-note':
113
+ // Sticky notes have text content, not a URL
114
+ return props.text || ''
115
+ case 'markdown':
116
+ // Markdown has content, not a URL
117
+ return props.content || ''
118
+ default:
119
+ return ''
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Debounce helper — returns a function that delays invocation.
125
+ * Exposes `.cancel()` to abort pending calls (used by undo/redo).
126
+ */
127
+ function debounce(fn, ms) {
128
+ let timer
129
+ const debounced = (...args) => {
130
+ clearTimeout(timer)
131
+ timer = setTimeout(() => fn(...args), ms)
132
+ }
133
+ debounced.cancel = () => clearTimeout(timer)
134
+ return debounced
135
+ }
136
+
137
+ /** Per-canvas viewport state persistence (zoom + scroll position). */
138
+ function getViewportStorageKey(canvasId) {
139
+ return `sb-canvas-viewport:${canvasId}`
140
+ }
141
+
142
+ function loadViewportState(canvasId) {
143
+ try {
144
+ const raw = localStorage.getItem(getViewportStorageKey(canvasId))
145
+ if (!raw) { if (getFlag('dev-logs')) console.log('[viewport] no saved state for', canvasId); return null }
146
+ const state = JSON.parse(raw)
147
+ const timestamp = typeof state.timestamp === 'number' ? state.timestamp : 0
148
+ const age = Date.now() - timestamp
149
+ if (age > VIEWPORT_TTL_MS) {
150
+ if (getFlag('dev-logs')) console.log('[viewport] stale state for', canvasId, '— age:', Math.round(age / 1000), 's')
151
+ localStorage.removeItem(getViewportStorageKey(canvasId))
152
+ return null
153
+ }
154
+ if (getFlag('dev-logs')) console.log('[viewport] loaded state for', canvasId, '— age:', Math.round(age / 1000), 's, zoom:', state.zoom, 'scroll:', state.scrollLeft, state.scrollTop)
155
+ const { ZOOM_MIN, ZOOM_MAX } = zoomLimits()
156
+ return {
157
+ zoom: typeof state.zoom === 'number' ? Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, state.zoom)) : null,
158
+ scrollLeft: typeof state.scrollLeft === 'number' ? state.scrollLeft : null,
159
+ scrollTop: typeof state.scrollTop === 'number' ? state.scrollTop : null,
160
+ }
161
+ } catch { return null }
162
+ }
163
+
164
+ function saveViewportState(canvasId, state) {
165
+ try {
166
+ localStorage.setItem(getViewportStorageKey(canvasId), JSON.stringify({
167
+ ...state,
168
+ timestamp: Date.now(),
169
+ }))
170
+ } catch { /* quota exceeded — non-critical */ }
171
+ }
172
+
173
+ /**
174
+ * Get viewport-center coordinates in canvas space for placing a new widget.
175
+ * Converts the visible center of the scroll container to unscaled canvas coordinates.
176
+ */
177
+ function getViewportCenter(scrollEl, scale) {
178
+ if (!scrollEl) {
179
+ return { x: 0, y: 0 }
180
+ }
181
+ const cx = scrollEl.scrollLeft + scrollEl.clientWidth / 2
182
+ const cy = scrollEl.scrollTop + scrollEl.clientHeight / 2
183
+ return {
184
+ x: Math.round(cx / scale),
185
+ y: Math.round(cy / scale),
186
+ }
187
+ }
188
+
189
+ /** Fallback sizes for widget types without explicit width/height defaults. */
190
+ const WIDGET_FALLBACK_SIZES = {
191
+ 'sticky-note': { width: 270, height: 170 },
192
+ 'markdown': { width: 530, height: 240 },
193
+ 'prototype': { width: 800, height: 600 },
194
+ 'link-preview': { width: 320, height: 120 },
195
+ 'figma-embed': { width: 800, height: 450 },
196
+ 'component': { width: 200, height: 150 },
197
+ 'image': { width: 400, height: 300 },
198
+ }
199
+
200
+ /**
201
+ * Offset a position so the widget's center (not its top-left corner)
202
+ * lands on the given point.
203
+ */
204
+ function centerPositionForWidget(pos, type, props) {
205
+ const fallback = WIDGET_FALLBACK_SIZES[type] || { width: 200, height: 150 }
206
+ const w = props?.width ?? fallback.width
207
+ const h = props?.height ?? fallback.height
208
+ return {
209
+ x: Math.round(pos.x - w / 2),
210
+ y: Math.round(pos.y - h / 2),
211
+ }
212
+ }
213
+
214
+ function roundPosition(value) {
215
+ return Math.round(value)
216
+ }
217
+
218
+ /** Snap a value to the nearest grid line. */
219
+ function snapValue(value, gridSize) {
220
+ return Math.round(value / gridSize) * gridSize
221
+ }
222
+
223
+ /** Snap a position to the grid if snapping is enabled. */
224
+ // eslint-disable-next-line no-unused-vars
225
+ function snapPosition(pos, gridSize, enabled) {
226
+ if (!enabled || !gridSize) return pos
227
+ return {
228
+ x: Math.max(0, snapValue(pos.x, gridSize)),
229
+ y: Math.max(0, snapValue(pos.y, gridSize)),
230
+ }
231
+ }
232
+
233
+ /** Snap a dimension to the grid if snapping is enabled. */
234
+ function snapDimension(value, gridSize, enabled, min = 0) {
235
+ if (!enabled || !gridSize) return value
236
+ return Math.max(min, snapValue(value, gridSize))
237
+ }
238
+
239
+ /** Padding (canvas-space pixels) around bounding box for zoom-to-fit. */
240
+ const FIT_PADDING = 48
241
+
242
+ /**
243
+ * Compute the axis-aligned bounding box that contains every widget and source.
244
+ * Returns { minX, minY, maxX, maxY } in canvas-space coordinates, or null if empty.
245
+ */
246
+ function computeCanvasBounds(widgets, componentEntries) {
247
+ let minX = Infinity
248
+ let minY = Infinity
249
+ let maxX = -Infinity
250
+ let maxY = -Infinity
251
+ let hasItems = false
252
+
253
+ // JSON widgets
254
+ for (const w of (widgets ?? [])) {
255
+ const x = w?.position?.x ?? 0
256
+ const y = w?.position?.y ?? 0
257
+ const fallback = WIDGET_FALLBACK_SIZES[w.type] || { width: 200, height: 150 }
258
+ const width = w.props?.width ?? fallback.width
259
+ const height = w.props?.height ?? fallback.height
260
+ minX = Math.min(minX, x)
261
+ minY = Math.min(minY, y)
262
+ maxX = Math.max(maxX, x + width)
263
+ maxY = Math.max(maxY, y + height)
264
+ hasItems = true
265
+ }
266
+
267
+ // Component widgets (from jsxExports or sources fallback)
268
+ for (const entry of componentEntries) {
269
+ const x = entry.sourceData?.position?.x ?? 0
270
+ const y = entry.sourceData?.position?.y ?? 0
271
+ const fallback = WIDGET_FALLBACK_SIZES['component']
272
+ const width = entry.sourceData?.width ?? fallback.width
273
+ const height = entry.sourceData?.height ?? fallback.height
274
+ minX = Math.min(minX, x)
275
+ minY = Math.min(minY, y)
276
+ maxX = Math.max(maxX, x + width)
277
+ maxY = Math.max(maxY, y + height)
278
+ hasItems = true
279
+ }
280
+
281
+ return hasItems ? { minX, minY, maxX, maxY } : null
282
+ }
283
+
284
+ /** Renders a single JSON-defined widget by type lookup. */
285
+ function WidgetRenderer({ widget, onUpdate, widgetRef, onRefreshGitHub, canRefreshGitHub, multiSelected }) {
286
+ const Component = getWidgetComponent(widget.type)
287
+ if (!Component) {
288
+ console.warn(`[canvas] Unknown widget type: ${widget.type}`)
289
+ return null
290
+ }
291
+ const resizable = (widget.type === 'terminal' || widget.type === 'agent')
292
+ ? isTerminalResizable(widget.props?.agentId) && !!onUpdate
293
+ : isResizable(widget.type) && !!onUpdate
294
+ // Only pass ref to forwardRef-wrapped components (e.g. PrototypeEmbed)
295
+ const elementProps = {
296
+ id: widget.id,
297
+ props: widget.props,
298
+ onUpdate,
299
+ resizable,
300
+ onRefreshGitHub,
301
+ canRefreshGitHub,
302
+ multiSelected,
303
+ }
304
+ if (Component.$$typeof === Symbol.for('react.forward_ref')) {
305
+ elementProps.ref = widgetRef
306
+ }
307
+ return createElement(Component, elementProps)
308
+ }
309
+
310
+ /**
311
+ * Wrapper for each JSON widget that holds its own ref for imperative actions.
312
+ * This allows WidgetChrome to dispatch actions to the widget via ref.
313
+ *
314
+ * Memoized to prevent re-renders during zoom and unrelated state changes.
315
+ */
316
+ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
317
+ widget,
318
+ selected,
319
+ multiSelected,
320
+ connectorCount,
321
+ allWidgets,
322
+ onSelect,
323
+ onDeselect,
324
+ onUpdate,
325
+ onRemove,
326
+ onCopy,
327
+ onCopyWithConnectors,
328
+ onRefreshGitHub,
329
+ canRefreshGitHub,
330
+ onConnectorDragStart,
331
+ readOnly,
332
+ }) {
333
+ const widgetRef = useRef(null)
334
+ const rawFeatures = getFeatures(widget.type, { isLocalDev: !readOnly })
335
+
336
+ // Dynamically adjust features based on widget state
337
+ const features = useMemo(() => {
338
+ const isGitHub = !!widget.props?.github
339
+ const adjusted = rawFeatures.map((f) => {
340
+ // Toggle collapse label and hide when content is short (no github = no collapse)
341
+ if (f.action === 'toggle-collapse') {
342
+ if (widget.type === 'link-preview' && !isGitHub) return null
343
+ return {
344
+ ...f,
345
+ label: widget.props?.collapsed ? 'Expand height' : 'Collapse height',
346
+ icon: widget.props?.collapsed ? 'unfold' : 'fold',
347
+ }
348
+ }
349
+ // Hide refresh-github for non-GitHub link previews
350
+ if (f.action === 'refresh-github' && !isGitHub) return null
351
+ return f
352
+ }).filter(Boolean)
353
+
354
+ // Add dynamic "Split Screen" action when a connected split target exists.
355
+ // Uses connectorCount/allWidgets props (reactive) instead of the global
356
+ // bridge state which may be stale during React render.
357
+ if (isExpandable(widget.type)) {
358
+ const hasConnected = (connectorCount || []).some((c) => {
359
+ const otherId = c.start?.widgetId === widget.id ? c.end?.widgetId : c.start?.widgetId
360
+ const otherWidget = (allWidgets || []).find((w) => w.id === otherId)
361
+ return otherWidget && isSplitScreenCapable(otherWidget.type)
362
+ })
363
+ if (hasConnected) {
364
+ // Insert before the first menu-only feature
365
+ const insertIdx = adjusted.findIndex((f) => f.menu)
366
+ const splitFeature = {
367
+ id: 'split-screen',
368
+ type: 'action',
369
+ action: 'split-screen',
370
+ label: 'Split Screen',
371
+ icon: 'columns',
372
+ prod: true,
373
+ }
374
+ if (insertIdx >= 0) adjusted.splice(insertIdx, 0, splitFeature)
375
+ else adjusted.push(splitFeature)
376
+ }
377
+ }
378
+
379
+ // Add dynamic "Broadcast" toggle for terminal/agent widgets with connected peers
380
+ if (widget.type === 'terminal' || widget.type === 'agent') {
381
+ const widgetConnectors = connectorCount || []
382
+ const widgetList = allWidgets || []
383
+ let hasBroadcastPeers = false
384
+ let allBroadcastActive = true
385
+ const broadcastConnectorIds = []
386
+
387
+ for (const conn of widgetConnectors) {
388
+ const peerId = conn.start?.widgetId === widget.id ? conn.end?.widgetId : conn.start?.widgetId
389
+ const peer = widgetList.find((w) => w.id === peerId)
390
+ if (peer && (peer.type === 'terminal' || peer.type === 'agent')) {
391
+ hasBroadcastPeers = true
392
+ broadcastConnectorIds.push(conn.id)
393
+ if (conn.meta?.messagingMode !== 'two-way') allBroadcastActive = false
394
+ }
395
+ }
396
+
397
+ if (hasBroadcastPeers) {
398
+ const isActive = allBroadcastActive
399
+ const insertIdx = adjusted.findIndex((f) => f.menu)
400
+ const broadcastFeature = {
401
+ id: 'broadcast',
402
+ type: 'action',
403
+ action: `broadcast-toggle:${broadcastConnectorIds.join(',')}:${isActive ? 'off' : 'on'}`,
404
+ label: isActive ? 'Broadcast On' : 'Broadcast',
405
+ icon: 'broadcast',
406
+ active: isActive,
407
+ }
408
+ if (insertIdx >= 0) adjusted.splice(insertIdx, 0, broadcastFeature)
409
+ else adjusted.push(broadcastFeature)
410
+ }
411
+ }
412
+
413
+ return adjusted
414
+ }, [rawFeatures, widget.props?.github, widget.props?.collapsed, widget.type, widget.id, connectorCount, allWidgets])
415
+
416
+ // eslint-disable-next-line react-hooks/preserve-manual-memoization
417
+ const handleAction = useCallback((actionId, opts) => {
418
+ if (actionId === 'delete') {
419
+ onRemove?.(widget.id)
420
+ } else if (actionId === 'copy') {
421
+ if (opts?.altKey && onCopyWithConnectors) {
422
+ onCopyWithConnectors(widget)
423
+ } else {
424
+ onCopy?.(widget)
425
+ }
426
+ } else if (actionId === 'copy-text') {
427
+ const title = widget.props?.title || ''
428
+ const body = widget.props?.text || widget.props?.content || widget.props?.github?.body || ''
429
+ const text = title && body ? `# ${title}\n\n${body}` : title || body
430
+ navigator.clipboard?.writeText(text).catch(() => {})
431
+ } else if (actionId === 'open-external') {
432
+ const url = widget.props?.url || widget.props?.src
433
+ if (url) window.open(url, '_blank', 'noopener,noreferrer')
434
+ } else if (actionId === 'refresh-github') {
435
+ const url = widget.props?.url
436
+ if (url && onRefreshGitHub) onRefreshGitHub(widget.id, url)
437
+ } else if (actionId === 'toggle-collapse') {
438
+ const wasCollapsed = !!widget.props?.collapsed
439
+ onUpdate?.(widget.id, { collapsed: !wasCollapsed })
440
+ // When collapsing, pan viewport to center the widget
441
+ if (!wasCollapsed) {
442
+ requestAnimationFrame(() => {
443
+ const el = document.getElementById(widget.id)
444
+ if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
445
+ })
446
+ }
447
+ } else if (actionId.startsWith('broadcast-toggle:')) {
448
+ // broadcast-toggle:<connectorId1,connectorId2,...>:<on|off>
449
+ const parts = actionId.split(':')
450
+ const connectorIds = parts[1].split(',')
451
+ const turnOn = parts[2] === 'on'
452
+ const bridge = window.__storyboardCanvasBridgeState
453
+ const canvasId = bridge?.canvasId || ''
454
+ const meta = turnOn ? { messagingMode: 'two-way' } : { messagingMode: null }
455
+ for (const cid of connectorIds) {
456
+ updateConnectorApi(canvasId, cid, meta)
457
+ .catch((err) => console.error('[canvas] Failed to toggle broadcast:', err))
458
+ }
459
+ }
460
+ }, [widget, onRemove, onCopy, onCopyWithConnectors, onRefreshGitHub])
461
+
462
+ const handleWidgetFieldUpdate = useCallback((updates) => {
463
+ onUpdate?.(widget.id, updates)
464
+ }, [onUpdate, widget.id])
465
+
466
+ return (
467
+ <WidgetChrome
468
+ widgetId={widget.id}
469
+ widgetType={widget.type}
470
+ features={features}
471
+ selected={selected}
472
+ multiSelected={multiSelected}
473
+ widgetProps={widget.props}
474
+ widgetRef={widgetRef}
475
+ onSelect={onSelect}
476
+ onDeselect={onDeselect}
477
+ onAction={handleAction}
478
+ onUpdate={onUpdate ? handleWidgetFieldUpdate : undefined}
479
+ onConnectorDragStart={onConnectorDragStart}
480
+ readOnly={readOnly}
481
+ >
482
+ <WidgetRenderer
483
+ widget={widget}
484
+ onUpdate={onUpdate ? handleWidgetFieldUpdate : undefined}
485
+ widgetRef={widgetRef}
486
+ onRefreshGitHub={onRefreshGitHub}
487
+ canRefreshGitHub={canRefreshGitHub}
488
+ multiSelected={multiSelected}
489
+ />
490
+ </WidgetChrome>
491
+ )
492
+ }, function chromeWidgetAreEqual(prev, next) {
493
+ return (
494
+ prev.widget === next.widget &&
495
+ prev.selected === next.selected &&
496
+ prev.multiSelected === next.multiSelected &&
497
+ prev.connectorCount === next.connectorCount &&
498
+ prev.allWidgets === next.allWidgets &&
499
+ prev.readOnly === next.readOnly &&
500
+ prev.onSelect === next.onSelect &&
501
+ prev.onDeselect === next.onDeselect &&
502
+ prev.onUpdate === next.onUpdate &&
503
+ prev.onRemove === next.onRemove &&
504
+ prev.onCopy === next.onCopy &&
505
+ prev.onConnectorDragStart === next.onConnectorDragStart
506
+ )
507
+ })
508
+
509
+ /**
510
+ * Editable canvas/folder title — always visible, double-click to edit in dev mode.
511
+ */
512
+ function CanvasTitleEditable({ canvasId, canvasMeta, canvas, isLocalDev }) {
513
+ const [editing, setEditing] = useState(false)
514
+ const [titleValue, setTitleValue] = useState('')
515
+ const inputRef = useRef(null)
516
+ const displayTitle = canvasMeta?.title || canvas?.title || canvasId.split('/').pop()
517
+
518
+ useEffect(() => {
519
+ if (editing && inputRef.current) {
520
+ inputRef.current.focus()
521
+ inputRef.current.select()
522
+ }
523
+ }, [editing])
524
+
525
+ const handleCommit = useCallback(async () => {
526
+ const trimmed = titleValue.trim()
527
+ setEditing(false)
528
+ if (!trimmed || trimmed === displayTitle) return
529
+ try {
530
+ if (canvasId.includes('/')) {
531
+ const folder = canvasId.split('/')[0]
532
+ const result = await updateFolderMeta(folder, trimmed)
533
+ if (result?.renamed && result?.folder) {
534
+ // Folder was renamed on disk — navigate to new route
535
+ const pageName = canvasId.split('/').slice(1).join('/')
536
+ const newCanvasId = `${result.folder}/${pageName}`
537
+ const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
538
+ const targetUrl = `${base}/canvas/${newCanvasId}`
539
+ if (import.meta.hot) {
540
+ const timer = setTimeout(() => { window.location.href = targetUrl }, 3000)
541
+ import.meta.hot.on('vite:beforeFullReload', () => {
542
+ clearTimeout(timer)
543
+ sessionStorage.setItem('sb-pending-navigate', targetUrl)
544
+ })
545
+ } else {
546
+ setTimeout(() => { window.location.href = targetUrl }, 1000)
547
+ }
548
+ return
549
+ }
550
+ } else {
551
+ await updateCanvas(canvasId, { settings: { title: trimmed } })
552
+ }
553
+ // Reload to pick up the updated metadata from the data plugin
554
+ if (import.meta.hot) {
555
+ const timer = setTimeout(() => { window.location.reload() }, 2000)
556
+ import.meta.hot.on('vite:beforeFullReload', () => clearTimeout(timer))
557
+ } else {
558
+ setTimeout(() => { window.location.reload() }, 1000)
559
+ }
560
+ } catch (err) {
561
+ console.error('Failed to update title:', err)
562
+ }
563
+ }, [titleValue, displayTitle, canvasId])
564
+
565
+ const handleDblClick = useCallback(() => {
566
+ if (!isLocalDev) return
567
+ setTitleValue(displayTitle)
568
+ setEditing(true)
569
+ }, [isLocalDev, displayTitle])
570
+
571
+ if (editing) {
572
+ return (
573
+ <input
574
+ ref={inputRef}
575
+ className={styles.canvasTitleEditing}
576
+ type="text"
577
+ value={titleValue}
578
+ onChange={(e) => setTitleValue(e.target.value)}
579
+ onKeyDown={(e) => {
580
+ if (e.key === 'Enter') { e.preventDefault(); handleCommit() }
581
+ if (e.key === 'Escape') { e.preventDefault(); setEditing(false) }
582
+ }}
583
+ onBlur={handleCommit}
584
+ />
585
+ )
586
+ }
587
+
588
+ return (
589
+ <h1
590
+ className={styles.canvasTitleStatic}
591
+ onDoubleClick={handleDblClick}
592
+ style={isLocalDev ? { cursor: 'default' } : undefined}
593
+ >
594
+ {displayTitle}
595
+ </h1>
596
+ )
597
+ }
598
+
599
+ /**
600
+ * Generic canvas page component.
601
+ * Reads canvas data from the index and renders all widgets on a draggable surface.
602
+ *
603
+ * @param {{ canvasId: string }} props - Canvas name as indexed by the data plugin
604
+ */
605
+ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages = [], canvasMeta = null }) {
606
+ const canvasId = canvasIdProp || name || ''
607
+ const { canvas, jsxExports, jsxError, loading } = useCanvas(canvasId)
608
+ const isLocalDev = typeof window !== 'undefined' && window.__SB_LOCAL_DEV__ === true && !new URLSearchParams(window.location.search).has('prodMode')
609
+
610
+ // Local mutable copy of widgets for instant UI updates
611
+ const [localWidgets, setLocalWidgets] = useState(canvas?.widgets ?? null)
612
+ const [localConnectors, setLocalConnectors] = useState(canvas?.connectors ?? [])
613
+ const [trackedCanvas, setTrackedCanvas] = useState(canvas)
614
+ const [selectedWidgetIds, setSelectedWidgetIds] = useState(() => new Set())
615
+ const initialViewport = loadViewportState(canvasId)
616
+ const [zoom, setZoom] = useState(initialViewport?.zoom ?? 100)
617
+ const zoomRef = useRef(initialViewport?.zoom ?? 100)
618
+ const scrollRef = useRef(null)
619
+ const zoomElRef = useRef(null)
620
+ const zoomCommitTimer = useRef(null)
621
+ const zoomEventTimer = useRef(null)
622
+ const pendingScrollRestore = useRef(initialViewport)
623
+ // Gate viewport persistence until initial positioning is complete.
624
+ // Tracks which canvasId was last initialized — save effects only
625
+ // write when this matches `canvasId`, preventing cross-canvas corruption.
626
+ const viewportInitName = useRef(null)
627
+ const [localSources, setLocalSources] = useState(canvas?.sources ?? [])
628
+ const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasThemeFromStorage())
629
+ const [snapEnabled, setSnapEnabled] = useState(canvas?.snapToGrid ?? false)
630
+ const [snapGridSize, setSnapGridSize] = useState(canvas?.gridSize || 40)
631
+ const [showGhInstallBanner, setShowGhInstallBanner] = useState(false)
632
+
633
+ // Scroll lock: prevents focus-triggered scroll jumps when adding terminal/agent widgets.
634
+ // The lock captures the current scroll position and forces it back on every scroll event
635
+ // until unlocked by the widget's ready signal or a safety timeout.
636
+ // Visual UI (outline + banner) only appears after 1.5s if still locked.
637
+
638
+ // Refs for snap settings (used by drop handler inside effect closure)
639
+ const snapEnabledRef = useRef(snapEnabled)
640
+ const snapGridSizeRef = useRef(snapGridSize)
641
+
642
+ // Centralized list of component export names.
643
+ // When jsxExports is available, use it (discovers new exports not yet in sources).
644
+ // When jsxExports is null (module import failed), fall back to sources so iframes
645
+ // still render — the error is contained inside each iframe.
646
+ const componentEntries = useMemo(() => {
647
+ const sourceMap = Object.fromEntries(
648
+ (localSources || []).filter((s) => s?.export).map((s) => [s.export, s]),
649
+ )
650
+ if (jsxExports) {
651
+ return Object.keys(jsxExports).map((exportName) => ({
652
+ exportName,
653
+ Component: jsxExports[exportName],
654
+ sourceData: sourceMap[exportName] || {},
655
+ }))
656
+ }
657
+ // Fallback: use sources when module import failed (iframe isolation still works)
658
+ if (jsxError && canvas?._jsxModule) {
659
+ return (localSources || [])
660
+ .filter((s) => s?.export)
661
+ .map((s) => ({
662
+ exportName: s.export,
663
+ Component: null,
664
+ sourceData: s,
665
+ }))
666
+ }
667
+ return []
668
+ }, [jsxExports, jsxError, localSources, canvas?._jsxModule])
669
+
670
+ // Undo/redo history — tracks both widgets and sources as a combined snapshot
671
+ const undoRedo = useUndoRedo()
672
+ const stateRef = useRef({ widgets: localWidgets, sources: localSources, connectors: localConnectors })
673
+ useEffect(() => {
674
+ stateRef.current = { widgets: localWidgets, sources: localSources, connectors: localConnectors }
675
+ }, [localWidgets, localSources, localConnectors])
676
+
677
+ // Dirty flag — true while optimistic edits haven't been persisted yet.
678
+ // Prevents HMR echoes from overwriting in-flight local state.
679
+ const dirtyRef = useRef(false)
680
+
681
+ // Counter of in-flight writes. dirtyRef is only cleared when this reaches 0,
682
+ // preventing early clears when multiple writes are queued in sequence.
683
+ const inflightWritesRef = useRef(0)
684
+
685
+ // Grace period timer — after all writes complete, dirtyRef stays true for a
686
+ // brief window to absorb delayed file-watcher HMR events that arrive after
687
+ // the server's immediate push. Defense-in-depth for the write guard.
688
+ const dirtyGraceTimerRef = useRef(null)
689
+
690
+ // Serialized write queue — ensures JSONL events land in the right order
691
+ const writeQueueRef = useRef(Promise.resolve())
692
+ function queueWrite(fn) {
693
+ clearTimeout(dirtyGraceTimerRef.current)
694
+ inflightWritesRef.current += 1
695
+ writeQueueRef.current = writeQueueRef.current
696
+ .then(fn)
697
+ .catch((err) => console.error('[canvas] Write queue error:', err))
698
+ .finally(() => {
699
+ inflightWritesRef.current -= 1
700
+ if (inflightWritesRef.current < 0) {
701
+ console.warn('[canvas] Write queue counter underflow — resetting')
702
+ inflightWritesRef.current = 0
703
+ }
704
+ if (inflightWritesRef.current === 0) {
705
+ // Grace period — absorb delayed watcher HMR events before clearing
706
+ dirtyGraceTimerRef.current = setTimeout(() => {
707
+ if (inflightWritesRef.current === 0) {
708
+ dirtyRef.current = false
709
+ }
710
+ }, 600)
711
+ }
712
+ })
713
+ return writeQueueRef.current
714
+ }
715
+
716
+ // Ref for selectedWidgetIds to avoid stale closures in callbacks
717
+ const selectedIdsRef = useRef(selectedWidgetIds)
718
+ useEffect(() => {
719
+ selectedIdsRef.current = selectedWidgetIds
720
+ }, [selectedWidgetIds])
721
+
722
+ const isMultiSelected = selectedWidgetIds.size > 1
723
+
724
+ /**
725
+ * Selection handler — shift+click toggles in/out of multi-select set,
726
+ * plain click single-selects (clears others).
727
+ * Suppressed immediately after a multi-drag to prevent the post-drag
728
+ * click from collapsing the selection.
729
+ */
730
+ const handleWidgetSelect = useCallback((widgetId, shiftKey) => {
731
+ if (justDraggedRef.current) return
732
+ if (shiftKey) {
733
+ setSelectedWidgetIds(prev => {
734
+ const next = new Set(prev)
735
+ if (next.has(widgetId)) {
736
+ next.delete(widgetId)
737
+ } else {
738
+ next.add(widgetId)
739
+ }
740
+ return next
741
+ })
742
+ } else {
743
+ setSelectedWidgetIds(new Set([widgetId]))
744
+ }
745
+ }, [])
746
+
747
+ // --- Multi-select drag: peers animate to new positions on drag end ---
748
+ // During drag, only the dragged widget moves (via neodrag). On drag end,
749
+ // peer widget positions are updated via React state, and we add the
750
+ // tc-on-translation class so they animate smoothly to their new spots.
751
+ const peerArticlesRef = useRef(new Map())
752
+ // Flag to suppress the click-based selection reset that fires after a drag
753
+ const justDraggedRef = useRef(false)
754
+
755
+ const handleItemDragStart = useCallback((dragId) => {
756
+ setWidgetDragging(true)
757
+ const ids = selectedIdsRef.current
758
+ peerArticlesRef.current.clear()
759
+ if (ids.size <= 1 || !ids.has(dragId)) return
760
+
761
+ // Suppress selection changes for the duration of the drag
762
+ justDraggedRef.current = true // eslint-disable-line react-hooks/immutability
763
+
764
+ // Collect peer article elements for transition on drag end
765
+ for (const id of ids) {
766
+ if (id === dragId) continue
767
+ const widgetEl = document.getElementById(id)
768
+ const article = widgetEl?.closest('article')
769
+ if (!article) continue
770
+ peerArticlesRef.current.set(id, article)
771
+ }
772
+ }, [])
773
+
774
+ const handleItemDrag = useCallback(() => {
775
+ // Peers stay put during drag — they animate on drag end
776
+ }, [])
777
+
778
+ /** Add transition class to peer articles so they animate to new positions. */
779
+ const transitionPeers = useCallback(() => {
780
+ for (const [, article] of peerArticlesRef.current) {
781
+ article.classList.add('tc-on-translation')
782
+ }
783
+ // Remove class after animation completes
784
+ const articles = [...peerArticlesRef.current.values()]
785
+ setTimeout(() => {
786
+ for (const article of articles) {
787
+ article.classList.remove('tc-on-translation')
788
+ }
789
+ }, 150 + 50 + 200)
790
+ peerArticlesRef.current.clear()
791
+ }, [])
792
+
793
+ const clearDragPreview = useCallback(() => {
794
+ peerArticlesRef.current.clear()
795
+ }, [])
796
+
797
+ if (canvas !== trackedCanvas) {
798
+ const isCanvasSwitch = trackedCanvas && canvas && trackedCanvas._route !== canvas._route
799
+ if (getFlag('dev-logs')) console.log('[viewport] canvas changed —', isCanvasSwitch ? 'new canvas, resetting viewport' : 'same canvas, updating widgets only')
800
+ setTrackedCanvas(canvas)
801
+
802
+ // Skip replacing local state with server data when optimistic edits are
803
+ // pending — the local state is more recent. The next save will persist it
804
+ // and the subsequent server push (after dirty clears) will reconcile.
805
+ if (!dirtyRef.current || isCanvasSwitch) {
806
+ setLocalWidgets(canvas?.widgets ?? null)
807
+ setLocalConnectors(canvas?.connectors ?? [])
808
+ setLocalSources(canvas?.sources ?? [])
809
+ }
810
+
811
+ setSnapEnabled(canvas?.snapToGrid ?? false)
812
+ setSnapGridSize(canvas?.gridSize || 40)
813
+ if (isCanvasSwitch) {
814
+ undoRedo.reset()
815
+ }
816
+ // Only reset viewport state when switching to a different canvas,
817
+ // not when the same canvas refreshes with server data.
818
+ if (isCanvasSwitch) {
819
+ viewportInitName.current = null
820
+ const newViewport = loadViewportState(canvasId)
821
+ pendingScrollRestore.current = newViewport
822
+ const newZoom = newViewport?.zoom ?? 100
823
+ zoomRef.current = newZoom
824
+ setZoom(newZoom)
825
+ }
826
+ }
827
+
828
+ // Debounced save to server — routed through queueWrite to serialize
829
+ // with deletes and other writes, preventing stale data from overwriting.
830
+ const debouncedSave = useRef(
831
+ debounce((canvasId, widgets) => {
832
+ queueWrite(() =>
833
+ updateCanvas(canvasId, { widgets })
834
+ .catch((err) => console.error('[canvas] Failed to save:', err))
835
+ )
836
+ }, 2000)
837
+ ).current
838
+
839
+ const handleWidgetUpdate = useCallback((widgetId, updates) => {
840
+ undoRedo.snapshot(stateRef.current, 'edit', widgetId)
841
+ // Snap width/height to grid when snap is enabled
842
+ const snapped = { ...updates }
843
+ if (snapEnabled && snapGridSize) {
844
+ if (snapped.width != null) snapped.width = snapDimension(snapped.width, snapGridSize, true, 60)
845
+ if (snapped.height != null) snapped.height = snapDimension(snapped.height, snapGridSize, true, 60)
846
+ }
847
+ setLocalWidgets((prev) => {
848
+ if (!prev) return prev
849
+ const next = prev.map((w) =>
850
+ w.id === widgetId ? { ...w, props: { ...w.props, ...snapped } } : w
851
+ )
852
+ dirtyRef.current = true
853
+ debouncedSave(canvasId, next)
854
+ return next
855
+ })
856
+ }, [canvasId, debouncedSave, undoRedo, snapEnabled, snapGridSize])
857
+
858
+ const handleWidgetRemove = useCallback((widgetId) => {
859
+ // Cancel any pending debounced save — it may contain stale data
860
+ // that includes the widget we're about to delete
861
+ debouncedSave.cancel()
862
+
863
+ undoRedo.snapshot(stateRef.current, 'remove', widgetId)
864
+ setLocalWidgets((prev) => prev ? prev.filter((w) => w.id !== widgetId) : prev)
865
+ // Cascade: remove connectors referencing this widget
866
+ setLocalConnectors((prev) => {
867
+ const orphaned = prev.filter((c) => c.start.widgetId === widgetId || c.end.widgetId === widgetId)
868
+ if (orphaned.length === 0) return prev
869
+ for (const c of orphaned) {
870
+ queueWrite(() =>
871
+ removeConnectorApi(canvasId, c.id).catch((err) =>
872
+ console.error('[canvas] Failed to remove orphaned connector:', err)
873
+ )
874
+ )
875
+ }
876
+ return prev.filter((c) => c.start.widgetId !== widgetId && c.end.widgetId !== widgetId)
877
+ })
878
+ dirtyRef.current = true
879
+ queueWrite(() =>
880
+ removeWidgetApi(canvasId, widgetId)
881
+ .catch((err) => console.error('[canvas] Failed to remove widget:', err))
882
+ )
883
+ }, [canvasId, undoRedo, debouncedSave])
884
+
885
+ const handleConnectorAdd = useCallback(async ({ startWidgetId, startAnchor, endWidgetId, endAnchor }) => {
886
+ try {
887
+ undoRedo.snapshot(stateRef.current, 'connector-add')
888
+ const result = await addConnectorApi(canvasId, { startWidgetId, startAnchor, endWidgetId, endAnchor })
889
+ if (result.success && result.connector) {
890
+ setLocalConnectors((prev) => [...prev, result.connector])
891
+ }
892
+ } catch (err) {
893
+ console.error('[canvas] Failed to add connector:', err)
894
+ }
895
+ }, [canvasId, undoRedo])
896
+
897
+ const handleConnectorRemove = useCallback((connectorId) => {
898
+ undoRedo.snapshot(stateRef.current, 'connector-remove')
899
+ setLocalConnectors((prev) => prev.filter((c) => c.id !== connectorId))
900
+ dirtyRef.current = true
901
+ queueWrite(() =>
902
+ removeConnectorApi(canvasId, connectorId).catch((err) =>
903
+ console.error('[canvas] Failed to remove connector:', err)
904
+ )
905
+ )
906
+ }, [canvasId, undoRedo])
907
+
908
+ // Connector drag state
909
+ const [connectorDrag, setConnectorDrag] = useState(null)
910
+ const [widgetDragging, setWidgetDragging] = useState(false)
911
+
912
+ const handleConnectorDragStart = useCallback((widgetId, anchor, e) => {
913
+ e.stopPropagation()
914
+ e.preventDefault()
915
+ const scrollEl = scrollRef.current
916
+ if (!scrollEl) return
917
+ const scale = zoomRef.current / 100
918
+ const rect = scrollEl.getBoundingClientRect()
919
+
920
+ const widgets = stateRef.current.widgets ?? []
921
+ const startWidget = widgets.find((w) => w.id === widgetId)
922
+ if (!startWidget) return
923
+
924
+ // Don't start drag from a disabled/unavailable anchor
925
+ const srcAnchorState = getAnchorState(startWidget.type, anchor)
926
+ if (srcAnchorState !== 'available') return
927
+
928
+ const computeAnchorPt = (widget, anch) => {
929
+ let ww, wh
930
+ const el = document.getElementById(widget.id)
931
+ if (el) {
932
+ const inner = el.querySelector('[data-widget-id]') || el.firstElementChild
933
+ if (inner) { ww = inner.offsetWidth; wh = inner.offsetHeight }
934
+ }
935
+ if (!ww) ww = widget.props?.width ?? widget.bounds?.width ?? 270
936
+ if (!wh) wh = widget.props?.height ?? widget.bounds?.height ?? 170
937
+ const px = widget.position?.x ?? 0
938
+ const py = widget.position?.y ?? 0
939
+ switch (anch) {
940
+ case 'top': return { x: px + ww / 2, y: py }
941
+ case 'bottom': return { x: px + ww / 2, y: py + wh }
942
+ case 'left': return { x: px, y: py + wh / 2 }
943
+ case 'right': return { x: px + ww, y: py + wh / 2 }
944
+ default: return { x: px + ww / 2, y: py + wh / 2 }
945
+ }
946
+ }
947
+
948
+ const startPt = computeAnchorPt(startWidget, anchor)
949
+
950
+ const toCanvasPoint = (clientX, clientY) => ({
951
+ x: (scrollEl.scrollLeft + clientX - rect.left) / scale,
952
+ y: (scrollEl.scrollTop + clientY - rect.top) / scale,
953
+ })
954
+
955
+ // Find nearest anchor on any other widget within a rectangular snap zone.
956
+ // Each anchor has a 30px-wide strip (15px each side) extending from the widget edge.
957
+ const SNAP_EXTEND = 15
958
+ const SNAP_DEPTH = 40
959
+ const SNAP_CROSS = 20 // perpendicular expansion so you can approach from any direction
960
+ const sourceType = startWidget.type
961
+ const findNearestAnchor = (canvasPt) => {
962
+ const currentWidgets = stateRef.current.widgets ?? []
963
+ let best = null
964
+ let bestDist = Infinity
965
+ for (const w of currentWidgets) {
966
+ if (w.id === widgetId) continue
967
+ if (!canAcceptConnection(w.type, sourceType)) continue
968
+
969
+ let ww, wh
970
+ const el = document.getElementById(w.id)
971
+ if (el) {
972
+ const inner = el.querySelector('[data-widget-id]') || el.firstElementChild
973
+ if (inner) { ww = inner.offsetWidth; wh = inner.offsetHeight }
974
+ }
975
+ if (!ww) ww = w.props?.width ?? w.bounds?.width ?? 270
976
+ if (!wh) wh = w.props?.height ?? w.bounds?.height ?? 170
977
+ const wx = w.position?.x ?? 0
978
+ const wy = w.position?.y ?? 0
979
+
980
+ for (const anch of ['top', 'bottom', 'left', 'right']) {
981
+ const anchorState = getAnchorState(w.type, anch)
982
+ if (anchorState !== 'available') continue
983
+
984
+ // Build a rectangular hit zone for this anchor
985
+ let inZone = false
986
+ if (anch === 'top') {
987
+ inZone = canvasPt.x >= wx - SNAP_CROSS && canvasPt.x <= wx + ww + SNAP_CROSS &&
988
+ canvasPt.y >= wy - SNAP_DEPTH && canvasPt.y <= wy + SNAP_EXTEND
989
+ } else if (anch === 'bottom') {
990
+ inZone = canvasPt.x >= wx - SNAP_CROSS && canvasPt.x <= wx + ww + SNAP_CROSS &&
991
+ canvasPt.y >= wy + wh - SNAP_EXTEND && canvasPt.y <= wy + wh + SNAP_DEPTH
992
+ } else if (anch === 'left') {
993
+ inZone = canvasPt.x >= wx - SNAP_DEPTH && canvasPt.x <= wx + SNAP_EXTEND &&
994
+ canvasPt.y >= wy - SNAP_CROSS && canvasPt.y <= wy + wh + SNAP_CROSS
995
+ } else if (anch === 'right') {
996
+ inZone = canvasPt.x >= wx + ww - SNAP_EXTEND && canvasPt.x <= wx + ww + SNAP_DEPTH &&
997
+ canvasPt.y >= wy - SNAP_CROSS && canvasPt.y <= wy + wh + SNAP_CROSS
998
+ }
999
+ if (!inZone) continue
1000
+
1001
+ const pt = computeAnchorPt(w, anch)
1002
+ const dist = Math.hypot(pt.x - canvasPt.x, pt.y - canvasPt.y)
1003
+ if (dist < bestDist) {
1004
+ bestDist = dist
1005
+ best = { widgetId: w.id, anchor: anch, pt }
1006
+ }
1007
+ }
1008
+ }
1009
+ return best
1010
+ }
1011
+
1012
+ const cursorPt = toCanvasPoint(e.clientX, e.clientY)
1013
+ const snap = findNearestAnchor(cursorPt)
1014
+ setConnectorDrag({
1015
+ startWidgetId: widgetId,
1016
+ startAnchor: anchor,
1017
+ startPt,
1018
+ endPt: snap ? snap.pt : cursorPt,
1019
+ endAnchor: snap ? snap.anchor : anchor,
1020
+ snapTarget: snap,
1021
+ })
1022
+
1023
+ const handlePointerMove = (moveE) => {
1024
+ const pt = toCanvasPoint(moveE.clientX, moveE.clientY)
1025
+ const nearSnap = findNearestAnchor(pt)
1026
+ setConnectorDrag((prev) => prev ? {
1027
+ ...prev,
1028
+ endPt: nearSnap ? nearSnap.pt : pt,
1029
+ endAnchor: nearSnap ? nearSnap.anchor : prev.startAnchor,
1030
+ snapTarget: nearSnap,
1031
+ } : null)
1032
+ }
1033
+
1034
+ const handlePointerUp = (upE) => {
1035
+ document.removeEventListener('pointermove', handlePointerMove)
1036
+ document.removeEventListener('pointerup', handlePointerUp)
1037
+
1038
+ const pt = toCanvasPoint(upE.clientX, upE.clientY)
1039
+ const nearSnap = findNearestAnchor(pt)
1040
+
1041
+ if (nearSnap) {
1042
+ handleConnectorAdd({
1043
+ startWidgetId: widgetId,
1044
+ startAnchor: anchor,
1045
+ endWidgetId: nearSnap.widgetId,
1046
+ endAnchor: nearSnap.anchor,
1047
+ })
1048
+ }
1049
+ setConnectorDrag(null)
1050
+ }
1051
+
1052
+ document.addEventListener('pointermove', handlePointerMove)
1053
+ document.addEventListener('pointerup', handlePointerUp)
1054
+ }, [handleConnectorAdd])
1055
+
1056
+ // Endpoint drag removed — dragging from a filled anchor now always
1057
+ // creates a new connection via handleConnectorDragStart instead of
1058
+ // repositioning the existing one.
1059
+
1060
+ const handleWidgetCopy = useCallback(async (widget) => {
1061
+ // Find the next free offset — check how many copies already exist at +n*40
1062
+ const baseX = widget.position?.x ?? 0
1063
+ const baseY = widget.position?.y ?? 0
1064
+ const occupied = new Set(
1065
+ (localWidgets ?? []).map((w) => `${w.position?.x ?? 0},${w.position?.y ?? 0}`)
1066
+ )
1067
+ let n = 1
1068
+ while (occupied.has(`${baseX + n * 40},${baseY + n * 40}`)) {
1069
+ n++
1070
+ }
1071
+ const position = { x: baseX + n * 40, y: baseY + n * 40 }
1072
+ const isTerminal = widget.type === 'terminal' || widget.type === 'agent'
1073
+ try {
1074
+ const copyProps = { ...widget.props }
1075
+ // Terminal widgets must get unique names — strip prettyName so the server generates a fresh one
1076
+ if (isTerminal) delete copyProps.prettyName
1077
+ // Image widgets: duplicate the asset file so each widget owns its own copy
1078
+ if (widget.type === 'image' && copyProps.src) {
1079
+ const dupResult = await duplicateImage(copyProps.src)
1080
+ if (dupResult.success) copyProps.src = dupResult.filename
1081
+ }
1082
+
1083
+ undoRedo.snapshot(stateRef.current, 'add')
1084
+ const result = await addWidgetApi(canvasId, {
1085
+ type: widget.type,
1086
+ props: copyProps,
1087
+ position,
1088
+ })
1089
+ if (result.success && result.widget) {
1090
+ if (result.hotSession?.webglReady) {
1091
+ result.widget.props = { ...result.widget.props, webglReady: true }
1092
+ }
1093
+ setLocalWidgets((prev) => [...(prev || []), result.widget])
1094
+ setSelectedWidgetIds(new Set([result.widget.id]))
1095
+ }
1096
+ } catch (err) {
1097
+ console.error('[canvas] Failed to copy widget:', err)
1098
+ }
1099
+ }, [canvasId, localWidgets, undoRedo])
1100
+
1101
+ // Duplicate a single widget WITH its connectors (Alt+click on duplicate button)
1102
+ const handleWidgetCopyWithConnectors = useCallback(async (widget) => {
1103
+ if (!widget) return
1104
+ const widgets = [widget]
1105
+
1106
+ undoRedo.snapshot(stateRef.current, 'add')
1107
+
1108
+ const occupied = new Set(
1109
+ (localWidgets ?? []).map((w) => `${w.position?.x ?? 0},${w.position?.y ?? 0}`)
1110
+ )
1111
+ let offset = 1
1112
+ while (occupied.has(`${(widget.position?.x ?? 0) + offset * 40},${(widget.position?.y ?? 0) + offset * 40}`)) offset++
1113
+
1114
+ const imageOverrides = new Map()
1115
+ if (widget.type === 'image' && widget.props?.src) {
1116
+ try {
1117
+ const dupResult = await duplicateImage(widget.props.src)
1118
+ if (dupResult.success) imageOverrides.set(widget.id, dupResult.filename)
1119
+ } catch { /* use original src as fallback */ }
1120
+ }
1121
+
1122
+ const selectedIds = new Set([widget.id])
1123
+ const relevantConnectors = (localConnectors ?? []).filter(
1124
+ (c) => selectedIds.has(c.start?.widgetId) || selectedIds.has(c.end?.widgetId)
1125
+ )
1126
+
1127
+ const ops = []
1128
+ for (const w of widgets) {
1129
+ const copyProps = { ...w.props }
1130
+ const isTerminal = w.type === 'terminal' || w.type === 'agent'
1131
+ if (isTerminal) delete copyProps.prettyName
1132
+ if (imageOverrides.has(w.id)) copyProps.src = imageOverrides.get(w.id)
1133
+ ops.push({
1134
+ op: 'create-widget',
1135
+ ref: `clone-${w.id}`,
1136
+ type: w.type,
1137
+ props: copyProps,
1138
+ position: {
1139
+ x: (w.position?.x ?? 0) + offset * 40,
1140
+ y: (w.position?.y ?? 0) + offset * 40,
1141
+ },
1142
+ })
1143
+ }
1144
+
1145
+ for (const conn of relevantConnectors) {
1146
+ const startInSelection = selectedIds.has(conn.start?.widgetId)
1147
+ const endInSelection = selectedIds.has(conn.end?.widgetId)
1148
+ ops.push({
1149
+ op: 'create-connector',
1150
+ startWidgetId: startInSelection ? `$clone-${conn.start.widgetId}` : conn.start.widgetId,
1151
+ startAnchor: conn.start.anchor,
1152
+ endWidgetId: endInSelection ? `$clone-${conn.end.widgetId}` : conn.end.widgetId,
1153
+ endAnchor: conn.end.anchor,
1154
+ connectorType: conn.connectorType || 'default',
1155
+ })
1156
+ }
1157
+
1158
+ try {
1159
+ const response = await batchOperations(canvasId, ops)
1160
+ if (!response.success) {
1161
+ console.error('[canvas] Batch duplicate failed:', response.error)
1162
+ return
1163
+ }
1164
+
1165
+ const newWidgets = []
1166
+ const newConnectors = []
1167
+ const refMap = response.refs || {}
1168
+
1169
+ for (const result of response.results) {
1170
+ if (result.op === 'create-widget' && result.widget) {
1171
+ newWidgets.push(result.widget)
1172
+ }
1173
+ if (result.op === 'create-connector' && result.connectorId) {
1174
+ const origOp = ops[result.index]
1175
+ const resolveId = (val) => {
1176
+ if (typeof val === 'string' && val.startsWith('$')) {
1177
+ return refMap[val.slice(1)] ?? val
1178
+ }
1179
+ return val
1180
+ }
1181
+ newConnectors.push({
1182
+ id: result.connectorId,
1183
+ type: 'connector',
1184
+ connectorType: origOp.connectorType || 'default',
1185
+ start: { widgetId: resolveId(origOp.startWidgetId), anchor: origOp.startAnchor },
1186
+ end: { widgetId: resolveId(origOp.endWidgetId), anchor: origOp.endAnchor },
1187
+ meta: {},
1188
+ })
1189
+ }
1190
+ }
1191
+
1192
+ if (newWidgets.length > 0) {
1193
+ setLocalWidgets((prev) => [...(prev || []), ...newWidgets])
1194
+ setSelectedWidgetIds(new Set(newWidgets.map((w) => w.id)))
1195
+ }
1196
+ if (newConnectors.length > 0) {
1197
+ setLocalConnectors((prev) => [...prev, ...newConnectors])
1198
+ }
1199
+ } catch (err) {
1200
+ console.error('[canvas] Failed to duplicate with connectors:', err)
1201
+ }
1202
+ }, [canvasId, localWidgets, localConnectors, undoRedo])
1203
+
1204
+ // Duplicate all selected widgets in one undo step (Cmd+D)
1205
+ const handleDuplicateSelected = useCallback(async () => {
1206
+ const widgets = (localWidgets ?? []).filter((w) => selectedWidgetIds.has(w.id))
1207
+ if (widgets.length === 0) return
1208
+
1209
+ // Single undo snapshot for the entire batch
1210
+ undoRedo.snapshot(stateRef.current, 'add')
1211
+
1212
+ // Compute occupied positions to find free offset
1213
+ const occupied = new Set(
1214
+ (localWidgets ?? []).map((w) => `${w.position?.x ?? 0},${w.position?.y ?? 0}`)
1215
+ )
1216
+ let offset = 1
1217
+ const anyOccupied = () => widgets.some((w) => {
1218
+ const bx = (w.position?.x ?? 0) + offset * 40
1219
+ const by = (w.position?.y ?? 0) + offset * 40
1220
+ return occupied.has(`${bx},${by}`)
1221
+ })
1222
+ while (anyOccupied()) offset++
1223
+
1224
+ const newWidgets = []
1225
+ for (const widget of widgets) {
1226
+ const position = {
1227
+ x: (widget.position?.x ?? 0) + offset * 40,
1228
+ y: (widget.position?.y ?? 0) + offset * 40,
1229
+ }
1230
+ const isTerminal = widget.type === 'terminal' || widget.type === 'agent'
1231
+ try {
1232
+ const copyProps = { ...widget.props }
1233
+ if (isTerminal) delete copyProps.prettyName
1234
+ if (widget.type === 'image' && copyProps.src) {
1235
+ try {
1236
+ const dupResult = await duplicateImage(copyProps.src)
1237
+ if (dupResult.success) copyProps.src = dupResult.filename
1238
+ } catch { /* use original src as fallback */ }
1239
+ }
1240
+ const result = await addWidgetApi(canvasId, {
1241
+ type: widget.type,
1242
+ props: copyProps,
1243
+ position,
1244
+ })
1245
+ if (result.success && result.widget) {
1246
+ if (result.hotSession?.webglReady) {
1247
+ result.widget.props = { ...result.widget.props, webglReady: true }
1248
+ }
1249
+ newWidgets.push(result.widget)
1250
+ }
1251
+ } catch (err) {
1252
+ console.error('[canvas] Failed to duplicate widget:', err)
1253
+ }
1254
+ }
1255
+
1256
+ if (newWidgets.length > 0) {
1257
+ setLocalWidgets((prev) => [...(prev || []), ...newWidgets])
1258
+ setSelectedWidgetIds(new Set(newWidgets.map((w) => w.id)))
1259
+ }
1260
+ }, [canvasId, localWidgets, selectedWidgetIds, undoRedo])
1261
+
1262
+ // Duplicate selected widgets WITH connectors (Cmd+Shift+D)
1263
+ // Uses the batch API for atomic operation — all widgets and connectors
1264
+ // are created in a single request with $ref resolution.
1265
+ const handleDuplicateWithConnectors = useCallback(async () => {
1266
+ const widgets = (localWidgets ?? []).filter((w) => selectedWidgetIds.has(w.id))
1267
+ if (widgets.length === 0) return
1268
+
1269
+ undoRedo.snapshot(stateRef.current, 'add')
1270
+
1271
+ // Compute offset — same logic as handleDuplicateSelected
1272
+ const occupied = new Set(
1273
+ (localWidgets ?? []).map((w) => `${w.position?.x ?? 0},${w.position?.y ?? 0}`)
1274
+ )
1275
+ let offset = 1
1276
+ const anyOccupied = () => widgets.some((w) => {
1277
+ const bx = (w.position?.x ?? 0) + offset * 40
1278
+ const by = (w.position?.y ?? 0) + offset * 40
1279
+ return occupied.has(`${bx},${by}`)
1280
+ })
1281
+ while (anyOccupied()) offset++
1282
+
1283
+ // Pre-process image widgets — duplicate asset files to get unique filenames
1284
+ const imageOverrides = new Map()
1285
+ for (const widget of widgets) {
1286
+ if (widget.type === 'image' && widget.props?.src) {
1287
+ try {
1288
+ const dupResult = await duplicateImage(widget.props.src)
1289
+ if (dupResult.success) imageOverrides.set(widget.id, dupResult.filename)
1290
+ } catch { /* use original src as fallback */ }
1291
+ }
1292
+ }
1293
+
1294
+ // Find all connectors touching at least one selected widget
1295
+ const selectedIds = new Set(widgets.map((w) => w.id))
1296
+ const relevantConnectors = (localConnectors ?? []).filter(
1297
+ (c) => selectedIds.has(c.start?.widgetId) || selectedIds.has(c.end?.widgetId)
1298
+ )
1299
+
1300
+ // Build batch operations
1301
+ const ops = []
1302
+
1303
+ // 1. Create-widget ops with ref names for $ref resolution
1304
+ for (const widget of widgets) {
1305
+ const copyProps = { ...widget.props }
1306
+ const isTerminal = widget.type === 'terminal' || widget.type === 'agent'
1307
+ if (isTerminal) delete copyProps.prettyName
1308
+ if (imageOverrides.has(widget.id)) copyProps.src = imageOverrides.get(widget.id)
1309
+
1310
+ ops.push({
1311
+ op: 'create-widget',
1312
+ ref: `clone-${widget.id}`,
1313
+ type: widget.type,
1314
+ props: copyProps,
1315
+ position: {
1316
+ x: (widget.position?.x ?? 0) + offset * 40,
1317
+ y: (widget.position?.y ?? 0) + offset * 40,
1318
+ },
1319
+ })
1320
+ }
1321
+
1322
+ // 2. Create-connector ops — remap selected endpoints to $ref clones
1323
+ for (const conn of relevantConnectors) {
1324
+ const startInSelection = selectedIds.has(conn.start?.widgetId)
1325
+ const endInSelection = selectedIds.has(conn.end?.widgetId)
1326
+
1327
+ ops.push({
1328
+ op: 'create-connector',
1329
+ startWidgetId: startInSelection ? `$clone-${conn.start.widgetId}` : conn.start.widgetId,
1330
+ startAnchor: conn.start.anchor,
1331
+ endWidgetId: endInSelection ? `$clone-${conn.end.widgetId}` : conn.end.widgetId,
1332
+ endAnchor: conn.end.anchor,
1333
+ connectorType: conn.connectorType || 'default',
1334
+ })
1335
+ }
1336
+
1337
+ try {
1338
+ const response = await batchOperations(canvasId, ops)
1339
+ if (!response.success) {
1340
+ console.error('[canvas] Batch duplicate failed:', response.error)
1341
+ return
1342
+ }
1343
+
1344
+ // Extract created widgets and connectors from results
1345
+ const newWidgets = []
1346
+ const newConnectors = []
1347
+ const refMap = response.refs || {}
1348
+
1349
+ for (const result of response.results) {
1350
+ if (result.op === 'create-widget' && result.widget) {
1351
+ newWidgets.push(result.widget)
1352
+ }
1353
+ if (result.op === 'create-connector' && result.connectorId) {
1354
+ // Reconstruct connector object from the operation + resolved refs
1355
+ const origOp = ops[result.index]
1356
+ const resolveId = (val) => {
1357
+ if (typeof val === 'string' && val.startsWith('$')) {
1358
+ return refMap[val.slice(1)] ?? val
1359
+ }
1360
+ return val
1361
+ }
1362
+ newConnectors.push({
1363
+ id: result.connectorId,
1364
+ type: 'connector',
1365
+ connectorType: origOp.connectorType || 'default',
1366
+ start: { widgetId: resolveId(origOp.startWidgetId), anchor: origOp.startAnchor },
1367
+ end: { widgetId: resolveId(origOp.endWidgetId), anchor: origOp.endAnchor },
1368
+ meta: {},
1369
+ })
1370
+ }
1371
+ }
1372
+
1373
+ if (newWidgets.length > 0) {
1374
+ setLocalWidgets((prev) => [...(prev || []), ...newWidgets])
1375
+ setSelectedWidgetIds(new Set(newWidgets.map((w) => w.id)))
1376
+ }
1377
+ if (newConnectors.length > 0) {
1378
+ setLocalConnectors((prev) => [...prev, ...newConnectors])
1379
+ }
1380
+ } catch (err) {
1381
+ console.error('[canvas] Failed to duplicate with connectors:', err)
1382
+ }
1383
+ }, [canvasId, localWidgets, localConnectors, selectedWidgetIds, undoRedo])
1384
+
1385
+ // Select all widgets (Cmd+A)
1386
+ const handleSelectAll = useCallback(() => {
1387
+ const allIds = (localWidgets ?? []).map((w) => w.id)
1388
+ if (allIds.length > 0) setSelectedWidgetIds(new Set(allIds))
1389
+ }, [localWidgets])
1390
+
1391
+ const showMissingGhBanner = useCallback(() => {
1392
+ setShowGhInstallBanner(true)
1393
+ }, [])
1394
+
1395
+ const buildGitHubPreviewUpdates = useCallback(async (url) => {
1396
+ try {
1397
+ const availability = await checkGitHubCliAvailable()
1398
+ if (!availability?.available) {
1399
+ showMissingGhBanner()
1400
+ return null
1401
+ }
1402
+
1403
+ const result = await fetchGitHubEmbed(url)
1404
+ if (result?.code === 'gh_unavailable') {
1405
+ showMissingGhBanner()
1406
+ return null
1407
+ }
1408
+ if (!result?.success || !result?.snapshot) return null
1409
+
1410
+ const snapshot = result.snapshot
1411
+ return {
1412
+ title: snapshot.title || '',
1413
+ width: 580,
1414
+ height: 400,
1415
+ github: {
1416
+ kind: snapshot.kind || 'issue',
1417
+ parentKind: snapshot.parentKind || snapshot.kind || 'issue',
1418
+ context: snapshot.context || '',
1419
+ body: snapshot.body || '',
1420
+ bodyHtml: snapshot.bodyHtml || '',
1421
+ authors: Array.isArray(snapshot.authors)
1422
+ ? snapshot.authors.filter((author) => typeof author === 'string' && author.trim())
1423
+ : [],
1424
+ createdAt: snapshot.createdAt ?? null,
1425
+ updatedAt: snapshot.updatedAt ?? null,
1426
+ fetchedAt: new Date().toISOString(),
1427
+ },
1428
+ }
1429
+ } catch (err) {
1430
+ console.error('[canvas] Failed to fetch GitHub embed metadata:', err)
1431
+ return null
1432
+ }
1433
+ }, [showMissingGhBanner])
1434
+
1435
+ const handleRefreshGitHubWidget = useCallback(async (widgetId, url) => {
1436
+ if (!widgetId || !url) return { updated: false }
1437
+ const updates = await buildGitHubPreviewUpdates(url)
1438
+ if (!updates) return { updated: false }
1439
+ handleWidgetUpdate(widgetId, updates)
1440
+ return { updated: true }
1441
+ }, [buildGitHubPreviewUpdates, handleWidgetUpdate])
1442
+
1443
+ const debouncedSourceSave = useRef(
1444
+ debounce((canvasId, sources) => {
1445
+ queueWrite(() =>
1446
+ updateCanvas(canvasId, { sources }).catch((err) =>
1447
+ console.error('[canvas] Failed to save sources:', err)
1448
+ )
1449
+ )
1450
+ }, 2000)
1451
+ ).current
1452
+
1453
+ const handleSourceUpdate = useCallback((exportName, updates) => {
1454
+ undoRedo.snapshot(stateRef.current, 'edit', `jsx-${exportName}`)
1455
+ const snapped = { ...updates }
1456
+ if (snapEnabled && snapGridSize) {
1457
+ if (snapped.width != null) snapped.width = snapDimension(snapped.width, snapGridSize, true, 100)
1458
+ if (snapped.height != null) snapped.height = snapDimension(snapped.height, snapGridSize, true, 60)
1459
+ }
1460
+ setLocalSources((prev) => {
1461
+ const current = Array.isArray(prev) ? prev : []
1462
+ const next = current.some((s) => s?.export === exportName)
1463
+ ? current.map((s) => (s?.export === exportName ? { ...s, ...snapped } : s))
1464
+ : [...current, { export: exportName, ...snapped }]
1465
+ dirtyRef.current = true
1466
+ debouncedSourceSave(canvasId, next)
1467
+ return next
1468
+ })
1469
+ }, [canvasId, debouncedSourceSave, undoRedo, snapEnabled, snapGridSize])
1470
+
1471
+ const handleItemDragEnd = useCallback((dragId, position) => {
1472
+ setWidgetDragging(false)
1473
+ if (!dragId || !position) {
1474
+ clearDragPreview()
1475
+ return
1476
+ }
1477
+ const rounded = { x: Math.max(0, roundPosition(position.x)), y: Math.max(0, roundPosition(position.y)) }
1478
+
1479
+ const ids = selectedIdsRef.current
1480
+ // Multi-select move: apply same delta to all selected widgets
1481
+ // Checked BEFORE the jsx- early return so mixed selections work
1482
+ if (ids.size > 1 && ids.has(dragId)) {
1483
+ transitionPeers()
1484
+ // Suppress the click-based selection reset that fires after pointerup
1485
+ justDraggedRef.current = true // eslint-disable-line react-hooks/immutability
1486
+ requestAnimationFrame(() => { justDraggedRef.current = false })
1487
+ undoRedo.snapshot(stateRef.current, 'multi-move')
1488
+
1489
+ // Compute delta from the dragged widget's old position
1490
+ const isJsx = dragId.startsWith('jsx-')
1491
+ let oldPos = { x: 0, y: 0 }
1492
+ if (isJsx) {
1493
+ const sourceExport = dragId.replace(/^jsx-/, '')
1494
+ const source = (stateRef.current.sources ?? []).find(s => s?.export === sourceExport)
1495
+ oldPos = source?.position || { x: 0, y: 0 }
1496
+ } else {
1497
+ const draggedWidget = (stateRef.current.widgets ?? []).find(w => w.id === dragId)
1498
+ oldPos = draggedWidget?.position || { x: 0, y: 0 }
1499
+ }
1500
+ const dx = rounded.x - oldPos.x
1501
+ const dy = rounded.y - oldPos.y
1502
+
1503
+ debouncedSave.cancel()
1504
+
1505
+ // Update JSON widget positions
1506
+ setLocalWidgets((prev) => {
1507
+ if (!prev) return prev
1508
+ const next = prev.map((w) => {
1509
+ if (w.id === dragId) return { ...w, position: rounded }
1510
+ if (ids.has(w.id)) {
1511
+ return {
1512
+ ...w,
1513
+ position: {
1514
+ x: Math.max(0, roundPosition((w.position?.x ?? 0) + dx)),
1515
+ y: Math.max(0, roundPosition((w.position?.y ?? 0) + dy)),
1516
+ },
1517
+ }
1518
+ }
1519
+ return w
1520
+ })
1521
+ dirtyRef.current = true
1522
+ queueWrite(() =>
1523
+ updateCanvas(canvasId, { widgets: next })
1524
+ .catch((err) => console.error('[canvas] Failed to save multi-move:', err))
1525
+ )
1526
+ return next
1527
+ })
1528
+
1529
+ // Update JSX source positions
1530
+ setLocalSources((prev) => {
1531
+ const current = Array.isArray(prev) ? prev : []
1532
+ let changed = false
1533
+ const next = current.map((s) => {
1534
+ if (!s?.export) return s
1535
+ const sid = `jsx-${s.export}`
1536
+ if (sid === dragId) {
1537
+ changed = true
1538
+ return { ...s, position: rounded }
1539
+ }
1540
+ if (ids.has(sid)) {
1541
+ changed = true
1542
+ return {
1543
+ ...s,
1544
+ position: {
1545
+ x: Math.max(0, roundPosition((s.position?.x ?? 0) + dx)),
1546
+ y: Math.max(0, roundPosition((s.position?.y ?? 0) + dy)),
1547
+ },
1548
+ }
1549
+ }
1550
+ return s
1551
+ })
1552
+ if (changed) {
1553
+ dirtyRef.current = true
1554
+ queueWrite(() =>
1555
+ updateCanvas(canvasId, { sources: next })
1556
+ .catch((err) => console.error('[canvas] Failed to save multi-move sources:', err))
1557
+ )
1558
+ }
1559
+ return changed ? next : current
1560
+ })
1561
+ return
1562
+ }
1563
+
1564
+ if (dragId.startsWith('jsx-')) {
1565
+ undoRedo.snapshot(stateRef.current, 'move', dragId)
1566
+ const sourceExport = dragId.replace(/^jsx-/, '')
1567
+ setLocalSources((prev) => {
1568
+ const current = Array.isArray(prev) ? prev : []
1569
+ const next = current.some((s) => s?.export === sourceExport)
1570
+ ? current.map((s) => (s?.export === sourceExport ? { ...s, position: rounded } : s))
1571
+ : [...current, { export: sourceExport, position: rounded }]
1572
+ dirtyRef.current = true
1573
+ queueWrite(() =>
1574
+ updateCanvas(canvasId, { sources: next })
1575
+ .catch((err) => console.error('[canvas] Failed to save source position:', err))
1576
+ )
1577
+ return next
1578
+ })
1579
+ return
1580
+ }
1581
+
1582
+ undoRedo.snapshot(stateRef.current, 'move', dragId)
1583
+ debouncedSave.cancel()
1584
+ setLocalWidgets((prev) => {
1585
+ if (!prev) return prev
1586
+ const next = prev.map((w) =>
1587
+ w.id === dragId ? { ...w, position: rounded } : w
1588
+ )
1589
+ dirtyRef.current = true
1590
+ queueWrite(() =>
1591
+ updateCanvas(canvasId, { widgets: next })
1592
+ .catch((err) => console.error('[canvas] Failed to save widget position:', err))
1593
+ )
1594
+ return next
1595
+ })
1596
+ }, [canvasId, undoRedo, debouncedSave, transitionPeers, clearDragPreview])
1597
+
1598
+ // Keep zoomRef in sync when React state is set (e.g. by toolbar or zoom-to-fit)
1599
+ useEffect(() => {
1600
+ zoomRef.current = zoom
1601
+ }, [zoom])
1602
+
1603
+ // Cleanup zoom timers on unmount
1604
+ useEffect(() => () => {
1605
+ clearTimeout(zoomCommitTimer.current)
1606
+ clearTimeout(zoomEventTimer.current)
1607
+ }, [])
1608
+
1609
+ // Restore scroll position from localStorage after first render.
1610
+ // When saved state is fresh (< 15 min), restore it. Otherwise zoom-to-fit
1611
+ // all objects so the user sees a useful overview instead of stale coordinates.
1612
+ useEffect(() => {
1613
+ const el = scrollRef.current
1614
+ if (!el || loading) return
1615
+ const saved = pendingScrollRestore.current
1616
+ if (saved) {
1617
+ if (getFlag('dev-logs')) console.log('[viewport] restoring saved viewport — zoom:', saved.zoom, 'scroll:', saved.scrollLeft, saved.scrollTop)
1618
+ // Fresh saved viewport — restore exactly
1619
+ if (saved.scrollLeft != null) el.scrollLeft = saved.scrollLeft
1620
+ if (saved.scrollTop != null) el.scrollTop = saved.scrollTop
1621
+ pendingScrollRestore.current = null
1622
+ } else {
1623
+ if (getFlag('dev-logs')) console.log('[viewport] no saved viewport — fitting to objects')
1624
+ // No saved state or stale — zoom-to-fit all objects
1625
+ const bounds = computeCanvasBounds(localWidgets, componentEntries)
1626
+ if (bounds && el.clientWidth > 0 && el.clientHeight > 0) {
1627
+ const boxW = bounds.maxX - bounds.minX + FIT_PADDING * 2
1628
+ const boxH = bounds.maxY - bounds.minY + FIT_PADDING * 2
1629
+ const fitScale = Math.min(el.clientWidth / boxW, el.clientHeight / boxH)
1630
+ const { ZOOM_MIN: zMin, ZOOM_MAX: zMax } = zoomLimits()
1631
+ const fitZoom = Math.min(zMax, Math.max(zMin, Math.round(fitScale * 100)))
1632
+ const newScale = fitZoom / 100
1633
+ zoomRef.current = fitZoom
1634
+ // Imperative DOM update for initial zoom-to-fit — same path as applyZoom
1635
+ const zoomEl = zoomElRef.current
1636
+ if (zoomEl) {
1637
+ zoomEl.style.transform = `scale(${newScale})`
1638
+ zoomEl.style.width = `${Math.max(10000, 100 / newScale)}vw`
1639
+ zoomEl.style.height = `${Math.max(10000, 100 / newScale)}vh`
1640
+ }
1641
+ setZoom(fitZoom)
1642
+ el.scrollLeft = (bounds.minX - FIT_PADDING) * newScale
1643
+ el.scrollTop = (bounds.minY - FIT_PADDING) * newScale
1644
+ } else {
1645
+ el.scrollLeft = 0
1646
+ el.scrollTop = 0
1647
+ }
1648
+ }
1649
+ // Allow save effects for this canvas now that positioning is settled.
1650
+ viewportInitName.current = canvasId
1651
+ }, [canvasId, loading])
1652
+
1653
+ // Center on a specific widget if `?widget=<id>` is in the URL
1654
+ useEffect(() => {
1655
+ const params = new URLSearchParams(window.location.search)
1656
+ const targetId = params.get('widget')
1657
+ if (!targetId || loading) return
1658
+
1659
+ const el = scrollRef.current
1660
+ if (!el) return
1661
+
1662
+ let x, y, w, h
1663
+
1664
+ // Check JSON widgets first
1665
+ const widgets = localWidgets ?? []
1666
+ const widget = widgets.find((wgt) => wgt.id === targetId)
1667
+ if (widget) {
1668
+ const fallback = WIDGET_FALLBACK_SIZES[widget.type] || { width: 200, height: 150 }
1669
+ x = widget.position?.x ?? 0
1670
+ y = widget.position?.y ?? 0
1671
+ w = widget.props?.width ?? fallback.width
1672
+ h = widget.props?.height ?? fallback.height
1673
+ }
1674
+
1675
+ // Check JSX sources (jsx-ExportName)
1676
+ if (!widget && targetId.startsWith('jsx-')) {
1677
+ const exportName = targetId.slice(4)
1678
+ const entry = componentEntries.find((e) => e.exportName === exportName)
1679
+ if (entry) {
1680
+ const fallback = WIDGET_FALLBACK_SIZES['component']
1681
+ x = entry.sourceData?.position?.x ?? 0
1682
+ y = entry.sourceData?.position?.y ?? 0
1683
+ w = entry.sourceData?.width ?? fallback.width
1684
+ h = entry.sourceData?.height ?? fallback.height
1685
+ }
1686
+ }
1687
+
1688
+ if (x == null) return
1689
+
1690
+ const scale = zoomRef.current / 100
1691
+ el.scrollLeft = (x + w / 2) * scale - el.clientWidth / 2
1692
+ el.scrollTop = (y + h / 2) * scale - el.clientHeight / 2
1693
+
1694
+ // Clean the URL param without triggering navigation
1695
+ const url = new URL(window.location.href)
1696
+ url.searchParams.delete('widget')
1697
+ window.history.replaceState({}, '', url.toString())
1698
+ }, [loading, localWidgets, componentEntries])
1699
+
1700
+ // Persist viewport state (zoom only) to localStorage on zoom changes.
1701
+ // Scroll position is persisted separately by the debounced scroll handler,
1702
+ // cleanup handler, and beforeunload — never here, because imperative zoom
1703
+ // operations (applyZoom, zoom-to-fit) adjust scroll AFTER setZoom, so the
1704
+ // scroll values would be stale at this point.
1705
+ useEffect(() => {
1706
+ if (viewportInitName.current !== canvasId) return
1707
+ const el = scrollRef.current
1708
+ if (getFlag('dev-logs')) console.log('[viewport] saving — zoom:', zoom, 'scroll:', el?.scrollLeft, el?.scrollTop)
1709
+ // Read current scroll so the zoom entry doesn't zero-out position,
1710
+ // but the authoritative scroll save comes from the scroll handler.
1711
+ saveViewportState(canvasId, {
1712
+ zoom,
1713
+ scrollLeft: el?.scrollLeft ?? 0,
1714
+ scrollTop: el?.scrollTop ?? 0,
1715
+ })
1716
+ }, [canvasId, zoom])
1717
+
1718
+ useEffect(() => {
1719
+ const el = scrollRef.current
1720
+ if (!el) return
1721
+ const saveNow = () => {
1722
+ if (viewportInitName.current !== canvasId) return
1723
+ saveViewportState(canvasId, {
1724
+ zoom: zoomRef.current,
1725
+ scrollLeft: el.scrollLeft,
1726
+ scrollTop: el.scrollTop,
1727
+ })
1728
+ }
1729
+ const debouncedScrollSave = debounce(saveNow, 150)
1730
+ function handleScroll() {
1731
+ if (viewportInitName.current !== canvasId) return
1732
+ debouncedScrollSave()
1733
+ }
1734
+ el.addEventListener('scroll', handleScroll, { passive: true })
1735
+
1736
+ // Flush viewport state on page unload so a refresh never misses it
1737
+ function handleBeforeUnload() {
1738
+ debouncedScrollSave.cancel()
1739
+ saveNow()
1740
+ }
1741
+ window.addEventListener('beforeunload', handleBeforeUnload)
1742
+
1743
+ return () => {
1744
+ debouncedScrollSave.cancel()
1745
+ el.removeEventListener('scroll', handleScroll)
1746
+ window.removeEventListener('beforeunload', handleBeforeUnload)
1747
+ // Save final state on cleanup (covers SPA navigation where
1748
+ // beforeunload doesn't fire).
1749
+ saveNow()
1750
+ }
1751
+ }, [canvasId, loading])
1752
+
1753
+ // Gather current viewport data from refs (safe for callbacks/timeouts)
1754
+ const getViewportData = useCallback(() => {
1755
+ const el = scrollRef.current
1756
+ if (!el) return null
1757
+ const scale = zoomRef.current / 100
1758
+ const scrollLeft = el.scrollLeft
1759
+ const scrollTop = el.scrollTop
1760
+ const cw = el.clientWidth
1761
+ const ch = el.clientHeight
1762
+ return {
1763
+ centerX: Math.round((scrollLeft + cw / 2) / scale),
1764
+ centerY: Math.round((scrollTop + ch / 2) / scale),
1765
+ zoom: zoomRef.current,
1766
+ topLeftX: Math.round(scrollLeft / scale),
1767
+ topLeftY: Math.round(scrollTop / scale),
1768
+ width: Math.round(cw / scale),
1769
+ height: Math.round(ch / scale),
1770
+ }
1771
+ }, [])
1772
+
1773
+ // Debounced viewport-changed HMR event — sends position/zoom to Vite server
1774
+ // so the selected-widgets bridge can write it to disk for agents.
1775
+ useEffect(() => {
1776
+ if (!import.meta.hot) return
1777
+ const el = scrollRef.current
1778
+ if (!el) return
1779
+
1780
+ const tabId = selectionTabIdRef.current
1781
+
1782
+ function sendViewport() {
1783
+ const viewport = getViewportData()
1784
+ if (viewport) {
1785
+ import.meta.hot.send('storyboard:viewport-changed', { tabId, canvasId, viewport })
1786
+ }
1787
+ }
1788
+
1789
+ const debouncedSend = debounce(sendViewport, 500)
1790
+
1791
+ function handleScroll() { debouncedSend() }
1792
+ el.addEventListener('scroll', handleScroll, { passive: true })
1793
+
1794
+ // Also send on zoom commits (zoom state changes trigger this effect)
1795
+ sendViewport()
1796
+
1797
+ return () => {
1798
+ debouncedSend.cancel()
1799
+ el.removeEventListener('scroll', handleScroll)
1800
+ }
1801
+ }, [canvasId, zoom, loading, getViewportData])
1802
+
1803
+ /**
1804
+ * Zoom to a new level, anchoring on an optional client-space point.
1805
+ * When a cursor position is provided (e.g. from a wheel event), the
1806
+ * canvas point under the cursor stays fixed. Otherwise falls back to
1807
+ * the viewport center.
1808
+ *
1809
+ * Performs an imperative DOM mutation instead of a React state update
1810
+ * to avoid triggering a full re-render of the widget tree on every
1811
+ * zoom tick. React state is committed after a debounce for toolbar
1812
+ * display updates.
1813
+ */
1814
+ function applyZoom(newZoom, clientX, clientY) {
1815
+ const el = scrollRef.current
1816
+ const zoomEl = zoomElRef.current
1817
+ const { ZOOM_MIN, ZOOM_MAX } = zoomLimits()
1818
+ const clampedZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, newZoom))
1819
+
1820
+ if (!el || !zoomEl) {
1821
+ zoomRef.current = clampedZoom
1822
+ setZoom(clampedZoom)
1823
+ return
1824
+ }
1825
+
1826
+ const oldScale = zoomRef.current / 100
1827
+ const newScale = clampedZoom / 100
1828
+
1829
+ // Anchor point in scroll-container space
1830
+ const rect = el.getBoundingClientRect()
1831
+ const useViewportCenter = clientX == null || clientY == null
1832
+ const anchorX = useViewportCenter ? el.clientWidth / 2 : clientX - rect.left
1833
+ const anchorY = useViewportCenter ? el.clientHeight / 2 : clientY - rect.top
1834
+
1835
+ // Anchor → canvas coordinate
1836
+ const canvasX = (el.scrollLeft + anchorX) / oldScale
1837
+ const canvasY = (el.scrollTop + anchorY) / oldScale
1838
+
1839
+ // Imperative DOM update — no React re-render
1840
+ zoomRef.current = clampedZoom
1841
+ zoomEl.style.transform = `scale(${newScale})`
1842
+ zoomEl.style.width = `${Math.max(10000, 100 / newScale)}vw`
1843
+ zoomEl.style.height = `${Math.max(10000, 100 / newScale)}vh`
1844
+
1845
+ // Hint GPU compositing during active zoom
1846
+ zoomEl.dataset.zooming = ''
1847
+
1848
+ // Scroll so the same canvas point stays under the anchor
1849
+ el.scrollLeft = canvasX * newScale - anchorX
1850
+ el.scrollTop = canvasY * newScale - anchorY
1851
+
1852
+ // Debounced commit: update React state for toolbar display + persistence
1853
+ clearTimeout(zoomCommitTimer.current)
1854
+ zoomCommitTimer.current = setTimeout(() => {
1855
+ // Remove GPU compositing hint
1856
+ delete zoomEl.dataset.zooming
1857
+ setZoom(clampedZoom)
1858
+ }, 150)
1859
+
1860
+ // Throttled zoom-changed event for external consumers (toolbar)
1861
+ if (!zoomEventTimer.current) {
1862
+ zoomEventTimer.current = setTimeout(() => {
1863
+ zoomEventTimer.current = null
1864
+ const bridge = window[CANVAS_BRIDGE_STATE_KEY] || {}
1865
+ bridge.active = true
1866
+ bridge.canvasId = canvasId
1867
+ bridge.zoom = zoomRef.current
1868
+ window[CANVAS_BRIDGE_STATE_KEY] = bridge
1869
+ document.dispatchEvent(new CustomEvent('storyboard:canvas:zoom-changed', {
1870
+ detail: { zoom: zoomRef.current }
1871
+ }))
1872
+ }, 100)
1873
+ }
1874
+ }
1875
+
1876
+ // Signal canvas mount/unmount to CoreUIBar
1877
+ useEffect(() => {
1878
+ const bridge = window[CANVAS_BRIDGE_STATE_KEY] || {}
1879
+ bridge.active = true
1880
+ bridge.canvasId = canvasId
1881
+ bridge.zoom = zoomRef.current
1882
+ window[CANVAS_BRIDGE_STATE_KEY] = bridge
1883
+ document.dispatchEvent(new CustomEvent('storyboard:canvas:mounted', {
1884
+ detail: { canvasId, zoom: zoomRef.current }
1885
+ }))
1886
+
1887
+ function handleStatusRequest() {
1888
+ const state = window[CANVAS_BRIDGE_STATE_KEY] || { active: true, canvasId, zoom: zoomRef.current }
1889
+ document.dispatchEvent(new CustomEvent('storyboard:canvas:status', { detail: state }))
1890
+ }
1891
+
1892
+ document.addEventListener('storyboard:canvas:status-request', handleStatusRequest)
1893
+
1894
+ return () => {
1895
+ document.removeEventListener('storyboard:canvas:status-request', handleStatusRequest)
1896
+ window[CANVAS_BRIDGE_STATE_KEY] = { active: false, canvasId: '', zoom: 100 }
1897
+ document.dispatchEvent(new CustomEvent('storyboard:canvas:unmounted'))
1898
+ }
1899
+ }, [canvasId])
1900
+
1901
+ // Tell the Vite dev server to suppress full-reloads while this canvas is active.
1902
+ // Controlled by the "canvas-auto-reload" feature flag (default: false = guard ON).
1903
+ // When the flag is true, the guard is skipped so canvas pages receive HMR updates.
1904
+ // Sends a heartbeat every 3s so the guard auto-expires if the tab closes.
1905
+ useEffect(() => {
1906
+ if (!import.meta.hot) return
1907
+ const autoReload = getFlag('canvas-auto-reload')
1908
+ if (autoReload) return
1909
+
1910
+ const msg = { active: true }
1911
+ import.meta.hot.send('storyboard:canvas-hmr-guard', msg)
1912
+ const interval = setInterval(() => {
1913
+ import.meta.hot.send('storyboard:canvas-hmr-guard', msg)
1914
+ }, 3000)
1915
+
1916
+ return () => {
1917
+ clearInterval(interval)
1918
+ import.meta.hot.send('storyboard:canvas-hmr-guard', { active: false })
1919
+ }
1920
+ }, [canvasId])
1921
+
1922
+ // --- Selected widgets bridge ---
1923
+ // Writes .selectedwidgets.json so Copilot knows which canvas/widgets are active.
1924
+ // Uses a stable tabId to survive WebSocket reconnects.
1925
+ const selectionTabIdRef = useRef(Math.random().toString(36).slice(2, 10))
1926
+
1927
+ // Gather selected widget data from refs (safe for callbacks/timeouts)
1928
+ const getSelectedWidgetData = useCallback(() => {
1929
+ const ids = [...selectedIdsRef.current]
1930
+ const widgets = (stateRef.current.widgets || [])
1931
+ .filter(w => ids.includes(w.id))
1932
+ .map(w => ({ id: w.id, type: w.type, props: w.props }))
1933
+
1934
+ // Include jsx-* component selections
1935
+ for (const id of ids) {
1936
+ if (id.startsWith('jsx-') && !widgets.some(w => w.id === id)) {
1937
+ widgets.push({ id, type: 'component', props: { exportName: id.slice(4) } })
1938
+ }
1939
+ }
1940
+
1941
+ return { widgetIds: ids, widgets }
1942
+ }, [])
1943
+
1944
+ // Send focus event on mount, tab focus, and visibility change
1945
+ useEffect(() => {
1946
+ if (!import.meta.hot) return
1947
+
1948
+ const tabId = selectionTabIdRef.current
1949
+
1950
+ function sendFocus() {
1951
+ const { widgetIds, widgets } = getSelectedWidgetData()
1952
+ const viewport = getViewportData()
1953
+ import.meta.hot.send('storyboard:canvas-focused', { tabId, canvasId, widgetIds, widgets, viewport })
1954
+ }
1955
+
1956
+ sendFocus()
1957
+
1958
+ function handleVisibility() {
1959
+ if (!document.hidden) sendFocus()
1960
+ }
1961
+ function handleFocus() { sendFocus() }
1962
+
1963
+ document.addEventListener('visibilitychange', handleVisibility)
1964
+ window.addEventListener('focus', handleFocus)
1965
+
1966
+ return () => {
1967
+ document.removeEventListener('visibilitychange', handleVisibility)
1968
+ window.removeEventListener('focus', handleFocus)
1969
+ import.meta.hot.send('storyboard:canvas-unfocused', { tabId })
1970
+ }
1971
+ }, [canvasId, getSelectedWidgetData])
1972
+
1973
+ // Debounced selection change (500ms) — reads from refs at fire time
1974
+ useEffect(() => {
1975
+ if (!import.meta.hot) return
1976
+
1977
+ const tabId = selectionTabIdRef.current
1978
+ const timer = setTimeout(() => {
1979
+ const { widgetIds, widgets } = getSelectedWidgetData()
1980
+ const viewport = getViewportData()
1981
+ import.meta.hot.send('storyboard:selection-changed', { tabId, canvasId, widgetIds: widgetIds, widgets, viewport })
1982
+ }, 500)
1983
+
1984
+ return () => clearTimeout(timer)
1985
+ }, [selectedWidgetIds, canvasId, getSelectedWidgetData])
1986
+
1987
+ // Add a widget by type — used by CanvasControls and CoreUIBar event
1988
+ const addWidget = useCallback(async (type, extraProps = {}) => {
1989
+ const defaultProps = schemas[type] ? getDefaults(schemas[type]) : {}
1990
+ // For terminal/agent, apply config-based dimension defaults over schema defaults
1991
+ if (type === 'terminal' || type === 'agent') {
1992
+ const dims = getTerminalDimensions(extraProps.agentId, { width: defaultProps.width ?? 800, height: defaultProps.height ?? 450 })
1993
+ defaultProps.width = dims.width
1994
+ defaultProps.height = dims.height
1995
+ }
1996
+ const mergedProps = { ...defaultProps, ...extraProps }
1997
+ const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
1998
+ const pos = centerPositionForWidget(center, type, mergedProps)
1999
+ try {
2000
+ const result = await addWidgetApi(canvasId, {
2001
+ type,
2002
+ props: mergedProps,
2003
+ position: pos,
2004
+ })
2005
+ if (result.success && result.widget) {
2006
+ // Hot pool WebGL-ready flag: add to props so TerminalWidget starts PINNED
2007
+ if (result.hotSession?.webglReady) {
2008
+ result.widget.props = { ...result.widget.props, webglReady: true }
2009
+ }
2010
+ undoRedo.snapshot(stateRef.current, 'add')
2011
+ setLocalWidgets((prev) => [...(prev || []), result.widget])
2012
+ setSelectedWidgetIds(new Set([result.widget.id]))
2013
+ }
2014
+ } catch (err) {
2015
+ console.error('[canvas] Failed to add widget:', err)
2016
+ }
2017
+ }, [canvasId, undoRedo])
2018
+
2019
+ // Add a story widget by storyId — used by CanvasControls story picker
2020
+ const addStoryWidget = useCallback(async (storyId) => {
2021
+ const storyProps = { storyId, exportName: '', width: 600, height: 400 }
2022
+ const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
2023
+ const pos = centerPositionForWidget(center, 'story', storyProps)
2024
+ try {
2025
+ const result = await addWidgetApi(canvasId, {
2026
+ type: 'story',
2027
+ props: storyProps,
2028
+ position: pos,
2029
+ })
2030
+ if (result.success && result.widget) {
2031
+ undoRedo.snapshot(stateRef.current, 'add')
2032
+ setLocalWidgets((prev) => [...(prev || []), result.widget])
2033
+ setSelectedWidgetIds(new Set([result.widget.id]))
2034
+ }
2035
+ } catch (err) {
2036
+ console.error('[canvas] Failed to add story widget:', err)
2037
+ }
2038
+ }, [canvasId, undoRedo])
2039
+
2040
+ // Listen for CoreUIBar add-widget and update-widget events
2041
+ useEffect(() => {
2042
+ function handleAddWidget(e) {
2043
+ addWidget(e.detail.type, e.detail.props)
2044
+ }
2045
+ function handleAddStoryWidget(e) {
2046
+ addStoryWidget(e.detail.storyId)
2047
+ }
2048
+ function handleUpdateWidget(e) {
2049
+ const { widgetId, updates } = e.detail || {}
2050
+ if (widgetId && updates) handleWidgetUpdate(widgetId, updates)
2051
+ }
2052
+ document.addEventListener('storyboard:canvas:add-widget', handleAddWidget)
2053
+ document.addEventListener('storyboard:canvas:add-story-widget', handleAddStoryWidget)
2054
+ document.addEventListener('storyboard:canvas:update-widget', handleUpdateWidget)
2055
+ return () => {
2056
+ document.removeEventListener('storyboard:canvas:add-widget', handleAddWidget)
2057
+ document.removeEventListener('storyboard:canvas:add-story-widget', handleAddStoryWidget)
2058
+ document.removeEventListener('storyboard:canvas:update-widget', handleUpdateWidget)
2059
+ }
2060
+ }, [addWidget, addStoryWidget, handleWidgetUpdate])
2061
+
2062
+ // Listen for zoom changes from CoreUIBar
2063
+ useEffect(() => {
2064
+ function handleZoom(e) {
2065
+ const { zoom: newZoom } = e.detail
2066
+ if (typeof newZoom === 'number') {
2067
+ applyZoom(newZoom)
2068
+ }
2069
+ }
2070
+ document.addEventListener('storyboard:canvas:set-zoom', handleZoom)
2071
+ return () => document.removeEventListener('storyboard:canvas:set-zoom', handleZoom)
2072
+ }, [])
2073
+
2074
+ // Listen for snap-to-grid toggle from CoreUIBar
2075
+ useEffect(() => {
2076
+ function handleSnapToggle() {
2077
+ setSnapEnabled((prev) => {
2078
+ const next = !prev
2079
+ updateCanvas(canvasId, { settings: { snapToGrid: next } }).catch((err) =>
2080
+ console.error('[canvas] Failed to persist snap setting:', err)
2081
+ )
2082
+ return next
2083
+ })
2084
+ }
2085
+ document.addEventListener('storyboard:canvas:toggle-snap', handleSnapToggle)
2086
+ return () => document.removeEventListener('storyboard:canvas:toggle-snap', handleSnapToggle)
2087
+ }, [canvasId])
2088
+
2089
+ // Broadcast snap state to toolbar
2090
+ useEffect(() => {
2091
+ document.dispatchEvent(new CustomEvent('storyboard:canvas:snap-state', {
2092
+ detail: { snapEnabled }
2093
+ }))
2094
+ snapEnabledRef.current = snapEnabled
2095
+ }, [snapEnabled])
2096
+
2097
+ // Respond to snap-state requests from toolbar (handles mount-order race)
2098
+ useEffect(() => {
2099
+ function handleRequest() {
2100
+ document.dispatchEvent(new CustomEvent('storyboard:canvas:snap-state', {
2101
+ detail: { snapEnabled: snapEnabledRef.current }
2102
+ }))
2103
+ }
2104
+ document.addEventListener('storyboard:canvas:snap-state-request', handleRequest)
2105
+ return () => document.removeEventListener('storyboard:canvas:snap-state-request', handleRequest)
2106
+ }, [])
2107
+
2108
+ // Listen for gridSize from toolbar config
2109
+ useEffect(() => {
2110
+ function handleGridSize(e) {
2111
+ const size = e.detail?.gridSize
2112
+ if (typeof size === 'number' && size > 0) setSnapGridSize(size)
2113
+ }
2114
+ document.addEventListener('storyboard:canvas:grid-size', handleGridSize)
2115
+ return () => document.removeEventListener('storyboard:canvas:grid-size', handleGridSize)
2116
+ }, [])
2117
+
2118
+ // Keep snapGridSize ref in sync for drop handler
2119
+ useEffect(() => {
2120
+ snapGridSizeRef.current = snapGridSize
2121
+ }, [snapGridSize])
2122
+
2123
+ // Listen for zoom-to-fit from CoreUIBar
2124
+ useEffect(() => {
2125
+ function handleZoomToFit() {
2126
+ const el = scrollRef.current
2127
+ if (!el) return
2128
+
2129
+ const bounds = computeCanvasBounds(localWidgets, componentEntries)
2130
+ if (!bounds) return
2131
+
2132
+ const boxW = bounds.maxX - bounds.minX + FIT_PADDING * 2
2133
+ const boxH = bounds.maxY - bounds.minY + FIT_PADDING * 2
2134
+
2135
+ const viewW = el.clientWidth
2136
+ const viewH = el.clientHeight
2137
+
2138
+ // Find the zoom level that fits the bounding box in the viewport
2139
+ const fitScale = Math.min(viewW / boxW, viewH / boxH)
2140
+ const { ZOOM_MIN: zMin, ZOOM_MAX: zMax } = zoomLimits()
2141
+ const fitZoom = Math.min(zMax, Math.max(zMin, Math.round(fitScale * 100)))
2142
+ const newScale = fitZoom / 100
2143
+
2144
+ // Imperative DOM update — same path as applyZoom
2145
+ zoomRef.current = fitZoom
2146
+ const zoomEl = zoomElRef.current
2147
+ if (zoomEl) {
2148
+ zoomEl.style.transform = `scale(${newScale})`
2149
+ zoomEl.style.width = `${Math.max(10000, 100 / newScale)}vw`
2150
+ zoomEl.style.height = `${Math.max(10000, 100 / newScale)}vh`
2151
+ }
2152
+ setZoom(fitZoom)
2153
+
2154
+ // Scroll so the bounding box top-left (with padding) is at viewport top-left
2155
+ el.scrollLeft = (bounds.minX - FIT_PADDING) * newScale
2156
+ el.scrollTop = (bounds.minY - FIT_PADDING) * newScale
2157
+
2158
+ // Persist after both zoom and scroll are settled
2159
+ if (viewportInitName.current === canvasId) {
2160
+ saveViewportState(canvasId, {
2161
+ zoom: fitZoom,
2162
+ scrollLeft: el.scrollLeft,
2163
+ scrollTop: el.scrollTop,
2164
+ })
2165
+ }
2166
+ }
2167
+ document.addEventListener('storyboard:canvas:zoom-to-fit', handleZoomToFit)
2168
+ return () => document.removeEventListener('storyboard:canvas:zoom-to-fit', handleZoomToFit)
2169
+ }, [localWidgets, componentEntries])
2170
+
2171
+ // Canvas background should follow toolbar theme target.
2172
+ useEffect(() => {
2173
+ function readMode() {
2174
+ setCanvasTheme(resolveCanvasThemeFromStorage())
2175
+ }
2176
+
2177
+ readMode()
2178
+ document.addEventListener('storyboard:theme:changed', readMode)
2179
+ return () => document.removeEventListener('storyboard:theme:changed', readMode)
2180
+ }, [])
2181
+
2182
+ // Broadcast zoom level to CoreUIBar whenever it changes
2183
+ useEffect(() => {
2184
+ const bridge = window[CANVAS_BRIDGE_STATE_KEY] || {}
2185
+ bridge.active = true
2186
+ bridge.canvasId = canvasId
2187
+ bridge.zoom = zoom
2188
+ window[CANVAS_BRIDGE_STATE_KEY] = bridge
2189
+ document.dispatchEvent(new CustomEvent('storyboard:canvas:zoom-changed', {
2190
+ detail: { zoom }
2191
+ }))
2192
+ }, [canvasId, zoom])
2193
+
2194
+ // Keep bridge in sync with widgets/connectors for expand features.
2195
+ // Child widgets now use props directly for split-screen gating, but
2196
+ // FigmaEmbed/PrototypeEmbed/etc. still read this bridge at expand time.
2197
+ useMemo(() => {
2198
+ const bridge = window[CANVAS_BRIDGE_STATE_KEY] || {}
2199
+ bridge.widgets = localWidgets
2200
+ bridge.connectors = localConnectors
2201
+ window[CANVAS_BRIDGE_STATE_KEY] = bridge
2202
+ }, [localWidgets, localConnectors])
2203
+
2204
+ // ── WebGL context pool: viewport-based visibility tracking ──
2205
+ const updatePoolVisibility = usePoolVisibilityUpdater()
2206
+ const poolRafRef = useRef(null)
2207
+
2208
+ // Compute viewport rect in canvas coordinates and update terminal priorities
2209
+ const syncPoolVisibility = useCallback(() => {
2210
+ const el = scrollRef.current
2211
+ if (!el || !localWidgets) return
2212
+ const currentZoom = zoomRef.current || 100
2213
+ const currentScale = currentZoom / 100
2214
+ const viewportRect = {
2215
+ x: el.scrollLeft / currentScale,
2216
+ y: el.scrollTop / currentScale,
2217
+ w: el.clientWidth / currentScale,
2218
+ h: el.clientHeight / currentScale,
2219
+ }
2220
+ updatePoolVisibility(viewportRect, localWidgets, selectedWidgetIds, null)
2221
+ }, [updatePoolVisibility, localWidgets, selectedWidgetIds])
2222
+
2223
+ // Throttle visibility updates via rAF on scroll
2224
+ useEffect(() => {
2225
+ const el = scrollRef.current
2226
+ if (!el) return
2227
+ function onScroll() {
2228
+ if (poolRafRef.current) return
2229
+ poolRafRef.current = requestAnimationFrame(() => {
2230
+ poolRafRef.current = null
2231
+ syncPoolVisibility()
2232
+ })
2233
+ }
2234
+ el.addEventListener('scroll', onScroll, { passive: true })
2235
+ // Initial sync
2236
+ syncPoolVisibility()
2237
+ return () => {
2238
+ el.removeEventListener('scroll', onScroll)
2239
+ if (poolRafRef.current) cancelAnimationFrame(poolRafRef.current)
2240
+ }
2241
+ }, [syncPoolVisibility])
2242
+
2243
+ // Re-sync on zoom changes
2244
+ useEffect(() => {
2245
+ syncPoolVisibility()
2246
+ }, [zoom, syncPoolVisibility])
2247
+
2248
+ // Delete selected widget on Delete/Backspace key
2249
+ useEffect(() => {
2250
+ function handleSelectStart(e) {
2251
+ if (shouldPreventCanvasTextSelection(e.target)) {
2252
+ e.preventDefault()
2253
+ }
2254
+ }
2255
+ document.addEventListener('selectstart', handleSelectStart)
2256
+ return () => document.removeEventListener('selectstart', handleSelectStart)
2257
+ }, [])
2258
+
2259
+ useEffect(() => {
2260
+ function handleKeyDown(e) {
2261
+ if (selectedWidgetIds.size === 0) return
2262
+ const tag = e.target.tagName
2263
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return
2264
+ if (e.key === 'Escape') {
2265
+ e.preventDefault()
2266
+ setSelectedWidgetIds(new Set())
2267
+ }
2268
+ // Copy shortcut (one or more widgets selected):
2269
+ // cmd+c → copy canvasId::id1,id2,... (for cross-canvas paste-duplicate)
2270
+ const mod = e.metaKey || e.ctrlKey
2271
+ if (mod && e.key === 'c' && !e.shiftKey && selectedWidgetIds.size >= 1) {
2272
+ // Filter out non-duplicable widgets (jsx- component widgets are code)
2273
+ const copyableIds = [...selectedWidgetIds].filter(id => !id.startsWith('jsx-'))
2274
+ if (copyableIds.length > 0) {
2275
+ e.preventDefault()
2276
+ navigator.clipboard.writeText(`${canvasId}::${copyableIds.join(',')}`).catch(() => {})
2277
+ }
2278
+ }
2279
+ if (e.key === 'Delete' || e.key === 'Backspace') {
2280
+ e.preventDefault()
2281
+ if (selectedWidgetIds.size > 1) {
2282
+ // Multi-delete — snapshot once, remove all, persist via updateCanvas
2283
+ undoRedo.snapshot(stateRef.current, 'multi-remove')
2284
+ debouncedSave.cancel()
2285
+ dirtyRef.current = true
2286
+ setLocalWidgets((prev) => {
2287
+ if (!prev) return prev
2288
+ const next = prev.filter(w => !selectedWidgetIds.has(w.id))
2289
+ queueWrite(() =>
2290
+ updateCanvas(canvasId, { widgets: next }).catch(err =>
2291
+ console.error('[canvas] Failed to save multi-delete:', err)
2292
+ )
2293
+ )
2294
+ return next
2295
+ })
2296
+ } else {
2297
+ const widgetId = [...selectedWidgetIds][0]
2298
+ if (widgetId) handleWidgetRemove(widgetId)
2299
+ }
2300
+ setSelectedWidgetIds(new Set())
2301
+ }
2302
+ }
2303
+ document.addEventListener('keydown', handleKeyDown)
2304
+ return () => document.removeEventListener('keydown', handleKeyDown)
2305
+ }, [selectedWidgetIds, localWidgets, handleWidgetRemove, undoRedo, canvasId, debouncedSave])
2306
+
2307
+ // Ref to store processImageFile for use by drop effect
2308
+ const processImageFileRef = useRef(null)
2309
+
2310
+ // Paste and drop handler — images become image widgets, same-origin URLs become prototypes,
2311
+ // other URLs become link previews, text becomes markdown
2312
+ useEffect(() => {
2313
+ const origin = window.location.origin
2314
+ const basePath = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
2315
+ const pasteCtx = createPasteContext(origin, basePath)
2316
+
2317
+ function blobToDataUrl(blob) {
2318
+ return new Promise((resolve, reject) => {
2319
+ const reader = new FileReader()
2320
+ reader.onload = () => resolve(reader.result)
2321
+ reader.onerror = reject
2322
+ reader.readAsDataURL(blob)
2323
+ })
2324
+ }
2325
+
2326
+ function getImageDimensions(dataUrl) {
2327
+ return new Promise((resolve) => {
2328
+ const img = new Image()
2329
+ img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight })
2330
+ img.onerror = () => resolve({ width: 400, height: 300 })
2331
+ img.src = dataUrl
2332
+ })
2333
+ }
2334
+
2335
+ /**
2336
+ * Process an image file (from paste or drop) and add it as a widget.
2337
+ * @param {File|Blob} file - Image file to process
2338
+ * @param {{ x: number, y: number }|null} position - Drop position, or null to use viewport center
2339
+ */
2340
+ async function processImageFile(file, position = null) {
2341
+ try {
2342
+ const dataUrl = await blobToDataUrl(file)
2343
+ const { width: natW, height: natH } = await getImageDimensions(dataUrl)
2344
+
2345
+ // Display at 2x retina: halve natural dimensions, then cap at 600px
2346
+ const maxWidth = 600
2347
+ let displayW = Math.round(natW / 2)
2348
+ let displayH = Math.round(natH / 2)
2349
+ if (displayW > maxWidth) {
2350
+ displayH = Math.round(displayH * (maxWidth / displayW))
2351
+ displayW = maxWidth
2352
+ }
2353
+
2354
+ const uploadResult = await uploadImage(dataUrl, canvasId)
2355
+ if (!uploadResult.success) {
2356
+ console.error('[canvas] Image upload failed:', uploadResult.error)
2357
+ return false
2358
+ }
2359
+
2360
+ // Use provided position or fall back to viewport center
2361
+ let pos
2362
+ if (position) {
2363
+ pos = { x: position.x, y: position.y }
2364
+ } else {
2365
+ const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
2366
+ pos = centerPositionForWidget(center, 'image', { width: displayW, height: displayH })
2367
+ }
2368
+
2369
+ const result = await addWidgetApi(canvasId, {
2370
+ type: 'image',
2371
+ props: { src: uploadResult.filename, private: false, width: displayW, height: displayH },
2372
+ position: pos,
2373
+ })
2374
+ if (result.success && result.widget) {
2375
+ undoRedo.snapshot(stateRef.current, 'add')
2376
+ setLocalWidgets((prev) => [...(prev || []), result.widget])
2377
+ setSelectedWidgetIds(new Set([result.widget.id]))
2378
+ navigator.clipboard?.writeText(result.widget.id).catch(() => {})
2379
+ }
2380
+ return true
2381
+ } catch (err) {
2382
+ console.error('[canvas] Failed to process image:', err)
2383
+ return false
2384
+ }
2385
+ }
2386
+
2387
+ // Store in ref for use by drag/drop effect
2388
+ processImageFileRef.current = processImageFile
2389
+
2390
+ async function handleImagePaste(e) {
2391
+ const items = e.clipboardData?.items
2392
+ if (!items) return false
2393
+
2394
+ for (const item of items) {
2395
+ if (!item.type.startsWith('image/')) continue
2396
+
2397
+ const blob = item.getAsFile()
2398
+ if (!blob) continue
2399
+
2400
+ e.preventDefault()
2401
+ await processImageFile(blob, null)
2402
+ return true
2403
+ }
2404
+ return false
2405
+ }
2406
+
2407
+ async function handlePaste(e) {
2408
+ const tag = e.target.tagName
2409
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return
2410
+
2411
+ // Image paste takes priority
2412
+ const handledImage = await handleImagePaste(e)
2413
+ if (handledImage) return
2414
+
2415
+ const text = e.clipboardData?.getData('text/plain')?.trim()
2416
+ if (!text) return
2417
+
2418
+ // Detect canvasId::widgetId or canvasId::id1,id2,id3 format for widget duplication
2419
+ // Also supports legacy canvasId/widgetId for basenames without slashes,
2420
+ // but only when the second segment looks like a widget ID (type-hash).
2421
+ const widgetRefMatch = text.match(/^(.+)::([^:]+)$/) || (text.indexOf('::') === -1 && text.match(/^([^/]+)\/((?:sticky-note|markdown|prototype|link-preview|figma-embed|component|image)-[a-z0-9]+)$/))
2422
+ if (widgetRefMatch) {
2423
+ e.preventDefault()
2424
+ const [, sourceCanvas, sourceWidgetRef] = widgetRefMatch
2425
+ const sourceWidgetIds = sourceWidgetRef.split(',').filter(id => !id.startsWith('jsx-'))
2426
+ if (sourceWidgetIds.length === 0) return
2427
+
2428
+ try {
2429
+ // Resolve source widgets in canvas order
2430
+ let sourceList
2431
+ if (sourceCanvas === canvasId) {
2432
+ sourceList = localWidgets ?? []
2433
+ } else {
2434
+ const canvasData = await getCanvasApi(sourceCanvas)
2435
+ sourceList = canvasData?.widgets ?? []
2436
+ }
2437
+
2438
+ const sourceWidgets = sourceList.filter(w => sourceWidgetIds.includes(w.id))
2439
+ if (sourceWidgets.length === 0) return
2440
+
2441
+ // Compute bounding box of source widgets for relative positioning
2442
+ const fallback = { width: 200, height: 150 }
2443
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
2444
+ for (const w of sourceWidgets) {
2445
+ const wx = w.position?.x ?? 0
2446
+ const wy = w.position?.y ?? 0
2447
+ const ww = w.props?.width ?? WIDGET_FALLBACK_SIZES[w.type]?.width ?? fallback.width
2448
+ const wh = w.props?.height ?? WIDGET_FALLBACK_SIZES[w.type]?.height ?? fallback.height
2449
+ if (wx < minX) minX = wx
2450
+ if (wy < minY) minY = wy
2451
+ if (wx + ww > maxX) maxX = wx + ww
2452
+ if (wy + wh > maxY) maxY = wy + wh
2453
+ }
2454
+ const groupW = maxX - minX
2455
+ const groupH = maxY - minY
2456
+
2457
+ // Center the group in the viewport
2458
+ const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
2459
+ const baseX = Math.round(center.x - groupW / 2)
2460
+ const baseY = Math.round(center.y - groupH / 2)
2461
+
2462
+ // Single undo snapshot for the entire paste
2463
+ undoRedo.snapshot(stateRef.current, 'add')
2464
+
2465
+ // Paste all widgets, collecting new IDs for selection
2466
+ const newWidgets = []
2467
+ for (const w of sourceWidgets) {
2468
+ const relX = (w.position?.x ?? 0) - minX
2469
+ const relY = (w.position?.y ?? 0) - minY
2470
+ const pasteProps = { ...w.props }
2471
+ if (w.type === 'terminal' || w.type === 'agent') delete pasteProps.prettyName
2472
+ // Image widgets: duplicate the asset so the paste owns its own copy
2473
+ if (w.type === 'image' && pasteProps.src) {
2474
+ try {
2475
+ const dupResult = await duplicateImage(pasteProps.src)
2476
+ if (dupResult.success) pasteProps.src = dupResult.filename
2477
+ } catch { /* use original src as fallback */ }
2478
+ }
2479
+ const result = await addWidgetApi(canvasId, {
2480
+ type: w.type,
2481
+ props: pasteProps,
2482
+ position: { x: baseX + relX, y: baseY + relY },
2483
+ })
2484
+ if (result.success && result.widget) {
2485
+ if (result.hotSession?.webglReady) {
2486
+ result.widget.props = { ...result.widget.props, webglReady: true }
2487
+ }
2488
+ newWidgets.push(result.widget)
2489
+ }
2490
+ }
2491
+
2492
+ if (newWidgets.length > 0) {
2493
+ setLocalWidgets((prev) => [...(prev || []), ...newWidgets])
2494
+ setSelectedWidgetIds(new Set(newWidgets.map(w => w.id)))
2495
+ }
2496
+ } catch (err) {
2497
+ console.error('[canvas] Failed to paste widget reference:', err)
2498
+ }
2499
+ // Always consume the ref — never fall through to markdown creation
2500
+ return
2501
+ }
2502
+
2503
+ e.preventDefault()
2504
+ await pasteTextAsWidget(text, pasteCtx)
2505
+ }
2506
+
2507
+ // Shared helper: resolve pasted text into a widget and add it to the canvas.
2508
+ // Used by both native paste and the programmatic paste-url event.
2509
+ async function pasteTextAsWidget(text, pasteCtx) {
2510
+ const resolved = resolvePaste(text, pasteCtx, getPasteRules())
2511
+ if (!resolved) return
2512
+ let { type } = resolved
2513
+ let props = resolved.props
2514
+
2515
+ // Component/story URLs → story widget (instead of prototype embed)
2516
+ if (type === 'prototype' && props?.src) {
2517
+ const srcPath = props.src.replace(/[?#].*$/, '').replace(/\/+$/, '')
2518
+ const storyId = storyRouteIndex.get(srcPath)
2519
+ if (storyId) {
2520
+ type = 'story'
2521
+ const parsed = pasteCtx.parseUrl(text)
2522
+ const searchParams = new URLSearchParams(parsed?.search || '')
2523
+ props = {
2524
+ storyId,
2525
+ exportName: searchParams.get('export') || '',
2526
+ width: 600,
2527
+ height: 400,
2528
+ }
2529
+ }
2530
+ }
2531
+
2532
+ if (type === 'link-preview' && isGitHubEmbedUrl(props?.url || text)) {
2533
+ const githubUpdates = await buildGitHubPreviewUpdates(props?.url || text)
2534
+ if (githubUpdates) props = { ...props, ...githubUpdates }
2535
+ }
2536
+
2537
+ const center = getViewportCenter(scrollRef.current, zoomRef.current / 100)
2538
+ const pos = centerPositionForWidget(center, type, props)
2539
+ try {
2540
+ const result = await addWidgetApi(canvasId, {
2541
+ type,
2542
+ props,
2543
+ position: pos,
2544
+ })
2545
+ if (result.success && result.widget) {
2546
+ undoRedo.snapshot(stateRef.current, 'add')
2547
+ setLocalWidgets((prev) => [...(prev || []), result.widget])
2548
+ setSelectedWidgetIds(new Set([result.widget.id]))
2549
+ }
2550
+ } catch (err) {
2551
+ console.error('[canvas] Failed to add widget from paste:', err)
2552
+ }
2553
+ }
2554
+
2555
+ // Listen for programmatic paste-url events from the command palette
2556
+ function handlePasteUrl(e) {
2557
+ const text = e.detail?.url?.trim()
2558
+ if (!text) return
2559
+ pasteTextAsWidget(text, pasteCtx)
2560
+ }
2561
+
2562
+ document.addEventListener('paste', handlePaste)
2563
+ document.addEventListener('storyboard:canvas:paste-url', handlePasteUrl)
2564
+ return () => {
2565
+ document.removeEventListener('paste', handlePaste)
2566
+ document.removeEventListener('storyboard:canvas:paste-url', handlePasteUrl)
2567
+ }
2568
+ // eslint-disable-next-line react-hooks/exhaustive-deps
2569
+ }, [canvasId, undoRedo, localWidgets])
2570
+
2571
+ // --- Drag and drop handlers for images from Finder/file manager ---
2572
+ // Separate effect to ensure listeners attach after scroll container mounts (loading=false)
2573
+ useEffect(() => {
2574
+ if (loading) return // Don't attach until canvas is loaded and scroll container exists
2575
+
2576
+ const scrollEl = scrollRef.current
2577
+ if (!scrollEl) return
2578
+
2579
+ function handleDragOver(e) {
2580
+ // Only handle if dragging files (not internal widget drag)
2581
+ if (!e.dataTransfer?.types?.includes('Files')) return
2582
+ e.preventDefault()
2583
+ e.dataTransfer.dropEffect = 'copy'
2584
+ }
2585
+
2586
+ async function handleDrop(e) {
2587
+ // Only handle file drops, not internal widget drags
2588
+ if (!e.dataTransfer?.types?.includes('Files')) return
2589
+
2590
+ // Prevent browser default (opening file) immediately for any file drop
2591
+ e.preventDefault()
2592
+ e.stopPropagation()
2593
+
2594
+ const files = e.dataTransfer.files
2595
+ if (!files || files.length === 0) return
2596
+
2597
+ // Filter to image files only — non-images are silently ignored (default already prevented)
2598
+ const imageFiles = Array.from(files).filter((f) => f.type.startsWith('image/'))
2599
+ if (imageFiles.length === 0) return
2600
+
2601
+ // Convert drop coordinates to canvas coordinates
2602
+ const rect = scrollEl.getBoundingClientRect()
2603
+ const scale = zoomRef.current / 100
2604
+
2605
+ // Mouse position relative to scroll container
2606
+ const mouseX = e.clientX - rect.left
2607
+ const mouseY = e.clientY - rect.top
2608
+
2609
+ // Convert to canvas coordinates (account for scroll and zoom)
2610
+ const canvasX = (scrollEl.scrollLeft + mouseX) / scale
2611
+ const canvasY = (scrollEl.scrollTop + mouseY) / scale
2612
+
2613
+ // Snap to grid if enabled, using current grid size
2614
+ const gridSize = snapGridSizeRef.current
2615
+ const shouldSnap = snapEnabledRef.current
2616
+ const snappedX = shouldSnap ? Math.round(canvasX / gridSize) * gridSize : Math.round(canvasX)
2617
+ const snappedY = shouldSnap ? Math.round(canvasY / gridSize) * gridSize : Math.round(canvasY)
2618
+
2619
+ // Process each image file, offsetting subsequent images
2620
+ for (let i = 0; i < imageFiles.length; i++) {
2621
+ const offset = shouldSnap ? i * gridSize : i * 24
2622
+ await processImageFileRef.current?.(imageFiles[i], { x: snappedX + offset, y: snappedY + offset })
2623
+ }
2624
+ }
2625
+
2626
+ scrollEl.addEventListener('dragover', handleDragOver)
2627
+ scrollEl.addEventListener('drop', handleDrop)
2628
+
2629
+ return () => {
2630
+ scrollEl.removeEventListener('dragover', handleDragOver)
2631
+ scrollEl.removeEventListener('drop', handleDrop)
2632
+ }
2633
+ }, [loading])
2634
+
2635
+ // --- Undo / Redo ---
2636
+ const handleUndo = useCallback(() => {
2637
+ const previous = undoRedo.undo(stateRef.current)
2638
+ if (!previous) return
2639
+ debouncedSave.cancel()
2640
+ debouncedSourceSave.cancel()
2641
+ dirtyRef.current = true
2642
+ setLocalWidgets(previous.widgets)
2643
+ setLocalSources(previous.sources)
2644
+ setLocalConnectors(previous.connectors ?? [])
2645
+ queueWrite(() =>
2646
+ updateCanvas(canvasId, { widgets: previous.widgets, sources: previous.sources, connectors: previous.connectors })
2647
+ .catch((err) => console.error('[canvas] Failed to persist undo:', err))
2648
+ )
2649
+ }, [canvasId, debouncedSave, debouncedSourceSave, undoRedo])
2650
+
2651
+ const handleRedo = useCallback(() => {
2652
+ const next = undoRedo.redo(stateRef.current)
2653
+ if (!next) return
2654
+ debouncedSave.cancel()
2655
+ debouncedSourceSave.cancel()
2656
+ dirtyRef.current = true
2657
+ setLocalWidgets(next.widgets)
2658
+ setLocalSources(next.sources)
2659
+ setLocalConnectors(next.connectors ?? [])
2660
+ queueWrite(() =>
2661
+ updateCanvas(canvasId, { widgets: next.widgets, sources: next.sources, connectors: next.connectors })
2662
+ .catch((err) => console.error('[canvas] Failed to persist redo:', err))
2663
+ )
2664
+ }, [canvasId, debouncedSave, debouncedSourceSave, undoRedo])
2665
+
2666
+ // Keyboard shortcuts — dev-only (Cmd+Z / Cmd+Shift+Z / Cmd+D / Cmd+A)
2667
+ useEffect(() => {
2668
+ if (!import.meta.hot) return
2669
+ function handleKeyDown(e) {
2670
+ const tag = e.target.tagName
2671
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return
2672
+ // Don't intercept shortcuts when the command palette is open
2673
+ if (e.target.closest?.('[cmdk-root]')) return
2674
+ const mod = e.metaKey || e.ctrlKey
2675
+ if (mod && e.key === 'z' && !e.shiftKey) {
2676
+ e.preventDefault()
2677
+ handleUndo()
2678
+ }
2679
+ if (mod && e.key === 'z' && e.shiftKey) {
2680
+ e.preventDefault()
2681
+ handleRedo()
2682
+ }
2683
+ if (mod && e.key.toLowerCase() === 'd' && e.shiftKey) {
2684
+ e.preventDefault()
2685
+ handleDuplicateWithConnectors()
2686
+ } else if (mod && e.key.toLowerCase() === 'd' && !e.shiftKey) {
2687
+ e.preventDefault()
2688
+ handleDuplicateSelected()
2689
+ }
2690
+ if (mod && e.key === 'a') {
2691
+ e.preventDefault()
2692
+ handleSelectAll()
2693
+ }
2694
+ }
2695
+ document.addEventListener('keydown', handleKeyDown)
2696
+ return () => document.removeEventListener('keydown', handleKeyDown)
2697
+ }, [handleUndo, handleRedo, handleDuplicateSelected, handleDuplicateWithConnectors, handleSelectAll])
2698
+
2699
+ // Listen for undo/redo from CoreUIBar
2700
+ useEffect(() => {
2701
+ function handleUndoEvent() { handleUndo() }
2702
+ function handleRedoEvent() { handleRedo() }
2703
+ document.addEventListener('storyboard:canvas:undo', handleUndoEvent)
2704
+ document.addEventListener('storyboard:canvas:redo', handleRedoEvent)
2705
+ return () => {
2706
+ document.removeEventListener('storyboard:canvas:undo', handleUndoEvent)
2707
+ document.removeEventListener('storyboard:canvas:redo', handleRedoEvent)
2708
+ }
2709
+ }, [handleUndo, handleRedo])
2710
+
2711
+ // Broadcast undo/redo availability to toolbar
2712
+ useEffect(() => {
2713
+ document.dispatchEvent(new CustomEvent('storyboard:canvas:undo-redo-state', {
2714
+ detail: { canUndo: undoRedo.canUndo, canRedo: undoRedo.canRedo }
2715
+ }))
2716
+ }, [undoRedo.canUndo, undoRedo.canRedo])
2717
+
2718
+ // Cmd+scroll / trackpad pinch to smooth-zoom the canvas
2719
+ // On macOS, pinch-to-zoom fires wheel events with ctrlKey: true and small
2720
+ // fractional deltaY values. We accumulate the delta to handle sub-pixel changes.
2721
+ const zoomAccum = useRef(0)
2722
+ useEffect(() => {
2723
+ function handleWheel(e) {
2724
+ if (!e.metaKey && !e.ctrlKey) return
2725
+ e.preventDefault()
2726
+ zoomAccum.current += -e.deltaY
2727
+ const step = Math.trunc(zoomAccum.current)
2728
+ if (step === 0) return
2729
+ zoomAccum.current -= step
2730
+ applyZoom(zoomRef.current + step, e.clientX, e.clientY)
2731
+ }
2732
+ document.addEventListener('wheel', handleWheel, { passive: false })
2733
+ return () => document.removeEventListener('wheel', handleWheel)
2734
+ }, [])
2735
+
2736
+ // Receive cmd+wheel events forwarded from prototype/story iframes
2737
+ useEffect(() => {
2738
+ function handleMessage(e) {
2739
+ if (e.data?.type !== 'storyboard:embed:wheel') return
2740
+ zoomAccum.current += -e.data.deltaY
2741
+ const step = Math.trunc(zoomAccum.current)
2742
+ if (step === 0) return
2743
+ zoomAccum.current -= step
2744
+ applyZoom(zoomRef.current + step)
2745
+ }
2746
+ window.addEventListener('message', handleMessage)
2747
+ return () => window.removeEventListener('message', handleMessage)
2748
+ }, [])
2749
+
2750
+ // Touch pinch-to-zoom for mobile — two-finger pinch zooms the canvas
2751
+ const pinchState = useRef({ active: false, startDist: 0, startZoom: 0, centerX: 0, centerY: 0 })
2752
+ useEffect(() => {
2753
+ const el = scrollRef.current
2754
+ if (!el) return
2755
+
2756
+ function getTouchDist(t1, t2) {
2757
+ const dx = t1.clientX - t2.clientX
2758
+ const dy = t1.clientY - t2.clientY
2759
+ return Math.sqrt(dx * dx + dy * dy)
2760
+ }
2761
+
2762
+ function handleTouchStart(e) {
2763
+ if (e.touches.length !== 2) return
2764
+ const dist = getTouchDist(e.touches[0], e.touches[1])
2765
+ pinchState.current = {
2766
+ active: true,
2767
+ startDist: dist,
2768
+ startZoom: zoomRef.current,
2769
+ centerX: (e.touches[0].clientX + e.touches[1].clientX) / 2,
2770
+ centerY: (e.touches[0].clientY + e.touches[1].clientY) / 2,
2771
+ }
2772
+ }
2773
+
2774
+ function handleTouchMove(e) {
2775
+ if (!pinchState.current.active || e.touches.length !== 2) return
2776
+ e.preventDefault()
2777
+ const dist = getTouchDist(e.touches[0], e.touches[1])
2778
+ const ratio = dist / pinchState.current.startDist
2779
+ const newZoom = Math.round(pinchState.current.startZoom * ratio)
2780
+ applyZoom(newZoom, pinchState.current.centerX, pinchState.current.centerY)
2781
+ }
2782
+
2783
+ function handleTouchEnd() {
2784
+ pinchState.current.active = false
2785
+ }
2786
+
2787
+ el.addEventListener('touchstart', handleTouchStart, { passive: true })
2788
+ el.addEventListener('touchmove', handleTouchMove, { passive: false })
2789
+ el.addEventListener('touchend', handleTouchEnd)
2790
+ el.addEventListener('touchcancel', handleTouchEnd)
2791
+ return () => {
2792
+ el.removeEventListener('touchstart', handleTouchStart)
2793
+ el.removeEventListener('touchmove', handleTouchMove)
2794
+ el.removeEventListener('touchend', handleTouchEnd)
2795
+ el.removeEventListener('touchcancel', handleTouchEnd)
2796
+ }
2797
+ }, [])
2798
+
2799
+ // Space + drag to pan the canvas
2800
+ const [spaceHeld, setSpaceHeld] = useState(false)
2801
+ const isPanning = useRef(false)
2802
+ const [panningActive, setPanningActive] = useState(false)
2803
+ const panStart = useRef({ x: 0, y: 0, scrollX: 0, scrollY: 0 })
2804
+
2805
+ useEffect(() => {
2806
+ function handleKeyDown(e) {
2807
+ if (e.key === ' ') {
2808
+ const tag = e.target.tagName
2809
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return
2810
+ e.preventDefault()
2811
+ if (!e.repeat) setSpaceHeld(true)
2812
+ }
2813
+ }
2814
+ function handleKeyUp(e) {
2815
+ if (e.key === ' ') {
2816
+ e.preventDefault()
2817
+ setSpaceHeld(false)
2818
+ isPanning.current = false
2819
+ setPanningActive(false)
2820
+ }
2821
+ }
2822
+ document.addEventListener('keydown', handleKeyDown)
2823
+ document.addEventListener('keyup', handleKeyUp)
2824
+ return () => {
2825
+ document.removeEventListener('keydown', handleKeyDown)
2826
+ document.removeEventListener('keyup', handleKeyUp)
2827
+ }
2828
+ }, [])
2829
+
2830
+ const handlePanStart = useCallback((e) => {
2831
+ if (!spaceHeld) return
2832
+ e.preventDefault()
2833
+ isPanning.current = true
2834
+ setPanningActive(true)
2835
+ const el = scrollRef.current
2836
+ panStart.current = {
2837
+ x: e.clientX,
2838
+ y: e.clientY,
2839
+ scrollX: el?.scrollLeft ?? 0,
2840
+ scrollY: el?.scrollTop ?? 0,
2841
+ }
2842
+
2843
+ function handlePanMove(ev) {
2844
+ if (!isPanning.current || !el) return
2845
+ el.scrollLeft = panStart.current.scrollX - (ev.clientX - panStart.current.x)
2846
+ el.scrollTop = panStart.current.scrollY - (ev.clientY - panStart.current.y)
2847
+ }
2848
+ function handlePanEnd() {
2849
+ isPanning.current = false
2850
+ setPanningActive(false)
2851
+ document.removeEventListener('mousemove', handlePanMove)
2852
+ document.removeEventListener('mouseup', handlePanEnd)
2853
+ }
2854
+ document.addEventListener('mousemove', handlePanMove)
2855
+ document.addEventListener('mouseup', handlePanEnd)
2856
+ }, [spaceHeld])
2857
+
2858
+ // Stable callback for deselecting all widgets
2859
+ const handleDeselectAll = useCallback(() => setSelectedWidgetIds(new Set()), [])
2860
+
2861
+ // Marquee (lasso) multi-select on canvas background drag
2862
+ const { marqueeScreenRect, handleMarqueeMouseDown } = useMarqueeSelect({
2863
+ scrollRef,
2864
+ zoomRef: zoomRef,
2865
+ setSelectedWidgetIds,
2866
+ widgets: localWidgets,
2867
+ connectors: localConnectors,
2868
+ componentEntries,
2869
+ fallbackSizes: WIDGET_FALLBACK_SIZES,
2870
+ spaceHeld,
2871
+ isLocalDev,
2872
+ })
2873
+
2874
+ // Stable callback for widget removal + deselect
2875
+ const handleWidgetRemoveAndDeselect = useCallback((id) => {
2876
+ handleWidgetRemove(id)
2877
+ setSelectedWidgetIds(new Set())
2878
+ }, [handleWidgetRemove])
2879
+
2880
+ if (!canvas) {
2881
+ return (
2882
+ <div className={styles.empty}>
2883
+ <p>Canvas &ldquo;{canvasId}&rdquo; not found</p>
2884
+ </div>
2885
+ )
2886
+ }
2887
+
2888
+ if (loading) {
2889
+ return (
2890
+ <div className={styles.loading}>
2891
+ <p>Loading canvas…</p>
2892
+ </div>
2893
+ )
2894
+ }
2895
+
2896
+ const canvasProps = {
2897
+ centered: canvas.centered ?? false,
2898
+ dotted: canvas.dotted ?? false,
2899
+ grid: canvas.grid ?? false,
2900
+ gridSize: canvas.gridSize ?? 18,
2901
+ snapGrid: snapEnabled ? [snapGridSize, snapGridSize] : undefined,
2902
+ colorMode: canvas.colorMode === 'auto'
2903
+ ? getToolbarColorMode(canvasTheme)
2904
+ : (canvas.colorMode ?? 'auto'),
2905
+ locked: !isLocalDev,
2906
+ }
2907
+
2908
+ const canvasThemeVars = getCanvasThemeVars(canvasTheme)
2909
+ const canvasPrimerAttrs = getCanvasPrimerAttrs(canvasTheme)
2910
+
2911
+ // Merge JSX-sourced widgets and JSON widgets
2912
+ const allChildren = []
2913
+
2914
+ // 1. Component widgets (from jsxExports or sources fallback)
2915
+ const componentFeatures = getFeatures('component', { isLocalDev })
2916
+ for (const entry of componentEntries) {
2917
+ const { exportName, Component, sourceData } = entry
2918
+ const sourcePosition = sourceData.position || { x: 0, y: 0 }
2919
+ allChildren.push(
2920
+ <div
2921
+ key={`jsx-${exportName}`}
2922
+ id={`jsx-${exportName}`}
2923
+ data-tc-x={sourcePosition.x}
2924
+ data-tc-y={sourcePosition.y}
2925
+ data-widget-raised={selectedWidgetIds.has(`jsx-${exportName}`) || undefined}
2926
+ {...(isLocalDev ? { 'data-tc-handle': '.tc-drag-handle, .tc-drag-surface' } : {})}
2927
+ {...canvasPrimerAttrs}
2928
+ style={canvasThemeVars}
2929
+ onClick={isLocalDev ? (e) => {
2930
+ e.stopPropagation()
2931
+ if (!e.target.closest('.tc-drag-handle')) {
2932
+ handleWidgetSelect(`jsx-${exportName}`, e.shiftKey)
2933
+ }
2934
+ } : undefined}
2935
+ >
2936
+ <WidgetChrome
2937
+ widgetId={`jsx-${exportName}`}
2938
+ features={componentFeatures}
2939
+ selected={selectedWidgetIds.has(`jsx-${exportName}`)}
2940
+ multiSelected={isMultiSelected && selectedWidgetIds.has(`jsx-${exportName}`)}
2941
+ onSelect={(shiftKey) => handleWidgetSelect(`jsx-${exportName}`, shiftKey)}
2942
+ onDeselect={handleDeselectAll}
2943
+ readOnly={!isLocalDev}
2944
+ >
2945
+ <ComponentWidget
2946
+ component={Component}
2947
+ jsxModule={canvas?._jsxModule}
2948
+ exportName={exportName}
2949
+ canvasTheme={canvasTheme}
2950
+ isLocalDev={isLocalDev}
2951
+ width={sourceData.width}
2952
+ height={sourceData.height}
2953
+ onUpdate={isLocalDev ? (updates) => handleSourceUpdate(exportName, updates) : undefined}
2954
+ resizable={isResizable('component') && isLocalDev}
2955
+ />
2956
+ </WidgetChrome>
2957
+ </div>
2958
+ )
2959
+ }
2960
+
2961
+ // 2. JSON-defined mutable widgets (selectable, wrapped in WidgetChrome)
2962
+ // Stable DOM order — visual stacking is controlled by z-index on the
2963
+ // wrapper div (data-widget-raised), NOT by re-sorting the array.
2964
+ // Re-sorting caused iframe widgets (stories, embeds) to remount and
2965
+ // reload every time selection changed, because moving an iframe node
2966
+ // in the DOM destroys its browsing context.
2967
+ for (const widget of (localWidgets ?? [])) {
2968
+ // In production, render terminal widgets as read-only instead of hiding them
2969
+ const effectiveWidget = (!isLocalDev && (widget.type === 'terminal' || widget.type === 'agent'))
2970
+ ? { ...widget, type: 'terminal-read' }
2971
+ : widget
2972
+ allChildren.push(
2973
+ <div
2974
+ key={effectiveWidget.id}
2975
+ id={effectiveWidget.id}
2976
+ data-tc-x={effectiveWidget?.position?.x ?? 0}
2977
+ data-tc-y={effectiveWidget?.position?.y ?? 0}
2978
+ data-widget-raised={selectedWidgetIds.has(widget.id) || undefined}
2979
+ {...(isLocalDev ? { 'data-tc-handle': '.tc-drag-handle, .tc-drag-surface' } : {})}
2980
+ {...canvasPrimerAttrs}
2981
+ style={canvasThemeVars}
2982
+ onClick={isLocalDev ? (e) => {
2983
+ e.stopPropagation()
2984
+ if (!e.target.closest('.tc-drag-handle')) {
2985
+ handleWidgetSelect(effectiveWidget.id, e.shiftKey)
2986
+ }
2987
+ } : undefined}
2988
+ >
2989
+ <ChromeWrappedWidget
2990
+ widget={effectiveWidget}
2991
+ selected={selectedWidgetIds.has(widget.id)}
2992
+ multiSelected={isMultiSelected && selectedWidgetIds.has(widget.id)}
2993
+ connectorCount={localConnectors.filter((c) => c.start?.widgetId === widget.id || c.end?.widgetId === widget.id)}
2994
+ allWidgets={localWidgets}
2995
+ onSelect={(shiftKey) => handleWidgetSelect(widget.id, shiftKey)}
2996
+ onDeselect={handleDeselectAll}
2997
+ onUpdate={isLocalDev ? handleWidgetUpdate : undefined}
2998
+ onCopy={isLocalDev ? handleWidgetCopy : undefined}
2999
+ onCopyWithConnectors={isLocalDev ? handleWidgetCopyWithConnectors : undefined}
3000
+ onRemove={isLocalDev ? handleWidgetRemoveAndDeselect : undefined}
3001
+ onRefreshGitHub={isLocalDev ? handleRefreshGitHubWidget : undefined}
3002
+ canRefreshGitHub={isLocalDev}
3003
+ onConnectorDragStart={isLocalDev ? handleConnectorDragStart : undefined}
3004
+ readOnly={!isLocalDev}
3005
+ />
3006
+ </div>
3007
+ )
3008
+ }
3009
+
3010
+ const scale = zoom / 100
3011
+
3012
+ const filteredConnectors = localConnectors
3013
+
3014
+ return (
3015
+ <WebGLContextPoolProvider>
3016
+ <div className={styles.canvasTitle}>
3017
+ <a href={(import.meta.env?.BASE_URL || '/')} className={styles.canvasLogo} aria-label="Go to homepage">
3018
+ <Icon name="home" size={16} color="#fff" />
3019
+ </a>
3020
+ <CanvasTitleEditable
3021
+ canvasId={canvasId}
3022
+ canvasMeta={canvasMeta}
3023
+ canvas={canvas}
3024
+ isLocalDev={isLocalDev}
3025
+ />
3026
+ <PageSelector currentName={canvasId} pages={siblingPages} isLocalDev={isLocalDev} />
3027
+ </div>
3028
+ <div
3029
+ ref={scrollRef}
3030
+ data-storyboard-canvas-scroll
3031
+ data-sb-canvas-theme={canvasTheme}
3032
+ {...canvasPrimerAttrs}
3033
+ className={styles.canvasScroll}
3034
+ style={{
3035
+ ...canvasThemeVars,
3036
+ ...(spaceHeld ? { cursor: panningActive ? 'grabbing' : 'grab' } : {}),
3037
+ }}
3038
+ onMouseDown={(e) => { handlePanStart(e); handleMarqueeMouseDown(e); }}
3039
+ >
3040
+ <MarqueeOverlay rect={marqueeScreenRect} />
3041
+ <div
3042
+ ref={zoomElRef}
3043
+ data-storyboard-canvas-zoom
3044
+ data-sb-canvas-theme={canvasTheme}
3045
+ className={styles.canvasZoom}
3046
+ style={{
3047
+ transform: `scale(${scale})`,
3048
+ transformOrigin: '0 0',
3049
+ width: `${Math.max(10000, 100 / scale)}vw`,
3050
+ height: `${Math.max(10000, 100 / scale)}vh`,
3051
+ ...(spaceHeld ? { pointerEvents: 'none' } : {}),
3052
+ }}
3053
+ >
3054
+ <ConnectorLayer
3055
+ connectors={filteredConnectors}
3056
+ widgets={localWidgets ?? []}
3057
+ selectedWidgetIds={selectedWidgetIds}
3058
+ onRemove={isLocalDev ? handleConnectorRemove : undefined}
3059
+ onEndpointDrag={undefined}
3060
+ dragPreview={connectorDrag}
3061
+ hidden={widgetDragging}
3062
+ />
3063
+ <Canvas {...canvasProps} onDragStart={isLocalDev ? handleItemDragStart : undefined} onDrag={isLocalDev ? handleItemDrag : undefined} onDragEnd={isLocalDev ? handleItemDragEnd : undefined}>
3064
+ {allChildren}
3065
+ </Canvas>
3066
+ </div>
3067
+ </div>
3068
+ {showGhInstallBanner && (
3069
+ <aside className={styles.ghInstallBanner} role="status" aria-live="polite">
3070
+ <span className={styles.ghInstallBannerText}>
3071
+ GitHub embeds require local <code>gh</code> CLI access.
3072
+ </span>
3073
+ <a
3074
+ href={GH_INSTALL_URL}
3075
+ target="_blank"
3076
+ rel="noopener noreferrer"
3077
+ className={styles.ghInstallBannerLink}
3078
+ >
3079
+ Install GitHub CLI
3080
+ </a>
3081
+ <button
3082
+ type="button"
3083
+ className={styles.ghInstallBannerDismiss}
3084
+ onClick={() => setShowGhInstallBanner(false)}
3085
+ >
3086
+ Dismiss
3087
+ </button>
3088
+ </aside>
3089
+ )}
3090
+ </WebGLContextPoolProvider>
3091
+ )
3092
+ }