@agent-native/core 0.80.8 → 0.80.10
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/corpus/README.md +2 -2
- package/corpus/core/CHANGELOG.md +19 -0
- package/corpus/core/docs/content/locales/ar-SA/template-design.mdx +6 -0
- package/corpus/core/docs/content/locales/de-DE/template-design.mdx +8 -0
- package/corpus/core/docs/content/locales/es-ES/template-design.mdx +10 -2
- package/corpus/core/docs/content/locales/fr-FR/template-design.mdx +10 -2
- package/corpus/core/docs/content/locales/hi-IN/template-design.mdx +7 -0
- package/corpus/core/docs/content/locales/ja-JP/template-design.mdx +7 -0
- package/corpus/core/docs/content/locales/ko-KR/template-design.mdx +7 -0
- package/corpus/core/docs/content/locales/pt-BR/template-design.mdx +9 -2
- package/corpus/core/docs/content/locales/zh-CN/template-design.mdx +6 -0
- package/corpus/core/docs/content/locales/zh-TW/template-design.mdx +6 -0
- package/corpus/core/docs/content/template-design.mdx +9 -2
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/cli/design-connect.ts +28 -2
- package/corpus/core/src/cli/mcp.ts +10 -0
- package/corpus/core/src/cli/skills.ts +103 -36
- package/corpus/core/src/client/error-format.ts +25 -0
- package/corpus/core/src/client/extensions/ExtensionViewer.tsx +13 -11
- package/corpus/core/src/client/guided-questions.tsx +32 -4
- package/corpus/core/src/client/onboarding/OnboardingPanel.tsx +33 -1
- package/corpus/core/src/client/resources/BuiltinCapabilityDetail.tsx +8 -0
- package/corpus/core/src/client/resources/use-builtin-capabilities.ts +4 -2
- package/corpus/core/src/client/session-replay.ts +34 -0
- package/corpus/core/src/client/sharing/ShareButton.tsx +106 -21
- package/corpus/core/src/coding-tools/index.ts +1 -1
- package/corpus/core/src/mcp/index.ts +2 -0
- package/corpus/core/src/mcp/screen-memory-stdio.ts +290 -0
- package/corpus/core/src/mcp-client/builtin-capabilities.ts +13 -1
- package/corpus/core/src/mcp-client/index.ts +9 -0
- package/corpus/core/src/mcp-client/screen-memory-local.ts +461 -0
- package/corpus/core/src/onboarding/types.ts +7 -0
- package/corpus/core/src/server/agent-chat-plugin.ts +25 -0
- package/corpus/core/src/server/agents-bundle.ts +35 -7
- package/corpus/core/src/styles/agent-native.css +5 -0
- package/corpus/core/src/templates/default/app/global.css +35 -35
- package/corpus/templates/analytics/AGENTS.md +6 -0
- package/corpus/templates/analytics/actions/github-repo-files.ts +9 -0
- package/corpus/templates/analytics/app/components/layout/Layout.tsx +4 -4
- package/corpus/templates/analytics/app/global.css +144 -40
- package/corpus/templates/analytics/app/pages/adhoc/explorer/index.tsx +1 -1
- package/corpus/templates/analytics/app/pages/adhoc/explorer-dashboard/ChartCard.tsx +1 -1
- package/corpus/templates/analytics/app/pages/adhoc/explorer-dashboard/index.tsx +1 -1
- package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +4 -4
- package/corpus/templates/analytics/app/pages/sessions/SessionsPage.tsx +9 -9
- package/corpus/templates/analytics/changelog/2026-06-29-agents-can-search-and-read-connected-github-repositories-whe.md +6 -0
- package/corpus/templates/analytics/changelog/2026-06-29-sessions-and-explorer-layouts-adapt-when-the-agent-sidebar-is.md +6 -0
- package/corpus/templates/assets/app/components/layout/Layout.tsx +3 -1
- package/corpus/templates/assets/app/components/library/LibraryPresetGrid.tsx +1 -1
- package/corpus/templates/assets/app/global.css +99 -35
- package/corpus/templates/assets/app/routes/asset.$id.tsx +2 -2
- package/corpus/templates/assets/app/routes/brand-kits.$id.tsx +4 -4
- package/corpus/templates/assets/app/routes/library.tsx +4 -4
- package/corpus/templates/assets/changelog/2026-06-29-asset-library-and-detail-layouts-adapt-when-the-agent-sidebar.md +6 -0
- package/corpus/templates/brain/app/components/layout/Layout.tsx +1 -1
- package/corpus/templates/brain/app/global.css +63 -35
- package/corpus/templates/brain/app/routes/search.tsx +1 -1
- package/corpus/templates/brain/app/routes/settings.tsx +1 -1
- package/corpus/templates/brain/app/routes/sources.tsx +2 -2
- package/corpus/templates/brain/changelog/2026-06-29-search-settings-and-source-layouts-adapt-when-the-agent-sidebar.md +6 -0
- package/corpus/templates/calendar/app/components/calendar/EventDetailPanel.tsx +4 -4
- package/corpus/templates/calendar/app/components/calendar/FindTimePanel.tsx +3 -2
- package/corpus/templates/calendar/app/components/layout/AppLayout.tsx +4 -2
- package/corpus/templates/calendar/app/global.css +69 -35
- package/corpus/templates/calendar/changelog/2026-06-29-event-and-find-time-layouts-adapt-when-the-agent-sidebar-is.md +6 -0
- package/corpus/templates/chat/app/components/layout/Layout.tsx +1 -1
- package/corpus/templates/chat/app/global.css +35 -35
- package/corpus/templates/chat/changelog/2026-06-29-chat-layouts-adapt-when-the-agent-sidebar-is-open.md +6 -0
- package/corpus/templates/clips/AGENTS.md +13 -0
- package/corpus/templates/clips/actions/get-screen-memory-status.ts +12 -0
- package/corpus/templates/clips/actions/query-screen-memory-context.ts +29 -0
- package/corpus/templates/clips/actions/view-screen.ts +2 -2
- package/corpus/templates/clips/app/components/library/library-layout.tsx +1 -1
- package/corpus/templates/clips/app/global.css +47 -35
- package/corpus/templates/clips/app/routes/_app.meetings.$meetingId.tsx +3 -3
- package/corpus/templates/clips/app/routes/r.$recordingId.tsx +45 -18
- package/corpus/templates/clips/changelog/2026-06-29-meeting-detail-layouts-adapt-when-the-agent-sidebar-is-open.md +6 -0
- package/corpus/templates/clips/changelog/2026-06-29-screen-memory-keeps-a-local-rolling-screen-buffer-for-agent-context.md +5 -0
- package/corpus/templates/clips/changelog/2026-06-29-video-chat-is-easier-to-find-on-recording-pages-and-s3-setup.md +6 -0
- package/corpus/templates/clips/chrome-extension/src/styles.css +39 -39
- package/corpus/templates/clips/desktop/src/app.tsx +290 -1
- package/corpus/templates/clips/desktop/src/shared/config.ts +83 -0
- package/corpus/templates/clips/desktop/src/styles.css +67 -52
- package/corpus/templates/clips/desktop/src-tauri/src/config.rs +50 -0
- package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +17 -0
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +13 -13
- package/corpus/templates/clips/desktop/src-tauri/src/screen_memory.rs +990 -0
- package/corpus/templates/clips/server/plugins/onboarding.ts +2 -0
- package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +3 -3
- package/corpus/templates/content/app/components/layout/Layout.tsx +1 -1
- package/corpus/templates/content/app/global.css +29 -15
- package/corpus/templates/content/changelog/2026-06-29-database-gallery-layouts-adapt-when-the-agent-sidebar-is-open.md +6 -0
- package/corpus/templates/design/.agents/skills/design-generation/SKILL.md +25 -20
- package/corpus/templates/design/.agents/skills/design-systems/SKILL.md +8 -0
- package/corpus/templates/design/.agents/skills/export-handoff/SKILL.md +8 -0
- package/corpus/templates/design/.agents/skills/visual-edit/SKILL.md +13 -0
- package/corpus/templates/design/AGENTS.md +20 -12
- package/corpus/templates/design/CODE_NATIVE_DESIGN_STUDIO_IMPLEMENTATION_PLAN.md +1054 -0
- package/corpus/templates/design/DESIGN-STUDIO-PLAN.md +717 -0
- package/corpus/templates/design/actions/add-breakpoint.ts +143 -0
- package/corpus/templates/design/actions/add-localhost-screens.ts +5 -0
- package/corpus/templates/design/actions/apply-a11y-fix.ts +317 -0
- package/corpus/templates/design/actions/apply-component-prop-edit.ts +433 -0
- package/corpus/templates/design/actions/apply-design-state.ts +200 -0
- package/corpus/templates/design/actions/apply-design-token-edit.ts +202 -0
- package/corpus/templates/design/actions/apply-motion-edit.ts +450 -0
- package/corpus/templates/design/actions/apply-shader-fill.ts +404 -0
- package/corpus/templates/design/actions/apply-visual-edit.ts +214 -8
- package/corpus/templates/design/actions/capture-design-state.ts +224 -0
- package/corpus/templates/design/actions/connect-builder-app.ts +162 -0
- package/corpus/templates/design/actions/create-component.ts +447 -0
- package/corpus/templates/design/actions/create-design-branch.ts +263 -0
- package/corpus/templates/design/actions/create-design-state.ts +162 -0
- package/corpus/templates/design/actions/delete-design-state.ts +55 -0
- package/corpus/templates/design/actions/deploy-design-preview.ts +275 -0
- package/corpus/templates/design/actions/export-coding-handoff.ts +12 -4
- package/corpus/templates/design/actions/generate-design.ts +6 -21
- package/corpus/templates/design/actions/generate-screens.ts +43 -2
- package/corpus/templates/design/actions/get-component-details.ts +242 -0
- package/corpus/templates/design/actions/get-design-branch-diff.ts +362 -0
- package/corpus/templates/design/actions/get-design-review.ts +314 -0
- package/corpus/templates/design/actions/get-design-surface-index.ts +582 -0
- package/corpus/templates/design/actions/get-design.ts +1 -0
- package/corpus/templates/design/actions/get-motion-timeline.ts +99 -0
- package/corpus/templates/design/actions/index-components.ts +258 -0
- package/corpus/templates/design/actions/index-design-tokens.ts +273 -0
- package/corpus/templates/design/actions/list-design-extensions.ts +309 -0
- package/corpus/templates/design/actions/list-design-source-capabilities.ts +153 -0
- package/corpus/templates/design/actions/list-design-states.ts +72 -0
- package/corpus/templates/design/actions/migrate-inline-design-to-app.ts +298 -0
- package/corpus/templates/design/actions/open-component-source.ts +237 -0
- package/corpus/templates/design/actions/present-design-variants.ts +355 -30
- package/corpus/templates/design/actions/preview-component-prop-edit.ts +254 -0
- package/corpus/templates/design/actions/preview-design-token-edit.ts +113 -0
- package/corpus/templates/design/actions/preview-shader-fill.ts +189 -0
- package/corpus/templates/design/actions/remove-breakpoint.ts +103 -0
- package/corpus/templates/design/actions/remove-motion-timeline.ts +196 -0
- package/corpus/templates/design/actions/run-design-audit.ts +421 -0
- package/corpus/templates/design/actions/run-design-extension-action.ts +284 -0
- package/corpus/templates/design/actions/set-active-breakpoint.ts +39 -0
- package/corpus/templates/design/actions/view-screen.ts +75 -31
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +1738 -135
- package/corpus/templates/design/app/components/design/DesignEditorSkeleton.tsx +1 -1
- package/corpus/templates/design/app/components/design/DesignExtensionsPanel.tsx +632 -43
- package/corpus/templates/design/app/components/design/DrawOverlay.tsx +1 -1
- package/corpus/templates/design/app/components/design/EditPanel.tsx +1873 -132
- package/corpus/templates/design/app/components/design/LayersPanel.tsx +384 -219
- package/corpus/templates/design/app/components/design/LocalSourceEditBanner.tsx +157 -0
- package/corpus/templates/design/app/components/design/MotionDock.tsx +1062 -0
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +2103 -295
- package/corpus/templates/design/app/components/design/QuestionFlow.tsx +83 -206
- package/corpus/templates/design/app/components/design/ReviewPanel.tsx +759 -0
- package/corpus/templates/design/app/components/design/StatesPanel.tsx +579 -0
- package/corpus/templates/design/app/components/design/TokensPanel.tsx +573 -0
- package/corpus/templates/design/app/components/design/TweaksPanel.tsx +1 -1
- package/corpus/templates/design/app/components/design/canvas-primitive-style.ts +241 -0
- package/corpus/templates/design/app/components/design/index.ts +16 -1
- package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +238 -137
- package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +188 -14
- package/corpus/templates/design/app/components/design/inspector/ExportSettingsPanel.tsx +24 -5
- package/corpus/templates/design/app/components/design/inspector/ImageFillControls.tsx +57 -1
- package/corpus/templates/design/app/components/design/inspector/InspectorAiActions.tsx +145 -0
- package/corpus/templates/design/app/components/design/inspector/SHADER_INTEGRATION.md +42 -12
- package/corpus/templates/design/app/components/design/inspector/ScrubInput.tsx +37 -12
- package/corpus/templates/design/app/components/design/inspector/index.ts +1 -0
- package/corpus/templates/design/app/components/design/inspector/scrub-input-utils.ts +12 -5
- package/corpus/templates/design/app/components/design/types.ts +9 -0
- package/corpus/templates/design/app/components/layout/Layout.tsx +23 -5
- package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +1 -1
- package/corpus/templates/design/app/global.css +77 -49
- package/corpus/templates/design/app/hooks/use-question-flow.ts +2 -2
- package/corpus/templates/design/app/hooks/useAgentEditRequest.ts +131 -0
- package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +11 -2
- package/corpus/templates/design/app/i18n/zh-TW.ts +34 -0
- package/corpus/templates/design/app/i18n-data.ts +314 -3
- package/corpus/templates/design/app/pages/DesignEditor.tsx +4929 -892
- package/corpus/templates/design/app/root.tsx +1 -1
- package/corpus/templates/design/changelog/2026-06-29-added-a-review-panel-in-the-design-editor-s-inspector-with-a.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-containers-no-longer-block-selecting-nested-elements-and-nor.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-copying-and-pasting-layers-no-longer-shows-success-notificat.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-design-agents-now-generate-and-refine-variants-from-stronger.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-design-inspector-controls-now-match-figmas-softer-dark-chrome.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-design-questions-now-appear-as-a-cleaner-canvas-intake-with-.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-design-variant-directions-now-appear-as-board-screens-with-chat-buttons.md +5 -0
- package/corpus/templates/design/changelog/2026-06-29-device-presets-in-all-screens-view-resize-the-selected-previ.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-element-selection-no-longer-snaps-back-to-the-previous-layer.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-frame-resize-measurements-now-stay-beside-the-cursor-while-d.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-inspector-number-fields-have-cleaner-tooltips-and-draggable-.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-layer-and-canvas-drags-keep-the-layer-list-stable-while-chan.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-layer-locking-layer-nesting-generated-question-intake-and-im.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-layer-moves-can-be-undone-and-redone-without-flashing-the-ca.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-layer-names-in-the-sidebar-can-scroll-horizontally-instead-o.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-layer-nesting-in-the-sidebar-now-uses-tighter-spacing-so-nam.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-layers-no-longer-repeat-document-wrapper-rows-when-a-screen-.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-overview-previews-now-keep-element-hover-and-selection-tied-.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-pressing-escape-now-cancels-a-stuck-screen-drag-in-the-overv.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-screen-overview-is-easier-to-find-and-named-screens-open-dir.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-screen-overview-only-highlights-screens-when-the-frame-or-ti.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-screen-overview-previews-now-resize-to-match-the-selected-de.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-screen-overview-selection-now-uses-a-pointer-cursor-instead-.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-screen-overview-selection-outlines-now-stay-locked-to-the-se.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-screen-overview-zoom-now-reports-real-screen-scale-separatel.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-screen-overview-zooming-feels-smoother-and-selection-outline.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-screen-overview-zooming-now-stays-on-the-overview-canvas-ins.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-screen-previews-use-a-single-blue-hover-border-in-all-screen.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-selected-containers-show-draggable-padding-and-gap-guides-on.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-selecting-a-nested-layer-from-all-screens-now-keeps-you-in-a.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-share-now-groups-link-sharing-exports-and-agent-handoff.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-the-design-editor-adds-a-studio-layer-with-tokens-respon.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-the-design-editor-loading-skeleton-now-uses-a-softer-charcoa.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-the-device-preview-menu-now-sits-next-to-the-preview-button-.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-the-inspector-stays-read-only-while-an-empty-design-is-still.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-the-missing-design-view-now-matches-the-rest-of-the-editor-c.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-the-pen-tool-icon-and-tool-option-menus-better-match-the-edi.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-typography-controls-now-keep-the-font-name-visible-and-move-.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-accessibility-findings-now-offer-a-one-click-fix.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-component-instances-now-have-editable-props-in-the-inspe.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-inspect-code-now-shows-the-elements-opening-tag-at-a-gla.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-shader-fill-presets-can-now-be-applied-to-an-element.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-the-motion-timeline-can-now-add-a-track-to-any-element-a.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-visual-editor-selection-layer-paint-and-drawing-controls-are-more-reliable.md +6 -0
- package/corpus/templates/design/e2e/README.md +3 -3
- package/corpus/templates/design/e2e/global-setup.ts +11 -22
- package/corpus/templates/design/e2e/helpers.ts +128 -11
- package/corpus/templates/design/playwright.config.ts +9 -3
- package/corpus/templates/design/server/db/schema.ts +123 -0
- package/corpus/templates/design/server/lib/coding-handoff.ts +126 -2
- package/corpus/templates/design/server/plugins/core-routes.ts +1 -2
- package/corpus/templates/design/server/plugins/db.ts +90 -0
- package/corpus/templates/design/server/routes/api/design-handoff/[id].zip.get.ts +86 -0
- package/corpus/templates/design/shared/builder-app.ts +297 -0
- package/corpus/templates/design/shared/capability-resolver.ts +123 -0
- package/corpus/templates/design/shared/capture-sanitize.ts +70 -0
- package/corpus/templates/design/shared/code-layer.ts +1141 -93
- package/corpus/templates/design/shared/component-model.ts +239 -0
- package/corpus/templates/design/shared/design-review.ts +275 -0
- package/corpus/templates/design/shared/design-source-capabilities.ts +286 -0
- package/corpus/templates/design/shared/design-state.ts +112 -0
- package/corpus/templates/design/shared/design-surface-index.ts +258 -0
- package/corpus/templates/design/shared/generation-session.ts +41 -0
- package/corpus/templates/design/shared/motion-compiler.ts +278 -0
- package/corpus/templates/design/shared/motion-timeline.ts +193 -0
- package/corpus/templates/design/shared/responsive-classes.ts +452 -0
- package/corpus/templates/design/shared/shader-fill.ts +323 -0
- package/corpus/templates/design/shared/source-mode.ts +245 -0
- package/corpus/templates/dispatch/app/global.css +79 -35
- package/corpus/templates/dispatch/app/routes/integrations.tsx +8 -7
- package/corpus/templates/dispatch/changelog/2026-06-29-integration-layouts-adapt-when-the-agent-sidebar-is-open.md +6 -0
- package/corpus/templates/forms/app/components/layout/Layout.tsx +6 -2
- package/corpus/templates/forms/app/global.css +64 -35
- package/corpus/templates/forms/app/pages/FormsListPage.tsx +2 -2
- package/corpus/templates/forms/app/pages/ResponseInsightsPage.tsx +3 -3
- package/corpus/templates/forms/changelog/2026-06-29-form-list-and-insight-layouts-adapt-when-the-agent-sidebar.md +6 -0
- package/corpus/templates/macros/app/components/layout/AppLayout.tsx +3 -1
- package/corpus/templates/macros/app/global.css +25 -9
- package/corpus/templates/macros/changelog/2026-06-29-macro-layouts-adapt-when-the-agent-sidebar-is-open.md +6 -0
- package/corpus/templates/mail/app/components/layout/AppLayout.tsx +6 -2
- package/corpus/templates/mail/app/global.css +42 -36
- package/corpus/templates/mail/app/pages/InboxPage.tsx +1 -1
- package/corpus/templates/mail/changelog/2026-06-29-the-contact-panel-now-adapts-to-the-available-mail-pane-width.md +6 -0
- package/corpus/templates/plan/app/components/layout/Layout.tsx +3 -1
- package/corpus/templates/plan/app/components/plan/CanvasArea.tsx +6 -2
- package/corpus/templates/plan/app/components/plan/DocumentArea.tsx +6 -7
- package/corpus/templates/plan/app/global.css +74 -42
- package/corpus/templates/plan/changelog/2026-06-29-plan-canvases-open-without-an-initial-pan-and-zoom-flicker.md +6 -0
- package/corpus/templates/plan/changelog/2026-06-29-plan-document-blocks-adapt-to-the-available-document-width.md +6 -0
- package/corpus/templates/plan/changelog/2026-06-29-plan-titles-and-summaries-can-be-edited-inline-without-focus.md +6 -0
- package/corpus/templates/slides/app/components/layout/Layout.tsx +3 -1
- package/corpus/templates/slides/app/global.css +49 -33
- package/corpus/templates/slides/changelog/2026-06-29-slide-layouts-adapt-when-the-agent-sidebar-is-open.md +6 -0
- package/corpus/templates/videos/app/components/layout/Layout.tsx +3 -1
- package/corpus/templates/videos/app/global.css +35 -35
- package/corpus/templates/videos/changelog/2026-06-29-video-layouts-adapt-when-the-agent-sidebar-is-open.md +6 -0
- package/dist/cli/design-connect.d.ts.map +1 -1
- package/dist/cli/design-connect.js +28 -2
- package/dist/cli/design-connect.js.map +1 -1
- package/dist/cli/mcp.d.ts.map +1 -1
- package/dist/cli/mcp.js +10 -0
- package/dist/cli/mcp.js.map +1 -1
- package/dist/cli/skills.d.ts.map +1 -1
- package/dist/cli/skills.js +95 -33
- package/dist/cli/skills.js.map +1 -1
- package/dist/client/error-format.d.ts.map +1 -1
- package/dist/client/error-format.js +17 -0
- package/dist/client/error-format.js.map +1 -1
- package/dist/client/extensions/ExtensionViewer.d.ts.map +1 -1
- package/dist/client/extensions/ExtensionViewer.js +3 -4
- package/dist/client/extensions/ExtensionViewer.js.map +1 -1
- package/dist/client/guided-questions.d.ts +4 -0
- package/dist/client/guided-questions.d.ts.map +1 -1
- package/dist/client/guided-questions.js +16 -7
- package/dist/client/guided-questions.js.map +1 -1
- package/dist/client/onboarding/OnboardingPanel.js +26 -1
- package/dist/client/onboarding/OnboardingPanel.js.map +1 -1
- package/dist/client/resources/BuiltinCapabilityDetail.d.ts.map +1 -1
- package/dist/client/resources/BuiltinCapabilityDetail.js +1 -1
- package/dist/client/resources/BuiltinCapabilityDetail.js.map +1 -1
- package/dist/client/resources/use-builtin-capabilities.d.ts +1 -1
- package/dist/client/resources/use-builtin-capabilities.d.ts.map +1 -1
- package/dist/client/resources/use-builtin-capabilities.js +2 -1
- package/dist/client/resources/use-builtin-capabilities.js.map +1 -1
- package/dist/client/session-replay.d.ts.map +1 -1
- package/dist/client/session-replay.js +31 -0
- package/dist/client/session-replay.js.map +1 -1
- package/dist/client/sharing/ShareButton.d.ts +14 -0
- package/dist/client/sharing/ShareButton.d.ts.map +1 -1
- package/dist/client/sharing/ShareButton.js +36 -7
- package/dist/client/sharing/ShareButton.js.map +1 -1
- package/dist/coding-tools/index.js +1 -1
- package/dist/coding-tools/index.js.map +1 -1
- package/dist/mcp/index.d.ts +2 -0
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +1 -0
- package/dist/mcp/index.js.map +1 -1
- package/dist/mcp/screen-memory-stdio.d.ts +6 -0
- package/dist/mcp/screen-memory-stdio.d.ts.map +1 -0
- package/dist/mcp/screen-memory-stdio.js +213 -0
- package/dist/mcp/screen-memory-stdio.js.map +1 -0
- package/dist/mcp-client/builtin-capabilities.d.ts +1 -1
- package/dist/mcp-client/builtin-capabilities.d.ts.map +1 -1
- package/dist/mcp-client/builtin-capabilities.js +10 -0
- package/dist/mcp-client/builtin-capabilities.js.map +1 -1
- package/dist/mcp-client/index.d.ts +1 -0
- package/dist/mcp-client/index.d.ts.map +1 -1
- package/dist/mcp-client/index.js +1 -0
- package/dist/mcp-client/index.js.map +1 -1
- package/dist/mcp-client/screen-memory-local.d.ts +61 -0
- package/dist/mcp-client/screen-memory-local.d.ts.map +1 -0
- package/dist/mcp-client/screen-memory-local.js +336 -0
- package/dist/mcp-client/screen-memory-local.js.map +1 -0
- package/dist/observability/routes.d.ts +3 -3
- package/dist/onboarding/types.d.ts +7 -0
- package/dist/onboarding/types.d.ts.map +1 -1
- package/dist/onboarding/types.js.map +1 -1
- package/dist/server/agent-chat-plugin.d.ts.map +1 -1
- package/dist/server/agent-chat-plugin.js +26 -0
- package/dist/server/agent-chat-plugin.js.map +1 -1
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/agents-bundle.d.ts.map +1 -1
- package/dist/server/agents-bundle.js +22 -7
- package/dist/server/agents-bundle.js.map +1 -1
- package/dist/styles/agent-native.css +5 -0
- package/dist/templates/default/app/global.css +35 -35
- package/docs/content/locales/ar-SA/template-design.mdx +6 -0
- package/docs/content/locales/de-DE/template-design.mdx +8 -0
- package/docs/content/locales/es-ES/template-design.mdx +10 -2
- package/docs/content/locales/fr-FR/template-design.mdx +10 -2
- package/docs/content/locales/hi-IN/template-design.mdx +7 -0
- package/docs/content/locales/ja-JP/template-design.mdx +7 -0
- package/docs/content/locales/ko-KR/template-design.mdx +7 -0
- package/docs/content/locales/pt-BR/template-design.mdx +9 -2
- package/docs/content/locales/zh-CN/template-design.mdx +6 -0
- package/docs/content/locales/zh-TW/template-design.mdx +6 -0
- package/docs/content/template-design.mdx +9 -2
- package/package.json +1 -1
- package/src/templates/default/app/global.css +35 -35
- package/corpus/templates/design/app/components/design/VariantGrid.tsx +0 -189
- package/corpus/templates/design/app/components/design/VariantHandoffCard.tsx +0 -115
- package/corpus/templates/design/app/hooks/use-variant-flow.ts +0 -342
|
@@ -32,12 +32,14 @@ import {
|
|
|
32
32
|
type PenNode,
|
|
33
33
|
type PenPath,
|
|
34
34
|
} from "@shared/pen-path";
|
|
35
|
-
import { IconCopy, IconMaximize } from "@tabler/icons-react";
|
|
35
|
+
import { IconCopy, IconMaximize, IconPlus } from "@tabler/icons-react";
|
|
36
36
|
import {
|
|
37
|
+
memo,
|
|
37
38
|
useRef,
|
|
38
39
|
useState,
|
|
39
40
|
useCallback,
|
|
40
41
|
useEffect,
|
|
42
|
+
useMemo,
|
|
41
43
|
type CSSProperties,
|
|
42
44
|
type ReactNode,
|
|
43
45
|
} from "react";
|
|
@@ -45,6 +47,9 @@ import {
|
|
|
45
47
|
import { prettyScreenName } from "@/lib/screen-names";
|
|
46
48
|
import { cn } from "@/lib/utils";
|
|
47
49
|
|
|
50
|
+
import { canvasPrimitiveReactStyle } from "./canvas-primitive-style";
|
|
51
|
+
import { DEVICE_FRAME_VIEWPORTS, type DeviceFrameType } from "./types";
|
|
52
|
+
|
|
48
53
|
interface ScreenFile {
|
|
49
54
|
id: string;
|
|
50
55
|
filename: string;
|
|
@@ -55,10 +60,19 @@ interface ScreenFile {
|
|
|
55
60
|
previewState?: string;
|
|
56
61
|
status?: string;
|
|
57
62
|
title?: string;
|
|
63
|
+
updatedAt?: string;
|
|
58
64
|
width?: number;
|
|
59
65
|
height?: number;
|
|
60
66
|
url?: string;
|
|
61
67
|
previewUrl?: string;
|
|
68
|
+
/**
|
|
69
|
+
* When set, renders multiple side-by-side breakpoint frames (mobile-first,
|
|
70
|
+
* §6.4). Each entry is a pixel width; the active breakpoint determines the
|
|
71
|
+
* edit scope (Tailwind prefix: base / md: / lg: / xl:).
|
|
72
|
+
*/
|
|
73
|
+
breakpointWidths?: number[];
|
|
74
|
+
/** Id of the currently active breakpoint frame for this screen. */
|
|
75
|
+
activeBreakpointWidth?: number;
|
|
62
76
|
}
|
|
63
77
|
|
|
64
78
|
type ScreenSourceType = "localhost" | "fusion" | "inline";
|
|
@@ -129,6 +143,12 @@ interface MultiScreenCanvasProps {
|
|
|
129
143
|
screens: ScreenFile[];
|
|
130
144
|
zoom: number;
|
|
131
145
|
activeId?: string | null;
|
|
146
|
+
selectedScreenIds?: string[];
|
|
147
|
+
fullViewScreenIds?: string[];
|
|
148
|
+
activeScreenHasHoveredChild?: boolean;
|
|
149
|
+
hoveredChildScreenId?: string | null;
|
|
150
|
+
directlyHoveredScreenId?: string | null;
|
|
151
|
+
previewDeviceFrame?: DeviceFrameType;
|
|
132
152
|
activeTool?: MultiScreenCanvasTool;
|
|
133
153
|
toolProps?: CanvasToolProps;
|
|
134
154
|
onActiveToolChange?: (tool: MultiScreenCanvasTool) => void;
|
|
@@ -147,26 +167,60 @@ interface MultiScreenCanvasProps {
|
|
|
147
167
|
screenId: string,
|
|
148
168
|
primitive: CanvasPrimitiveInsert,
|
|
149
169
|
) => boolean | string;
|
|
170
|
+
onPrimitiveCreated?: (screenId: string, nodeId: string) => void;
|
|
171
|
+
onPrimitiveReparent?: (args: {
|
|
172
|
+
sourceNodeId: string;
|
|
173
|
+
sourceScreenId: string;
|
|
174
|
+
targetNodeId: string;
|
|
175
|
+
targetScreenId: string;
|
|
176
|
+
placement: "inside";
|
|
177
|
+
}) => void;
|
|
150
178
|
onCreateScreenFrame?: (geometry: FrameGeometry) => void;
|
|
151
179
|
onDeleteSelection?: (ids: string[]) => boolean | void;
|
|
152
180
|
onZoomChange?: (zoom: number) => void;
|
|
153
|
-
onZoomToEdit?: (id: string) => void;
|
|
154
|
-
zoomToEditThreshold?: number;
|
|
155
181
|
renderScreenContent?: (
|
|
156
182
|
screen: ScreenFile,
|
|
157
183
|
metadata: ResolvedScreenMetadata,
|
|
158
184
|
geometry: FrameGeometry,
|
|
159
185
|
) => ReactNode;
|
|
186
|
+
onScreenSelectionChange?: (ids: string[]) => void;
|
|
160
187
|
selectAllRequest?: number;
|
|
161
188
|
clearSelectionRequest?: number;
|
|
189
|
+
/**
|
|
190
|
+
* Called when the user clicks the + affordance on a screen's breakpoint
|
|
191
|
+
* row to add the next standard breakpoint width (390 / 768 / 1280).
|
|
192
|
+
*/
|
|
193
|
+
onAddBreakpoint?: (screenId: string, widthPx: number) => void;
|
|
194
|
+
/**
|
|
195
|
+
* Called when the user clicks a breakpoint frame header to make it the
|
|
196
|
+
* active edit scope.
|
|
197
|
+
*/
|
|
198
|
+
onActiveBreakpointChange?: (
|
|
199
|
+
screenId: string,
|
|
200
|
+
widthPx: number | undefined,
|
|
201
|
+
) => void;
|
|
162
202
|
onSelectionChange?: (selectedIds: string[]) => void;
|
|
203
|
+
/**
|
|
204
|
+
* Called when the user drags an element out of the active screen's iframe
|
|
205
|
+
* and drops it onto a different screen. The bridge in the source iframe
|
|
206
|
+
* posts { type:"agent-native:cross-screen-drag" } messages; the host
|
|
207
|
+
* translates them to board coords, finds the target frame, and calls this
|
|
208
|
+
* prop with the resolved ids.
|
|
209
|
+
*/
|
|
210
|
+
onCrossScreenElementDrop?: (args: {
|
|
211
|
+
sourceSelector: string;
|
|
212
|
+
sourceNodeId?: string;
|
|
213
|
+
sourceScreenId: string;
|
|
214
|
+
targetScreenId: string;
|
|
215
|
+
}) => void;
|
|
163
216
|
}
|
|
164
217
|
|
|
165
218
|
/**
|
|
166
219
|
* design-editor overview canvas. Renders every file in the design as a movable,
|
|
167
220
|
* resizable frame inside an infinite, pannable surface.
|
|
168
221
|
*/
|
|
169
|
-
const
|
|
222
|
+
export const OVERVIEW_FRAME_WIDTH = 320;
|
|
223
|
+
const SCREEN_WIDTH = OVERVIEW_FRAME_WIDTH;
|
|
170
224
|
const SCREEN_HEIGHT = 640;
|
|
171
225
|
const SCREEN_CARD_HEIGHT = SCREEN_HEIGHT + 26;
|
|
172
226
|
const SCREEN_GAP = 56;
|
|
@@ -174,12 +228,70 @@ const SURFACE_PADDING = 240;
|
|
|
174
228
|
const DUPLICATE_DRAG_THRESHOLD = 6;
|
|
175
229
|
const DRAG_THRESHOLD = 3;
|
|
176
230
|
const FRAME_LABEL_HEIGHT = 28;
|
|
231
|
+
const FRAME_HEADER_BUTTON_OUTSIDE_WIDTH = 260;
|
|
232
|
+
const FRAME_HEADER_BUTTON_RESERVE = 116;
|
|
233
|
+
const FRAME_HEADER_DIMENSIONS_WIDTH = 72;
|
|
234
|
+
const FRAME_HEADER_TITLE_CHAR_WIDTH = 6.5;
|
|
235
|
+
const FRAME_HEADER_MIN_TITLE_WIDTH = 28;
|
|
236
|
+
const FRAME_HEADER_MAX_TITLE_WIDTH = 180;
|
|
237
|
+
const TRANSFORM_BADGE_OFFSET = 12;
|
|
238
|
+
const TRANSFORM_BADGE_EDGE_PADDING = 8;
|
|
239
|
+
const TRANSFORM_BADGE_HEIGHT = 28;
|
|
240
|
+
const TRANSFORM_BADGE_MIN_WIDTH = 64;
|
|
241
|
+
const TRANSFORM_BADGE_MAX_WIDTH = 180;
|
|
177
242
|
const MIN_ZOOM = 2;
|
|
178
243
|
const MAX_ZOOM = 800;
|
|
179
|
-
const ZOOM_SENSITIVITY = 0.
|
|
180
|
-
const MAX_WHEEL_ZOOM_DELTA =
|
|
244
|
+
const ZOOM_SENSITIVITY = 0.01;
|
|
245
|
+
const MAX_WHEEL_ZOOM_DELTA = 120;
|
|
181
246
|
const MAX_WHEEL_PAN_DELTA = 140;
|
|
182
247
|
const PIXEL_GRID_ZOOM = 800;
|
|
248
|
+
// During a zoom gesture the constant-size selection chrome is frozen (we don't
|
|
249
|
+
// re-render); on commit it recomputes to its fixed screen size. These transitions
|
|
250
|
+
// are enabled only for that brief settle, so normal selection, resize, and
|
|
251
|
+
// screen-switch geometry stays pinned to the frame.
|
|
252
|
+
const CHROME_SETTLE_MS = 150;
|
|
253
|
+
const CHROME_OPACITY_TRANSITION = "opacity 150ms ease-out";
|
|
254
|
+
const CHROME_BORDER_SETTLE_TRANSITION = `inset ${CHROME_SETTLE_MS}ms ease-out, border-width ${CHROME_SETTLE_MS}ms ease-out, border-radius ${CHROME_SETTLE_MS}ms ease-out, ${CHROME_OPACITY_TRANSITION}`;
|
|
255
|
+
const SELECTION_BOX_SETTLE_TRANSITION = `border-width ${CHROME_SETTLE_MS}ms ease-out, border-radius ${CHROME_SETTLE_MS}ms ease-out, ${CHROME_OPACITY_TRANSITION}`;
|
|
256
|
+
const CHROME_HANDLE_SETTLE_TRANSITION = `width ${CHROME_SETTLE_MS}ms ease-out, height ${CHROME_SETTLE_MS}ms ease-out, border-width ${CHROME_SETTLE_MS}ms ease-out, top ${CHROME_SETTLE_MS}ms ease-out, bottom ${CHROME_SETTLE_MS}ms ease-out, left ${CHROME_SETTLE_MS}ms ease-out, right ${CHROME_SETTLE_MS}ms ease-out, ${CHROME_OPACITY_TRANSITION}`;
|
|
257
|
+
// Frame header (name + dimensions + "Full view" button) is counter-scaled via
|
|
258
|
+
// transform to stay a fixed screen size; ease that scale on zoom-settle. opacity
|
|
259
|
+
// is included so the button's hover-fade (transition-opacity) keeps working.
|
|
260
|
+
const CHROME_LABEL_SETTLE_TRANSITION = `transform ${CHROME_SETTLE_MS}ms ease-out, ${CHROME_OPACITY_TRANSITION}`;
|
|
261
|
+
|
|
262
|
+
export function getChromeBorderTransition(chromeSettling: boolean) {
|
|
263
|
+
return chromeSettling
|
|
264
|
+
? CHROME_BORDER_SETTLE_TRANSITION
|
|
265
|
+
: CHROME_OPACITY_TRANSITION;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function getSelectionBoxTransition(chromeSettling: boolean) {
|
|
269
|
+
return chromeSettling ? SELECTION_BOX_SETTLE_TRANSITION : "none";
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function isDirectScreenHoverTarget(
|
|
273
|
+
target: EventTarget | null,
|
|
274
|
+
currentTarget: HTMLElement,
|
|
275
|
+
) {
|
|
276
|
+
if (target === currentTarget) return true;
|
|
277
|
+
const element =
|
|
278
|
+
target && typeof (target as Element).closest === "function"
|
|
279
|
+
? (target as Element)
|
|
280
|
+
: null;
|
|
281
|
+
return !!element && !element.closest("[data-screen-content]");
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function getChromeHandleTransition(chromeSettling: boolean) {
|
|
285
|
+
return chromeSettling
|
|
286
|
+
? CHROME_HANDLE_SETTLE_TRANSITION
|
|
287
|
+
: CHROME_OPACITY_TRANSITION;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function getChromeLabelTransition(chromeSettling: boolean) {
|
|
291
|
+
return chromeSettling
|
|
292
|
+
? CHROME_LABEL_SETTLE_TRANSITION
|
|
293
|
+
: CHROME_OPACITY_TRANSITION;
|
|
294
|
+
}
|
|
183
295
|
const DRAFT_FRAME_WIDTH = 320;
|
|
184
296
|
const DRAFT_FRAME_HEIGHT = 640;
|
|
185
297
|
const DRAFT_RECT_WIDTH = 160;
|
|
@@ -394,10 +506,30 @@ type DragState =
|
|
|
394
506
|
| DraftCreateDragState
|
|
395
507
|
| PenNodeDragState;
|
|
396
508
|
|
|
509
|
+
type PendingWheelGesture =
|
|
510
|
+
| {
|
|
511
|
+
mode: "zoom";
|
|
512
|
+
deltaY: number;
|
|
513
|
+
cursor: Point;
|
|
514
|
+
clientX: number;
|
|
515
|
+
clientY: number;
|
|
516
|
+
}
|
|
517
|
+
| {
|
|
518
|
+
mode: "pan";
|
|
519
|
+
deltaX: number;
|
|
520
|
+
deltaY: number;
|
|
521
|
+
};
|
|
522
|
+
|
|
397
523
|
export function MultiScreenCanvas({
|
|
398
524
|
screens,
|
|
399
525
|
zoom,
|
|
400
526
|
activeId,
|
|
527
|
+
selectedScreenIds,
|
|
528
|
+
fullViewScreenIds,
|
|
529
|
+
activeScreenHasHoveredChild = false,
|
|
530
|
+
hoveredChildScreenId,
|
|
531
|
+
directlyHoveredScreenId,
|
|
532
|
+
previewDeviceFrame = "none",
|
|
401
533
|
activeTool,
|
|
402
534
|
toolProps,
|
|
403
535
|
onActiveToolChange,
|
|
@@ -410,15 +542,19 @@ export function MultiScreenCanvas({
|
|
|
410
542
|
onGeometryChange,
|
|
411
543
|
onGeometryCommit,
|
|
412
544
|
onCreatePrimitive,
|
|
545
|
+
onPrimitiveCreated,
|
|
546
|
+
onPrimitiveReparent,
|
|
413
547
|
onCreateScreenFrame,
|
|
414
548
|
onDeleteSelection,
|
|
415
549
|
onZoomChange,
|
|
416
|
-
onZoomToEdit,
|
|
417
|
-
zoomToEditThreshold,
|
|
418
550
|
renderScreenContent,
|
|
551
|
+
onScreenSelectionChange,
|
|
419
552
|
selectAllRequest,
|
|
420
553
|
clearSelectionRequest,
|
|
554
|
+
onAddBreakpoint,
|
|
555
|
+
onActiveBreakpointChange,
|
|
421
556
|
onSelectionChange,
|
|
557
|
+
onCrossScreenElementDrop,
|
|
422
558
|
}: MultiScreenCanvasProps) {
|
|
423
559
|
const t = useT();
|
|
424
560
|
const surfaceRef = useRef<HTMLDivElement>(null);
|
|
@@ -430,6 +566,7 @@ export function MultiScreenCanvas({
|
|
|
430
566
|
const frameGeometryRef = useRef(frameGeometry);
|
|
431
567
|
const onGeometryChangeRef = useRef(onGeometryChange);
|
|
432
568
|
const onGeometryCommitRef = useRef(onGeometryCommit);
|
|
569
|
+
const screensRef = useRef(screens);
|
|
433
570
|
const [draftPrimitives, setDraftPrimitives] = useState<DraftPrimitive[]>([]);
|
|
434
571
|
const draftPrimitivesRef = useRef(draftPrimitives);
|
|
435
572
|
const [selectedDraftIds, setSelectedDraftIds] = useState<string[]>([]);
|
|
@@ -446,7 +583,7 @@ export function MultiScreenCanvas({
|
|
|
446
583
|
const [localActiveTool, setLocalActiveTool] =
|
|
447
584
|
useState<MultiScreenCanvasTool>("move");
|
|
448
585
|
const [selectedIds, setSelectedIds] = useState<string[]>(
|
|
449
|
-
|
|
586
|
+
selectedScreenIds ?? [],
|
|
450
587
|
);
|
|
451
588
|
const selectedIdsRef = useRef(selectedIds);
|
|
452
589
|
const dragState = useRef<DragState | null>(null);
|
|
@@ -464,8 +601,56 @@ export function MultiScreenCanvas({
|
|
|
464
601
|
null,
|
|
465
602
|
);
|
|
466
603
|
const [dragCursor, setDragCursor] = useState<string | null>(null);
|
|
604
|
+
const [primitiveDropTarget, setPrimitiveDropTarget] =
|
|
605
|
+
useState<PrimitiveDropTarget | null>(null);
|
|
606
|
+
const primitiveDropTargetRef = useRef<PrimitiveDropTarget | null>(null);
|
|
607
|
+
const onPrimitiveReparentRef = useRef(onPrimitiveReparent);
|
|
608
|
+
|
|
609
|
+
// Cross-screen element drag state — driven by postMessage from the source iframe.
|
|
610
|
+
interface CrossScreenDragGhost {
|
|
611
|
+
/** Board-space point where the ghost is shown (follows the cursor). */
|
|
612
|
+
boardX: number;
|
|
613
|
+
boardY: number;
|
|
614
|
+
}
|
|
615
|
+
interface CrossScreenDragTarget {
|
|
616
|
+
/** The screen frame that is the candidate drop target. */
|
|
617
|
+
id: string;
|
|
618
|
+
geometry: FrameGeometry;
|
|
619
|
+
}
|
|
620
|
+
const [crossScreenGhost, setCrossScreenGhost] =
|
|
621
|
+
useState<CrossScreenDragGhost | null>(null);
|
|
622
|
+
const [crossScreenTarget, setCrossScreenTarget] =
|
|
623
|
+
useState<CrossScreenDragTarget | null>(null);
|
|
624
|
+
/** Ref kept in sync with state so the message handler can read without closures. */
|
|
625
|
+
const crossScreenTargetRef = useRef<CrossScreenDragTarget | null>(null);
|
|
626
|
+
/** The most-recent drag message payload — kept for use in the "end" handler. */
|
|
627
|
+
const crossScreenDragMsgRef = useRef<{
|
|
628
|
+
selector: string;
|
|
629
|
+
sourceId?: string;
|
|
630
|
+
} | null>(null);
|
|
631
|
+
const onCrossScreenElementDropRef = useRef(onCrossScreenElementDrop);
|
|
467
632
|
const suppressNextPick = useRef(false);
|
|
468
633
|
const feedbackTimerRef = useRef<number | null>(null);
|
|
634
|
+
const pendingWheelGestureRef = useRef<PendingWheelGesture | null>(null);
|
|
635
|
+
const wheelGestureFrameRef = useRef<number | null>(null);
|
|
636
|
+
const worldRef = useRef<HTMLDivElement>(null);
|
|
637
|
+
const pixelGridRef = useRef<HTMLDivElement>(null);
|
|
638
|
+
const viewCommitTimerRef = useRef<number | null>(null);
|
|
639
|
+
const pendingChromeSettleRef = useRef(false);
|
|
640
|
+
const chromeSettleTimerRef = useRef<number | null>(null);
|
|
641
|
+
const [chromeSettling, setChromeSettling] = useState(false);
|
|
642
|
+
const previousPreviewDeviceFrameRef = useRef(previewDeviceFrame);
|
|
643
|
+
|
|
644
|
+
const getResolvedMetadata = useCallback(
|
|
645
|
+
(screen: ScreenFile) =>
|
|
646
|
+
resolveScreenMetadata(
|
|
647
|
+
screen,
|
|
648
|
+
metadataById?.[screen.id],
|
|
649
|
+
getScreenMetadata?.(screen),
|
|
650
|
+
previewDeviceFrame,
|
|
651
|
+
),
|
|
652
|
+
[getScreenMetadata, metadataById, previewDeviceFrame],
|
|
653
|
+
);
|
|
469
654
|
|
|
470
655
|
useEffect(() => {
|
|
471
656
|
onGeometryChangeRef.current = onGeometryChange;
|
|
@@ -475,6 +660,18 @@ export function MultiScreenCanvas({
|
|
|
475
660
|
onGeometryCommitRef.current = onGeometryCommit;
|
|
476
661
|
}, [onGeometryCommit]);
|
|
477
662
|
|
|
663
|
+
useEffect(() => {
|
|
664
|
+
onPrimitiveReparentRef.current = onPrimitiveReparent;
|
|
665
|
+
}, [onPrimitiveReparent]);
|
|
666
|
+
|
|
667
|
+
useEffect(() => {
|
|
668
|
+
onCrossScreenElementDropRef.current = onCrossScreenElementDrop;
|
|
669
|
+
}, [onCrossScreenElementDrop]);
|
|
670
|
+
|
|
671
|
+
useEffect(() => {
|
|
672
|
+
screensRef.current = screens;
|
|
673
|
+
}, [screens]);
|
|
674
|
+
|
|
478
675
|
useEffect(() => {
|
|
479
676
|
activePenPathRef.current = activePenPath;
|
|
480
677
|
}, [activePenPath]);
|
|
@@ -559,6 +756,10 @@ export function MultiScreenCanvas({
|
|
|
559
756
|
onSelectionChangeRef.current?.(selectedIds);
|
|
560
757
|
}, [selectedIds]);
|
|
561
758
|
|
|
759
|
+
useEffect(() => {
|
|
760
|
+
onScreenSelectionChange?.(selectedIds);
|
|
761
|
+
}, [onScreenSelectionChange, selectedIds]);
|
|
762
|
+
|
|
562
763
|
useEffect(() => {
|
|
563
764
|
draftPrimitivesRef.current = draftPrimitives;
|
|
564
765
|
}, [draftPrimitives]);
|
|
@@ -581,8 +782,9 @@ export function MultiScreenCanvas({
|
|
|
581
782
|
screens.forEach((screen, index) => {
|
|
582
783
|
const existing = current[screen.id];
|
|
583
784
|
const persisted = geometryById?.[screen.id];
|
|
785
|
+
const metadata = getResolvedMetadata(screen);
|
|
584
786
|
const resolved = {
|
|
585
|
-
...getInitialFrameGeometry(index),
|
|
787
|
+
...getInitialFrameGeometry(index, metadata),
|
|
586
788
|
...persisted,
|
|
587
789
|
} as FrameGeometry;
|
|
588
790
|
next[screen.id] = persisted ? resolved : (existing ?? resolved);
|
|
@@ -600,14 +802,45 @@ export function MultiScreenCanvas({
|
|
|
600
802
|
const next = current.filter((id) => currentIds.has(id));
|
|
601
803
|
return next.length === current.length ? current : next;
|
|
602
804
|
});
|
|
603
|
-
}, [
|
|
805
|
+
}, [
|
|
806
|
+
geometryById,
|
|
807
|
+
getResolvedMetadata,
|
|
808
|
+
screens,
|
|
809
|
+
updateFrameGeometry,
|
|
810
|
+
updateSelectedIds,
|
|
811
|
+
]);
|
|
812
|
+
|
|
813
|
+
useEffect(() => {
|
|
814
|
+
const previous = previousPreviewDeviceFrameRef.current;
|
|
815
|
+
previousPreviewDeviceFrameRef.current = previewDeviceFrame;
|
|
816
|
+
if (previous === previewDeviceFrame) return;
|
|
817
|
+
|
|
818
|
+
updateFrameGeometry((current) => {
|
|
819
|
+
const next = { ...current };
|
|
820
|
+
let changed = false;
|
|
821
|
+
|
|
822
|
+
screens.forEach((screen, index) => {
|
|
823
|
+
const metadata = getResolvedMetadata(screen);
|
|
824
|
+
const currentGeometry =
|
|
825
|
+
current[screen.id] ?? getInitialFrameGeometry(index, metadata);
|
|
826
|
+
const nextGeometry = getPreviewDeviceFrameGeometry({
|
|
827
|
+
currentGeometry,
|
|
828
|
+
metadata,
|
|
829
|
+
previewDeviceFrame,
|
|
830
|
+
});
|
|
831
|
+
if (sameFrameGeometry(currentGeometry, nextGeometry)) return;
|
|
832
|
+
next[screen.id] = nextGeometry;
|
|
833
|
+
changed = true;
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
return changed ? next : current;
|
|
837
|
+
});
|
|
838
|
+
}, [getResolvedMetadata, previewDeviceFrame, screens, updateFrameGeometry]);
|
|
604
839
|
|
|
605
840
|
useEffect(() => {
|
|
606
|
-
if (!
|
|
607
|
-
updateSelectedIds((
|
|
608
|
-
|
|
609
|
-
);
|
|
610
|
-
}, [activeId, updateSelectedIds]);
|
|
841
|
+
if (!selectedScreenIds) return;
|
|
842
|
+
updateSelectedIds(() => selectedScreenIds);
|
|
843
|
+
}, [screens, selectedScreenIds, updateSelectedIds]);
|
|
611
844
|
|
|
612
845
|
useEffect(() => {
|
|
613
846
|
if (
|
|
@@ -640,11 +873,26 @@ export function MultiScreenCanvas({
|
|
|
640
873
|
useEffect(() => {
|
|
641
874
|
if (!surfaceRef.current || screens.length === 0) return;
|
|
642
875
|
const rect = surfaceRef.current.getBoundingClientRect();
|
|
643
|
-
const columns = Math.min(screens.length, 3);
|
|
644
|
-
const rows = Math.ceil(screens.length / columns);
|
|
645
876
|
const scale = zoomRef.current / 100;
|
|
646
|
-
const
|
|
647
|
-
|
|
877
|
+
const frames = screens.map((screen, index) => {
|
|
878
|
+
const metadata = getResolvedMetadata(screen);
|
|
879
|
+
const currentGeometry =
|
|
880
|
+
frameGeometryRef.current[screen.id] ??
|
|
881
|
+
getInitialFrameGeometry(index, metadata);
|
|
882
|
+
return getPreviewDeviceFrameGeometry({
|
|
883
|
+
currentGeometry,
|
|
884
|
+
metadata,
|
|
885
|
+
previewDeviceFrame,
|
|
886
|
+
});
|
|
887
|
+
});
|
|
888
|
+
const bounds = getFrameGroupBounds(
|
|
889
|
+
frames.map((geometry, index) => ({
|
|
890
|
+
id: screens[index]?.id ?? String(index),
|
|
891
|
+
geometry,
|
|
892
|
+
})),
|
|
893
|
+
);
|
|
894
|
+
const totalWidth = bounds?.width ?? SCREEN_WIDTH;
|
|
895
|
+
const totalHeight = bounds?.height ?? SCREEN_CARD_HEIGHT;
|
|
648
896
|
const visualLeft = Math.max(24, (rect.width - totalWidth * scale) / 2);
|
|
649
897
|
const visualTop = Math.max(24, (rect.height - totalHeight * scale) / 2);
|
|
650
898
|
const nextPan = {
|
|
@@ -653,9 +901,9 @@ export function MultiScreenCanvas({
|
|
|
653
901
|
};
|
|
654
902
|
panRef.current = nextPan;
|
|
655
903
|
setPan(nextPan);
|
|
656
|
-
// Only on mount
|
|
904
|
+
// Only on mount, screen-count changes, or device-preview changes.
|
|
657
905
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
658
|
-
}, [screens.length]);
|
|
906
|
+
}, [previewDeviceFrame, screens.length]);
|
|
659
907
|
|
|
660
908
|
useEffect(() => {
|
|
661
909
|
return () => {
|
|
@@ -664,6 +912,15 @@ export function MultiScreenCanvas({
|
|
|
664
912
|
if (feedbackTimerRef.current !== null) {
|
|
665
913
|
window.clearTimeout(feedbackTimerRef.current);
|
|
666
914
|
}
|
|
915
|
+
if (wheelGestureFrameRef.current !== null) {
|
|
916
|
+
window.cancelAnimationFrame(wheelGestureFrameRef.current);
|
|
917
|
+
}
|
|
918
|
+
if (viewCommitTimerRef.current !== null) {
|
|
919
|
+
window.clearTimeout(viewCommitTimerRef.current);
|
|
920
|
+
}
|
|
921
|
+
if (chromeSettleTimerRef.current !== null) {
|
|
922
|
+
window.clearTimeout(chromeSettleTimerRef.current);
|
|
923
|
+
}
|
|
667
924
|
};
|
|
668
925
|
}, []);
|
|
669
926
|
|
|
@@ -694,8 +951,17 @@ export function MultiScreenCanvas({
|
|
|
694
951
|
}, []);
|
|
695
952
|
|
|
696
953
|
const getCurrentFrameEntries = useCallback(
|
|
697
|
-
() =>
|
|
698
|
-
|
|
954
|
+
() =>
|
|
955
|
+
screens.map((screen, index) => {
|
|
956
|
+
const metadata = getResolvedMetadata(screen);
|
|
957
|
+
return {
|
|
958
|
+
id: screen.id,
|
|
959
|
+
geometry:
|
|
960
|
+
frameGeometryRef.current[screen.id] ??
|
|
961
|
+
getInitialFrameGeometry(index, metadata),
|
|
962
|
+
};
|
|
963
|
+
}),
|
|
964
|
+
[getResolvedMetadata, screens],
|
|
699
965
|
);
|
|
700
966
|
|
|
701
967
|
const getCurrentDraftEntries = useCallback(
|
|
@@ -737,13 +1003,154 @@ export function MultiScreenCanvas({
|
|
|
737
1003
|
[getCurrentFrameEntries],
|
|
738
1004
|
);
|
|
739
1005
|
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
1006
|
+
// ── Cross-screen element drag receiver ────────────────────────────────────
|
|
1007
|
+
// The source iframe (the active interactive screen) posts
|
|
1008
|
+
// { type: "agent-native:cross-screen-drag", phase, selector, sourceId,
|
|
1009
|
+
// iframeX, iframeY, viewportW, viewportH }
|
|
1010
|
+
// during element drags that the bridge wants the host to handle.
|
|
1011
|
+
useEffect(() => {
|
|
1012
|
+
if (!onCrossScreenElementDrop) return;
|
|
1013
|
+
|
|
1014
|
+
const clearCrossScreenDrag = () => {
|
|
1015
|
+
setCrossScreenGhost(null);
|
|
1016
|
+
setCrossScreenTarget(null);
|
|
1017
|
+
crossScreenTargetRef.current = null;
|
|
1018
|
+
crossScreenDragMsgRef.current = null;
|
|
1019
|
+
};
|
|
1020
|
+
|
|
1021
|
+
const handleMessage = (event: MessageEvent) => {
|
|
1022
|
+
if (!event.data || event.data.type !== "agent-native:cross-screen-drag") {
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
const msg = event.data as {
|
|
1026
|
+
type: string;
|
|
1027
|
+
phase: "move" | "end" | "cancel";
|
|
1028
|
+
selector?: string;
|
|
1029
|
+
sourceId?: string;
|
|
1030
|
+
iframeX?: number;
|
|
1031
|
+
iframeY?: number;
|
|
1032
|
+
viewportW?: number;
|
|
1033
|
+
viewportH?: number;
|
|
1034
|
+
};
|
|
1035
|
+
|
|
1036
|
+
if (msg.phase === "cancel") {
|
|
1037
|
+
clearCrossScreenDrag();
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// The drag originates from the active screen (only the active screen has
|
|
1042
|
+
// a live interactive iframe posting these messages).
|
|
1043
|
+
const sourceScreenId = activeId;
|
|
1044
|
+
if (!sourceScreenId) {
|
|
1045
|
+
// Always clear visual state (ghost + highlight) when we have no active
|
|
1046
|
+
// screen to attribute the drag to — regardless of the phase. Without
|
|
1047
|
+
// this, a "move" message arriving after activeId became null would leave
|
|
1048
|
+
// stale ghost/target state visible on the canvas.
|
|
1049
|
+
clearCrossScreenDrag();
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
if (msg.phase === "move") {
|
|
1054
|
+
const { iframeX, iframeY, viewportW, viewportH, selector, sourceId } =
|
|
1055
|
+
msg;
|
|
1056
|
+
if (
|
|
1057
|
+
iframeX === undefined ||
|
|
1058
|
+
iframeY === undefined ||
|
|
1059
|
+
viewportW === undefined ||
|
|
1060
|
+
viewportH === undefined
|
|
1061
|
+
) {
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// Remember the latest drag payload for use on "end".
|
|
1066
|
+
crossScreenDragMsgRef.current = {
|
|
1067
|
+
selector: selector ?? "",
|
|
1068
|
+
sourceId,
|
|
1069
|
+
};
|
|
1070
|
+
|
|
1071
|
+
// Pointer is inside the source iframe — let the bridge handle it.
|
|
1072
|
+
if (
|
|
1073
|
+
iframeX >= 0 &&
|
|
1074
|
+
iframeY >= 0 &&
|
|
1075
|
+
iframeX <= viewportW &&
|
|
1076
|
+
iframeY <= viewportH
|
|
1077
|
+
) {
|
|
1078
|
+
clearCrossScreenDrag();
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// Translate iframe coords → board coords using source frame geometry.
|
|
1083
|
+
// Board math mirrors primitiveLocalToBoardRect:
|
|
1084
|
+
// boardX = frame.x + iframeX * (frame.width / metadata.width)
|
|
1085
|
+
// boardY = frame.y + iframeY * (frame.height / metadata.height)
|
|
1086
|
+
const sourceScreen = screensRef.current.find(
|
|
1087
|
+
(s) => s.id === sourceScreenId,
|
|
1088
|
+
);
|
|
1089
|
+
const sourceGeometry = frameGeometryRef.current[sourceScreenId];
|
|
1090
|
+
if (!sourceScreen || !sourceGeometry) {
|
|
1091
|
+
clearCrossScreenDrag();
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
const sourceMetadata = getResolvedMetadata(sourceScreen);
|
|
1095
|
+
const scaleX = sourceGeometry.width / Math.max(1, sourceMetadata.width);
|
|
1096
|
+
const scaleY =
|
|
1097
|
+
sourceGeometry.height / Math.max(1, sourceMetadata.height);
|
|
1098
|
+
const boardX = sourceGeometry.x + iframeX * scaleX;
|
|
1099
|
+
const boardY = sourceGeometry.y + iframeY * scaleY;
|
|
1100
|
+
const boardPoint = { x: boardX, y: boardY };
|
|
1101
|
+
|
|
1102
|
+
setCrossScreenGhost({ boardX, boardY });
|
|
1103
|
+
|
|
1104
|
+
// Find which screen frame the board point falls in.
|
|
1105
|
+
const target = getFrameEntryAtPoint(boardPoint);
|
|
1106
|
+
if (target && target.id !== sourceScreenId) {
|
|
1107
|
+
const nextTarget = { id: target.id, geometry: target.geometry };
|
|
1108
|
+
crossScreenTargetRef.current = nextTarget;
|
|
1109
|
+
setCrossScreenTarget(nextTarget);
|
|
1110
|
+
} else {
|
|
1111
|
+
crossScreenTargetRef.current = null;
|
|
1112
|
+
setCrossScreenTarget(null);
|
|
1113
|
+
}
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
if (msg.phase === "end") {
|
|
1118
|
+
const candidate = crossScreenTargetRef.current;
|
|
1119
|
+
// Use the saved payload from the last "move" as the primary source of
|
|
1120
|
+
// truth; fall back to the "end" message's own fields in case the ref
|
|
1121
|
+
// was cleared (e.g. a brief re-entry into the source iframe nulled it
|
|
1122
|
+
// via clearCrossScreenDrag while pointerOutsideIframe remained true).
|
|
1123
|
+
const payload = crossScreenDragMsgRef.current ?? {
|
|
1124
|
+
selector: msg.selector ?? "",
|
|
1125
|
+
sourceId: msg.sourceId,
|
|
1126
|
+
};
|
|
1127
|
+
clearCrossScreenDrag();
|
|
1128
|
+
// Guard: require at least one stable identifier (selector or sourceId)
|
|
1129
|
+
// so the drop handler in DesignEditor can resolve the node. An empty
|
|
1130
|
+
// selector AND a missing sourceId would produce nodeAttrId="" and
|
|
1131
|
+
// moveNodeBetweenDocuments would fail with a generic error toast.
|
|
1132
|
+
const hasIdentifier = !!(payload.selector || payload.sourceId);
|
|
1133
|
+
if (candidate && hasIdentifier && sourceScreenId) {
|
|
1134
|
+
onCrossScreenElementDropRef.current?.({
|
|
1135
|
+
sourceSelector: payload.selector,
|
|
1136
|
+
sourceNodeId: payload.sourceId,
|
|
1137
|
+
sourceScreenId,
|
|
1138
|
+
targetScreenId: candidate.id,
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
};
|
|
1143
|
+
|
|
1144
|
+
window.addEventListener("message", handleMessage);
|
|
1145
|
+
return () => {
|
|
1146
|
+
window.removeEventListener("message", handleMessage);
|
|
1147
|
+
};
|
|
1148
|
+
}, [
|
|
1149
|
+
activeId,
|
|
1150
|
+
getFrameEntryAtPoint,
|
|
1151
|
+
getResolvedMetadata,
|
|
1152
|
+
onCrossScreenElementDrop,
|
|
1153
|
+
]);
|
|
747
1154
|
|
|
748
1155
|
const deleteSelectedItems = useCallback(() => {
|
|
749
1156
|
const frameIds = selectedIdsRef.current.filter(
|
|
@@ -797,6 +1204,9 @@ export function MultiScreenCanvas({
|
|
|
797
1204
|
handleMouseUp: (ev: MouseEvent) => void,
|
|
798
1205
|
) => {
|
|
799
1206
|
dragCleanup.current?.();
|
|
1207
|
+
const restorePreviewPointerEvents = mutePreviewIframePointerEvents(
|
|
1208
|
+
surfaceRef.current,
|
|
1209
|
+
);
|
|
800
1210
|
let lastMouseEvent: MouseEvent | null = null;
|
|
801
1211
|
const move = (ev: MouseEvent) => {
|
|
802
1212
|
lastMouseEvent = ev;
|
|
@@ -814,6 +1224,7 @@ export function MultiScreenCanvas({
|
|
|
814
1224
|
window.removeEventListener("mousemove", move);
|
|
815
1225
|
window.removeEventListener("mouseup", up);
|
|
816
1226
|
window.removeEventListener("blur", cleanupOnBlur);
|
|
1227
|
+
restorePreviewPointerEvents();
|
|
817
1228
|
dragCleanup.current = null;
|
|
818
1229
|
};
|
|
819
1230
|
window.addEventListener("mousemove", move);
|
|
@@ -836,15 +1247,64 @@ export function MultiScreenCanvas({
|
|
|
836
1247
|
|
|
837
1248
|
const showTransformFeedback = useCallback(
|
|
838
1249
|
(text: string, clientX: number, clientY: number) => {
|
|
1250
|
+
const estimatedWidth = Math.min(
|
|
1251
|
+
TRANSFORM_BADGE_MAX_WIDTH,
|
|
1252
|
+
Math.max(TRANSFORM_BADGE_MIN_WIDTH, text.length * 7 + 16),
|
|
1253
|
+
);
|
|
1254
|
+
const maxX = Math.max(
|
|
1255
|
+
TRANSFORM_BADGE_EDGE_PADDING,
|
|
1256
|
+
window.innerWidth - estimatedWidth - TRANSFORM_BADGE_EDGE_PADDING,
|
|
1257
|
+
);
|
|
1258
|
+
const maxY = Math.max(
|
|
1259
|
+
TRANSFORM_BADGE_EDGE_PADDING,
|
|
1260
|
+
window.innerHeight -
|
|
1261
|
+
TRANSFORM_BADGE_HEIGHT -
|
|
1262
|
+
TRANSFORM_BADGE_EDGE_PADDING,
|
|
1263
|
+
);
|
|
1264
|
+
const preferredX =
|
|
1265
|
+
clientX + TRANSFORM_BADGE_OFFSET + estimatedWidth <=
|
|
1266
|
+
window.innerWidth - TRANSFORM_BADGE_EDGE_PADDING
|
|
1267
|
+
? clientX + TRANSFORM_BADGE_OFFSET
|
|
1268
|
+
: clientX - estimatedWidth - TRANSFORM_BADGE_OFFSET;
|
|
1269
|
+
const preferredY =
|
|
1270
|
+
clientY + TRANSFORM_BADGE_OFFSET + TRANSFORM_BADGE_HEIGHT <=
|
|
1271
|
+
window.innerHeight - TRANSFORM_BADGE_EDGE_PADDING
|
|
1272
|
+
? clientY + TRANSFORM_BADGE_OFFSET
|
|
1273
|
+
: clientY - TRANSFORM_BADGE_HEIGHT - TRANSFORM_BADGE_OFFSET;
|
|
839
1274
|
setTransformBadge({
|
|
840
1275
|
text,
|
|
841
|
-
x:
|
|
842
|
-
y:
|
|
1276
|
+
x: clampNumber(preferredX, TRANSFORM_BADGE_EDGE_PADDING, maxX),
|
|
1277
|
+
y: clampNumber(preferredY, TRANSFORM_BADGE_EDGE_PADDING, maxY),
|
|
843
1278
|
});
|
|
844
1279
|
},
|
|
845
1280
|
[],
|
|
846
1281
|
);
|
|
847
1282
|
|
|
1283
|
+
const updatePrimitiveDropTarget = useCallback(
|
|
1284
|
+
(target: PrimitiveDropTarget | null) => {
|
|
1285
|
+
primitiveDropTargetRef.current = target;
|
|
1286
|
+
setPrimitiveDropTarget(target);
|
|
1287
|
+
},
|
|
1288
|
+
[],
|
|
1289
|
+
);
|
|
1290
|
+
|
|
1291
|
+
const findPrimitiveDropTarget = useCallback(
|
|
1292
|
+
(
|
|
1293
|
+
point: Point,
|
|
1294
|
+
draggedNodeId: string | null,
|
|
1295
|
+
): PrimitiveDropTarget | null => {
|
|
1296
|
+
if (!onPrimitiveReparentRef.current) return null;
|
|
1297
|
+
return getPrimitiveDropTargetForPoint(
|
|
1298
|
+
point,
|
|
1299
|
+
draggedNodeId,
|
|
1300
|
+
screensRef.current,
|
|
1301
|
+
frameGeometryRef.current,
|
|
1302
|
+
getResolvedMetadata,
|
|
1303
|
+
);
|
|
1304
|
+
},
|
|
1305
|
+
[getResolvedMetadata],
|
|
1306
|
+
);
|
|
1307
|
+
|
|
848
1308
|
const finishDrag = useCallback(() => {
|
|
849
1309
|
if (feedbackTimerRef.current !== null) {
|
|
850
1310
|
window.clearTimeout(feedbackTimerRef.current);
|
|
@@ -858,9 +1318,79 @@ export function MultiScreenCanvas({
|
|
|
858
1318
|
setAlignmentGuides([]);
|
|
859
1319
|
setTransformBadge(null);
|
|
860
1320
|
setDragCursor(null);
|
|
1321
|
+
primitiveDropTargetRef.current = null;
|
|
1322
|
+
setPrimitiveDropTarget(null);
|
|
861
1323
|
dragCleanup.current?.();
|
|
862
1324
|
}, []);
|
|
863
1325
|
|
|
1326
|
+
const cancelActiveDrag = useCallback(() => {
|
|
1327
|
+
let cancelled = false;
|
|
1328
|
+
const state = dragState.current;
|
|
1329
|
+
|
|
1330
|
+
if (state) {
|
|
1331
|
+
cancelled = true;
|
|
1332
|
+
if (state.type === "move" || state.type === "resize") {
|
|
1333
|
+
updateFrameGeometry((current) =>
|
|
1334
|
+
frameGeometryWithOverrides(current, state.originFrames),
|
|
1335
|
+
);
|
|
1336
|
+
} else if (state.type === "rotate") {
|
|
1337
|
+
updateFrameGeometry((current) => ({
|
|
1338
|
+
...current,
|
|
1339
|
+
[state.frameId]: { ...state.originFrame },
|
|
1340
|
+
}));
|
|
1341
|
+
} else if (state.type === "draft-move" || state.type === "draft-resize") {
|
|
1342
|
+
updateDraftPrimitives((current) =>
|
|
1343
|
+
current.map((draft) => {
|
|
1344
|
+
const origin = state.originDrafts[draft.id];
|
|
1345
|
+
return origin ? cloneDraftPrimitive(origin) : draft;
|
|
1346
|
+
}),
|
|
1347
|
+
);
|
|
1348
|
+
} else if (state.type === "marquee") {
|
|
1349
|
+
updateSelectedIds(() => state.baseSelectedIds);
|
|
1350
|
+
updateSelectedDraftIds(() => state.baseSelectedDraftIds);
|
|
1351
|
+
} else if (state.type === "pen-node") {
|
|
1352
|
+
setActivePenPath(
|
|
1353
|
+
state.pathBefore ? clonePenPath(state.pathBefore) : null,
|
|
1354
|
+
);
|
|
1355
|
+
setPenGesturePreview(null);
|
|
1356
|
+
setPenPointer(null);
|
|
1357
|
+
setPenCloseHover(false);
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
if (duplicateCleanup.current) {
|
|
1362
|
+
cancelled = true;
|
|
1363
|
+
duplicateCleanup.current();
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
if (cancelled || dragCleanup.current) {
|
|
1367
|
+
finishDrag();
|
|
1368
|
+
return true;
|
|
1369
|
+
}
|
|
1370
|
+
return false;
|
|
1371
|
+
}, [
|
|
1372
|
+
finishDrag,
|
|
1373
|
+
updateDraftPrimitives,
|
|
1374
|
+
updateFrameGeometry,
|
|
1375
|
+
updateSelectedDraftIds,
|
|
1376
|
+
updateSelectedIds,
|
|
1377
|
+
]);
|
|
1378
|
+
|
|
1379
|
+
useEffect(() => {
|
|
1380
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
1381
|
+
if (event.key !== "Escape") return;
|
|
1382
|
+
if (!cancelActiveDrag()) return;
|
|
1383
|
+
event.preventDefault();
|
|
1384
|
+
event.stopPropagation();
|
|
1385
|
+
event.stopImmediatePropagation();
|
|
1386
|
+
};
|
|
1387
|
+
|
|
1388
|
+
window.addEventListener("keydown", handleKeyDown, true);
|
|
1389
|
+
return () => {
|
|
1390
|
+
window.removeEventListener("keydown", handleKeyDown, true);
|
|
1391
|
+
};
|
|
1392
|
+
}, [cancelActiveDrag]);
|
|
1393
|
+
|
|
864
1394
|
const beginPan = useCallback(
|
|
865
1395
|
(e: React.MouseEvent) => {
|
|
866
1396
|
e.preventDefault();
|
|
@@ -987,7 +1517,10 @@ export function MultiScreenCanvas({
|
|
|
987
1517
|
: undefined;
|
|
988
1518
|
if (preferred) return preferred;
|
|
989
1519
|
|
|
990
|
-
|
|
1520
|
+
const draftCenter = getFrameCenter(draft.geometry);
|
|
1521
|
+
|
|
1522
|
+
// Primary: find the frame whose bounds contain the draft's center point.
|
|
1523
|
+
const containing = entries
|
|
991
1524
|
.filter(({ geometry }) => {
|
|
992
1525
|
const bounds = {
|
|
993
1526
|
left: geometry.x,
|
|
@@ -995,7 +1528,6 @@ export function MultiScreenCanvas({
|
|
|
995
1528
|
right: geometry.x + geometry.width,
|
|
996
1529
|
bottom: geometry.y + geometry.height,
|
|
997
1530
|
};
|
|
998
|
-
const draftCenter = getFrameCenter(draft.geometry);
|
|
999
1531
|
const local = rotatePointAroundCenter(
|
|
1000
1532
|
draftCenter,
|
|
1001
1533
|
getFrameCenter(geometry),
|
|
@@ -1004,6 +1536,22 @@ export function MultiScreenCanvas({
|
|
|
1004
1536
|
return rectContainsPoint(bounds, local);
|
|
1005
1537
|
})
|
|
1006
1538
|
.sort((a, b) => (b.geometry.z ?? 0) - (a.geometry.z ?? 0))[0];
|
|
1539
|
+
|
|
1540
|
+
if (containing) return containing;
|
|
1541
|
+
|
|
1542
|
+
// Fallback: if no frame contains the center (shape drawn outside all
|
|
1543
|
+
// frames), use the nearest frame so the shape still persists rather than
|
|
1544
|
+
// becoming a lost draft primitive. With a single screen this is always
|
|
1545
|
+
// that screen; with multiple screens it's the visually closest one.
|
|
1546
|
+
if (entries.length === 0) return undefined;
|
|
1547
|
+
if (entries.length === 1) return entries[0];
|
|
1548
|
+
return entries.slice().sort((a, b) => {
|
|
1549
|
+
const ca = getFrameCenter(a.geometry);
|
|
1550
|
+
const cb = getFrameCenter(b.geometry);
|
|
1551
|
+
const da = Math.hypot(draftCenter.x - ca.x, draftCenter.y - ca.y);
|
|
1552
|
+
const db = Math.hypot(draftCenter.x - cb.x, draftCenter.y - cb.y);
|
|
1553
|
+
return da - db;
|
|
1554
|
+
})[0];
|
|
1007
1555
|
},
|
|
1008
1556
|
[getCurrentFrameEntries],
|
|
1009
1557
|
);
|
|
@@ -1057,7 +1605,8 @@ export function MultiScreenCanvas({
|
|
|
1057
1605
|
current.filter((draft) => draft.id !== nextDraft.id),
|
|
1058
1606
|
);
|
|
1059
1607
|
updateSelectedDraftIds(() => []);
|
|
1060
|
-
updateSelectedIds(() => [
|
|
1608
|
+
updateSelectedIds(() => []);
|
|
1609
|
+
onPrimitiveCreated?.(persisted.frameId, persisted.nodeId);
|
|
1061
1610
|
return;
|
|
1062
1611
|
}
|
|
1063
1612
|
|
|
@@ -1067,6 +1616,7 @@ export function MultiScreenCanvas({
|
|
|
1067
1616
|
},
|
|
1068
1617
|
[
|
|
1069
1618
|
persistDraftPrimitive,
|
|
1619
|
+
onPrimitiveCreated,
|
|
1070
1620
|
updateDraftPrimitives,
|
|
1071
1621
|
updateSelectedDraftIds,
|
|
1072
1622
|
updateSelectedIds,
|
|
@@ -1254,11 +1804,15 @@ export function MultiScreenCanvas({
|
|
|
1254
1804
|
|
|
1255
1805
|
const originCanvas = getCanvasPoint(e.clientX, e.clientY);
|
|
1256
1806
|
const originFrameId = getFrameEntryAtPoint(originCanvas)?.id;
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1807
|
+
// B1 fix: seed a zero-size preview at the click origin instead of
|
|
1808
|
+
// getDraftGeometryForTool(tool, origin, origin) which returns the
|
|
1809
|
+
// default 160x120 size and causes a visible flash before the drag delta
|
|
1810
|
+
// applies. The real size only matters at mouseup (createDraftPrimitive)
|
|
1811
|
+
// which already handles the "no drag" click-to-place case correctly.
|
|
1812
|
+
const initialGeometry =
|
|
1813
|
+
tool === "pen" || tool === "line" || tool === "arrow"
|
|
1814
|
+
? getDraftGeometryForTool(tool, originCanvas, originCanvas)
|
|
1815
|
+
: { x: originCanvas.x, y: originCanvas.y, width: 0, height: 0 };
|
|
1262
1816
|
const initialPoints =
|
|
1263
1817
|
tool === "pen"
|
|
1264
1818
|
? [originCanvas]
|
|
@@ -1494,40 +2048,94 @@ export function MultiScreenCanvas({
|
|
|
1494
2048
|
ev.clientX,
|
|
1495
2049
|
ev.clientY,
|
|
1496
2050
|
);
|
|
2051
|
+
|
|
2052
|
+
// Primitive drop-into-container detection: check if the dragged draft
|
|
2053
|
+
// is hovering over a committed container primitive on any screen.
|
|
2054
|
+
const canvasPoint = getCanvasPoint(ev.clientX, ev.clientY);
|
|
2055
|
+
const primitiveTarget = findPrimitiveDropTarget(canvasPoint, null);
|
|
2056
|
+
updatePrimitiveDropTarget(primitiveTarget);
|
|
1497
2057
|
};
|
|
1498
2058
|
|
|
1499
2059
|
const handleMouseUp = () => {
|
|
1500
2060
|
const state = dragState.current;
|
|
2061
|
+
const dropTarget = primitiveDropTargetRef.current;
|
|
1501
2062
|
if (state?.type === "draft-move" && state.hasMoved) {
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
2063
|
+
if (dropTarget) {
|
|
2064
|
+
// Drop into a container primitive: persist the draft into the
|
|
2065
|
+
// target's screen, then call onPrimitiveReparent to nest it.
|
|
2066
|
+
const persisted: Array<{
|
|
2067
|
+
draftId: string;
|
|
2068
|
+
frameId: string;
|
|
2069
|
+
nodeId: string;
|
|
2070
|
+
}> = [];
|
|
2071
|
+
draftPrimitivesRef.current.forEach((draft) => {
|
|
2072
|
+
if (!state.targetIds.includes(draft.id)) return;
|
|
2073
|
+
// Persist into the target's screen (not just any containing frame)
|
|
2074
|
+
const result = persistDraftPrimitive(draft, dropTarget.screenId);
|
|
2075
|
+
if (result) {
|
|
2076
|
+
persisted.push({
|
|
2077
|
+
draftId: draft.id,
|
|
2078
|
+
frameId: result.frameId,
|
|
2079
|
+
nodeId: result.nodeId,
|
|
2080
|
+
});
|
|
2081
|
+
}
|
|
2082
|
+
});
|
|
2083
|
+
|
|
2084
|
+
if (persisted.length > 0) {
|
|
2085
|
+
const persistedDraftIds = new Set(
|
|
2086
|
+
persisted.map((entry) => entry.draftId),
|
|
2087
|
+
);
|
|
2088
|
+
updateDraftPrimitives((current) =>
|
|
2089
|
+
current.filter((draft) => !persistedDraftIds.has(draft.id)),
|
|
2090
|
+
);
|
|
2091
|
+
updateSelectedDraftIds((current) =>
|
|
2092
|
+
current.filter((draftId) => !persistedDraftIds.has(draftId)),
|
|
2093
|
+
);
|
|
2094
|
+
// Reparent each persisted node into the container primitive.
|
|
2095
|
+
persisted.forEach((entry) => {
|
|
2096
|
+
onPrimitiveReparentRef.current?.({
|
|
2097
|
+
sourceNodeId: entry.nodeId,
|
|
2098
|
+
sourceScreenId: entry.frameId,
|
|
2099
|
+
targetNodeId: dropTarget.nodeId,
|
|
2100
|
+
targetScreenId: dropTarget.screenId,
|
|
2101
|
+
placement: "inside",
|
|
2102
|
+
});
|
|
1515
2103
|
});
|
|
2104
|
+
const lastNodeId = persisted[persisted.length - 1]?.nodeId;
|
|
2105
|
+
if (lastNodeId) updateSelectedIds(() => [lastNodeId]);
|
|
2106
|
+
}
|
|
2107
|
+
} else {
|
|
2108
|
+
// Normal drop: persist into whichever screen contains the draft.
|
|
2109
|
+
const persisted: Array<{
|
|
2110
|
+
draftId: string;
|
|
2111
|
+
frameId: string;
|
|
2112
|
+
nodeId: string;
|
|
2113
|
+
}> = [];
|
|
2114
|
+
draftPrimitivesRef.current.forEach((draft) => {
|
|
2115
|
+
if (!state.targetIds.includes(draft.id)) return;
|
|
2116
|
+
const result = persistDraftPrimitive(draft);
|
|
2117
|
+
if (result) {
|
|
2118
|
+
persisted.push({
|
|
2119
|
+
draftId: draft.id,
|
|
2120
|
+
frameId: result.frameId,
|
|
2121
|
+
nodeId: result.nodeId,
|
|
2122
|
+
});
|
|
2123
|
+
}
|
|
2124
|
+
});
|
|
2125
|
+
|
|
2126
|
+
if (persisted.length > 0) {
|
|
2127
|
+
const persistedDraftIds = new Set(
|
|
2128
|
+
persisted.map((entry) => entry.draftId),
|
|
2129
|
+
);
|
|
2130
|
+
updateDraftPrimitives((current) =>
|
|
2131
|
+
current.filter((draft) => !persistedDraftIds.has(draft.id)),
|
|
2132
|
+
);
|
|
2133
|
+
updateSelectedDraftIds((current) =>
|
|
2134
|
+
current.filter((draftId) => !persistedDraftIds.has(draftId)),
|
|
2135
|
+
);
|
|
2136
|
+
const lastNodeId = persisted[persisted.length - 1]?.nodeId;
|
|
2137
|
+
if (lastNodeId) updateSelectedIds(() => [lastNodeId]);
|
|
1516
2138
|
}
|
|
1517
|
-
});
|
|
1518
|
-
|
|
1519
|
-
if (persisted.length > 0) {
|
|
1520
|
-
const persistedDraftIds = new Set(
|
|
1521
|
-
persisted.map((entry) => entry.draftId),
|
|
1522
|
-
);
|
|
1523
|
-
updateDraftPrimitives((current) =>
|
|
1524
|
-
current.filter((draft) => !persistedDraftIds.has(draft.id)),
|
|
1525
|
-
);
|
|
1526
|
-
updateSelectedDraftIds((current) =>
|
|
1527
|
-
current.filter((draftId) => !persistedDraftIds.has(draftId)),
|
|
1528
|
-
);
|
|
1529
|
-
const lastNodeId = persisted[persisted.length - 1]?.nodeId;
|
|
1530
|
-
if (lastNodeId) updateSelectedIds(() => [lastNodeId]);
|
|
1531
2139
|
}
|
|
1532
2140
|
}
|
|
1533
2141
|
finishDrag();
|
|
@@ -1536,12 +2144,15 @@ export function MultiScreenCanvas({
|
|
|
1536
2144
|
installDragListeners(handleMouseMove, handleMouseUp);
|
|
1537
2145
|
},
|
|
1538
2146
|
[
|
|
2147
|
+
findPrimitiveDropTarget,
|
|
1539
2148
|
finishDrag,
|
|
2149
|
+
getCanvasPoint,
|
|
1540
2150
|
getCurrentCanvasEntries,
|
|
1541
2151
|
installDragListeners,
|
|
1542
2152
|
persistDraftPrimitive,
|
|
1543
2153
|
showTransformFeedback,
|
|
1544
2154
|
updateDraftPrimitives,
|
|
2155
|
+
updatePrimitiveDropTarget,
|
|
1545
2156
|
updateSelectedDraftIds,
|
|
1546
2157
|
updateSelectedIds,
|
|
1547
2158
|
],
|
|
@@ -1708,9 +2319,11 @@ export function MultiScreenCanvas({
|
|
|
1708
2319
|
const targetIds = currentSelectedIds.includes(id)
|
|
1709
2320
|
? currentSelectedIds
|
|
1710
2321
|
: [id];
|
|
2322
|
+
if (activeId !== id) {
|
|
2323
|
+
onPick(id);
|
|
2324
|
+
}
|
|
1711
2325
|
if (!currentSelectedIds.includes(id)) {
|
|
1712
2326
|
updateSelectedIds(() => [id]);
|
|
1713
|
-
onPick(id);
|
|
1714
2327
|
}
|
|
1715
2328
|
updateSelectedDraftIds(() => []);
|
|
1716
2329
|
|
|
@@ -1787,11 +2400,51 @@ export function MultiScreenCanvas({
|
|
|
1787
2400
|
ev.clientX,
|
|
1788
2401
|
ev.clientY,
|
|
1789
2402
|
);
|
|
2403
|
+
|
|
2404
|
+
// When all dragged ids are committed primitive nodeIds (not screen
|
|
2405
|
+
// frames), check for a container primitive drop target to highlight.
|
|
2406
|
+
const currentFrameIds = Object.keys(frameGeometryRef.current);
|
|
2407
|
+
const allCommitted = state.targetIds.every(
|
|
2408
|
+
(targetId) => !currentFrameIds.includes(targetId),
|
|
2409
|
+
);
|
|
2410
|
+
if (allCommitted) {
|
|
2411
|
+
const canvasPoint = getCanvasPoint(ev.clientX, ev.clientY);
|
|
2412
|
+
updatePrimitiveDropTarget(
|
|
2413
|
+
findPrimitiveDropTarget(canvasPoint, state.primaryId),
|
|
2414
|
+
);
|
|
2415
|
+
}
|
|
1790
2416
|
};
|
|
1791
2417
|
|
|
1792
2418
|
const handleMouseUp = () => {
|
|
1793
2419
|
const state = dragState.current;
|
|
2420
|
+
const dropTarget = primitiveDropTargetRef.current;
|
|
1794
2421
|
if (state?.type === "move" && state.hasMoved) {
|
|
2422
|
+
// If all dragged ids are committed primitive nodeIds (not screen
|
|
2423
|
+
// frames), attempt a primitive reparent on drop.
|
|
2424
|
+
const currentFrameIds = Object.keys(frameGeometryRef.current);
|
|
2425
|
+
const allCommitted = state.targetIds.every(
|
|
2426
|
+
(targetId) => !currentFrameIds.includes(targetId),
|
|
2427
|
+
);
|
|
2428
|
+
if (allCommitted && dropTarget) {
|
|
2429
|
+
const sourceScreenId = resolveNodeScreenId(
|
|
2430
|
+
state.primaryId,
|
|
2431
|
+
screensRef.current,
|
|
2432
|
+
);
|
|
2433
|
+
if (sourceScreenId) {
|
|
2434
|
+
onPrimitiveReparentRef.current?.({
|
|
2435
|
+
sourceNodeId: state.primaryId,
|
|
2436
|
+
sourceScreenId,
|
|
2437
|
+
targetNodeId: dropTarget.nodeId,
|
|
2438
|
+
targetScreenId: dropTarget.screenId,
|
|
2439
|
+
placement: "inside",
|
|
2440
|
+
});
|
|
2441
|
+
suppressNextPick.current = true;
|
|
2442
|
+
finishDrag();
|
|
2443
|
+
return;
|
|
2444
|
+
}
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
// Normal screen-frame geometry commit.
|
|
1795
2448
|
const after = cloneFrameGeometryById(frameGeometryRef.current);
|
|
1796
2449
|
onGeometryCommitRef.current?.(
|
|
1797
2450
|
frameGeometryWithOverrides(after, state.originFrames),
|
|
@@ -1805,12 +2458,16 @@ export function MultiScreenCanvas({
|
|
|
1805
2458
|
installDragListeners(handleMouseMove, handleMouseUp);
|
|
1806
2459
|
},
|
|
1807
2460
|
[
|
|
2461
|
+
activeId,
|
|
2462
|
+
findPrimitiveDropTarget,
|
|
1808
2463
|
finishDrag,
|
|
2464
|
+
getCanvasPoint,
|
|
1809
2465
|
getCurrentFrameEntries,
|
|
1810
2466
|
installDragListeners,
|
|
1811
2467
|
onPick,
|
|
1812
2468
|
showTransformFeedback,
|
|
1813
2469
|
updateFrameGeometry,
|
|
2470
|
+
updatePrimitiveDropTarget,
|
|
1814
2471
|
updateSelectedDraftIds,
|
|
1815
2472
|
updateSelectedIds,
|
|
1816
2473
|
],
|
|
@@ -1823,6 +2480,10 @@ export function MultiScreenCanvas({
|
|
|
1823
2480
|
e.stopPropagation();
|
|
1824
2481
|
suppressNextPick.current = true;
|
|
1825
2482
|
|
|
2483
|
+
if (activeId !== id) {
|
|
2484
|
+
onPick(id);
|
|
2485
|
+
}
|
|
2486
|
+
|
|
1826
2487
|
const currentSelectedIds = selectedIdsRef.current;
|
|
1827
2488
|
const targetIds = currentSelectedIds.includes(id)
|
|
1828
2489
|
? currentSelectedIds
|
|
@@ -1931,9 +2592,11 @@ export function MultiScreenCanvas({
|
|
|
1931
2592
|
installDragListeners(handleMouseMove, handleMouseUp);
|
|
1932
2593
|
},
|
|
1933
2594
|
[
|
|
2595
|
+
activeId,
|
|
1934
2596
|
finishDrag,
|
|
1935
2597
|
getCurrentFrameEntries,
|
|
1936
2598
|
installDragListeners,
|
|
2599
|
+
onPick,
|
|
1937
2600
|
showTransformFeedback,
|
|
1938
2601
|
updateFrameGeometry,
|
|
1939
2602
|
updateSelectedIds,
|
|
@@ -1956,6 +2619,10 @@ export function MultiScreenCanvas({
|
|
|
1956
2619
|
e.stopPropagation();
|
|
1957
2620
|
suppressNextPick.current = true;
|
|
1958
2621
|
|
|
2622
|
+
if (activeId !== id) {
|
|
2623
|
+
onPick(id);
|
|
2624
|
+
}
|
|
2625
|
+
|
|
1959
2626
|
const originFrame = getCurrentFrameEntries().find(
|
|
1960
2627
|
(entry) => entry.id === id,
|
|
1961
2628
|
)?.geometry;
|
|
@@ -2027,10 +2694,12 @@ export function MultiScreenCanvas({
|
|
|
2027
2694
|
installDragListeners(handleMouseMove, handleMouseUp);
|
|
2028
2695
|
},
|
|
2029
2696
|
[
|
|
2697
|
+
activeId,
|
|
2030
2698
|
finishDrag,
|
|
2031
2699
|
getCanvasPoint,
|
|
2032
2700
|
getCurrentFrameEntries,
|
|
2033
2701
|
installDragListeners,
|
|
2702
|
+
onPick,
|
|
2034
2703
|
showTransformFeedback,
|
|
2035
2704
|
updateFrameGeometry,
|
|
2036
2705
|
updateSelectedIds,
|
|
@@ -2047,11 +2716,20 @@ export function MultiScreenCanvas({
|
|
|
2047
2716
|
|
|
2048
2717
|
if (e.shiftKey) {
|
|
2049
2718
|
updateSelectedDraftIds(() => []);
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
);
|
|
2719
|
+
const currentSelectedIds = selectedIdsRef.current;
|
|
2720
|
+
const nextSelectedIds = currentSelectedIds.includes(id)
|
|
2721
|
+
? currentSelectedIds.filter((selectedId) => selectedId !== id)
|
|
2722
|
+
: [...currentSelectedIds, id];
|
|
2723
|
+
updateSelectedIds(() => nextSelectedIds);
|
|
2724
|
+
const nextPrimaryId =
|
|
2725
|
+
nextSelectedIds.length === 0
|
|
2726
|
+
? null
|
|
2727
|
+
: nextSelectedIds.includes(id)
|
|
2728
|
+
? id
|
|
2729
|
+
: (nextSelectedIds[nextSelectedIds.length - 1] ?? null);
|
|
2730
|
+
if (nextPrimaryId && nextPrimaryId !== activeId) {
|
|
2731
|
+
onPick(nextPrimaryId);
|
|
2732
|
+
}
|
|
2055
2733
|
return;
|
|
2056
2734
|
}
|
|
2057
2735
|
|
|
@@ -2059,7 +2737,7 @@ export function MultiScreenCanvas({
|
|
|
2059
2737
|
updateSelectedIds(() => [id]);
|
|
2060
2738
|
onPick(id);
|
|
2061
2739
|
},
|
|
2062
|
-
[onPick, updateSelectedDraftIds, updateSelectedIds],
|
|
2740
|
+
[activeId, onPick, updateSelectedDraftIds, updateSelectedIds],
|
|
2063
2741
|
);
|
|
2064
2742
|
|
|
2065
2743
|
const handleFrameDoubleClick = useCallback(
|
|
@@ -2232,6 +2910,136 @@ export function MultiScreenCanvas({
|
|
|
2232
2910
|
[activeTool, localActiveTool, updatePenPointer],
|
|
2233
2911
|
);
|
|
2234
2912
|
|
|
2913
|
+
// Push the current pan/zoom straight to the DOM. A wheel/pinch gesture must
|
|
2914
|
+
// NEVER re-render React's canvas tree during the gesture: each render re-runs
|
|
2915
|
+
// renderScreenContent (which re-creates the active screen's live DesignCanvas
|
|
2916
|
+
// iframe) and, with React DevTools attached, serializes every render over the
|
|
2917
|
+
// extension bridge — that re-render storm is the real source of zoom jank, not
|
|
2918
|
+
// layout/paint. We mutate the transform directly here and reconcile React
|
|
2919
|
+
// state once, after the gesture settles, via scheduleViewCommit().
|
|
2920
|
+
const applyViewToDom = useCallback(() => {
|
|
2921
|
+
const nextScale = zoomRef.current / 100;
|
|
2922
|
+
const p = panRef.current;
|
|
2923
|
+
const world = worldRef.current;
|
|
2924
|
+
if (world) {
|
|
2925
|
+
// 2D translate (not translate3d) so the layer is not GPU-pinned to a
|
|
2926
|
+
// stale low-res raster — keeps zoomed-in content crisp.
|
|
2927
|
+
world.style.transform = `translate(${p.x}px, ${p.y}px) scale(${nextScale})`;
|
|
2928
|
+
}
|
|
2929
|
+
const grid = pixelGridRef.current;
|
|
2930
|
+
if (grid) {
|
|
2931
|
+
grid.style.backgroundPosition = `${p.x}px ${p.y}px`;
|
|
2932
|
+
grid.style.backgroundSize = `${nextScale}px ${nextScale}px`;
|
|
2933
|
+
}
|
|
2934
|
+
}, []);
|
|
2935
|
+
|
|
2936
|
+
const startChromeSettle = useCallback(() => {
|
|
2937
|
+
if (chromeSettleTimerRef.current !== null) {
|
|
2938
|
+
window.clearTimeout(chromeSettleTimerRef.current);
|
|
2939
|
+
}
|
|
2940
|
+
setChromeSettling(true);
|
|
2941
|
+
chromeSettleTimerRef.current = window.setTimeout(() => {
|
|
2942
|
+
chromeSettleTimerRef.current = null;
|
|
2943
|
+
setChromeSettling(false);
|
|
2944
|
+
}, CHROME_SETTLE_MS);
|
|
2945
|
+
}, []);
|
|
2946
|
+
|
|
2947
|
+
const commitView = useCallback(() => {
|
|
2948
|
+
viewCommitTimerRef.current = null;
|
|
2949
|
+
const shouldSettleChrome = pendingChromeSettleRef.current;
|
|
2950
|
+
pendingChromeSettleRef.current = false;
|
|
2951
|
+
if (shouldSettleChrome) startChromeSettle();
|
|
2952
|
+
setCanvasZoom(zoomRef.current);
|
|
2953
|
+
setPan(panRef.current);
|
|
2954
|
+
onZoomChange?.(zoomRef.current);
|
|
2955
|
+
}, [onZoomChange, startChromeSettle]);
|
|
2956
|
+
|
|
2957
|
+
// Debounced: only commit to React state once the gesture has been idle for a
|
|
2958
|
+
// beat, so a continuous pinch produces zero re-renders until the user pauses.
|
|
2959
|
+
const scheduleViewCommit = useCallback(
|
|
2960
|
+
(options?: { settleChrome?: boolean }) => {
|
|
2961
|
+
if (options?.settleChrome) {
|
|
2962
|
+
pendingChromeSettleRef.current = true;
|
|
2963
|
+
}
|
|
2964
|
+
if (viewCommitTimerRef.current !== null) {
|
|
2965
|
+
window.clearTimeout(viewCommitTimerRef.current);
|
|
2966
|
+
}
|
|
2967
|
+
viewCommitTimerRef.current = window.setTimeout(commitView, 120);
|
|
2968
|
+
},
|
|
2969
|
+
[commitView],
|
|
2970
|
+
);
|
|
2971
|
+
|
|
2972
|
+
const flushPendingWheelGesture = useCallback(() => {
|
|
2973
|
+
wheelGestureFrameRef.current = null;
|
|
2974
|
+
const gesture = pendingWheelGestureRef.current;
|
|
2975
|
+
pendingWheelGestureRef.current = null;
|
|
2976
|
+
if (!gesture) return;
|
|
2977
|
+
|
|
2978
|
+
if (gesture.mode === "zoom") {
|
|
2979
|
+
const currentZoom = zoomRef.current;
|
|
2980
|
+
const zoomDeltaY = clamp(
|
|
2981
|
+
gesture.deltaY,
|
|
2982
|
+
-MAX_WHEEL_ZOOM_DELTA,
|
|
2983
|
+
MAX_WHEEL_ZOOM_DELTA,
|
|
2984
|
+
);
|
|
2985
|
+
const nextZoom = clamp(
|
|
2986
|
+
currentZoom * Math.exp(-zoomDeltaY * ZOOM_SENSITIVITY),
|
|
2987
|
+
MIN_ZOOM,
|
|
2988
|
+
MAX_ZOOM,
|
|
2989
|
+
);
|
|
2990
|
+
if (nextZoom === currentZoom) return;
|
|
2991
|
+
|
|
2992
|
+
const nextPan = getPanForZoomToCursor({
|
|
2993
|
+
pan: panRef.current,
|
|
2994
|
+
cursor: gesture.cursor,
|
|
2995
|
+
oldZoom: currentZoom,
|
|
2996
|
+
nextZoom,
|
|
2997
|
+
});
|
|
2998
|
+
zoomRef.current = nextZoom;
|
|
2999
|
+
panRef.current = nextPan;
|
|
3000
|
+
applyViewToDom();
|
|
3001
|
+
scheduleViewCommit({ settleChrome: true });
|
|
3002
|
+
return;
|
|
3003
|
+
}
|
|
3004
|
+
|
|
3005
|
+
const nextPan = {
|
|
3006
|
+
x: panRef.current.x - gesture.deltaX,
|
|
3007
|
+
y: panRef.current.y - gesture.deltaY,
|
|
3008
|
+
};
|
|
3009
|
+
panRef.current = nextPan;
|
|
3010
|
+
applyViewToDom();
|
|
3011
|
+
scheduleViewCommit();
|
|
3012
|
+
}, [applyViewToDom, scheduleViewCommit]);
|
|
3013
|
+
|
|
3014
|
+
const enqueueWheelGesture = useCallback(
|
|
3015
|
+
(gesture: PendingWheelGesture) => {
|
|
3016
|
+
const pending = pendingWheelGestureRef.current;
|
|
3017
|
+
if (pending?.mode === "zoom" && gesture.mode === "zoom") {
|
|
3018
|
+
pendingWheelGestureRef.current = {
|
|
3019
|
+
mode: "zoom",
|
|
3020
|
+
deltaY: pending.deltaY + gesture.deltaY,
|
|
3021
|
+
cursor: gesture.cursor,
|
|
3022
|
+
clientX: gesture.clientX,
|
|
3023
|
+
clientY: gesture.clientY,
|
|
3024
|
+
};
|
|
3025
|
+
} else if (pending?.mode === "pan" && gesture.mode === "pan") {
|
|
3026
|
+
pendingWheelGestureRef.current = {
|
|
3027
|
+
mode: "pan",
|
|
3028
|
+
deltaX: pending.deltaX + gesture.deltaX,
|
|
3029
|
+
deltaY: pending.deltaY + gesture.deltaY,
|
|
3030
|
+
};
|
|
3031
|
+
} else {
|
|
3032
|
+
pendingWheelGestureRef.current = gesture;
|
|
3033
|
+
}
|
|
3034
|
+
|
|
3035
|
+
if (wheelGestureFrameRef.current !== null) return;
|
|
3036
|
+
wheelGestureFrameRef.current = window.requestAnimationFrame(
|
|
3037
|
+
flushPendingWheelGesture,
|
|
3038
|
+
);
|
|
3039
|
+
},
|
|
3040
|
+
[flushPendingWheelGesture],
|
|
3041
|
+
);
|
|
3042
|
+
|
|
2235
3043
|
const handleWheelEvent = useCallback(
|
|
2236
3044
|
(event: WheelEvent) => {
|
|
2237
3045
|
const rect = surfaceRef.current?.getBoundingClientRect();
|
|
@@ -2241,40 +3049,18 @@ export function MultiScreenCanvas({
|
|
|
2241
3049
|
const delta = getWheelDelta(event);
|
|
2242
3050
|
|
|
2243
3051
|
if (event.ctrlKey || event.metaKey) {
|
|
2244
|
-
const currentZoom = zoomRef.current;
|
|
2245
3052
|
const zoomDeltaY = clamp(
|
|
2246
3053
|
delta.y,
|
|
2247
3054
|
-MAX_WHEEL_ZOOM_DELTA,
|
|
2248
3055
|
MAX_WHEEL_ZOOM_DELTA,
|
|
2249
3056
|
);
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
MAX_ZOOM,
|
|
2254
|
-
);
|
|
2255
|
-
if (nextZoom === currentZoom) return;
|
|
2256
|
-
|
|
2257
|
-
const nextPan = getPanForZoomToCursor({
|
|
2258
|
-
pan: panRef.current,
|
|
3057
|
+
enqueueWheelGesture({
|
|
3058
|
+
mode: "zoom",
|
|
3059
|
+
deltaY: zoomDeltaY,
|
|
2259
3060
|
cursor: { x: event.clientX - rect.left, y: event.clientY - rect.top },
|
|
2260
|
-
|
|
2261
|
-
|
|
3061
|
+
clientX: event.clientX,
|
|
3062
|
+
clientY: event.clientY,
|
|
2262
3063
|
});
|
|
2263
|
-
const zoomEditFrameId =
|
|
2264
|
-
zoomToEditThreshold !== undefined &&
|
|
2265
|
-
currentZoom < zoomToEditThreshold &&
|
|
2266
|
-
nextZoom >= zoomToEditThreshold
|
|
2267
|
-
? getFrameAtClientPoint(event.clientX, event.clientY)
|
|
2268
|
-
: undefined;
|
|
2269
|
-
|
|
2270
|
-
zoomRef.current = nextZoom;
|
|
2271
|
-
panRef.current = nextPan;
|
|
2272
|
-
setCanvasZoom(nextZoom);
|
|
2273
|
-
setPan(nextPan);
|
|
2274
|
-
onZoomChange?.(nextZoom);
|
|
2275
|
-
if (zoomEditFrameId) {
|
|
2276
|
-
onZoomToEdit?.(zoomEditFrameId);
|
|
2277
|
-
}
|
|
2278
3064
|
return;
|
|
2279
3065
|
}
|
|
2280
3066
|
|
|
@@ -2288,14 +3074,9 @@ export function MultiScreenCanvas({
|
|
|
2288
3074
|
-MAX_WHEEL_PAN_DELTA,
|
|
2289
3075
|
MAX_WHEEL_PAN_DELTA,
|
|
2290
3076
|
);
|
|
2291
|
-
|
|
2292
|
-
x: panRef.current.x - deltaX,
|
|
2293
|
-
y: panRef.current.y - deltaY,
|
|
2294
|
-
};
|
|
2295
|
-
panRef.current = nextPan;
|
|
2296
|
-
setPan(nextPan);
|
|
3077
|
+
enqueueWheelGesture({ mode: "pan", deltaX, deltaY });
|
|
2297
3078
|
},
|
|
2298
|
-
[
|
|
3079
|
+
[enqueueWheelGesture],
|
|
2299
3080
|
);
|
|
2300
3081
|
|
|
2301
3082
|
useEffect(() => {
|
|
@@ -2490,25 +3271,73 @@ export function MultiScreenCanvas({
|
|
|
2490
3271
|
if (tool !== "pen") clearActivePenPath();
|
|
2491
3272
|
}, [activeTool, clearActivePenPath, localActiveTool]);
|
|
2492
3273
|
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
3274
|
+
// Cmd+D / Ctrl+D: duplicate the selected frame with a visible offset so the
|
|
3275
|
+
// copy doesn't land exactly on top of the original (Figma-style behaviour).
|
|
3276
|
+
useEffect(() => {
|
|
3277
|
+
if (!onDuplicate) return;
|
|
3278
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
3279
|
+
if (activePenPathRef.current) return;
|
|
3280
|
+
if (
|
|
3281
|
+
!(event.metaKey || event.ctrlKey) ||
|
|
3282
|
+
event.key.toLowerCase() !== "d" ||
|
|
3283
|
+
isEditableHotkeyTarget(event.target)
|
|
3284
|
+
) {
|
|
3285
|
+
return;
|
|
3286
|
+
}
|
|
3287
|
+
// Only act on frame IDs — filter out canvas primitives (sub-elements).
|
|
3288
|
+
const frameIds = selectedIdsRef.current.filter(
|
|
3289
|
+
(id) => frameGeometryRef.current[id],
|
|
3290
|
+
);
|
|
3291
|
+
const targetId = frameIds[0];
|
|
3292
|
+
if (!targetId) return;
|
|
3293
|
+
const screen = screens.find((s) => s.id === targetId);
|
|
3294
|
+
if (!screen) return;
|
|
3295
|
+
const sourceGeometry = frameGeometryRef.current[targetId];
|
|
3296
|
+
if (!sourceGeometry) return;
|
|
3297
|
+
event.preventDefault();
|
|
3298
|
+
event.stopPropagation();
|
|
3299
|
+
event.stopImmediatePropagation();
|
|
3300
|
+
// Offset the duplicate by one grid gap to the right (and slightly down)
|
|
3301
|
+
// so it is visually distinct from the original, mirroring Figma's behaviour.
|
|
3302
|
+
const canvasPosition = {
|
|
3303
|
+
x: sourceGeometry.x + sourceGeometry.width + SCREEN_GAP,
|
|
3304
|
+
y: sourceGeometry.y,
|
|
3305
|
+
};
|
|
3306
|
+
onDuplicate(targetId, {
|
|
3307
|
+
mode: "alt-click",
|
|
3308
|
+
screen,
|
|
3309
|
+
canvasPosition,
|
|
3310
|
+
dropCanvasPosition: canvasPosition,
|
|
3311
|
+
});
|
|
3312
|
+
};
|
|
3313
|
+
|
|
3314
|
+
window.addEventListener("keydown", handleKeyDown, true);
|
|
3315
|
+
return () => {
|
|
3316
|
+
window.removeEventListener("keydown", handleKeyDown, true);
|
|
3317
|
+
};
|
|
3318
|
+
}, [onDuplicate, screens]);
|
|
3319
|
+
|
|
3320
|
+
const scale = canvasZoom / 100;
|
|
3321
|
+
const chromeScale = scale > 0 ? 1 / scale : 1;
|
|
3322
|
+
const showPixelGrid = canvasZoom >= PIXEL_GRID_ZOOM;
|
|
3323
|
+
const effectiveTool = normalizeCanvasTool(activeTool ?? localActiveTool);
|
|
3324
|
+
const penActive = effectiveTool === "pen";
|
|
3325
|
+
const creationToolActive = Boolean(getDraftCreationTool(effectiveTool));
|
|
3326
|
+
const canvasGestureActive = isDragging || isPanning;
|
|
3327
|
+
const displayedPenPath = penGesturePreview
|
|
3328
|
+
? penGesturePreview
|
|
3329
|
+
: activePenPath && penPointer && activePenPath.nodes.length > 0
|
|
3330
|
+
? penCloseHover
|
|
3331
|
+
? closePenPath(activePenPath)
|
|
3332
|
+
: appendPenNode(activePenPath, createCornerNode(penPointer))
|
|
3333
|
+
: activePenPath;
|
|
3334
|
+
const selectedIdSet = new Set(selectedIds);
|
|
3335
|
+
const fullViewIdSet = new Set(fullViewScreenIds ?? []);
|
|
3336
|
+
const selectedDraftIdSet = new Set(selectedDraftIds);
|
|
3337
|
+
const surfaceCursor = isPanning
|
|
3338
|
+
? "grabbing"
|
|
3339
|
+
: dragCursor
|
|
3340
|
+
? dragCursor
|
|
2512
3341
|
: isDragging && marquee
|
|
2513
3342
|
? "crosshair"
|
|
2514
3343
|
: penActive || getDraftCreationTool(effectiveTool)
|
|
@@ -2516,10 +3345,29 @@ export function MultiScreenCanvas({
|
|
|
2516
3345
|
: effectiveTool === "hand"
|
|
2517
3346
|
? "grab"
|
|
2518
3347
|
: "default";
|
|
2519
|
-
const canvasFrames = screens.map((screen, index) =>
|
|
2520
|
-
screen
|
|
2521
|
-
|
|
2522
|
-
|
|
3348
|
+
const canvasFrames = screens.map((screen, index) => {
|
|
3349
|
+
const metadata = getResolvedMetadata(screen);
|
|
3350
|
+
return {
|
|
3351
|
+
screen,
|
|
3352
|
+
metadata,
|
|
3353
|
+
geometry:
|
|
3354
|
+
frameGeometry[screen.id] ?? getInitialFrameGeometry(index, metadata),
|
|
3355
|
+
};
|
|
3356
|
+
});
|
|
3357
|
+
const screenContentById = useMemo(() => {
|
|
3358
|
+
if (!renderScreenContent) return new Map<string, ReactNode>();
|
|
3359
|
+
return new Map(
|
|
3360
|
+
screens.map((screen, index) => {
|
|
3361
|
+
const metadata = getResolvedMetadata(screen);
|
|
3362
|
+
const geometry =
|
|
3363
|
+
frameGeometry[screen.id] ?? getInitialFrameGeometry(index, metadata);
|
|
3364
|
+
return [
|
|
3365
|
+
screen.id,
|
|
3366
|
+
renderScreenContent(screen, metadata, geometry),
|
|
3367
|
+
] as const;
|
|
3368
|
+
}),
|
|
3369
|
+
);
|
|
3370
|
+
}, [frameGeometry, getResolvedMetadata, renderScreenContent, screens]);
|
|
2523
3371
|
const selectedFrameEntries = canvasFrames
|
|
2524
3372
|
.filter(({ screen }) => selectedIdSet.has(screen.id))
|
|
2525
3373
|
.map(({ screen, geometry }) => ({ id: screen.id, geometry }));
|
|
@@ -2535,16 +3383,25 @@ export function MultiScreenCanvas({
|
|
|
2535
3383
|
selectedDraftEntries.length > 1
|
|
2536
3384
|
? getFrameGroupBounds(selectedDraftEntries)
|
|
2537
3385
|
: null;
|
|
3386
|
+
const singleSelectedFrame =
|
|
3387
|
+
selectedFrameEntries.length === 1 && !selectedGroupBounds
|
|
3388
|
+
? selectedFrameEntries[0]
|
|
3389
|
+
: null;
|
|
3390
|
+
const singleSelectedDraft =
|
|
3391
|
+
selectedDraftEntries.length === 1 && !selectedDraftGroupBounds
|
|
3392
|
+
? selectedDraftEntries[0]
|
|
3393
|
+
: null;
|
|
2538
3394
|
return (
|
|
2539
3395
|
<div
|
|
2540
3396
|
ref={surfaceRef}
|
|
2541
|
-
className="relative h-full w-full select-none overflow-hidden
|
|
3397
|
+
className="relative h-full w-full select-none overflow-hidden"
|
|
2542
3398
|
onMouseDown={handleMouseDown}
|
|
2543
3399
|
onMouseMove={handleMouseMove}
|
|
2544
3400
|
style={{ cursor: surfaceCursor, touchAction: "none" }}
|
|
2545
3401
|
>
|
|
2546
3402
|
{showPixelGrid ? (
|
|
2547
3403
|
<div
|
|
3404
|
+
ref={pixelGridRef}
|
|
2548
3405
|
className="pointer-events-none absolute inset-0 opacity-60"
|
|
2549
3406
|
style={{
|
|
2550
3407
|
backgroundImage:
|
|
@@ -2556,40 +3413,61 @@ export function MultiScreenCanvas({
|
|
|
2556
3413
|
) : null}
|
|
2557
3414
|
|
|
2558
3415
|
<div
|
|
3416
|
+
ref={worldRef}
|
|
2559
3417
|
className="pointer-events-none absolute"
|
|
2560
3418
|
style={{
|
|
2561
|
-
left:
|
|
2562
|
-
top:
|
|
2563
|
-
transform
|
|
3419
|
+
left: 0,
|
|
3420
|
+
top: 0,
|
|
3421
|
+
// Plain 2D transform — NO will-change / translate3d. Forcing a
|
|
3422
|
+
// compositor layer pins a low-res cached raster that the GPU stretches
|
|
3423
|
+
// when you zoom in, leaving screen content permanently blurry. A 2D
|
|
3424
|
+
// transform lets the browser re-rasterize crisply at rest. Zoom smoothness
|
|
3425
|
+
// comes from never re-rendering React during the gesture (see
|
|
3426
|
+
// flushPendingWheelGesture / applyViewToDom), not from layer promotion —
|
|
3427
|
+
// the trace proved paint/composite was never the bottleneck.
|
|
3428
|
+
transform: `translate(${pan.x}px, ${pan.y}px) scale(${scale})`,
|
|
2564
3429
|
transformOrigin: "top left",
|
|
2565
3430
|
}}
|
|
2566
3431
|
>
|
|
2567
|
-
{canvasFrames.map(({ screen, geometry }) => {
|
|
2568
|
-
const metadata = resolveScreenMetadata(
|
|
2569
|
-
screen,
|
|
2570
|
-
metadataById?.[screen.id],
|
|
2571
|
-
getScreenMetadata?.(screen),
|
|
2572
|
-
);
|
|
3432
|
+
{canvasFrames.map(({ screen, metadata, geometry }) => {
|
|
2573
3433
|
return (
|
|
2574
3434
|
<Screen
|
|
2575
3435
|
key={screen.id}
|
|
2576
3436
|
screen={screen}
|
|
2577
3437
|
metadata={metadata}
|
|
2578
3438
|
geometry={geometry}
|
|
2579
|
-
screenContent={
|
|
3439
|
+
screenContent={screenContentById.get(screen.id)}
|
|
2580
3440
|
isActive={screen.id === activeId}
|
|
2581
3441
|
isSelected={selectedIdSet.has(screen.id)}
|
|
3442
|
+
showFullView={fullViewIdSet.has(screen.id)}
|
|
3443
|
+
isDirectlyHovered={screen.id === directlyHoveredScreenId}
|
|
3444
|
+
hasHoveredChild={
|
|
3445
|
+
(screen.id === activeId && activeScreenHasHoveredChild) ||
|
|
3446
|
+
screen.id === hoveredChildScreenId
|
|
3447
|
+
}
|
|
2582
3448
|
groupSelected={hasGroupSelection}
|
|
2583
3449
|
handlesEnabled={!hasGroupSelection}
|
|
2584
3450
|
penActive={penActive}
|
|
2585
3451
|
creationToolActive={creationToolActive}
|
|
3452
|
+
canvasGestureActive={canvasGestureActive}
|
|
2586
3453
|
chromeScale={chromeScale}
|
|
3454
|
+
chromeSettling={chromeSettling}
|
|
2587
3455
|
onPick={handleFrameClick}
|
|
2588
3456
|
onEdit={handleFrameDoubleClick}
|
|
2589
3457
|
onStartFrameDrag={beginFrameDrag}
|
|
2590
3458
|
onStartResize={beginResize}
|
|
2591
3459
|
onStartRotate={beginRotate}
|
|
2592
3460
|
onStartDuplicateGesture={beginDuplicateGesture}
|
|
3461
|
+
onAddBreakpoint={
|
|
3462
|
+
onAddBreakpoint
|
|
3463
|
+
? (widthPx) => onAddBreakpoint(screen.id, widthPx)
|
|
3464
|
+
: undefined
|
|
3465
|
+
}
|
|
3466
|
+
onActiveBreakpointChange={
|
|
3467
|
+
onActiveBreakpointChange
|
|
3468
|
+
? (widthPx) => onActiveBreakpointChange(screen.id, widthPx)
|
|
3469
|
+
: undefined
|
|
3470
|
+
}
|
|
2593
3471
|
/>
|
|
2594
3472
|
);
|
|
2595
3473
|
})}
|
|
@@ -2602,6 +3480,7 @@ export function MultiScreenCanvas({
|
|
|
2602
3480
|
groupSelected={Boolean(selectedDraftGroupBounds)}
|
|
2603
3481
|
penActive={penActive}
|
|
2604
3482
|
chromeScale={chromeScale}
|
|
3483
|
+
chromeSettling={chromeSettling}
|
|
2605
3484
|
onClick={handleDraftClick}
|
|
2606
3485
|
onStartDrag={beginDraftDrag}
|
|
2607
3486
|
onStartResize={beginDraftResize}
|
|
@@ -2616,6 +3495,7 @@ export function MultiScreenCanvas({
|
|
|
2616
3495
|
groupSelected={false}
|
|
2617
3496
|
penActive={penActive}
|
|
2618
3497
|
chromeScale={chromeScale}
|
|
3498
|
+
chromeSettling={chromeSettling}
|
|
2619
3499
|
onClick={() => {}}
|
|
2620
3500
|
onStartDrag={() => {}}
|
|
2621
3501
|
onStartResize={() => {}}
|
|
@@ -2626,10 +3506,39 @@ export function MultiScreenCanvas({
|
|
|
2626
3506
|
<PenPathOverlay path={displayedPenPath} closeHover={penCloseHover} />
|
|
2627
3507
|
) : null}
|
|
2628
3508
|
|
|
3509
|
+
{singleSelectedFrame ? (
|
|
3510
|
+
<SelectionBox
|
|
3511
|
+
geometry={singleSelectedFrame.geometry}
|
|
3512
|
+
chromeScale={chromeScale}
|
|
3513
|
+
chromeSettling={chromeSettling}
|
|
3514
|
+
showRotate
|
|
3515
|
+
onStartResize={(handle, event) =>
|
|
3516
|
+
beginResize(singleSelectedFrame.id, handle, event)
|
|
3517
|
+
}
|
|
3518
|
+
onStartRotate={(event) =>
|
|
3519
|
+
beginRotate(singleSelectedFrame.id, event)
|
|
3520
|
+
}
|
|
3521
|
+
/>
|
|
3522
|
+
) : null}
|
|
3523
|
+
|
|
3524
|
+
{singleSelectedDraft ? (
|
|
3525
|
+
<SelectionBox
|
|
3526
|
+
geometry={singleSelectedDraft.geometry}
|
|
3527
|
+
chromeScale={chromeScale}
|
|
3528
|
+
chromeSettling={chromeSettling}
|
|
3529
|
+
showRotate={false}
|
|
3530
|
+
onStartResize={(handle, event) =>
|
|
3531
|
+
beginDraftResize(singleSelectedDraft.id, handle, event)
|
|
3532
|
+
}
|
|
3533
|
+
onStartRotate={() => {}}
|
|
3534
|
+
/>
|
|
3535
|
+
) : null}
|
|
3536
|
+
|
|
2629
3537
|
{selectedGroupBounds ? (
|
|
2630
3538
|
<GroupSelectionBox
|
|
2631
3539
|
bounds={selectedGroupBounds}
|
|
2632
3540
|
chromeScale={chromeScale}
|
|
3541
|
+
chromeSettling={chromeSettling}
|
|
2633
3542
|
onStartResize={beginGroupResize}
|
|
2634
3543
|
/>
|
|
2635
3544
|
) : null}
|
|
@@ -2638,6 +3547,7 @@ export function MultiScreenCanvas({
|
|
|
2638
3547
|
<GroupSelectionBox
|
|
2639
3548
|
bounds={selectedDraftGroupBounds}
|
|
2640
3549
|
chromeScale={chromeScale}
|
|
3550
|
+
chromeSettling={chromeSettling}
|
|
2641
3551
|
onStartResize={beginDraftGroupResize}
|
|
2642
3552
|
/>
|
|
2643
3553
|
) : null}
|
|
@@ -2663,20 +3573,46 @@ export function MultiScreenCanvas({
|
|
|
2663
3573
|
}
|
|
2664
3574
|
/>
|
|
2665
3575
|
))}
|
|
2666
|
-
|
|
2667
|
-
{marquee ? (
|
|
2668
|
-
<span
|
|
2669
|
-
className="pointer-events-none absolute z-40 border border-[var(--design-editor-accent-color)] bg-[var(--design-editor-selection-color)]"
|
|
2670
|
-
style={{
|
|
2671
|
-
left: SURFACE_PADDING + marquee.x,
|
|
2672
|
-
top: SURFACE_PADDING + marquee.y,
|
|
2673
|
-
width: marquee.width,
|
|
2674
|
-
height: marquee.height,
|
|
2675
|
-
}}
|
|
2676
|
-
/>
|
|
2677
|
-
) : null}
|
|
2678
3576
|
</div>
|
|
2679
3577
|
|
|
3578
|
+
{marquee ? (
|
|
3579
|
+
<span
|
|
3580
|
+
className="pointer-events-none absolute z-40 border border-[var(--design-editor-accent-color)] bg-[var(--design-editor-selection-color)]"
|
|
3581
|
+
style={{
|
|
3582
|
+
// Convert canvas-space marquee to surface-space so this overlay
|
|
3583
|
+
// is never clipped or hidden by the canvas transform container.
|
|
3584
|
+
// Surface position = pan + (SURFACE_PADDING + canvasCoord) * scale
|
|
3585
|
+
left: pan.x + (SURFACE_PADDING + marquee.x) * scale,
|
|
3586
|
+
top: pan.y + (SURFACE_PADDING + marquee.y) * scale,
|
|
3587
|
+
width: Math.max(1, marquee.width * scale),
|
|
3588
|
+
height: Math.max(1, marquee.height * scale),
|
|
3589
|
+
}}
|
|
3590
|
+
/>
|
|
3591
|
+
) : null}
|
|
3592
|
+
|
|
3593
|
+
{primitiveDropTarget ? (
|
|
3594
|
+
<span
|
|
3595
|
+
data-primitive-drop-target
|
|
3596
|
+
className="pointer-events-none absolute z-40 rounded-sm"
|
|
3597
|
+
style={{
|
|
3598
|
+
// Surface position = pan + (SURFACE_PADDING + canvasCoord) * scale
|
|
3599
|
+
left:
|
|
3600
|
+
pan.x +
|
|
3601
|
+
(SURFACE_PADDING + primitiveDropTarget.boardRect.x) * scale,
|
|
3602
|
+
top:
|
|
3603
|
+
pan.y +
|
|
3604
|
+
(SURFACE_PADDING + primitiveDropTarget.boardRect.y) * scale,
|
|
3605
|
+
width: Math.max(1, primitiveDropTarget.boardRect.width * scale),
|
|
3606
|
+
height: Math.max(1, primitiveDropTarget.boardRect.height * scale),
|
|
3607
|
+
// Match the in-screen inside-guide style: 2px accent border + 14%
|
|
3608
|
+
// accent fill. Uses the same CSS variable as the DesignCanvas guide.
|
|
3609
|
+
border: "2px solid var(--design-editor-accent-color)",
|
|
3610
|
+
background:
|
|
3611
|
+
"color-mix(in srgb, var(--design-editor-accent-color) 14%, transparent)",
|
|
3612
|
+
}}
|
|
3613
|
+
/>
|
|
3614
|
+
) : null}
|
|
3615
|
+
|
|
2680
3616
|
{duplicatePreview ? (
|
|
2681
3617
|
<div
|
|
2682
3618
|
className={cn(
|
|
@@ -2712,12 +3648,53 @@ export function MultiScreenCanvas({
|
|
|
2712
3648
|
|
|
2713
3649
|
{transformBadge ? (
|
|
2714
3650
|
<div
|
|
2715
|
-
|
|
3651
|
+
data-transform-badge
|
|
3652
|
+
className="pointer-events-none fixed z-50 rounded border border-border bg-background/95 px-1.5 py-0.5 font-mono text-[11px] leading-5 text-foreground shadow-lg backdrop-blur"
|
|
2716
3653
|
style={{ left: transformBadge.x, top: transformBadge.y }}
|
|
2717
3654
|
>
|
|
2718
3655
|
{transformBadge.text}
|
|
2719
3656
|
</div>
|
|
2720
3657
|
) : null}
|
|
3658
|
+
|
|
3659
|
+
{/* Cross-screen element drag: highlight the candidate target screen frame.
|
|
3660
|
+
Uses the same style as the primitiveDropTarget overlay (2px accent border
|
|
3661
|
+
+ 14% accent fill) so it is visually consistent. */}
|
|
3662
|
+
{crossScreenTarget ? (
|
|
3663
|
+
<span
|
|
3664
|
+
data-cross-screen-drop-target
|
|
3665
|
+
className="pointer-events-none absolute z-40 rounded-sm"
|
|
3666
|
+
style={{
|
|
3667
|
+
// Surface position = pan + (SURFACE_PADDING + canvasCoord) * scale
|
|
3668
|
+
left:
|
|
3669
|
+
pan.x + (SURFACE_PADDING + crossScreenTarget.geometry.x) * scale,
|
|
3670
|
+
top:
|
|
3671
|
+
pan.y + (SURFACE_PADDING + crossScreenTarget.geometry.y) * scale,
|
|
3672
|
+
width: Math.max(1, crossScreenTarget.geometry.width * scale),
|
|
3673
|
+
height: Math.max(1, crossScreenTarget.geometry.height * scale),
|
|
3674
|
+
border: "2px solid var(--design-editor-accent-color)",
|
|
3675
|
+
background:
|
|
3676
|
+
"color-mix(in srgb, var(--design-editor-accent-color) 14%, transparent)",
|
|
3677
|
+
}}
|
|
3678
|
+
/>
|
|
3679
|
+
) : null}
|
|
3680
|
+
|
|
3681
|
+
{/* Cross-screen element drag: small ghost following the board-space cursor. */}
|
|
3682
|
+
{crossScreenGhost ? (
|
|
3683
|
+
<span
|
|
3684
|
+
data-cross-screen-drag-ghost
|
|
3685
|
+
className="pointer-events-none absolute z-50 rounded border border-[var(--design-editor-accent-color)] bg-[var(--design-editor-accent-color)]/20 shadow"
|
|
3686
|
+
style={{
|
|
3687
|
+
// A fixed-size ghost (16×16 canvas-px) centred on the board point.
|
|
3688
|
+
// Surface position = pan + (SURFACE_PADDING + canvasCoord) * scale
|
|
3689
|
+
left:
|
|
3690
|
+
pan.x + (SURFACE_PADDING + crossScreenGhost.boardX) * scale - 8,
|
|
3691
|
+
top:
|
|
3692
|
+
pan.y + (SURFACE_PADDING + crossScreenGhost.boardY) * scale - 8,
|
|
3693
|
+
width: 16,
|
|
3694
|
+
height: 16,
|
|
3695
|
+
}}
|
|
3696
|
+
/>
|
|
3697
|
+
) : null}
|
|
2721
3698
|
</div>
|
|
2722
3699
|
);
|
|
2723
3700
|
}
|
|
@@ -2728,6 +3705,7 @@ function DraftPrimitiveLayer({
|
|
|
2728
3705
|
groupSelected,
|
|
2729
3706
|
penActive,
|
|
2730
3707
|
chromeScale,
|
|
3708
|
+
chromeSettling,
|
|
2731
3709
|
preview = false,
|
|
2732
3710
|
onClick,
|
|
2733
3711
|
onStartDrag,
|
|
@@ -2738,6 +3716,7 @@ function DraftPrimitiveLayer({
|
|
|
2738
3716
|
groupSelected: boolean;
|
|
2739
3717
|
penActive: boolean;
|
|
2740
3718
|
chromeScale: number;
|
|
3719
|
+
chromeSettling: boolean;
|
|
2741
3720
|
preview?: boolean;
|
|
2742
3721
|
onClick: (id: string, e: React.MouseEvent) => void;
|
|
2743
3722
|
onStartDrag: (id: string, e: React.MouseEvent) => void;
|
|
@@ -2755,7 +3734,7 @@ function DraftPrimitiveLayer({
|
|
|
2755
3734
|
type="button"
|
|
2756
3735
|
className={cn(
|
|
2757
3736
|
"group/artboard pointer-events-auto absolute block overflow-visible text-left outline-none",
|
|
2758
|
-
preview || penActive ? "cursor-crosshair" : "cursor-
|
|
3737
|
+
preview || penActive ? "cursor-crosshair" : "cursor-pointer",
|
|
2759
3738
|
)}
|
|
2760
3739
|
style={{
|
|
2761
3740
|
left: SURFACE_PADDING + geometry.x,
|
|
@@ -2777,23 +3756,32 @@ function DraftPrimitiveLayer({
|
|
|
2777
3756
|
}}
|
|
2778
3757
|
>
|
|
2779
3758
|
<DraftPrimitiveContent draft={draft} preview={preview} />
|
|
3759
|
+
{/* B3 fix: for the creation preview the outline must sit flush with the
|
|
3760
|
+
geometry box (inset: 0) so the blue accent border lands exactly on the
|
|
3761
|
+
shape edge with no visible gap between the gray content border and the
|
|
3762
|
+
blue selection outline. For placed / hovered draft-primitives the
|
|
3763
|
+
existing -5px inset is intentional (matches the screen-frame chrome). */}
|
|
2780
3764
|
<span
|
|
2781
3765
|
className={cn(
|
|
2782
3766
|
"pointer-events-none absolute rounded-sm border transition-opacity",
|
|
2783
|
-
|
|
3767
|
+
preview
|
|
2784
3768
|
? "border-[var(--design-editor-accent-color)] opacity-100"
|
|
2785
|
-
:
|
|
3769
|
+
: selected
|
|
3770
|
+
? "border-transparent opacity-0"
|
|
3771
|
+
: "border-[var(--design-editor-accent-color)] opacity-0 group-hover/artboard:opacity-100",
|
|
2786
3772
|
)}
|
|
2787
3773
|
style={{
|
|
2788
|
-
inset: -5 * chromeScale,
|
|
3774
|
+
inset: preview ? 0 : -5 * chromeScale,
|
|
2789
3775
|
borderWidth: 1.5 * chromeScale,
|
|
3776
|
+
transition: getChromeBorderTransition(chromeSettling),
|
|
2790
3777
|
}}
|
|
2791
3778
|
/>
|
|
2792
3779
|
<ResizeHandles
|
|
2793
|
-
active={
|
|
2794
|
-
enabled={!penActive &&
|
|
3780
|
+
active={preview}
|
|
3781
|
+
enabled={!penActive && preview}
|
|
2795
3782
|
showRotate={false}
|
|
2796
3783
|
chromeScale={chromeScale}
|
|
3784
|
+
chromeSettling={chromeSettling}
|
|
2797
3785
|
onStartResize={(handle, event) =>
|
|
2798
3786
|
onStartResize(draft.id, handle, event)
|
|
2799
3787
|
}
|
|
@@ -2856,12 +3844,19 @@ function DraftPrimitiveContent({
|
|
|
2856
3844
|
}
|
|
2857
3845
|
|
|
2858
3846
|
if (draft.kind === "text") {
|
|
3847
|
+
// B5 fix: use canonical style so preview matches the committed element.
|
|
3848
|
+
const textStyle = canvasPrimitiveReactStyle("text", {
|
|
3849
|
+
fill: draft.fill,
|
|
3850
|
+
stroke: draft.stroke,
|
|
3851
|
+
strokeWidth: draft.strokeWidth,
|
|
3852
|
+
});
|
|
2859
3853
|
return (
|
|
2860
3854
|
<div
|
|
2861
3855
|
className={cn(
|
|
2862
|
-
"flex size-full items-start
|
|
3856
|
+
"flex size-full items-start px-2 py-1 text-sm font-medium text-foreground",
|
|
2863
3857
|
muted,
|
|
2864
3858
|
)}
|
|
3859
|
+
style={textStyle}
|
|
2865
3860
|
>
|
|
2866
3861
|
<span className="truncate">{draft.text}</span>
|
|
2867
3862
|
</div>
|
|
@@ -2869,27 +3864,24 @@ function DraftPrimitiveContent({
|
|
|
2869
3864
|
}
|
|
2870
3865
|
|
|
2871
3866
|
if (draft.kind === "frame") {
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
);
|
|
3867
|
+
// B5 fix: use canonical style so preview matches the committed element.
|
|
3868
|
+
const frameStyle = canvasPrimitiveReactStyle("frame", {
|
|
3869
|
+
fill: draft.fill,
|
|
3870
|
+
stroke: draft.stroke,
|
|
3871
|
+
strokeWidth: draft.strokeWidth,
|
|
3872
|
+
});
|
|
3873
|
+
return <div className={cn("size-full", muted)} style={frameStyle} />;
|
|
2880
3874
|
}
|
|
2881
3875
|
|
|
2882
3876
|
if (draft.kind === "ellipse") {
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
/>
|
|
2892
|
-
);
|
|
3877
|
+
// B5/B6 fix: use canonical style — ellipse gets borderRadius:50% in both
|
|
3878
|
+
// the preview and the committed path, and the same calm neutral fill.
|
|
3879
|
+
const ellipseStyle = canvasPrimitiveReactStyle("ellipse", {
|
|
3880
|
+
fill: draft.fill,
|
|
3881
|
+
stroke: draft.stroke,
|
|
3882
|
+
strokeWidth: draft.strokeWidth,
|
|
3883
|
+
});
|
|
3884
|
+
return <div className={cn("size-full", muted)} style={ellipseStyle} />;
|
|
2893
3885
|
}
|
|
2894
3886
|
|
|
2895
3887
|
if (draft.kind === "polygon" || draft.kind === "star") {
|
|
@@ -2916,16 +3908,13 @@ function DraftPrimitiveContent({
|
|
|
2916
3908
|
);
|
|
2917
3909
|
}
|
|
2918
3910
|
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
}}
|
|
2927
|
-
/>
|
|
2928
|
-
);
|
|
3911
|
+
// B5 fix: rect/rectangle — use canonical style so preview matches committed.
|
|
3912
|
+
const rectStyle = canvasPrimitiveReactStyle("rect", {
|
|
3913
|
+
fill: draft.fill,
|
|
3914
|
+
stroke: draft.stroke,
|
|
3915
|
+
strokeWidth: draft.strokeWidth,
|
|
3916
|
+
});
|
|
3917
|
+
return <div className={cn("size-full", muted)} style={rectStyle} />;
|
|
2929
3918
|
}
|
|
2930
3919
|
|
|
2931
3920
|
function PenPathOverlay({
|
|
@@ -3034,35 +4023,37 @@ function isPoint(point: Point | undefined): point is Point {
|
|
|
3034
4023
|
return !!point;
|
|
3035
4024
|
}
|
|
3036
4025
|
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
onStartDuplicateGesture,
|
|
3054
|
-
screenContent,
|
|
3055
|
-
}: {
|
|
4026
|
+
/** Standard Tailwind breakpoint widths, mobile-first (base / md: / lg: / xl:). */
|
|
4027
|
+
const STANDARD_BREAKPOINT_WIDTHS = [390, 768, 1280] as const;
|
|
4028
|
+
|
|
4029
|
+
/** Derive the Tailwind prefix for a given frame width. */
|
|
4030
|
+
function breakpointLabel(widthPx: number): string {
|
|
4031
|
+
if (widthPx <= 640) return "Mobile";
|
|
4032
|
+
if (widthPx <= 1024) return "Tablet";
|
|
4033
|
+
return "Desktop";
|
|
4034
|
+
}
|
|
4035
|
+
|
|
4036
|
+
/** Suggest the next standard breakpoint not yet in the set. */
|
|
4037
|
+
function nextBreakpointWidth(existing: number[]): number | undefined {
|
|
4038
|
+
return STANDARD_BREAKPOINT_WIDTHS.find((w) => !existing.includes(w));
|
|
4039
|
+
}
|
|
4040
|
+
|
|
4041
|
+
interface ScreenProps {
|
|
3056
4042
|
screen: ScreenFile;
|
|
3057
4043
|
metadata: ResolvedScreenMetadata;
|
|
3058
4044
|
geometry: FrameGeometry;
|
|
3059
4045
|
isActive: boolean;
|
|
3060
4046
|
isSelected: boolean;
|
|
4047
|
+
showFullView: boolean;
|
|
4048
|
+
isDirectlyHovered: boolean;
|
|
4049
|
+
hasHoveredChild: boolean;
|
|
3061
4050
|
groupSelected: boolean;
|
|
3062
4051
|
handlesEnabled: boolean;
|
|
3063
4052
|
penActive: boolean;
|
|
3064
4053
|
creationToolActive: boolean;
|
|
4054
|
+
canvasGestureActive: boolean;
|
|
3065
4055
|
chromeScale: number;
|
|
4056
|
+
chromeSettling: boolean;
|
|
3066
4057
|
screenContent?: ReactNode;
|
|
3067
4058
|
onPick: (id: string, e: React.MouseEvent<HTMLElement>) => void;
|
|
3068
4059
|
onEdit: (id: string, e: React.MouseEvent<HTMLElement>) => void;
|
|
@@ -3078,15 +4069,81 @@ function Screen({
|
|
|
3078
4069
|
display: string,
|
|
3079
4070
|
e: React.MouseEvent<HTMLElement>,
|
|
3080
4071
|
) => void;
|
|
3081
|
-
|
|
4072
|
+
onAddBreakpoint?: (widthPx: number) => void;
|
|
4073
|
+
onActiveBreakpointChange?: (widthPx: number | undefined) => void;
|
|
4074
|
+
}
|
|
4075
|
+
|
|
4076
|
+
const Screen = memo(function Screen({
|
|
4077
|
+
screen,
|
|
4078
|
+
metadata,
|
|
4079
|
+
geometry,
|
|
4080
|
+
isActive,
|
|
4081
|
+
isSelected,
|
|
4082
|
+
showFullView,
|
|
4083
|
+
isDirectlyHovered,
|
|
4084
|
+
hasHoveredChild,
|
|
4085
|
+
groupSelected,
|
|
4086
|
+
handlesEnabled,
|
|
4087
|
+
penActive,
|
|
4088
|
+
creationToolActive,
|
|
4089
|
+
canvasGestureActive,
|
|
4090
|
+
chromeScale,
|
|
4091
|
+
chromeSettling,
|
|
4092
|
+
onPick,
|
|
4093
|
+
onEdit,
|
|
4094
|
+
onStartFrameDrag,
|
|
4095
|
+
onStartResize,
|
|
4096
|
+
onStartRotate,
|
|
4097
|
+
onStartDuplicateGesture,
|
|
4098
|
+
screenContent,
|
|
4099
|
+
onAddBreakpoint,
|
|
4100
|
+
onActiveBreakpointChange,
|
|
4101
|
+
}: ScreenProps) {
|
|
3082
4102
|
const t = useT();
|
|
3083
4103
|
const display = metadata.title ?? prettyScreenName(screen.filename);
|
|
3084
4104
|
const previewUrl = metadata.previewUrl ?? getPreviewUrl(screen.content);
|
|
4105
|
+
const previewViewport = getScreenPreviewViewport(metadata, geometry);
|
|
3085
4106
|
const suppressNextClick = useRef(false);
|
|
3086
|
-
const
|
|
4107
|
+
const [directlyHovered, setDirectlyHovered] = useState(false);
|
|
4108
|
+
const frameDirectlyHovered =
|
|
4109
|
+
(directlyHovered || isDirectlyHovered) &&
|
|
4110
|
+
!creationToolActive &&
|
|
4111
|
+
!canvasGestureActive;
|
|
4112
|
+
const emphasized = isSelected || frameDirectlyHovered;
|
|
4113
|
+
const fullViewVisible = emphasized || showFullView;
|
|
4114
|
+
const activeOrEmphasized = isActive || emphasized;
|
|
3087
4115
|
const selectionOutlined = isSelected && !groupSelected;
|
|
3088
|
-
const
|
|
3089
|
-
|
|
4116
|
+
const showHoverChrome =
|
|
4117
|
+
frameDirectlyHovered && !isSelected && !groupSelected && !hasHoveredChild;
|
|
4118
|
+
const screenContentInteractive =
|
|
4119
|
+
Boolean(screenContent) &&
|
|
4120
|
+
!penActive &&
|
|
4121
|
+
!creationToolActive &&
|
|
4122
|
+
!canvasGestureActive;
|
|
4123
|
+
const updateDirectHover = useCallback((next: boolean) => {
|
|
4124
|
+
setDirectlyHovered((current) => (current === next ? current : next));
|
|
4125
|
+
}, []);
|
|
4126
|
+
const frameLabelHeight = FRAME_LABEL_HEIGHT * chromeScale;
|
|
4127
|
+
const frameScreenWidth = geometry.width / Math.max(chromeScale, 0.001);
|
|
4128
|
+
const fullViewOutsideFrame =
|
|
4129
|
+
frameScreenWidth < FRAME_HEADER_BUTTON_OUTSIDE_WIDTH;
|
|
4130
|
+
const labelInfoMaxWidth = Math.max(
|
|
4131
|
+
64,
|
|
4132
|
+
frameScreenWidth - (fullViewOutsideFrame ? 8 : FRAME_HEADER_BUTTON_RESERVE),
|
|
4133
|
+
);
|
|
4134
|
+
const estimatedTitleWidth = clamp(
|
|
4135
|
+
display.length * FRAME_HEADER_TITLE_CHAR_WIDTH,
|
|
4136
|
+
FRAME_HEADER_MIN_TITLE_WIDTH,
|
|
4137
|
+
FRAME_HEADER_MAX_TITLE_WIDTH,
|
|
4138
|
+
);
|
|
4139
|
+
const showDimensions =
|
|
4140
|
+
frameScreenWidth >=
|
|
4141
|
+
estimatedTitleWidth +
|
|
4142
|
+
FRAME_HEADER_DIMENSIONS_WIDTH +
|
|
4143
|
+
(fullViewOutsideFrame ? 16 : FRAME_HEADER_BUTTON_RESERVE);
|
|
4144
|
+
const fullViewMaxWidth = fullViewOutsideFrame
|
|
4145
|
+
? 120
|
|
4146
|
+
: Math.max(84, Math.min(180, frameScreenWidth * 0.46));
|
|
3090
4147
|
|
|
3091
4148
|
return (
|
|
3092
4149
|
<div
|
|
@@ -3094,17 +4151,32 @@ function Screen({
|
|
|
3094
4151
|
className="group/frame pointer-events-auto absolute"
|
|
3095
4152
|
style={{
|
|
3096
4153
|
left: SURFACE_PADDING + geometry.x,
|
|
3097
|
-
top: SURFACE_PADDING + geometry.y -
|
|
4154
|
+
top: SURFACE_PADDING + geometry.y - frameLabelHeight,
|
|
3098
4155
|
width: geometry.width,
|
|
3099
4156
|
transform: geometry.rotation
|
|
3100
4157
|
? `rotate(${geometry.rotation}deg)`
|
|
3101
4158
|
: undefined,
|
|
3102
|
-
transformOrigin: `${geometry.width / 2}px ${
|
|
4159
|
+
transformOrigin: `${geometry.width / 2}px ${frameLabelHeight + geometry.height / 2}px`,
|
|
3103
4160
|
zIndex: geometry.z,
|
|
3104
4161
|
}}
|
|
3105
4162
|
>
|
|
3106
4163
|
<div
|
|
3107
|
-
className="
|
|
4164
|
+
className="relative w-full cursor-default"
|
|
4165
|
+
style={{ height: frameLabelHeight }}
|
|
4166
|
+
onClick={(e) => {
|
|
4167
|
+
e.stopPropagation();
|
|
4168
|
+
if (suppressNextClick.current) {
|
|
4169
|
+
suppressNextClick.current = false;
|
|
4170
|
+
return;
|
|
4171
|
+
}
|
|
4172
|
+
if (e.detail > 1) return;
|
|
4173
|
+
onPick(screen.id, e);
|
|
4174
|
+
}}
|
|
4175
|
+
onDoubleClick={(e) => {
|
|
4176
|
+
e.preventDefault();
|
|
4177
|
+
e.stopPropagation();
|
|
4178
|
+
onEdit(screen.id, e);
|
|
4179
|
+
}}
|
|
3108
4180
|
onMouseDown={(e) => {
|
|
3109
4181
|
if (e.button !== 0) return;
|
|
3110
4182
|
if (penActive || creationToolActive) return;
|
|
@@ -3114,13 +4186,23 @@ function Screen({
|
|
|
3114
4186
|
}
|
|
3115
4187
|
if (e.shiftKey) {
|
|
3116
4188
|
e.stopPropagation();
|
|
3117
|
-
onPick(screen.id, e);
|
|
3118
4189
|
return;
|
|
3119
4190
|
}
|
|
3120
4191
|
onStartFrameDrag(screen.id, e);
|
|
3121
4192
|
}}
|
|
3122
4193
|
>
|
|
3123
|
-
<div
|
|
4194
|
+
<div
|
|
4195
|
+
data-frame-label
|
|
4196
|
+
className="absolute left-1 top-1/2 flex min-w-0 items-center gap-1.5"
|
|
4197
|
+
onMouseEnter={() => updateDirectHover(true)}
|
|
4198
|
+
onMouseLeave={() => updateDirectHover(false)}
|
|
4199
|
+
style={{
|
|
4200
|
+
maxWidth: labelInfoMaxWidth,
|
|
4201
|
+
transform: `translateY(-50%) scale(${chromeScale})`,
|
|
4202
|
+
transformOrigin: "left center",
|
|
4203
|
+
transition: getChromeLabelTransition(chromeSettling),
|
|
4204
|
+
}}
|
|
4205
|
+
>
|
|
3124
4206
|
<span
|
|
3125
4207
|
className={cn(
|
|
3126
4208
|
"h-1.5 w-1.5 shrink-0 rounded-full",
|
|
@@ -3128,26 +4210,45 @@ function Screen({
|
|
|
3128
4210
|
)}
|
|
3129
4211
|
/>
|
|
3130
4212
|
<span
|
|
4213
|
+
data-frame-title
|
|
3131
4214
|
className={cn(
|
|
3132
|
-
"truncate text-[11px] font-medium",
|
|
3133
|
-
emphasized
|
|
4215
|
+
"min-w-0 truncate text-[11px] font-medium",
|
|
4216
|
+
emphasized
|
|
4217
|
+
? "text-[var(--design-editor-accent-color)]"
|
|
4218
|
+
: activeOrEmphasized
|
|
4219
|
+
? "text-foreground"
|
|
4220
|
+
: "text-muted-foreground",
|
|
3134
4221
|
)}
|
|
3135
4222
|
title={screen.filename}
|
|
3136
4223
|
>
|
|
3137
4224
|
{display}
|
|
3138
4225
|
</span>
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
4226
|
+
{showDimensions ? (
|
|
4227
|
+
<span
|
|
4228
|
+
data-frame-dimensions
|
|
4229
|
+
className="hidden shrink-0 text-[10px] tabular-nums text-muted-foreground/70 sm:inline"
|
|
4230
|
+
>
|
|
4231
|
+
{previewViewport.displayWidth} x {previewViewport.displayHeight}
|
|
4232
|
+
</span>
|
|
4233
|
+
) : null}
|
|
3142
4234
|
</div>
|
|
3143
4235
|
<button
|
|
3144
4236
|
type="button"
|
|
4237
|
+
data-frame-full-view
|
|
3145
4238
|
className={cn(
|
|
3146
|
-
"
|
|
4239
|
+
"absolute top-1/2 z-40 flex h-5 shrink-0 items-center gap-1 overflow-hidden rounded-md border border-border bg-background/95 px-1.5 text-[10px] font-medium text-foreground opacity-0 shadow-sm transition-opacity",
|
|
3147
4240
|
"hover:bg-accent hover:text-accent-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
3148
|
-
"
|
|
3149
|
-
|
|
4241
|
+
fullViewVisible && "opacity-100",
|
|
4242
|
+
fullViewOutsideFrame ? "left-full" : "right-1",
|
|
3150
4243
|
)}
|
|
4244
|
+
style={{
|
|
4245
|
+
maxWidth: fullViewMaxWidth,
|
|
4246
|
+
transform: `translateY(-50%) scale(${chromeScale})`,
|
|
4247
|
+
transformOrigin: fullViewOutsideFrame
|
|
4248
|
+
? "left center"
|
|
4249
|
+
: "right center",
|
|
4250
|
+
transition: getChromeLabelTransition(chromeSettling),
|
|
4251
|
+
}}
|
|
3151
4252
|
aria-label={t("multiScreenCanvas.fullView")}
|
|
3152
4253
|
title={t("multiScreenCanvas.fullView")}
|
|
3153
4254
|
onClick={(event) => onEdit(screen.id, event)}
|
|
@@ -3155,6 +4256,8 @@ function Screen({
|
|
|
3155
4256
|
event.preventDefault();
|
|
3156
4257
|
event.stopPropagation();
|
|
3157
4258
|
}}
|
|
4259
|
+
onMouseEnter={() => updateDirectHover(true)}
|
|
4260
|
+
onMouseLeave={() => updateDirectHover(false)}
|
|
3158
4261
|
>
|
|
3159
4262
|
<IconMaximize className="size-3 shrink-0" />
|
|
3160
4263
|
<span className="truncate">{t("multiScreenCanvas.fullView")}</span>
|
|
@@ -3165,7 +4268,10 @@ function Screen({
|
|
|
3165
4268
|
role="button"
|
|
3166
4269
|
tabIndex={0}
|
|
3167
4270
|
onClick={(e) => {
|
|
3168
|
-
if (isInteractiveScreenContentTarget(e.target))
|
|
4271
|
+
if (isInteractiveScreenContentTarget(e.target)) {
|
|
4272
|
+
e.stopPropagation();
|
|
4273
|
+
return;
|
|
4274
|
+
}
|
|
3169
4275
|
e.stopPropagation();
|
|
3170
4276
|
if (suppressNextClick.current) {
|
|
3171
4277
|
suppressNextClick.current = false;
|
|
@@ -3175,13 +4281,19 @@ function Screen({
|
|
|
3175
4281
|
onPick(screen.id, e);
|
|
3176
4282
|
}}
|
|
3177
4283
|
onDoubleClick={(e) => {
|
|
3178
|
-
if (isInteractiveScreenContentTarget(e.target))
|
|
4284
|
+
if (isInteractiveScreenContentTarget(e.target)) {
|
|
4285
|
+
e.stopPropagation();
|
|
4286
|
+
return;
|
|
4287
|
+
}
|
|
3179
4288
|
e.preventDefault();
|
|
3180
4289
|
e.stopPropagation();
|
|
3181
4290
|
}}
|
|
3182
4291
|
onMouseDown={(e) => {
|
|
4292
|
+
if (isInteractiveScreenContentTarget(e.target)) {
|
|
4293
|
+
e.stopPropagation();
|
|
4294
|
+
return;
|
|
4295
|
+
}
|
|
3183
4296
|
if (creationToolActive) return;
|
|
3184
|
-
if (isInteractiveScreenContentTarget(e.target)) return;
|
|
3185
4297
|
if (e.detail > 1) {
|
|
3186
4298
|
e.stopPropagation();
|
|
3187
4299
|
return;
|
|
@@ -3200,46 +4312,38 @@ function Screen({
|
|
|
3200
4312
|
onStartFrameDrag(screen.id, e);
|
|
3201
4313
|
}
|
|
3202
4314
|
}}
|
|
4315
|
+
onMouseMove={(e) => {
|
|
4316
|
+
updateDirectHover(
|
|
4317
|
+
isDirectScreenHoverTarget(e.target, e.currentTarget),
|
|
4318
|
+
);
|
|
4319
|
+
}}
|
|
4320
|
+
onMouseLeave={() => updateDirectHover(false)}
|
|
3203
4321
|
className={cn(
|
|
3204
4322
|
"group/artboard relative block overflow-visible rounded-lg bg-background text-left outline-none transition-colors",
|
|
3205
4323
|
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
3206
4324
|
emphasized
|
|
3207
4325
|
? "text-foreground"
|
|
3208
|
-
:
|
|
4326
|
+
: cn(
|
|
4327
|
+
"text-muted-foreground",
|
|
4328
|
+
showHoverChrome && "hover:text-foreground",
|
|
4329
|
+
),
|
|
3209
4330
|
)}
|
|
3210
4331
|
style={{
|
|
3211
4332
|
width: geometry.width,
|
|
3212
4333
|
height: geometry.height,
|
|
3213
|
-
cursor:
|
|
3214
|
-
penActive || creationToolActive
|
|
3215
|
-
? "crosshair"
|
|
3216
|
-
: isSelected
|
|
3217
|
-
? "move"
|
|
3218
|
-
: "pointer",
|
|
4334
|
+
cursor: penActive || creationToolActive ? "crosshair" : "pointer",
|
|
3219
4335
|
touchAction: "none",
|
|
3220
4336
|
}}
|
|
3221
4337
|
>
|
|
3222
4338
|
<span
|
|
3223
|
-
|
|
3224
|
-
"pointer-events-none absolute border transition-opacity",
|
|
3225
|
-
selectionOutlined
|
|
3226
|
-
? "border-[var(--design-editor-accent-color)] opacity-100"
|
|
3227
|
-
: showHoverOutline
|
|
3228
|
-
? "border-[var(--design-editor-accent-color)] opacity-0 group-hover/artboard:opacity-100"
|
|
3229
|
-
: "border-transparent opacity-0",
|
|
3230
|
-
)}
|
|
3231
|
-
style={{
|
|
3232
|
-
inset: -5 * chromeScale,
|
|
3233
|
-
borderRadius: 13 * chromeScale,
|
|
3234
|
-
borderWidth: 1.5 * chromeScale,
|
|
3235
|
-
}}
|
|
3236
|
-
/>
|
|
3237
|
-
<span
|
|
4339
|
+
data-screen-content
|
|
3238
4340
|
className={cn(
|
|
3239
4341
|
"relative block h-full w-full overflow-hidden rounded-lg border bg-white shadow-2xl transition-colors",
|
|
3240
|
-
|
|
4342
|
+
showHoverChrome
|
|
4343
|
+
? "border-[var(--design-editor-accent-color)]"
|
|
4344
|
+
: "border-border",
|
|
3241
4345
|
)}
|
|
3242
|
-
style={{ pointerEvents:
|
|
4346
|
+
style={{ pointerEvents: screenContentInteractive ? "auto" : "none" }}
|
|
3243
4347
|
>
|
|
3244
4348
|
{screenContent ?? (
|
|
3245
4349
|
<iframe
|
|
@@ -3249,12 +4353,24 @@ function Screen({
|
|
|
3249
4353
|
loading="lazy"
|
|
3250
4354
|
className="pointer-events-none border-0"
|
|
3251
4355
|
style={{
|
|
3252
|
-
width:
|
|
3253
|
-
height:
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
4356
|
+
width: previewViewport.viewportWidth,
|
|
4357
|
+
height: previewViewport.viewportHeight,
|
|
4358
|
+
// Untouched same-aspect overview thumbnails may scale uniformly.
|
|
4359
|
+
// User-resized frames use their real iframe viewport so
|
|
4360
|
+
// responsive layouts recompute instead of getting stretched.
|
|
4361
|
+
transform:
|
|
4362
|
+
previewViewport.scale === 1
|
|
4363
|
+
? undefined
|
|
4364
|
+
: `scale(${previewViewport.scale})`,
|
|
3257
4365
|
transformOrigin: "top left",
|
|
4366
|
+
backgroundColor: "white",
|
|
4367
|
+
colorScheme: "light",
|
|
4368
|
+
// Prevent the browser from discarding the composited layer at
|
|
4369
|
+
// fractional zoom levels, which causes the iframe to go black.
|
|
4370
|
+
// backface-visibility:hidden forces the browser to keep the
|
|
4371
|
+
// backing store alive even when the effective scale is very small
|
|
4372
|
+
// (e.g. 0.25 iframe scale × 0.5 canvas zoom = 0.125 total).
|
|
4373
|
+
backfaceVisibility: "hidden",
|
|
3258
4374
|
}}
|
|
3259
4375
|
title={screen.filename}
|
|
3260
4376
|
/>
|
|
@@ -3265,50 +4381,320 @@ function Screen({
|
|
|
3265
4381
|
aria-hidden="true"
|
|
3266
4382
|
/>
|
|
3267
4383
|
) : null}
|
|
4384
|
+
{canvasGestureActive && screenContent ? (
|
|
4385
|
+
<span
|
|
4386
|
+
data-screen-interaction-shield
|
|
4387
|
+
className="pointer-events-auto absolute inset-0 z-30"
|
|
4388
|
+
aria-hidden="true"
|
|
4389
|
+
/>
|
|
4390
|
+
) : null}
|
|
3268
4391
|
<span className="pointer-events-none absolute inset-0 rounded-[7px] border border-black/5" />
|
|
3269
4392
|
</span>
|
|
3270
4393
|
<ResizeHandles
|
|
3271
|
-
active={
|
|
3272
|
-
enabled={
|
|
4394
|
+
active={false}
|
|
4395
|
+
enabled={
|
|
4396
|
+
!selectionOutlined &&
|
|
4397
|
+
!penActive &&
|
|
4398
|
+
!creationToolActive &&
|
|
4399
|
+
handlesEnabled
|
|
4400
|
+
}
|
|
4401
|
+
showOnHover={false}
|
|
3273
4402
|
showRotate
|
|
3274
4403
|
chromeScale={chromeScale}
|
|
4404
|
+
chromeSettling={chromeSettling}
|
|
3275
4405
|
onStartResize={(handle, e) => onStartResize(screen.id, handle, e)}
|
|
3276
4406
|
onStartRotate={(e) => onStartRotate(screen.id, e)}
|
|
3277
4407
|
/>
|
|
3278
4408
|
</div>
|
|
4409
|
+
|
|
4410
|
+
{/* Multi-breakpoint preview row (§6.4 — Framer/Figma-Sites style).
|
|
4411
|
+
Rendered as a sibling row to the right of the primary frame when
|
|
4412
|
+
the screen has breakpointWidths set. Each frame shares the same
|
|
4413
|
+
srcdoc content at a different viewport width. The active breakpoint
|
|
4414
|
+
is highlighted and clicking a frame header sets the edit scope. */}
|
|
4415
|
+
{screen.breakpointWidths && screen.breakpointWidths.length > 0 ? (
|
|
4416
|
+
<BreakpointPreviewRow
|
|
4417
|
+
screen={screen}
|
|
4418
|
+
primaryGeometry={geometry}
|
|
4419
|
+
previewUrl={previewUrl}
|
|
4420
|
+
activeBreakpointWidth={screen.activeBreakpointWidth}
|
|
4421
|
+
penActive={penActive}
|
|
4422
|
+
creationToolActive={creationToolActive}
|
|
4423
|
+
chromeScale={chromeScale}
|
|
4424
|
+
onActiveBreakpointChange={onActiveBreakpointChange}
|
|
4425
|
+
onAddBreakpoint={onAddBreakpoint}
|
|
4426
|
+
/>
|
|
4427
|
+
) : null}
|
|
3279
4428
|
</div>
|
|
3280
4429
|
);
|
|
4430
|
+
}, areScreenPropsEqual);
|
|
4431
|
+
|
|
4432
|
+
function areScreenPropsEqual(prev: ScreenProps, next: ScreenProps) {
|
|
4433
|
+
return (
|
|
4434
|
+
prev.screen === next.screen &&
|
|
4435
|
+
prev.screenContent === next.screenContent &&
|
|
4436
|
+
sameResolvedMetadata(prev.metadata, next.metadata) &&
|
|
4437
|
+
sameFrameGeometry(prev.geometry, next.geometry) &&
|
|
4438
|
+
prev.isActive === next.isActive &&
|
|
4439
|
+
prev.isSelected === next.isSelected &&
|
|
4440
|
+
prev.showFullView === next.showFullView &&
|
|
4441
|
+
prev.isDirectlyHovered === next.isDirectlyHovered &&
|
|
4442
|
+
prev.hasHoveredChild === next.hasHoveredChild &&
|
|
4443
|
+
prev.groupSelected === next.groupSelected &&
|
|
4444
|
+
prev.handlesEnabled === next.handlesEnabled &&
|
|
4445
|
+
prev.penActive === next.penActive &&
|
|
4446
|
+
prev.creationToolActive === next.creationToolActive &&
|
|
4447
|
+
prev.canvasGestureActive === next.canvasGestureActive &&
|
|
4448
|
+
prev.chromeScale === next.chromeScale &&
|
|
4449
|
+
prev.chromeSettling === next.chromeSettling &&
|
|
4450
|
+
prev.onPick === next.onPick &&
|
|
4451
|
+
prev.onEdit === next.onEdit &&
|
|
4452
|
+
prev.onStartFrameDrag === next.onStartFrameDrag &&
|
|
4453
|
+
prev.onStartResize === next.onStartResize &&
|
|
4454
|
+
prev.onStartRotate === next.onStartRotate &&
|
|
4455
|
+
prev.onStartDuplicateGesture === next.onStartDuplicateGesture
|
|
4456
|
+
);
|
|
4457
|
+
}
|
|
4458
|
+
|
|
4459
|
+
// ── Breakpoint preview row (§6.4) ────────────────────────────────────────────
|
|
4460
|
+
|
|
4461
|
+
/** Gap between adjacent breakpoint frames in canvas pixels. */
|
|
4462
|
+
const BREAKPOINT_FRAME_GAP = 24;
|
|
4463
|
+
|
|
4464
|
+
function BreakpointPreviewRow({
|
|
4465
|
+
screen,
|
|
4466
|
+
primaryGeometry,
|
|
4467
|
+
previewUrl,
|
|
4468
|
+
activeBreakpointWidth,
|
|
4469
|
+
penActive,
|
|
4470
|
+
creationToolActive,
|
|
4471
|
+
chromeScale,
|
|
4472
|
+
onActiveBreakpointChange,
|
|
4473
|
+
onAddBreakpoint,
|
|
4474
|
+
}: {
|
|
4475
|
+
screen: ScreenFile;
|
|
4476
|
+
primaryGeometry: FrameGeometry;
|
|
4477
|
+
previewUrl: string | undefined;
|
|
4478
|
+
activeBreakpointWidth: number | undefined;
|
|
4479
|
+
penActive: boolean;
|
|
4480
|
+
creationToolActive: boolean;
|
|
4481
|
+
chromeScale: number;
|
|
4482
|
+
onActiveBreakpointChange?: (widthPx: number | undefined) => void;
|
|
4483
|
+
onAddBreakpoint?: (widthPx: number) => void;
|
|
4484
|
+
}) {
|
|
4485
|
+
const breakpointWidths = screen.breakpointWidths ?? [];
|
|
4486
|
+
// Scale factor used to shrink additional frames to match the primary frame height
|
|
4487
|
+
const scaleY = primaryGeometry.height / (primaryGeometry.width || 1);
|
|
4488
|
+
// Place additional frames to the right of the primary, starting after the gap
|
|
4489
|
+
let offsetX = primaryGeometry.width + BREAKPOINT_FRAME_GAP;
|
|
4490
|
+
|
|
4491
|
+
const nextWidth = nextBreakpointWidth(breakpointWidths);
|
|
4492
|
+
|
|
4493
|
+
return (
|
|
4494
|
+
<>
|
|
4495
|
+
{breakpointWidths.map((widthPx) => {
|
|
4496
|
+
// Scale the additional frame proportionally (same height as primary)
|
|
4497
|
+
const frameWidth = Math.round(widthPx * scaleY);
|
|
4498
|
+
const frameHeight = primaryGeometry.height;
|
|
4499
|
+
const isActive = activeBreakpointWidth === widthPx;
|
|
4500
|
+
const currentOffsetX = offsetX;
|
|
4501
|
+
offsetX += frameWidth + BREAKPOINT_FRAME_GAP;
|
|
4502
|
+
|
|
4503
|
+
return (
|
|
4504
|
+
<div
|
|
4505
|
+
key={widthPx}
|
|
4506
|
+
data-frame-shell
|
|
4507
|
+
data-breakpoint-frame
|
|
4508
|
+
className="pointer-events-auto absolute"
|
|
4509
|
+
// Positioned relative to the parent Screen wrapper, which is already
|
|
4510
|
+
// absolute at left: SURFACE_PADDING + geometry.x / top:
|
|
4511
|
+
// SURFACE_PADDING + geometry.y - FRAME_LABEL_HEIGHT. Re-adding those
|
|
4512
|
+
// surface/primary terms here would double-offset every breakpoint
|
|
4513
|
+
// frame (~240px+ down-right), so we offset only within the wrapper.
|
|
4514
|
+
style={{
|
|
4515
|
+
left: currentOffsetX,
|
|
4516
|
+
top: -FRAME_LABEL_HEIGHT,
|
|
4517
|
+
width: frameWidth,
|
|
4518
|
+
zIndex: primaryGeometry.z,
|
|
4519
|
+
}}
|
|
4520
|
+
>
|
|
4521
|
+
{/* Frame label row */}
|
|
4522
|
+
<div
|
|
4523
|
+
className={cn(
|
|
4524
|
+
"flex h-7 w-full items-center justify-between gap-1 px-1 cursor-pointer select-none",
|
|
4525
|
+
)}
|
|
4526
|
+
onClick={(e) => {
|
|
4527
|
+
e.stopPropagation();
|
|
4528
|
+
onActiveBreakpointChange?.(isActive ? undefined : widthPx);
|
|
4529
|
+
}}
|
|
4530
|
+
>
|
|
4531
|
+
<div className="flex min-w-0 items-center gap-1.5">
|
|
4532
|
+
<span
|
|
4533
|
+
className={cn(
|
|
4534
|
+
"h-1.5 w-1.5 shrink-0 rounded-full",
|
|
4535
|
+
isActive
|
|
4536
|
+
? "bg-[var(--design-editor-accent-color)]"
|
|
4537
|
+
: "bg-muted-foreground/40",
|
|
4538
|
+
)}
|
|
4539
|
+
/>
|
|
4540
|
+
<span
|
|
4541
|
+
className={cn(
|
|
4542
|
+
"truncate text-[11px] font-medium",
|
|
4543
|
+
isActive
|
|
4544
|
+
? "text-[var(--design-editor-accent-color)]"
|
|
4545
|
+
: "text-muted-foreground",
|
|
4546
|
+
)}
|
|
4547
|
+
>
|
|
4548
|
+
{breakpointLabel(widthPx)}
|
|
4549
|
+
</span>
|
|
4550
|
+
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground/50">
|
|
4551
|
+
{widthPx}px
|
|
4552
|
+
</span>
|
|
4553
|
+
</div>
|
|
4554
|
+
</div>
|
|
4555
|
+
{/* Frame card */}
|
|
4556
|
+
<div
|
|
4557
|
+
className={cn(
|
|
4558
|
+
"relative block overflow-hidden rounded-lg border bg-white shadow-lg transition-colors",
|
|
4559
|
+
isActive
|
|
4560
|
+
? "border-[var(--design-editor-accent-color)]"
|
|
4561
|
+
: "border-border",
|
|
4562
|
+
)}
|
|
4563
|
+
style={{ width: frameWidth, height: frameHeight }}
|
|
4564
|
+
>
|
|
4565
|
+
<iframe
|
|
4566
|
+
src={previewUrl}
|
|
4567
|
+
srcDoc={previewUrl ? undefined : screen.content}
|
|
4568
|
+
sandbox="allow-scripts"
|
|
4569
|
+
loading="lazy"
|
|
4570
|
+
className="pointer-events-none border-0"
|
|
4571
|
+
style={{
|
|
4572
|
+
width: widthPx,
|
|
4573
|
+
height: Math.round(
|
|
4574
|
+
widthPx * (primaryGeometry.height / primaryGeometry.width),
|
|
4575
|
+
),
|
|
4576
|
+
transform: `scale(${frameWidth / widthPx}, ${frameHeight / Math.round(widthPx * (primaryGeometry.height / primaryGeometry.width))})`,
|
|
4577
|
+
transformOrigin: "top left",
|
|
4578
|
+
}}
|
|
4579
|
+
title={`${screen.filename} — ${breakpointLabel(widthPx)}`}
|
|
4580
|
+
/>
|
|
4581
|
+
{creationToolActive || penActive ? (
|
|
4582
|
+
<span className="absolute inset-0 z-20 cursor-crosshair" />
|
|
4583
|
+
) : null}
|
|
4584
|
+
<span className="pointer-events-none absolute inset-0 rounded-[7px] border border-black/5" />
|
|
4585
|
+
</div>
|
|
4586
|
+
</div>
|
|
4587
|
+
);
|
|
4588
|
+
})}
|
|
4589
|
+
|
|
4590
|
+
{/* + affordance: add the next standard breakpoint */}
|
|
4591
|
+
{onAddBreakpoint && nextWidth !== undefined ? (
|
|
4592
|
+
<div
|
|
4593
|
+
className="pointer-events-auto absolute flex items-center"
|
|
4594
|
+
// Same wrapper-relative coordinate space as the breakpoint frames
|
|
4595
|
+
// above — no SURFACE_PADDING / primaryGeometry.x/y terms or it would
|
|
4596
|
+
// double-offset.
|
|
4597
|
+
style={{
|
|
4598
|
+
left: offsetX,
|
|
4599
|
+
top: -FRAME_LABEL_HEIGHT + primaryGeometry.height / 2,
|
|
4600
|
+
zIndex: primaryGeometry.z,
|
|
4601
|
+
}}
|
|
4602
|
+
>
|
|
4603
|
+
<button
|
|
4604
|
+
type="button"
|
|
4605
|
+
className={cn(
|
|
4606
|
+
"flex size-7 items-center justify-center rounded-full border border-border bg-background/95 text-muted-foreground shadow-sm transition-colors",
|
|
4607
|
+
"hover:border-[var(--design-editor-accent-color)] hover:text-[var(--design-editor-accent-color)]",
|
|
4608
|
+
)}
|
|
4609
|
+
style={{
|
|
4610
|
+
transform: `scale(${chromeScale})`,
|
|
4611
|
+
transformOrigin: "center",
|
|
4612
|
+
}}
|
|
4613
|
+
title={`Add ${breakpointLabel(nextWidth)} breakpoint (${nextWidth}px)`}
|
|
4614
|
+
onClick={(e) => {
|
|
4615
|
+
e.stopPropagation();
|
|
4616
|
+
onAddBreakpoint(nextWidth);
|
|
4617
|
+
}}
|
|
4618
|
+
>
|
|
4619
|
+
<IconPlus className="size-3.5" />
|
|
4620
|
+
</button>
|
|
4621
|
+
</div>
|
|
4622
|
+
) : null}
|
|
4623
|
+
</>
|
|
4624
|
+
);
|
|
3281
4625
|
}
|
|
3282
4626
|
|
|
3283
4627
|
function GroupSelectionBox({
|
|
3284
4628
|
bounds,
|
|
3285
4629
|
chromeScale,
|
|
4630
|
+
chromeSettling,
|
|
3286
4631
|
onStartResize,
|
|
3287
4632
|
}: {
|
|
3288
4633
|
bounds: NonNullable<ReturnType<typeof getFrameGroupBounds>>;
|
|
3289
4634
|
chromeScale: number;
|
|
4635
|
+
chromeSettling: boolean;
|
|
4636
|
+
onStartResize: (handle: ResizeHandle, e: React.MouseEvent) => void;
|
|
4637
|
+
}) {
|
|
4638
|
+
return (
|
|
4639
|
+
<SelectionBox
|
|
4640
|
+
geometry={{
|
|
4641
|
+
x: bounds.left,
|
|
4642
|
+
y: bounds.top,
|
|
4643
|
+
width: bounds.width,
|
|
4644
|
+
height: bounds.height,
|
|
4645
|
+
}}
|
|
4646
|
+
chromeScale={chromeScale}
|
|
4647
|
+
chromeSettling={chromeSettling}
|
|
4648
|
+
showRotate={false}
|
|
4649
|
+
onStartResize={onStartResize}
|
|
4650
|
+
onStartRotate={() => {}}
|
|
4651
|
+
/>
|
|
4652
|
+
);
|
|
4653
|
+
}
|
|
4654
|
+
|
|
4655
|
+
function SelectionBox({
|
|
4656
|
+
geometry,
|
|
4657
|
+
chromeScale,
|
|
4658
|
+
chromeSettling,
|
|
4659
|
+
showRotate = true,
|
|
4660
|
+
onStartResize,
|
|
4661
|
+
onStartRotate,
|
|
4662
|
+
}: {
|
|
4663
|
+
geometry: FrameGeometry;
|
|
4664
|
+
chromeScale: number;
|
|
4665
|
+
chromeSettling: boolean;
|
|
4666
|
+
showRotate?: boolean;
|
|
3290
4667
|
onStartResize: (handle: ResizeHandle, e: React.MouseEvent) => void;
|
|
4668
|
+
onStartRotate: (e: React.MouseEvent) => void;
|
|
3291
4669
|
}) {
|
|
3292
4670
|
return (
|
|
3293
4671
|
<div
|
|
4672
|
+
data-frame-selection-box
|
|
3294
4673
|
data-frame-shell
|
|
3295
|
-
className="pointer-events-none absolute
|
|
4674
|
+
className="pointer-events-none absolute border border-[var(--design-editor-accent-color)]"
|
|
3296
4675
|
style={{
|
|
3297
|
-
left: SURFACE_PADDING +
|
|
3298
|
-
top: SURFACE_PADDING +
|
|
3299
|
-
width:
|
|
3300
|
-
height:
|
|
4676
|
+
left: SURFACE_PADDING + geometry.x,
|
|
4677
|
+
top: SURFACE_PADDING + geometry.y,
|
|
4678
|
+
width: geometry.width,
|
|
4679
|
+
height: geometry.height,
|
|
3301
4680
|
borderRadius: 13 * chromeScale,
|
|
3302
4681
|
borderWidth: 1.5 * chromeScale,
|
|
4682
|
+
transition: getSelectionBoxTransition(chromeSettling),
|
|
4683
|
+
transform: geometry.rotation
|
|
4684
|
+
? `rotate(${geometry.rotation}deg)`
|
|
4685
|
+
: undefined,
|
|
4686
|
+
transformOrigin: `${geometry.width / 2}px ${geometry.height / 2}px`,
|
|
4687
|
+
zIndex: 1_000_000,
|
|
3303
4688
|
}}
|
|
3304
4689
|
>
|
|
3305
4690
|
<ResizeHandles
|
|
3306
4691
|
active
|
|
3307
4692
|
enabled
|
|
3308
|
-
showRotate={
|
|
4693
|
+
showRotate={showRotate}
|
|
3309
4694
|
chromeScale={chromeScale}
|
|
4695
|
+
chromeSettling={chromeSettling}
|
|
3310
4696
|
onStartResize={onStartResize}
|
|
3311
|
-
onStartRotate={
|
|
4697
|
+
onStartRotate={onStartRotate}
|
|
3312
4698
|
/>
|
|
3313
4699
|
</div>
|
|
3314
4700
|
);
|
|
@@ -3317,15 +4703,19 @@ function GroupSelectionBox({
|
|
|
3317
4703
|
function ResizeHandles({
|
|
3318
4704
|
active,
|
|
3319
4705
|
enabled,
|
|
4706
|
+
showOnHover = true,
|
|
3320
4707
|
showRotate = true,
|
|
3321
4708
|
chromeScale = 1,
|
|
4709
|
+
chromeSettling = false,
|
|
3322
4710
|
onStartResize,
|
|
3323
4711
|
onStartRotate,
|
|
3324
4712
|
}: {
|
|
3325
4713
|
active: boolean;
|
|
3326
4714
|
enabled: boolean;
|
|
4715
|
+
showOnHover?: boolean;
|
|
3327
4716
|
showRotate?: boolean;
|
|
3328
4717
|
chromeScale?: number;
|
|
4718
|
+
chromeSettling?: boolean;
|
|
3329
4719
|
onStartResize: (handle: ResizeHandle, e: React.MouseEvent) => void;
|
|
3330
4720
|
onStartRotate: (e: React.MouseEvent) => void;
|
|
3331
4721
|
}) {
|
|
@@ -3335,7 +4725,11 @@ function ResizeHandles({
|
|
|
3335
4725
|
"pointer-events-auto absolute z-20 rounded-[2px] border border-[var(--design-editor-accent-color)] bg-[var(--design-editor-accent-contrast-color)] shadow transition-opacity",
|
|
3336
4726
|
active
|
|
3337
4727
|
? "opacity-100"
|
|
3338
|
-
:
|
|
4728
|
+
: cn(
|
|
4729
|
+
"opacity-0",
|
|
4730
|
+
showOnHover &&
|
|
4731
|
+
"group-hover/artboard:opacity-100 group-focus-visible/artboard:opacity-100",
|
|
4732
|
+
),
|
|
3339
4733
|
);
|
|
3340
4734
|
const edgeHandleClass =
|
|
3341
4735
|
"pointer-events-auto absolute z-10 bg-transparent opacity-0";
|
|
@@ -3345,18 +4739,28 @@ function ResizeHandles({
|
|
|
3345
4739
|
{EDGE_RESIZE_HANDLE_CONFIGS.map((config) => (
|
|
3346
4740
|
<span
|
|
3347
4741
|
key={config.handle}
|
|
3348
|
-
data-resize-handle
|
|
4742
|
+
data-resize-handle={config.handle}
|
|
3349
4743
|
className={edgeHandleClass}
|
|
3350
|
-
style={edgeHandleStyle(
|
|
4744
|
+
style={edgeHandleStyle(
|
|
4745
|
+
config.handle,
|
|
4746
|
+
config.cursor,
|
|
4747
|
+
chromeScale,
|
|
4748
|
+
chromeSettling,
|
|
4749
|
+
)}
|
|
3351
4750
|
onMouseDown={(e) => onStartResize(config.handle, e)}
|
|
3352
4751
|
/>
|
|
3353
4752
|
))}
|
|
3354
4753
|
{CORNER_RESIZE_HANDLE_CONFIGS.map((config) => (
|
|
3355
4754
|
<span
|
|
3356
4755
|
key={config.handle}
|
|
3357
|
-
data-resize-handle
|
|
4756
|
+
data-resize-handle={config.handle}
|
|
3358
4757
|
className={visibleHandleClass}
|
|
3359
|
-
style={cornerHandleStyle(
|
|
4758
|
+
style={cornerHandleStyle(
|
|
4759
|
+
config.handle,
|
|
4760
|
+
config.cursor,
|
|
4761
|
+
chromeScale,
|
|
4762
|
+
chromeSettling,
|
|
4763
|
+
)}
|
|
3360
4764
|
onMouseDown={(e) => onStartResize(config.handle, e)}
|
|
3361
4765
|
/>
|
|
3362
4766
|
))}
|
|
@@ -3369,9 +4773,17 @@ function ResizeHandles({
|
|
|
3369
4773
|
"pointer-events-auto absolute z-10 size-5 rounded-full transition-opacity active:cursor-grabbing",
|
|
3370
4774
|
active
|
|
3371
4775
|
? "opacity-100"
|
|
3372
|
-
:
|
|
4776
|
+
: cn(
|
|
4777
|
+
"opacity-0",
|
|
4778
|
+
showOnHover &&
|
|
4779
|
+
"group-hover/artboard:opacity-100 group-focus-visible/artboard:opacity-100",
|
|
4780
|
+
),
|
|
4781
|
+
)}
|
|
4782
|
+
style={rotateHandleStyle(
|
|
4783
|
+
config.corner,
|
|
4784
|
+
chromeScale,
|
|
4785
|
+
chromeSettling,
|
|
3373
4786
|
)}
|
|
3374
|
-
style={rotateHandleStyle(config.corner, chromeScale)}
|
|
3375
4787
|
onMouseDown={onStartRotate}
|
|
3376
4788
|
/>
|
|
3377
4789
|
))
|
|
@@ -3413,12 +4825,14 @@ function edgeHandleStyle(
|
|
|
3413
4825
|
handle: ResizeHandle,
|
|
3414
4826
|
cursor: string,
|
|
3415
4827
|
chromeScale: number,
|
|
4828
|
+
chromeSettling: boolean,
|
|
3416
4829
|
): CSSProperties {
|
|
3417
4830
|
const size = 14 * chromeScale;
|
|
3418
4831
|
const offset = -size / 2;
|
|
3419
4832
|
if (handle === "n" || handle === "s") {
|
|
3420
4833
|
return {
|
|
3421
4834
|
cursor,
|
|
4835
|
+
transition: getChromeHandleTransition(chromeSettling),
|
|
3422
4836
|
left: 0,
|
|
3423
4837
|
right: 0,
|
|
3424
4838
|
height: size,
|
|
@@ -3427,6 +4841,7 @@ function edgeHandleStyle(
|
|
|
3427
4841
|
}
|
|
3428
4842
|
return {
|
|
3429
4843
|
cursor,
|
|
4844
|
+
transition: getChromeHandleTransition(chromeSettling),
|
|
3430
4845
|
top: 0,
|
|
3431
4846
|
bottom: 0,
|
|
3432
4847
|
width: size,
|
|
@@ -3438,11 +4853,13 @@ function cornerHandleStyle(
|
|
|
3438
4853
|
handle: ResizeHandle,
|
|
3439
4854
|
cursor: string,
|
|
3440
4855
|
chromeScale: number,
|
|
4856
|
+
chromeSettling: boolean,
|
|
3441
4857
|
): CSSProperties {
|
|
3442
4858
|
const size = 10 * chromeScale;
|
|
3443
4859
|
const offset = -size / 2;
|
|
3444
4860
|
return {
|
|
3445
4861
|
cursor,
|
|
4862
|
+
transition: getChromeHandleTransition(chromeSettling),
|
|
3446
4863
|
width: size,
|
|
3447
4864
|
height: size,
|
|
3448
4865
|
borderWidth: Math.max(1, 1.25 * chromeScale),
|
|
@@ -3451,11 +4868,16 @@ function cornerHandleStyle(
|
|
|
3451
4868
|
};
|
|
3452
4869
|
}
|
|
3453
4870
|
|
|
3454
|
-
function rotateHandleStyle(
|
|
4871
|
+
function rotateHandleStyle(
|
|
4872
|
+
corner: string,
|
|
4873
|
+
chromeScale: number,
|
|
4874
|
+
chromeSettling: boolean,
|
|
4875
|
+
): CSSProperties {
|
|
3455
4876
|
const size = 28 * chromeScale;
|
|
3456
4877
|
const offset = -34 * chromeScale;
|
|
3457
4878
|
return {
|
|
3458
4879
|
cursor: "grab",
|
|
4880
|
+
transition: getChromeHandleTransition(chromeSettling),
|
|
3459
4881
|
width: size,
|
|
3460
4882
|
height: size,
|
|
3461
4883
|
...(corner.includes("n") ? { top: offset } : { bottom: offset }),
|
|
@@ -3475,7 +4897,15 @@ interface BoundsRect {
|
|
|
3475
4897
|
bottom: number;
|
|
3476
4898
|
}
|
|
3477
4899
|
|
|
3478
|
-
|
|
4900
|
+
interface ScreenViewportSize {
|
|
4901
|
+
width: number;
|
|
4902
|
+
height: number;
|
|
4903
|
+
}
|
|
4904
|
+
|
|
4905
|
+
function getInitialFrameGeometry(
|
|
4906
|
+
index: number,
|
|
4907
|
+
metadata?: ScreenViewportSize,
|
|
4908
|
+
): FrameGeometry {
|
|
3479
4909
|
// Seed default frames with the actual screen dimensions and the 3-column grid
|
|
3480
4910
|
// the overview centering math (which uses SCREEN_WIDTH/SCREEN_GAP) expects, so
|
|
3481
4911
|
// a design without persisted geometry opens centered. (The larger
|
|
@@ -3483,22 +4913,80 @@ function getInitialFrameGeometry(index: number): FrameGeometry {
|
|
|
3483
4913
|
// editor's default placement.)
|
|
3484
4914
|
const column = index % 3;
|
|
3485
4915
|
const row = Math.floor(index / 3);
|
|
4916
|
+
const height = getOverviewFrameHeight(SCREEN_WIDTH, metadata);
|
|
3486
4917
|
return {
|
|
3487
4918
|
x: column * (SCREEN_WIDTH + SCREEN_GAP),
|
|
3488
|
-
y: row * (
|
|
4919
|
+
y: row * (height + FRAME_LABEL_HEIGHT + SCREEN_GAP),
|
|
3489
4920
|
width: SCREEN_WIDTH,
|
|
3490
|
-
height
|
|
4921
|
+
height,
|
|
3491
4922
|
};
|
|
3492
4923
|
}
|
|
3493
4924
|
|
|
3494
|
-
function
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
4925
|
+
function getOverviewFrameHeight(width: number, metadata?: ScreenViewportSize) {
|
|
4926
|
+
const sourceWidth =
|
|
4927
|
+
metadata?.width && metadata.width > 0 ? metadata.width : 1280;
|
|
4928
|
+
const sourceHeight =
|
|
4929
|
+
metadata?.height && metadata.height > 0 ? metadata.height : 2560;
|
|
4930
|
+
return Math.max(80, Math.round((width * sourceHeight) / sourceWidth));
|
|
4931
|
+
}
|
|
4932
|
+
|
|
4933
|
+
export function getPreviewDeviceFrameGeometry({
|
|
4934
|
+
currentGeometry,
|
|
4935
|
+
metadata,
|
|
4936
|
+
previewDeviceFrame,
|
|
4937
|
+
}: {
|
|
4938
|
+
currentGeometry: FrameGeometry;
|
|
4939
|
+
metadata?: { width: number; height: number };
|
|
4940
|
+
previewDeviceFrame: DeviceFrameType;
|
|
4941
|
+
}): FrameGeometry {
|
|
4942
|
+
if (previewDeviceFrame === "none") {
|
|
4943
|
+
return {
|
|
4944
|
+
...currentGeometry,
|
|
4945
|
+
height: getOverviewFrameHeight(currentGeometry.width, metadata),
|
|
4946
|
+
};
|
|
4947
|
+
}
|
|
4948
|
+
|
|
4949
|
+
const viewport = DEVICE_FRAME_VIEWPORTS[previewDeviceFrame];
|
|
4950
|
+
return {
|
|
4951
|
+
...currentGeometry,
|
|
4952
|
+
width: Math.max(1, Math.round(metadata?.width ?? viewport.width)),
|
|
4953
|
+
height: Math.max(1, Math.round(metadata?.height ?? viewport.height)),
|
|
4954
|
+
};
|
|
4955
|
+
}
|
|
4956
|
+
|
|
4957
|
+
function getScreenPreviewViewport(
|
|
4958
|
+
metadata: ScreenViewportSize,
|
|
4959
|
+
geometry: ScreenViewportSize,
|
|
4960
|
+
) {
|
|
4961
|
+
const metadataWidth = Math.max(1, Math.round(metadata.width));
|
|
4962
|
+
const metadataHeight = Math.max(1, Math.round(metadata.height));
|
|
4963
|
+
const geometryWidth = Math.max(1, Math.round(geometry.width));
|
|
4964
|
+
const geometryHeight = Math.max(1, Math.round(geometry.height));
|
|
4965
|
+
const metadataAspect = metadataWidth / metadataHeight;
|
|
4966
|
+
const geometryAspect = geometryWidth / geometryHeight;
|
|
4967
|
+
const aspectMatches = Math.abs(metadataAspect - geometryAspect) < 0.005;
|
|
4968
|
+
|
|
4969
|
+
if (aspectMatches) {
|
|
4970
|
+
return {
|
|
4971
|
+
viewportWidth: metadataWidth,
|
|
4972
|
+
viewportHeight: metadataHeight,
|
|
4973
|
+
displayWidth: metadataWidth,
|
|
4974
|
+
displayHeight: metadataHeight,
|
|
4975
|
+
scale:
|
|
4976
|
+
Math.abs(metadataWidth - geometryWidth) < 0.5 &&
|
|
4977
|
+
Math.abs(metadataHeight - geometryHeight) < 0.5
|
|
4978
|
+
? 1
|
|
4979
|
+
: geometryWidth / metadataWidth,
|
|
4980
|
+
};
|
|
4981
|
+
}
|
|
4982
|
+
|
|
4983
|
+
return {
|
|
4984
|
+
viewportWidth: geometryWidth,
|
|
4985
|
+
viewportHeight: geometryHeight,
|
|
4986
|
+
displayWidth: geometryWidth,
|
|
4987
|
+
displayHeight: geometryHeight,
|
|
4988
|
+
scale: 1,
|
|
4989
|
+
};
|
|
3502
4990
|
}
|
|
3503
4991
|
|
|
3504
4992
|
function dedupeIds(ids: string[]) {
|
|
@@ -3556,6 +5044,22 @@ function isInteractiveScreenContentTarget(target: EventTarget | null) {
|
|
|
3556
5044
|
);
|
|
3557
5045
|
}
|
|
3558
5046
|
|
|
5047
|
+
function mutePreviewIframePointerEvents(root: HTMLElement | null) {
|
|
5048
|
+
if (!root) return () => {};
|
|
5049
|
+
const previous = new Map<HTMLIFrameElement, string>();
|
|
5050
|
+
root
|
|
5051
|
+
.querySelectorAll<HTMLIFrameElement>("[data-design-preview-iframe]")
|
|
5052
|
+
.forEach((iframe) => {
|
|
5053
|
+
previous.set(iframe, iframe.style.pointerEvents);
|
|
5054
|
+
iframe.style.pointerEvents = "none";
|
|
5055
|
+
});
|
|
5056
|
+
return () => {
|
|
5057
|
+
previous.forEach((pointerEvents, iframe) => {
|
|
5058
|
+
if (iframe.isConnected) iframe.style.pointerEvents = pointerEvents;
|
|
5059
|
+
});
|
|
5060
|
+
};
|
|
5061
|
+
}
|
|
5062
|
+
|
|
3559
5063
|
function frameBoundsToGeometry(bounds: {
|
|
3560
5064
|
left: number;
|
|
3561
5065
|
top: number;
|
|
@@ -3697,6 +5201,20 @@ function sameFrameGeometry(a: FrameGeometry, b: FrameGeometry) {
|
|
|
3697
5201
|
);
|
|
3698
5202
|
}
|
|
3699
5203
|
|
|
5204
|
+
function sameResolvedMetadata(
|
|
5205
|
+
a: ResolvedScreenMetadata,
|
|
5206
|
+
b: ResolvedScreenMetadata,
|
|
5207
|
+
) {
|
|
5208
|
+
return (
|
|
5209
|
+
a.source === b.source &&
|
|
5210
|
+
a.previewState === b.previewState &&
|
|
5211
|
+
a.title === b.title &&
|
|
5212
|
+
a.width === b.width &&
|
|
5213
|
+
a.height === b.height &&
|
|
5214
|
+
a.previewUrl === b.previewUrl
|
|
5215
|
+
);
|
|
5216
|
+
}
|
|
5217
|
+
|
|
3700
5218
|
function cloneFrameGeometryById(
|
|
3701
5219
|
geometryById: FrameGeometryById,
|
|
3702
5220
|
): FrameGeometryById {
|
|
@@ -4124,24 +5642,37 @@ function resolveScreenMetadata(
|
|
|
4124
5642
|
screen: ScreenFile,
|
|
4125
5643
|
keyedMetadata?: ScreenMetadata,
|
|
4126
5644
|
getterMetadata?: ScreenMetadata,
|
|
5645
|
+
previewDeviceFrame: DeviceFrameType = "none",
|
|
4127
5646
|
): ResolvedScreenMetadata {
|
|
4128
|
-
|
|
5647
|
+
// Normalize content to a string up front: a screen whose content has not yet
|
|
5648
|
+
// loaded (or is otherwise not a plain string) must not crash the overview
|
|
5649
|
+
// render via the content.trim()/slice() helpers below.
|
|
5650
|
+
const safeScreen: ScreenFile =
|
|
5651
|
+
typeof screen.content === "string" ? screen : { ...screen, content: "" };
|
|
5652
|
+
const metadata = { ...safeScreen, ...keyedMetadata, ...getterMetadata };
|
|
4129
5653
|
const previewUrl =
|
|
4130
5654
|
metadata.url ??
|
|
4131
5655
|
metadata.previewUrl ??
|
|
4132
|
-
|
|
4133
|
-
getPreviewUrl(
|
|
4134
|
-
const
|
|
5656
|
+
safeScreen.previewUrl ??
|
|
5657
|
+
getPreviewUrl(safeScreen.content);
|
|
5658
|
+
const deviceViewport =
|
|
5659
|
+
previewDeviceFrame === "none"
|
|
5660
|
+
? undefined
|
|
5661
|
+
: DEVICE_FRAME_VIEWPORTS[previewDeviceFrame];
|
|
5662
|
+
const width =
|
|
5663
|
+
deviceViewport?.width ??
|
|
5664
|
+
(metadata.width && metadata.width > 0 ? metadata.width : 1280);
|
|
4135
5665
|
const height =
|
|
4136
|
-
|
|
5666
|
+
deviceViewport?.height ??
|
|
5667
|
+
(metadata.height && metadata.height > 0 ? metadata.height : 2560);
|
|
4137
5668
|
return {
|
|
4138
5669
|
source:
|
|
4139
5670
|
normalizeSource(metadata.sourceType ?? metadata.source) ??
|
|
4140
|
-
deriveSource(
|
|
5671
|
+
deriveSource(safeScreen, previewUrl),
|
|
4141
5672
|
previewState:
|
|
4142
5673
|
normalizePreviewState(
|
|
4143
5674
|
metadata.lod ?? metadata.previewState ?? metadata.status,
|
|
4144
|
-
) ?? derivePreviewState(
|
|
5675
|
+
) ?? derivePreviewState(safeScreen, previewUrl),
|
|
4145
5676
|
title: metadata.title,
|
|
4146
5677
|
width,
|
|
4147
5678
|
height,
|
|
@@ -4217,7 +5748,11 @@ function derivePreviewState(
|
|
|
4217
5748
|
}
|
|
4218
5749
|
|
|
4219
5750
|
function getPreviewUrl(content: string) {
|
|
4220
|
-
|
|
5751
|
+
// Tolerate non-string content (e.g. a screen whose content has not loaded):
|
|
5752
|
+
// a missing URL is correct here, a crash is not.
|
|
5753
|
+
return getUrl(
|
|
5754
|
+
typeof content === "string" ? content.trim() : undefined,
|
|
5755
|
+
)?.toString();
|
|
4221
5756
|
}
|
|
4222
5757
|
|
|
4223
5758
|
function getUrl(value: string | undefined) {
|
|
@@ -4231,3 +5766,276 @@ function getUrl(value: string | undefined) {
|
|
|
4231
5766
|
return undefined;
|
|
4232
5767
|
}
|
|
4233
5768
|
}
|
|
5769
|
+
|
|
5770
|
+
function clampNumber(value: number, min: number, max: number) {
|
|
5771
|
+
return Math.min(max, Math.max(min, value));
|
|
5772
|
+
}
|
|
5773
|
+
|
|
5774
|
+
// ---------------------------------------------------------------------------
|
|
5775
|
+
// Primitive-into-primitive drop target detection
|
|
5776
|
+
// ---------------------------------------------------------------------------
|
|
5777
|
+
|
|
5778
|
+
/** A committed container primitive that can accept a dropped primitive. */
|
|
5779
|
+
export interface PrimitiveDropTarget {
|
|
5780
|
+
/** The node's data-agent-native-node-id value. */
|
|
5781
|
+
nodeId: string;
|
|
5782
|
+
/** The screen frame id (ScreenFile.id) that owns this primitive. */
|
|
5783
|
+
screenId: string;
|
|
5784
|
+
/** The primitive's bounding rect in board/canvas space. */
|
|
5785
|
+
boardRect: FrameGeometry;
|
|
5786
|
+
}
|
|
5787
|
+
|
|
5788
|
+
/**
|
|
5789
|
+
* Parsed representation of a committed primitive found in a screen's HTML.
|
|
5790
|
+
* Geometry is in screen-local CSS pixels (as written by appendCanvasPrimitiveToHtml).
|
|
5791
|
+
*/
|
|
5792
|
+
export interface ParsedScreenPrimitive {
|
|
5793
|
+
nodeId: string;
|
|
5794
|
+
screenId: string;
|
|
5795
|
+
/** Position relative to screen body (CSS px). */
|
|
5796
|
+
localLeft: number;
|
|
5797
|
+
localTop: number;
|
|
5798
|
+
localWidth: number;
|
|
5799
|
+
localHeight: number;
|
|
5800
|
+
isContainer: boolean;
|
|
5801
|
+
}
|
|
5802
|
+
|
|
5803
|
+
/** Fast djb2-variant hash of a string. Runs in O(n) but is inlined for the
|
|
5804
|
+
* JIT — no allocations, no imports. Produces a 32-bit unsigned integer as a
|
|
5805
|
+
* hex string. Used only for cache-key disambiguation, not cryptography. */
|
|
5806
|
+
function hashString(s: string): string {
|
|
5807
|
+
let h = 5381;
|
|
5808
|
+
for (let i = 0; i < s.length; i++) {
|
|
5809
|
+
// h = h * 33 ^ charCode (djb2 xor variant)
|
|
5810
|
+
h = ((h << 5) + h) ^ s.charCodeAt(i);
|
|
5811
|
+
h = h >>> 0; // keep as unsigned 32-bit
|
|
5812
|
+
}
|
|
5813
|
+
return h.toString(16);
|
|
5814
|
+
}
|
|
5815
|
+
|
|
5816
|
+
/** Simple LRU-style cache to avoid re-parsing the same screen HTML on every
|
|
5817
|
+
* mousemove frame. Keyed by `screenId:contentLength:hash` so that ANY edit
|
|
5818
|
+
* (including changes deep in the middle of a large HTML document) invalidates
|
|
5819
|
+
* the entry. A length:hash pair eliminates the collision zone that existed
|
|
5820
|
+
* when only prefix48+suffix48 were used — edits in `content[48..len-49]` would
|
|
5821
|
+
* produce the same key even though the content differed. */
|
|
5822
|
+
export const primitiveParseCache = new Map<string, ParsedScreenPrimitive[]>();
|
|
5823
|
+
const PRIMITIVE_PARSE_CACHE_MAX = 64;
|
|
5824
|
+
|
|
5825
|
+
export function parsePrimitivesFromScreen(
|
|
5826
|
+
screen: ScreenFile,
|
|
5827
|
+
): ParsedScreenPrimitive[] {
|
|
5828
|
+
const cacheKey = `${screen.id}:${screen.content.length}:${hashString(screen.content)}`;
|
|
5829
|
+
const cached = primitiveParseCache.get(cacheKey);
|
|
5830
|
+
if (cached) return cached;
|
|
5831
|
+
|
|
5832
|
+
const result: ParsedScreenPrimitive[] = [];
|
|
5833
|
+
if (typeof DOMParser === "undefined" || !screen.content) {
|
|
5834
|
+
return result;
|
|
5835
|
+
}
|
|
5836
|
+
|
|
5837
|
+
try {
|
|
5838
|
+
const doc = new DOMParser().parseFromString(screen.content, "text/html");
|
|
5839
|
+
const nodes = doc.querySelectorAll("[data-agent-native-node-id]");
|
|
5840
|
+
nodes.forEach((el) => {
|
|
5841
|
+
const nodeId = el.getAttribute("data-agent-native-node-id");
|
|
5842
|
+
if (!nodeId) return;
|
|
5843
|
+
|
|
5844
|
+
const htmlEl = el as HTMLElement;
|
|
5845
|
+
const style = htmlEl.style;
|
|
5846
|
+
const tag = el.tagName.toLowerCase();
|
|
5847
|
+
|
|
5848
|
+
// Only block elements with explicit absolute positioning are considered
|
|
5849
|
+
// positioned primitives drawn by appendCanvasPrimitiveToHtml.
|
|
5850
|
+
if (style.position !== "absolute") return;
|
|
5851
|
+
|
|
5852
|
+
const left = parseFloat(style.left) || 0;
|
|
5853
|
+
const top = parseFloat(style.top) || 0;
|
|
5854
|
+
const width = parseFloat(style.width) || 0;
|
|
5855
|
+
const height = parseFloat(style.height) || 0;
|
|
5856
|
+
|
|
5857
|
+
// Validity: must have non-zero size
|
|
5858
|
+
if (width <= 0 || height <= 0) return;
|
|
5859
|
+
|
|
5860
|
+
// Container check:
|
|
5861
|
+
// - Must be a div (not svg/path/circle/polygon/etc.)
|
|
5862
|
+
// - Must NOT be an ellipse (border-radius:50%)
|
|
5863
|
+
// - NOT a text primitive (but text divs could be containers in principle;
|
|
5864
|
+
// we exclude them by checking for display:inline-block autoSize pattern)
|
|
5865
|
+
const isDiv = tag === "div";
|
|
5866
|
+
const isEllipse =
|
|
5867
|
+
style.borderRadius === "50%" ||
|
|
5868
|
+
style.borderRadius === "50% 50% 50% 50%";
|
|
5869
|
+
const isTextAutoSize = style.display === "inline-block";
|
|
5870
|
+
const isContainer = isDiv && !isEllipse && !isTextAutoSize;
|
|
5871
|
+
|
|
5872
|
+
result.push({
|
|
5873
|
+
nodeId,
|
|
5874
|
+
screenId: screen.id,
|
|
5875
|
+
localLeft: left,
|
|
5876
|
+
localTop: top,
|
|
5877
|
+
localWidth: width,
|
|
5878
|
+
localHeight: height,
|
|
5879
|
+
isContainer,
|
|
5880
|
+
});
|
|
5881
|
+
});
|
|
5882
|
+
} catch {
|
|
5883
|
+
// Silently ignore parse errors
|
|
5884
|
+
}
|
|
5885
|
+
|
|
5886
|
+
if (primitiveParseCache.size >= PRIMITIVE_PARSE_CACHE_MAX) {
|
|
5887
|
+
// Evict the oldest entry
|
|
5888
|
+
const firstKey = primitiveParseCache.keys().next().value;
|
|
5889
|
+
if (firstKey !== undefined) primitiveParseCache.delete(firstKey);
|
|
5890
|
+
}
|
|
5891
|
+
primitiveParseCache.set(cacheKey, result);
|
|
5892
|
+
return result;
|
|
5893
|
+
}
|
|
5894
|
+
|
|
5895
|
+
/**
|
|
5896
|
+
* Convert a screen-local primitive rect to board/canvas coordinates.
|
|
5897
|
+
*
|
|
5898
|
+
* appendCanvasPrimitiveToHtml stores positions in screen-local CSS pixels
|
|
5899
|
+
* scaled from the board draft geometry:
|
|
5900
|
+
* localX = (boardX - frame.x) * (metadata.width / frame.width)
|
|
5901
|
+
* Inverting:
|
|
5902
|
+
* boardX = frame.x + localX * (frame.width / metadata.width)
|
|
5903
|
+
*/
|
|
5904
|
+
export function primitiveLocalToBoardRect(
|
|
5905
|
+
localLeft: number,
|
|
5906
|
+
localTop: number,
|
|
5907
|
+
localWidth: number,
|
|
5908
|
+
localHeight: number,
|
|
5909
|
+
frameGeometry: FrameGeometry,
|
|
5910
|
+
metadata: { width: number; height: number },
|
|
5911
|
+
): FrameGeometry {
|
|
5912
|
+
const scaleX = frameGeometry.width / Math.max(1, metadata.width);
|
|
5913
|
+
const scaleY = frameGeometry.height / Math.max(1, metadata.height);
|
|
5914
|
+
return {
|
|
5915
|
+
x: frameGeometry.x + localLeft * scaleX,
|
|
5916
|
+
y: frameGeometry.y + localTop * scaleY,
|
|
5917
|
+
width: Math.max(1, localWidth * scaleX),
|
|
5918
|
+
height: Math.max(1, localHeight * scaleY),
|
|
5919
|
+
};
|
|
5920
|
+
}
|
|
5921
|
+
|
|
5922
|
+
/**
|
|
5923
|
+
* Find the topmost committed container primitive at `point` (canvas coords),
|
|
5924
|
+
* excluding `draggedNodeId` and any of its descendants.
|
|
5925
|
+
*
|
|
5926
|
+
* Descendants are detected geometrically: a primitive whose board rect is
|
|
5927
|
+
* fully enclosed by the dragged node's board rect is treated as a descendant
|
|
5928
|
+
* and excluded. This avoids a circular parent-child relationship on drop.
|
|
5929
|
+
*
|
|
5930
|
+
* Returns null if no valid target found.
|
|
5931
|
+
*/
|
|
5932
|
+
export function getPrimitiveDropTargetForPoint(
|
|
5933
|
+
point: Point,
|
|
5934
|
+
draggedNodeId: string | null,
|
|
5935
|
+
screens: ScreenFile[],
|
|
5936
|
+
frameGeometryById: FrameGeometryById,
|
|
5937
|
+
getMetadata: (screen: ScreenFile) => { width: number; height: number },
|
|
5938
|
+
): PrimitiveDropTarget | null {
|
|
5939
|
+
// Pre-compute the dragged node's board rect so we can exclude its descendants.
|
|
5940
|
+
let draggedBoardRect: FrameGeometry | null = null;
|
|
5941
|
+
if (draggedNodeId) {
|
|
5942
|
+
outer: for (const screen of screens) {
|
|
5943
|
+
const frameGeometry = frameGeometryById[screen.id];
|
|
5944
|
+
if (!frameGeometry) continue;
|
|
5945
|
+
const metadata = getMetadata(screen);
|
|
5946
|
+
const primitives = parsePrimitivesFromScreen(screen);
|
|
5947
|
+
for (const prim of primitives) {
|
|
5948
|
+
if (prim.nodeId === draggedNodeId) {
|
|
5949
|
+
draggedBoardRect = primitiveLocalToBoardRect(
|
|
5950
|
+
prim.localLeft,
|
|
5951
|
+
prim.localTop,
|
|
5952
|
+
prim.localWidth,
|
|
5953
|
+
prim.localHeight,
|
|
5954
|
+
frameGeometry,
|
|
5955
|
+
metadata,
|
|
5956
|
+
);
|
|
5957
|
+
break outer;
|
|
5958
|
+
}
|
|
5959
|
+
}
|
|
5960
|
+
}
|
|
5961
|
+
}
|
|
5962
|
+
|
|
5963
|
+
let best: PrimitiveDropTarget | null = null;
|
|
5964
|
+
|
|
5965
|
+
for (const screen of screens) {
|
|
5966
|
+
const frameGeometry = frameGeometryById[screen.id];
|
|
5967
|
+
if (!frameGeometry) continue;
|
|
5968
|
+
|
|
5969
|
+
const frameBounds = {
|
|
5970
|
+
left: frameGeometry.x,
|
|
5971
|
+
top: frameGeometry.y,
|
|
5972
|
+
right: frameGeometry.x + frameGeometry.width,
|
|
5973
|
+
bottom: frameGeometry.y + frameGeometry.height,
|
|
5974
|
+
};
|
|
5975
|
+
if (!rectContainsPoint(frameBounds, point)) {
|
|
5976
|
+
continue;
|
|
5977
|
+
}
|
|
5978
|
+
|
|
5979
|
+
const metadata = getMetadata(screen);
|
|
5980
|
+
const primitives = parsePrimitivesFromScreen(screen);
|
|
5981
|
+
|
|
5982
|
+
for (const prim of primitives) {
|
|
5983
|
+
if (!prim.isContainer) continue;
|
|
5984
|
+
if (draggedNodeId && prim.nodeId === draggedNodeId) continue;
|
|
5985
|
+
|
|
5986
|
+
const boardRect = primitiveLocalToBoardRect(
|
|
5987
|
+
prim.localLeft,
|
|
5988
|
+
prim.localTop,
|
|
5989
|
+
prim.localWidth,
|
|
5990
|
+
prim.localHeight,
|
|
5991
|
+
frameGeometry,
|
|
5992
|
+
metadata,
|
|
5993
|
+
);
|
|
5994
|
+
|
|
5995
|
+
// Exclude geometric descendants: a primitive whose board rect is fully
|
|
5996
|
+
// contained within the dragged node's board rect is a child/descendant
|
|
5997
|
+
// and cannot be a valid reparent target (would create a cycle).
|
|
5998
|
+
if (
|
|
5999
|
+
draggedBoardRect &&
|
|
6000
|
+
boardRect.x >= draggedBoardRect.x &&
|
|
6001
|
+
boardRect.y >= draggedBoardRect.y &&
|
|
6002
|
+
boardRect.x + boardRect.width <=
|
|
6003
|
+
draggedBoardRect.x + draggedBoardRect.width &&
|
|
6004
|
+
boardRect.y + boardRect.height <=
|
|
6005
|
+
draggedBoardRect.y + draggedBoardRect.height
|
|
6006
|
+
) {
|
|
6007
|
+
continue;
|
|
6008
|
+
}
|
|
6009
|
+
|
|
6010
|
+
if (
|
|
6011
|
+
point.x >= boardRect.x &&
|
|
6012
|
+
point.x <= boardRect.x + boardRect.width &&
|
|
6013
|
+
point.y >= boardRect.y &&
|
|
6014
|
+
point.y <= boardRect.y + boardRect.height
|
|
6015
|
+
) {
|
|
6016
|
+
// Later in the DOM = higher paint order = topmost visually.
|
|
6017
|
+
// We take the last match within each screen (DOM order).
|
|
6018
|
+
best = { nodeId: prim.nodeId, screenId: screen.id, boardRect };
|
|
6019
|
+
}
|
|
6020
|
+
}
|
|
6021
|
+
}
|
|
6022
|
+
|
|
6023
|
+
return best;
|
|
6024
|
+
}
|
|
6025
|
+
|
|
6026
|
+
/**
|
|
6027
|
+
* Resolve which screen (ScreenFile.id) owns a committed primitive nodeId by
|
|
6028
|
+
* scanning all screen HTML for the given data-agent-native-node-id value.
|
|
6029
|
+
*/
|
|
6030
|
+
export function resolveNodeScreenId(
|
|
6031
|
+
nodeId: string,
|
|
6032
|
+
screens: ScreenFile[],
|
|
6033
|
+
): string | null {
|
|
6034
|
+
for (const screen of screens) {
|
|
6035
|
+
const primitives = parsePrimitivesFromScreen(screen);
|
|
6036
|
+
if (primitives.some((p) => p.nodeId === nodeId)) {
|
|
6037
|
+
return screen.id;
|
|
6038
|
+
}
|
|
6039
|
+
}
|
|
6040
|
+
return null;
|
|
6041
|
+
}
|