@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.
- package/commandpalette.config.json +152 -0
- package/dist/storyboard-ui.css +1 -0
- package/dist/storyboard-ui.js +21328 -0
- package/dist/storyboard-ui.js.map +1 -0
- package/dist/tailwind.css +2 -0
- package/dist/tiny-canvas.css +1 -0
- package/dist/tiny-canvas.js +389 -0
- package/package.json +121 -0
- package/paste.config.json +67 -0
- package/scaffold/AGENTS.md +432 -0
- package/scaffold/agents/prompt-agent.agent.md +181 -0
- package/scaffold/agents/terminal-agent.agent.md +351 -0
- package/scaffold/codex/config.toml +246 -0
- package/scaffold/deploy.yml +103 -0
- package/scaffold/githooks/pre-push +114 -0
- package/scaffold/gitignore +64 -0
- package/scaffold/manifest.json +56 -0
- package/scaffold/preview.yml +181 -0
- package/scaffold/scripts/link.sh +26 -0
- package/scaffold/scripts/unlink.sh +10 -0
- package/scaffold/skills/agent-browser/SKILL.md +260 -0
- package/scaffold/skills/canvas/SKILL.md +364 -0
- package/scaffold/skills/create/SKILL.md +501 -0
- package/scaffold/skills/ship/SKILL.md +237 -0
- package/scaffold/skills/storyboard/SKILL.md +360 -0
- package/scaffold/skills/update-storyboard/SKILL.md +16 -0
- package/scaffold/skills/update-storyboard/update-storyboard-packages.sh +26 -0
- package/scaffold/skills/vitest/GENERATION.md +5 -0
- package/scaffold/skills/vitest/SKILL.md +52 -0
- package/scaffold/skills/vitest/references/advanced-environments.md +264 -0
- package/scaffold/skills/vitest/references/advanced-projects.md +300 -0
- package/scaffold/skills/vitest/references/advanced-type-testing.md +237 -0
- package/scaffold/skills/vitest/references/advanced-vi.md +249 -0
- package/scaffold/skills/vitest/references/core-cli.md +166 -0
- package/scaffold/skills/vitest/references/core-config.md +174 -0
- package/scaffold/skills/vitest/references/core-describe.md +193 -0
- package/scaffold/skills/vitest/references/core-expect.md +219 -0
- package/scaffold/skills/vitest/references/core-hooks.md +244 -0
- package/scaffold/skills/vitest/references/core-test-api.md +233 -0
- package/scaffold/skills/vitest/references/features-concurrency.md +250 -0
- package/scaffold/skills/vitest/references/features-context.md +238 -0
- package/scaffold/skills/vitest/references/features-coverage.md +207 -0
- package/scaffold/skills/vitest/references/features-filtering.md +211 -0
- package/scaffold/skills/vitest/references/features-mocking.md +265 -0
- package/scaffold/skills/vitest/references/features-snapshots.md +207 -0
- package/scaffold/skills/worktree/SKILL.md +93 -0
- package/scaffold/storyboard.config.json +44 -0
- package/src/canvas/Canvas.jsx +78 -0
- package/src/canvas/Draggable.jsx +235 -0
- package/src/canvas/index.d.ts +41 -0
- package/src/canvas/index.js +6 -0
- package/src/canvas/style.css +118 -0
- package/src/canvas/useResetCanvas.js +17 -0
- package/src/canvas/utils.js +136 -0
- package/src/core/assets/fonts/IoskeleyMono-Bold.woff2 +0 -0
- package/src/core/assets/fonts/IoskeleyMono-Italic.woff2 +0 -0
- package/src/core/assets/fonts/IoskeleyMono-Medium.woff2 +0 -0
- package/src/core/assets/fonts/IoskeleyMono-Regular.woff2 +0 -0
- package/src/core/assets/fonts/IoskeleyMono-SemiBold.woff2 +0 -0
- package/src/core/autosync/server.js +714 -0
- package/src/core/autosync/server.test.js +158 -0
- package/src/core/canvas/__tests__/agent-integration.test.js +596 -0
- package/src/core/canvas/__tests__/helpers/browser.js +95 -0
- package/src/core/canvas/__tests__/helpers/canvas-api.js +129 -0
- package/src/core/canvas/__tests__/helpers/perf.js +118 -0
- package/src/core/canvas/__tests__/helpers/setup.js +176 -0
- package/src/core/canvas/__tests__/helpers/tmux.js +130 -0
- package/src/core/canvas/__tests__/helpers/transcript.js +132 -0
- package/src/core/canvas/__tests__/terminal-integration.test.js +177 -0
- package/src/core/canvas/collision.js +292 -0
- package/src/core/canvas/collision.test.js +371 -0
- package/src/core/canvas/compact.js +83 -0
- package/src/core/canvas/deriveCanvasId.test.js +40 -0
- package/src/core/canvas/githubEmbeds.js +527 -0
- package/src/core/canvas/githubEmbeds.test.js +302 -0
- package/src/core/canvas/hot-pool.js +766 -0
- package/src/core/canvas/identity.js +107 -0
- package/src/core/canvas/identity.test.js +100 -0
- package/src/core/canvas/materializer.js +259 -0
- package/src/core/canvas/materializer.test.js +356 -0
- package/src/core/canvas/selectedWidgets.js +270 -0
- package/src/core/canvas/selectedWidgets.test.js +321 -0
- package/src/core/canvas/server.js +3134 -0
- package/src/core/canvas/server.test.js +379 -0
- package/src/core/canvas/terminal-config.js +330 -0
- package/src/core/canvas/terminal-registry.js +465 -0
- package/src/core/canvas/terminal-server.js +1436 -0
- package/src/core/canvas/writeGuard.js +53 -0
- package/src/core/cli/agent.js +85 -0
- package/src/core/cli/branch.js +386 -0
- package/src/core/cli/canvasAdd.js +241 -0
- package/src/core/cli/canvasBatch.js +98 -0
- package/src/core/cli/canvasBounds.js +160 -0
- package/src/core/cli/canvasRead.js +236 -0
- package/src/core/cli/canvasUpdate.js +179 -0
- package/src/core/cli/code.js +67 -0
- package/src/core/cli/compact.js +62 -0
- package/src/core/cli/create.js +674 -0
- package/src/core/cli/dev-helpers.js +53 -0
- package/src/core/cli/dev-helpers.test.js +53 -0
- package/src/core/cli/dev.js +430 -0
- package/src/core/cli/exit.js +38 -0
- package/src/core/cli/flags.js +174 -0
- package/src/core/cli/flags.test.js +155 -0
- package/src/core/cli/index.js +233 -0
- package/src/core/cli/intro.js +37 -0
- package/src/core/cli/proxy.js +319 -0
- package/src/core/cli/proxy.test.js +63 -0
- package/src/core/cli/schemas.js +223 -0
- package/src/core/cli/server.js +192 -0
- package/src/core/cli/serverUrl.js +61 -0
- package/src/core/cli/sessions.js +459 -0
- package/src/core/cli/setup.js +404 -0
- package/src/core/cli/terminal-commands.js +287 -0
- package/src/core/cli/terminal-messaging.js +231 -0
- package/src/core/cli/terminal-welcome.js +515 -0
- package/src/core/cli/updateVersion.js +124 -0
- package/src/core/comments/api.js +284 -0
- package/src/core/comments/api.test.js +282 -0
- package/src/core/comments/auth.js +151 -0
- package/src/core/comments/auth.test.js +167 -0
- package/src/core/comments/commentCache.js +109 -0
- package/src/core/comments/commentCache.test.js +48 -0
- package/src/core/comments/commentDrafts.js +68 -0
- package/src/core/comments/commentMode.js +63 -0
- package/src/core/comments/commentMode.test.js +90 -0
- package/src/core/comments/config.js +47 -0
- package/src/core/comments/config.test.js +77 -0
- package/src/core/comments/graphql.js +65 -0
- package/src/core/comments/graphql.test.js +95 -0
- package/src/core/comments/index.js +42 -0
- package/src/core/comments/metadata.js +52 -0
- package/src/core/comments/metadata.test.js +110 -0
- package/src/core/comments/queries.js +245 -0
- package/src/core/comments/ui/AuthModal.jsx +114 -0
- package/src/core/comments/ui/CommentOverlay.js +52 -0
- package/src/core/comments/ui/CommentWindow.jsx +329 -0
- package/src/core/comments/ui/CommentsDrawer.jsx +102 -0
- package/src/core/comments/ui/Composer.jsx +64 -0
- package/src/core/comments/ui/authModal.js +66 -0
- package/src/core/comments/ui/authModal.test.js +76 -0
- package/src/core/comments/ui/comment-cursor-dark.svg +1 -0
- package/src/core/comments/ui/comment-cursor.svg +1 -0
- package/src/core/comments/ui/comment-layout.css +142 -0
- package/src/core/comments/ui/commentWindow.js +121 -0
- package/src/core/comments/ui/comments.css +242 -0
- package/src/core/comments/ui/commentsDrawer.js +84 -0
- package/src/core/comments/ui/composer.js +136 -0
- package/src/core/comments/ui/index.js +14 -0
- package/src/core/comments/ui/mount.js +687 -0
- package/src/core/comments/ui/mount.test.js +336 -0
- package/src/core/data/dotPath.js +53 -0
- package/src/core/data/dotPath.test.js +114 -0
- package/src/core/data/loader.js +409 -0
- package/src/core/data/loader.test.js +599 -0
- package/src/core/data/viewfinder.js +363 -0
- package/src/core/data/viewfinder.test.js +456 -0
- package/src/core/devtools/devtools-consumer.js +28 -0
- package/src/core/devtools/devtools.js +144 -0
- package/src/core/devtools/devtools.test.js +75 -0
- package/src/core/devtools/sceneDebug.js +112 -0
- package/src/core/devtools/sceneDebug.test.js +141 -0
- package/src/core/index.js +124 -0
- package/src/core/inspector/fiberWalker.js +239 -0
- package/src/core/inspector/highlighter.js +275 -0
- package/src/core/inspector/mouseMode.js +259 -0
- package/src/core/lib/components/ui/alert/alert-action.jsx +11 -0
- package/src/core/lib/components/ui/alert/alert-description.jsx +11 -0
- package/src/core/lib/components/ui/alert/alert-title.jsx +11 -0
- package/src/core/lib/components/ui/alert/alert.jsx +25 -0
- package/src/core/lib/components/ui/alert/index.js +17 -0
- package/src/core/lib/components/ui/avatar/avatar-badge.jsx +22 -0
- package/src/core/lib/components/ui/avatar/avatar-fallback.jsx +18 -0
- package/src/core/lib/components/ui/avatar/avatar-group-count.jsx +19 -0
- package/src/core/lib/components/ui/avatar/avatar-group.jsx +19 -0
- package/src/core/lib/components/ui/avatar/avatar-image.jsx +15 -0
- package/src/core/lib/components/ui/avatar/avatar.jsx +19 -0
- package/src/core/lib/components/ui/avatar/index.js +22 -0
- package/src/core/lib/components/ui/badge/badge.jsx +31 -0
- package/src/core/lib/components/ui/badge/index.js +2 -0
- package/src/core/lib/components/ui/button/button.jsx +100 -0
- package/src/core/lib/components/ui/button/index.js +12 -0
- package/src/core/lib/components/ui/card/card-action.jsx +11 -0
- package/src/core/lib/components/ui/card/card-content.jsx +11 -0
- package/src/core/lib/components/ui/card/card-description.jsx +11 -0
- package/src/core/lib/components/ui/card/card-footer.jsx +11 -0
- package/src/core/lib/components/ui/card/card-header.jsx +19 -0
- package/src/core/lib/components/ui/card/card-title.jsx +11 -0
- package/src/core/lib/components/ui/card/card.jsx +17 -0
- package/src/core/lib/components/ui/card/index.js +25 -0
- package/src/core/lib/components/ui/checkbox/checkbox.jsx +29 -0
- package/src/core/lib/components/ui/checkbox/index.js +6 -0
- package/src/core/lib/components/ui/collapsible/collapsible-content.jsx +7 -0
- package/src/core/lib/components/ui/collapsible/collapsible-trigger.jsx +7 -0
- package/src/core/lib/components/ui/collapsible/collapsible.jsx +7 -0
- package/src/core/lib/components/ui/collapsible/index.js +13 -0
- package/src/core/lib/components/ui/dialog/dialog-close.jsx +7 -0
- package/src/core/lib/components/ui/dialog/dialog-content.jsx +34 -0
- package/src/core/lib/components/ui/dialog/dialog-description.jsx +15 -0
- package/src/core/lib/components/ui/dialog/dialog-footer.jsx +23 -0
- package/src/core/lib/components/ui/dialog/dialog-header.jsx +11 -0
- package/src/core/lib/components/ui/dialog/dialog-overlay.jsx +15 -0
- package/src/core/lib/components/ui/dialog/dialog-portal.jsx +4 -0
- package/src/core/lib/components/ui/dialog/dialog-title.jsx +15 -0
- package/src/core/lib/components/ui/dialog/dialog-trigger.jsx +7 -0
- package/src/core/lib/components/ui/dialog/dialog.jsx +4 -0
- package/src/core/lib/components/ui/dialog/index.js +34 -0
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.jsx +8 -0
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.jsx +30 -0
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-content.jsx +22 -0
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.jsx +16 -0
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-group.jsx +7 -0
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-item.jsx +20 -0
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-label.jsx +17 -0
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-portal.jsx +4 -0
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.jsx +7 -0
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.jsx +29 -0
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-separator.jsx +15 -0
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.jsx +16 -0
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.jsx +15 -0
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.jsx +23 -0
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-sub.jsx +4 -0
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-trigger.jsx +7 -0
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu.jsx +4 -0
- package/src/core/lib/components/ui/dropdown-menu/index.js +54 -0
- package/src/core/lib/components/ui/input/index.js +7 -0
- package/src/core/lib/components/ui/input/input.jsx +19 -0
- package/src/core/lib/components/ui/label/index.js +7 -0
- package/src/core/lib/components/ui/label/label.jsx +19 -0
- package/src/core/lib/components/ui/panel/index.js +24 -0
- package/src/core/lib/components/ui/panel/panel-body.jsx +11 -0
- package/src/core/lib/components/ui/panel/panel-close.jsx +16 -0
- package/src/core/lib/components/ui/panel/panel-content.jsx +29 -0
- package/src/core/lib/components/ui/panel/panel-footer.jsx +11 -0
- package/src/core/lib/components/ui/panel/panel-header.jsx +11 -0
- package/src/core/lib/components/ui/panel/panel-title.jsx +12 -0
- package/src/core/lib/components/ui/panel/panel.jsx +4 -0
- package/src/core/lib/components/ui/popover/index.js +28 -0
- package/src/core/lib/components/ui/popover/popover-close.jsx +7 -0
- package/src/core/lib/components/ui/popover/popover-content.jsx +22 -0
- package/src/core/lib/components/ui/popover/popover-description.jsx +11 -0
- package/src/core/lib/components/ui/popover/popover-header.jsx +11 -0
- package/src/core/lib/components/ui/popover/popover-portal.jsx +4 -0
- package/src/core/lib/components/ui/popover/popover-title.jsx +11 -0
- package/src/core/lib/components/ui/popover/popover-trigger.jsx +8 -0
- package/src/core/lib/components/ui/popover/popover.jsx +4 -0
- package/src/core/lib/components/ui/searchable-list.jsx +160 -0
- package/src/core/lib/components/ui/select/index.js +37 -0
- package/src/core/lib/components/ui/select/select-content.jsx +30 -0
- package/src/core/lib/components/ui/select/select-group-heading.jsx +17 -0
- package/src/core/lib/components/ui/select/select-group.jsx +15 -0
- package/src/core/lib/components/ui/select/select-item.jsx +26 -0
- package/src/core/lib/components/ui/select/select-label.jsx +11 -0
- package/src/core/lib/components/ui/select/select-portal.jsx +4 -0
- package/src/core/lib/components/ui/select/select-scroll-down-button.jsx +18 -0
- package/src/core/lib/components/ui/select/select-scroll-up-button.jsx +18 -0
- package/src/core/lib/components/ui/select/select-separator.jsx +15 -0
- package/src/core/lib/components/ui/select/select-trigger.jsx +25 -0
- package/src/core/lib/components/ui/select/select.jsx +4 -0
- package/src/core/lib/components/ui/separator/index.js +7 -0
- package/src/core/lib/components/ui/separator/separator.jsx +22 -0
- package/src/core/lib/components/ui/sheet/index.js +34 -0
- package/src/core/lib/components/ui/sheet/sheet-close.jsx +7 -0
- package/src/core/lib/components/ui/sheet/sheet-content.jsx +35 -0
- package/src/core/lib/components/ui/sheet/sheet-description.jsx +15 -0
- package/src/core/lib/components/ui/sheet/sheet-footer.jsx +11 -0
- package/src/core/lib/components/ui/sheet/sheet-header.jsx +11 -0
- package/src/core/lib/components/ui/sheet/sheet-overlay.jsx +15 -0
- package/src/core/lib/components/ui/sheet/sheet-portal.jsx +4 -0
- package/src/core/lib/components/ui/sheet/sheet-title.jsx +15 -0
- package/src/core/lib/components/ui/sheet/sheet-trigger.jsx +7 -0
- package/src/core/lib/components/ui/sheet/sheet.jsx +4 -0
- package/src/core/lib/components/ui/textarea/index.js +7 -0
- package/src/core/lib/components/ui/textarea/textarea.jsx +18 -0
- package/src/core/lib/components/ui/toggle/index.js +8 -0
- package/src/core/lib/components/ui/toggle/toggle.jsx +36 -0
- package/src/core/lib/components/ui/toggle-group/index.js +10 -0
- package/src/core/lib/components/ui/toggle-group/toggle-group-item.jsx +29 -0
- package/src/core/lib/components/ui/toggle-group/toggle-group.jsx +43 -0
- package/src/core/lib/components/ui/tooltip/index.js +3 -0
- package/src/core/lib/components/ui/tooltip/tooltip-content.jsx +21 -0
- package/src/core/lib/components/ui/tooltip/tooltip-trigger.jsx +23 -0
- package/src/core/lib/components/ui/tooltip/tooltip.jsx +11 -0
- package/src/core/lib/components/ui/trigger-button/index.js +6 -0
- package/src/core/lib/components/ui/trigger-button/trigger-button.css +38 -0
- package/src/core/lib/components/ui/trigger-button/trigger-button.jsx +63 -0
- package/src/core/lib/utils/index.js +6 -0
- package/src/core/logger/devLogger.js +238 -0
- package/src/core/logger/devLogger.test.js +193 -0
- package/src/core/modes/modes.css +98 -0
- package/src/core/modes/modes.js +492 -0
- package/src/core/modes/modes.test.js +562 -0
- package/src/core/mountStoryboardCore.js +478 -0
- package/src/core/rename-watcher/config.json +23 -0
- package/src/core/rename-watcher/watcher.js +531 -0
- package/src/core/scaffold.js +100 -0
- package/src/core/server/index.js +391 -0
- package/src/core/session/bodyClasses.js +128 -0
- package/src/core/session/bodyClasses.test.js +192 -0
- package/src/core/session/hashSubscribe.js +19 -0
- package/src/core/session/hashSubscribe.test.js +62 -0
- package/src/core/session/hideMode.js +424 -0
- package/src/core/session/hideMode.test.js +268 -0
- package/src/core/session/interceptHideParams.js +35 -0
- package/src/core/session/interceptHideParams.test.js +90 -0
- package/src/core/session/localStorage.js +134 -0
- package/src/core/session/localStorage.test.js +148 -0
- package/src/core/session/session.js +76 -0
- package/src/core/session/session.test.js +91 -0
- package/src/core/stores/canvasConfig.js +134 -0
- package/src/core/stores/canvasConfig.test.js +120 -0
- package/src/core/stores/commandActions.js +284 -0
- package/src/core/stores/commandPaletteConfig.js +31 -0
- package/src/core/stores/configSchema.js +232 -0
- package/src/core/stores/configSchema.test.js +72 -0
- package/src/core/stores/configStore.js +161 -0
- package/src/core/stores/customerModeConfig.js +30 -0
- package/src/core/stores/featureFlags.js +127 -0
- package/src/core/stores/paletteProviders.js +360 -0
- package/src/core/stores/paletteProviders.test.js +186 -0
- package/src/core/stores/plugins.js +40 -0
- package/src/core/stores/plugins.test.js +68 -0
- package/src/core/stores/recentArtifacts.js +68 -0
- package/src/core/stores/recentArtifacts.test.js +71 -0
- package/src/core/stores/sidePanelStore.ts +143 -0
- package/src/core/stores/themeStore.ts +291 -0
- package/src/core/stores/toolRegistry.js +227 -0
- package/src/core/stores/toolStateStore.js +183 -0
- package/src/core/stores/toolStateStore.test.js +220 -0
- package/src/core/stores/toolbarConfigStore.js +165 -0
- package/src/core/stores/uiConfig.js +64 -0
- package/src/core/stores/uiConfig.test.js +63 -0
- package/src/core/styles/tailwind.css +204 -0
- package/src/core/tools/handlers/autosync.js +12 -0
- package/src/core/tools/handlers/canvasAddWidget.js +11 -0
- package/src/core/tools/handlers/canvasAgents.js +20 -0
- package/src/core/tools/handlers/canvasToolbar.js +56 -0
- package/src/core/tools/handlers/commandPalette.js +9 -0
- package/src/core/tools/handlers/comments.js +16 -0
- package/src/core/tools/handlers/create.js +39 -0
- package/src/core/tools/handlers/devtools.js +122 -0
- package/src/core/tools/handlers/devtools.test.js +87 -0
- package/src/core/tools/handlers/featureFlags.js +21 -0
- package/src/core/tools/handlers/flows.js +68 -0
- package/src/core/tools/handlers/hideChrome.js +9 -0
- package/src/core/tools/handlers/hideToolbars.js +25 -0
- package/src/core/tools/handlers/inspector.js +19 -0
- package/src/core/tools/handlers/paletteTheme.js +35 -0
- package/src/core/tools/handlers/theme.js +9 -0
- package/src/core/tools/registry.js +26 -0
- package/src/core/tools/surfaces/canvasToolbar.js +10 -0
- package/src/core/tools/surfaces/commandList.js +10 -0
- package/src/core/tools/surfaces/mainToolbar.js +11 -0
- package/src/core/tools/surfaces/registry.js +19 -0
- package/src/core/ui/ActionMenuButton.jsx +114 -0
- package/src/core/ui/AutosyncMenuButton.css +67 -0
- package/src/core/ui/AutosyncMenuButton.jsx +242 -0
- package/src/core/ui/BranchSelect.jsx +29 -0
- package/src/core/ui/BranchSelect.module.css +30 -0
- package/src/core/ui/CanvasAgentsMenu.jsx +89 -0
- package/src/core/ui/CanvasCreateMenu.jsx +611 -0
- package/src/core/ui/CanvasSnap.css +27 -0
- package/src/core/ui/CanvasSnap.jsx +51 -0
- package/src/core/ui/CanvasUndoRedo.css +36 -0
- package/src/core/ui/CanvasUndoRedo.jsx +62 -0
- package/src/core/ui/CanvasZoomControl.css +53 -0
- package/src/core/ui/CanvasZoomControl.jsx +49 -0
- package/src/core/ui/CanvasZoomToFit.css +18 -0
- package/src/core/ui/CanvasZoomToFit.jsx +26 -0
- package/src/core/ui/CommandMenu.css +8 -0
- package/src/core/ui/CommandMenu.jsx +287 -0
- package/src/core/ui/CommandPalette.jsx +35 -0
- package/src/core/ui/CommandPaletteTrigger.jsx +25 -0
- package/src/core/ui/CommentsMenuButton.jsx +40 -0
- package/src/core/ui/CoreUIBar.css +47 -0
- package/src/core/ui/CoreUIBar.jsx +905 -0
- package/src/core/ui/CreateMenuButton.jsx +117 -0
- package/src/core/ui/HideChromeTrigger.jsx +48 -0
- package/src/core/ui/Icon.jsx +279 -0
- package/src/core/ui/InspectorPanel.css +109 -0
- package/src/core/ui/InspectorPanel.jsx +632 -0
- package/src/core/ui/PwaInstallBanner.css +42 -0
- package/src/core/ui/PwaInstallBanner.jsx +124 -0
- package/src/core/ui/SidePanel.jsx +261 -0
- package/src/core/ui/ThemeMenuButton.jsx +139 -0
- package/src/core/ui/core-ui-colors.css +129 -0
- package/src/core/ui/design-modes.ts +7 -0
- package/src/core/ui/sidepanel.css +301 -0
- package/src/core/ui/viewfinder.ts +7 -0
- package/src/core/ui-entry.js +30 -0
- package/src/core/utils/fuzzySearch.js +117 -0
- package/src/core/utils/fuzzySearch.test.js +119 -0
- package/src/core/utils/mobileViewport.js +57 -0
- package/src/core/utils/mobileViewport.test.js +68 -0
- package/src/core/utils/prodMode.js +38 -0
- package/src/core/utils/smoothCorners.js +20 -0
- package/src/core/vite/docs-handler.js +155 -0
- package/src/core/vite/server-plugin.js +797 -0
- package/src/core/workshop/features/createCanvas/CreateCanvasForm.jsx +260 -0
- package/src/core/workshop/features/createCanvas/index.js +14 -0
- package/src/core/workshop/features/createFlow/CreateFlowForm.jsx +334 -0
- package/src/core/workshop/features/createFlow/index.js +19 -0
- package/src/core/workshop/features/createFlow/server.js +663 -0
- package/src/core/workshop/features/createPage/CreatePageForm.jsx +304 -0
- package/src/core/workshop/features/createPage/index.js +11 -0
- package/src/core/workshop/features/createPrototype/CreatePrototypeForm.jsx +289 -0
- package/src/core/workshop/features/createPrototype/index.js +19 -0
- package/src/core/workshop/features/createPrototype/server.js +433 -0
- package/src/core/workshop/features/createStory/CreateStoryForm.jsx +208 -0
- package/src/core/workshop/features/createStory/index.js +14 -0
- package/src/core/workshop/features/registry-server.js +22 -0
- package/src/core/workshop/features/registry.js +28 -0
- package/src/core/workshop/features/templateIndex.js +155 -0
- package/src/core/workshop/ui/WorkshopPanel.jsx +98 -0
- package/src/core/workshop/ui/mount.ts +6 -0
- package/src/core/worktree/port.js +268 -0
- package/src/core/worktree/port.test.js +222 -0
- package/src/core/worktree/serverRegistry.js +120 -0
- package/src/internals/AuthModal/AuthModal.jsx +132 -0
- package/src/internals/AuthModal/AuthModal.module.css +221 -0
- package/src/internals/BranchBar/BranchBar.jsx +87 -0
- package/src/internals/BranchBar/BranchBar.module.css +247 -0
- package/src/internals/BranchBar/useBranches.js +93 -0
- package/src/internals/BranchBar/useBranches.test.js +68 -0
- package/src/internals/CommandPalette/CommandPalette.jsx +1361 -0
- package/src/internals/CommandPalette/CreateDialog.jsx +219 -0
- package/src/internals/CommandPalette/command-palette.css +180 -0
- package/src/internals/FlowError.module.css +30 -0
- package/src/internals/Icon.jsx +279 -0
- package/src/internals/StoryboardContext.js +3 -0
- package/src/internals/Viewfinder.jsx +1479 -0
- package/src/internals/Viewfinder.module.css +1540 -0
- package/src/internals/Workspace.jsx +7 -0
- package/src/internals/__mocks__/virtual-storyboard-data-index.js +4 -0
- package/src/internals/canvas/CanvasControls.jsx +112 -0
- package/src/internals/canvas/CanvasControls.module.css +135 -0
- package/src/internals/canvas/CanvasPage.bridge.test.jsx +387 -0
- package/src/internals/canvas/CanvasPage.dragdrop.test.jsx +350 -0
- package/src/internals/canvas/CanvasPage.jsx +3092 -0
- package/src/internals/canvas/CanvasPage.module.css +187 -0
- package/src/internals/canvas/CanvasPage.multiselect.test.jsx +358 -0
- package/src/internals/canvas/CanvasToolbar.jsx +73 -0
- package/src/internals/canvas/CanvasToolbar.module.css +92 -0
- package/src/internals/canvas/ComponentErrorBoundary.jsx +50 -0
- package/src/internals/canvas/ConnectorLayer.jsx +208 -0
- package/src/internals/canvas/ConnectorLayer.module.css +129 -0
- package/src/internals/canvas/MarqueeOverlay.jsx +20 -0
- package/src/internals/canvas/PageSelector.jsx +587 -0
- package/src/internals/canvas/PageSelector.module.css +261 -0
- package/src/internals/canvas/PageSelector.test.jsx +113 -0
- package/src/internals/canvas/WebGLContextPool.jsx +292 -0
- package/src/internals/canvas/WebGLContextPool.test.jsx +165 -0
- package/src/internals/canvas/canvasApi.js +164 -0
- package/src/internals/canvas/canvasReloadGuard.js +37 -0
- package/src/internals/canvas/canvasReloadGuard.test.js +27 -0
- package/src/internals/canvas/canvasTheme.js +118 -0
- package/src/internals/canvas/componentIsolate.jsx +165 -0
- package/src/internals/canvas/componentSetIsolate.jsx +257 -0
- package/src/internals/canvas/computeCanvasBounds.test.js +121 -0
- package/src/internals/canvas/connectorGeometry.js +132 -0
- package/src/internals/canvas/hotPoolDevLogs.js +25 -0
- package/src/internals/canvas/textSelection.js +10 -0
- package/src/internals/canvas/textSelection.test.js +26 -0
- package/src/internals/canvas/useCanvas.js +126 -0
- package/src/internals/canvas/useCanvas.test.js +26 -0
- package/src/internals/canvas/useMarqueeSelect.js +213 -0
- package/src/internals/canvas/useMarqueeSelect.test.js +78 -0
- package/src/internals/canvas/useUndoRedo.js +86 -0
- package/src/internals/canvas/useUndoRedo.test.js +231 -0
- package/src/internals/canvas/widgets/CodePenEmbed.jsx +293 -0
- package/src/internals/canvas/widgets/CodePenEmbed.module.css +161 -0
- package/src/internals/canvas/widgets/ComponentSetWidget.jsx +2 -0
- package/src/internals/canvas/widgets/ComponentSetWidget.module.css +89 -0
- package/src/internals/canvas/widgets/ComponentWidget.jsx +14 -0
- package/src/internals/canvas/widgets/ComponentWidget.module.css +0 -0
- package/src/internals/canvas/widgets/CropOverlay.jsx +179 -0
- package/src/internals/canvas/widgets/CropOverlay.module.css +154 -0
- package/src/internals/canvas/widgets/ExpandedPane.jsx +474 -0
- package/src/internals/canvas/widgets/ExpandedPane.module.css +179 -0
- package/src/internals/canvas/widgets/ExpandedPane.test.jsx +240 -0
- package/src/internals/canvas/widgets/ExpandedPaneTopBar.jsx +111 -0
- package/src/internals/canvas/widgets/ExpandedPaneTopBar.module.css +59 -0
- package/src/internals/canvas/widgets/ExpandedPaneTopBar.test.jsx +45 -0
- package/src/internals/canvas/widgets/FigmaEmbed.jsx +296 -0
- package/src/internals/canvas/widgets/FigmaEmbed.module.css +222 -0
- package/src/internals/canvas/widgets/FrozenTerminalOverlay.jsx +151 -0
- package/src/internals/canvas/widgets/FrozenTerminalOverlay.module.css +83 -0
- package/src/internals/canvas/widgets/ImageWidget.jsx +287 -0
- package/src/internals/canvas/widgets/ImageWidget.module.css +81 -0
- package/src/internals/canvas/widgets/LinkPreview.jsx +439 -0
- package/src/internals/canvas/widgets/LinkPreview.module.css +585 -0
- package/src/internals/canvas/widgets/LinkPreview.test.jsx +193 -0
- package/src/internals/canvas/widgets/MarkdownBlock.jsx +354 -0
- package/src/internals/canvas/widgets/MarkdownBlock.module.css +377 -0
- package/src/internals/canvas/widgets/MarkdownBlock.test.jsx +92 -0
- package/src/internals/canvas/widgets/PromptWidget.jsx +428 -0
- package/src/internals/canvas/widgets/PromptWidget.module.css +273 -0
- package/src/internals/canvas/widgets/PrototypeEmbed.jsx +463 -0
- package/src/internals/canvas/widgets/PrototypeEmbed.module.css +579 -0
- package/src/internals/canvas/widgets/PrototypeEmbed.test.jsx +10 -0
- package/src/internals/canvas/widgets/ResizeHandle.jsx +67 -0
- package/src/internals/canvas/widgets/ResizeHandle.module.css +29 -0
- package/src/internals/canvas/widgets/StickyNote.jsx +92 -0
- package/src/internals/canvas/widgets/StickyNote.module.css +70 -0
- package/src/internals/canvas/widgets/StickyNote.test.jsx +116 -0
- package/src/internals/canvas/widgets/StorySetWidget.jsx +208 -0
- package/src/internals/canvas/widgets/StorySetWidget.module.css +89 -0
- package/src/internals/canvas/widgets/StoryWidget.jsx +334 -0
- package/src/internals/canvas/widgets/StoryWidget.module.css +211 -0
- package/src/internals/canvas/widgets/TerminalReadWidget.jsx +146 -0
- package/src/internals/canvas/widgets/TerminalReadWidget.module.css +94 -0
- package/src/internals/canvas/widgets/TerminalWidget.jsx +704 -0
- package/src/internals/canvas/widgets/TerminalWidget.module.css +444 -0
- package/src/internals/canvas/widgets/TilesWidget.jsx +300 -0
- package/src/internals/canvas/widgets/TilesWidget.module.css +133 -0
- package/src/internals/canvas/widgets/WidgetChrome.jsx +580 -0
- package/src/internals/canvas/widgets/WidgetChrome.module.css +421 -0
- package/src/internals/canvas/widgets/WidgetWrapper.jsx +15 -0
- package/src/internals/canvas/widgets/WidgetWrapper.module.css +25 -0
- package/src/internals/canvas/widgets/codepenUrl.js +75 -0
- package/src/internals/canvas/widgets/codepenUrl.test.js +76 -0
- package/src/internals/canvas/widgets/embedInteraction.test.jsx +173 -0
- package/src/internals/canvas/widgets/embedOverlay.module.css +35 -0
- package/src/internals/canvas/widgets/embedTheme.js +148 -0
- package/src/internals/canvas/widgets/expandUtils.js +559 -0
- package/src/internals/canvas/widgets/expandUtils.test.js +155 -0
- package/src/internals/canvas/widgets/figmaUrl.js +118 -0
- package/src/internals/canvas/widgets/figmaUrl.test.js +139 -0
- package/src/internals/canvas/widgets/githubUrl.js +82 -0
- package/src/internals/canvas/widgets/githubUrl.test.js +74 -0
- package/src/internals/canvas/widgets/iframeDevLogs.js +49 -0
- package/src/internals/canvas/widgets/iframeDevLogs.test.jsx +81 -0
- package/src/internals/canvas/widgets/index.js +42 -0
- package/src/internals/canvas/widgets/pasteRules.js +295 -0
- package/src/internals/canvas/widgets/pasteRules.test.js +474 -0
- package/src/internals/canvas/widgets/snapshotDisplay.test.jsx +211 -0
- package/src/internals/canvas/widgets/tilePool.js +23 -0
- package/src/internals/canvas/widgets/tiles/diagonal-bl.png +0 -0
- package/src/internals/canvas/widgets/tiles/diagonal-br.png +0 -0
- package/src/internals/canvas/widgets/tiles/diagonal-tl.png +0 -0
- package/src/internals/canvas/widgets/tiles/leaf.png +0 -0
- package/src/internals/canvas/widgets/tiles/quarter-tl.png +0 -0
- package/src/internals/canvas/widgets/tiles/quarter-tr.png +0 -0
- package/src/internals/canvas/widgets/tiles/solid-a.png +0 -0
- package/src/internals/canvas/widgets/tiles/solid-b.png +0 -0
- package/src/internals/canvas/widgets/widgetConfig.js +291 -0
- package/src/internals/canvas/widgets/widgetConfig.test.js +68 -0
- package/src/internals/canvas/widgets/widgetIcons.jsx +190 -0
- package/src/internals/canvas/widgets/widgetProps.js +133 -0
- package/src/internals/context/FormContext.js +13 -0
- package/src/internals/context/FormContext.test.js +48 -0
- package/src/internals/context.jsx +481 -0
- package/src/internals/context.test.jsx +296 -0
- package/src/internals/hashPreserver.js +73 -0
- package/src/internals/hashPreserver.test.js +107 -0
- package/src/internals/hooks/useConfig.js +14 -0
- package/src/internals/hooks/useFeatureFlag.js +14 -0
- package/src/internals/hooks/useFlows.js +50 -0
- package/src/internals/hooks/useFlows.test.js +134 -0
- package/src/internals/hooks/useHideMode.js +31 -0
- package/src/internals/hooks/useHideMode.test.js +43 -0
- package/src/internals/hooks/useLocalStorage.js +57 -0
- package/src/internals/hooks/useLocalStorage.test.js +75 -0
- package/src/internals/hooks/useMode.js +43 -0
- package/src/internals/hooks/useObject.js +101 -0
- package/src/internals/hooks/useObject.test.js +74 -0
- package/src/internals/hooks/useOverride.js +84 -0
- package/src/internals/hooks/useOverride.test.js +71 -0
- package/src/internals/hooks/usePrototypeReloadGuard.js +64 -0
- package/src/internals/hooks/useRecord.js +158 -0
- package/src/internals/hooks/useRecord.test.js +221 -0
- package/src/internals/hooks/useScene.js +38 -0
- package/src/internals/hooks/useScene.test.js +66 -0
- package/src/internals/hooks/useSceneData.js +108 -0
- package/src/internals/hooks/useSceneData.test.js +136 -0
- package/src/internals/hooks/useSession.js +4 -0
- package/src/internals/hooks/useSession.test.js +8 -0
- package/src/internals/hooks/useThemeState.js +61 -0
- package/src/internals/hooks/useThemeState.test.js +66 -0
- package/src/internals/hooks/useUndoRedo.js +28 -0
- package/src/internals/hooks/useUndoRedo.test.js +64 -0
- package/src/internals/index.js +58 -0
- package/src/internals/story/ComponentSetPage.jsx +198 -0
- package/src/internals/story/ComponentSetPage.module.css +129 -0
- package/src/internals/story/StoryPage.jsx +147 -0
- package/src/internals/story/StoryPage.module.css +18 -0
- package/src/internals/test-utils.js +45 -0
- package/src/internals/vite/data-plugin.js +1508 -0
- package/src/internals/vite/data-plugin.test.js +1223 -0
- package/src/test-utils.js +44 -0
- package/toolbar.config.json +271 -0
- 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 “{canvasId}” 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
|
+
}
|