@agent-native/core 0.85.6 → 0.86.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/corpus/README.md +2 -2
- package/corpus/core/CHANGELOG.md +130 -1
- package/corpus/core/README.md +0 -11
- package/corpus/core/docs/content/cloneable-saas.mdx +0 -1
- package/corpus/core/docs/content/locales/ar-SA/cloneable-saas.mdx +0 -1
- package/corpus/core/docs/content/locales/ar-SA/plan-plugin.mdx +10 -9
- package/corpus/core/docs/content/locales/ar-SA/sharing.mdx +1 -2
- package/corpus/core/docs/content/locales/ar-SA/skills-guide.mdx +2 -3
- package/corpus/core/docs/content/locales/ar-SA/template-plan.mdx +23 -7
- package/corpus/core/docs/content/locales/ar-SA/tracking.mdx +1 -1
- package/corpus/core/docs/content/locales/ar-SA/workspace-connections.mdx +11 -11
- package/corpus/core/docs/content/locales/de-DE/cloneable-saas.mdx +0 -1
- package/corpus/core/docs/content/locales/de-DE/plan-plugin.mdx +10 -9
- package/corpus/core/docs/content/locales/de-DE/sharing.mdx +1 -2
- package/corpus/core/docs/content/locales/de-DE/skills-guide.mdx +2 -3
- package/corpus/core/docs/content/locales/de-DE/template-plan.mdx +23 -7
- package/corpus/core/docs/content/locales/de-DE/tracking.mdx +1 -1
- package/corpus/core/docs/content/locales/de-DE/workspace-connections.mdx +1 -1
- package/corpus/core/docs/content/locales/es-ES/cloneable-saas.mdx +0 -1
- package/corpus/core/docs/content/locales/es-ES/plan-plugin.mdx +10 -9
- package/corpus/core/docs/content/locales/es-ES/sharing.mdx +1 -2
- package/corpus/core/docs/content/locales/es-ES/skills-guide.mdx +2 -3
- package/corpus/core/docs/content/locales/es-ES/template-plan.mdx +23 -7
- package/corpus/core/docs/content/locales/es-ES/tracking.mdx +1 -1
- package/corpus/core/docs/content/locales/es-ES/workspace-connections.mdx +1 -1
- package/corpus/core/docs/content/locales/fr-FR/cloneable-saas.mdx +0 -1
- package/corpus/core/docs/content/locales/fr-FR/plan-plugin.mdx +10 -9
- package/corpus/core/docs/content/locales/fr-FR/sharing.mdx +1 -2
- package/corpus/core/docs/content/locales/fr-FR/skills-guide.mdx +2 -3
- package/corpus/core/docs/content/locales/fr-FR/template-plan.mdx +23 -7
- package/corpus/core/docs/content/locales/fr-FR/tracking.mdx +1 -1
- package/corpus/core/docs/content/locales/fr-FR/workspace-connections.mdx +1 -1
- package/corpus/core/docs/content/locales/hi-IN/cloneable-saas.mdx +0 -1
- package/corpus/core/docs/content/locales/hi-IN/plan-plugin.mdx +10 -9
- package/corpus/core/docs/content/locales/hi-IN/sharing.mdx +1 -2
- package/corpus/core/docs/content/locales/hi-IN/skills-guide.mdx +2 -3
- package/corpus/core/docs/content/locales/hi-IN/template-plan.mdx +23 -7
- package/corpus/core/docs/content/locales/hi-IN/tracking.mdx +1 -1
- package/corpus/core/docs/content/locales/hi-IN/workspace-connections.mdx +1 -1
- package/corpus/core/docs/content/locales/ja-JP/cloneable-saas.mdx +0 -1
- package/corpus/core/docs/content/locales/ja-JP/plan-plugin.mdx +10 -9
- package/corpus/core/docs/content/locales/ja-JP/sharing.mdx +1 -2
- package/corpus/core/docs/content/locales/ja-JP/skills-guide.mdx +2 -3
- package/corpus/core/docs/content/locales/ja-JP/template-plan.mdx +23 -7
- package/corpus/core/docs/content/locales/ja-JP/tracking.mdx +1 -1
- package/corpus/core/docs/content/locales/ja-JP/workspace-connections.mdx +1 -1
- package/corpus/core/docs/content/locales/ko-KR/cloneable-saas.mdx +0 -1
- package/corpus/core/docs/content/locales/ko-KR/plan-plugin.mdx +10 -9
- package/corpus/core/docs/content/locales/ko-KR/sharing.mdx +1 -2
- package/corpus/core/docs/content/locales/ko-KR/skills-guide.mdx +2 -3
- package/corpus/core/docs/content/locales/ko-KR/template-plan.mdx +23 -7
- package/corpus/core/docs/content/locales/ko-KR/tracking.mdx +1 -1
- package/corpus/core/docs/content/locales/ko-KR/workspace-connections.mdx +1 -1
- package/corpus/core/docs/content/locales/pt-BR/cloneable-saas.mdx +0 -1
- package/corpus/core/docs/content/locales/pt-BR/plan-plugin.mdx +10 -9
- package/corpus/core/docs/content/locales/pt-BR/sharing.mdx +1 -2
- package/corpus/core/docs/content/locales/pt-BR/skills-guide.mdx +2 -3
- package/corpus/core/docs/content/locales/pt-BR/template-plan.mdx +23 -7
- package/corpus/core/docs/content/locales/pt-BR/tracking.mdx +1 -1
- package/corpus/core/docs/content/locales/pt-BR/workspace-connections.mdx +1 -1
- package/corpus/core/docs/content/locales/zh-CN/cloneable-saas.mdx +0 -1
- package/corpus/core/docs/content/locales/zh-CN/plan-plugin.mdx +10 -9
- package/corpus/core/docs/content/locales/zh-CN/sharing.mdx +1 -2
- package/corpus/core/docs/content/locales/zh-CN/skills-guide.mdx +2 -3
- package/corpus/core/docs/content/locales/zh-CN/template-plan.mdx +23 -7
- package/corpus/core/docs/content/locales/zh-CN/tracking.mdx +1 -1
- package/corpus/core/docs/content/locales/zh-CN/workspace-connections.mdx +1 -1
- package/corpus/core/docs/content/locales/zh-TW/cloneable-saas.mdx +0 -1
- package/corpus/core/docs/content/locales/zh-TW/plan-plugin.mdx +10 -9
- package/corpus/core/docs/content/locales/zh-TW/sharing.mdx +1 -2
- package/corpus/core/docs/content/locales/zh-TW/skills-guide.mdx +2 -3
- package/corpus/core/docs/content/locales/zh-TW/template-plan.mdx +23 -7
- package/corpus/core/docs/content/locales/zh-TW/tracking.mdx +1 -1
- package/corpus/core/docs/content/locales/zh-TW/workspace-connections.mdx +1 -1
- package/corpus/core/docs/content/plan-plugin.mdx +12 -11
- package/corpus/core/docs/content/sharing.mdx +1 -2
- package/corpus/core/docs/content/skills-guide.mdx +4 -3
- package/corpus/core/docs/content/template-plan.mdx +37 -6
- package/corpus/core/docs/content/tracking.mdx +1 -1
- package/corpus/core/docs/content/workspace-connections.mdx +1 -1
- package/corpus/core/package.json +6 -6
- package/corpus/core/src/agent/run-store.ts +1 -1
- package/corpus/core/src/chat-threads/store.ts +73 -11
- package/corpus/core/src/checkpoints/store.ts +13 -2
- package/corpus/core/src/cli/create.ts +3 -4
- package/corpus/core/src/cli/design-connect.ts +469 -9
- package/corpus/core/src/cli/index.ts +13 -0
- package/corpus/core/src/cli/plan-local.ts +281 -24
- package/corpus/core/src/cli/skills.ts +177 -21
- package/corpus/core/src/cli/templates-meta.ts +0 -13
- package/corpus/core/src/cli/visualize-repo.ts +715 -0
- package/corpus/core/src/client/AssistantChat.tsx +126 -17
- package/corpus/core/src/client/agent-chat-adapter.ts +6 -5
- package/corpus/core/src/client/app-providers.tsx +2 -2
- package/corpus/core/src/client/collab/index.ts +31 -0
- package/corpus/core/src/client/components/LiveCursorOverlay.tsx +16 -1
- package/corpus/core/src/client/components/PresenceBar.tsx +14 -1
- package/corpus/core/src/client/components/RecentEditHighlights.tsx +219 -0
- package/corpus/core/src/client/components/RemoteSelectionRings.tsx +40 -6
- package/corpus/core/src/client/index.ts +48 -0
- package/corpus/core/src/client/org/OrgSwitcher.tsx +0 -4
- package/corpus/core/src/client/rich-markdown-editor/SharedRichEditor.tsx +49 -32
- package/corpus/core/src/client/rich-markdown-editor/index.ts +6 -0
- package/corpus/core/src/client/rich-markdown-editor/surgical-apply.ts +192 -0
- package/corpus/core/src/client/rich-markdown-editor/useCollabReconcile.ts +59 -8
- package/corpus/core/src/client/session-replay.ts +181 -15
- package/corpus/core/src/client/sse-event-processor.ts +90 -1
- package/corpus/core/src/client/use-action.ts +123 -25
- package/corpus/core/src/client/use-agent-engine-configured.ts +7 -1
- package/corpus/core/src/client/use-chat-threads.ts +18 -7
- package/corpus/core/src/client/use-db-sync.ts +104 -19
- package/corpus/core/src/client/use-pinch-zoom.ts +76 -11
- package/corpus/core/src/client/visual-style-controls.tsx +475 -0
- package/corpus/core/src/collab/agent-presence.ts +191 -62
- package/corpus/core/src/collab/awareness.ts +83 -6
- package/corpus/core/src/collab/client.ts +89 -146
- package/corpus/core/src/collab/index.ts +34 -0
- package/corpus/core/src/collab/presence.ts +63 -3
- package/corpus/core/src/collab/recent-edits.ts +171 -0
- package/corpus/core/src/collab/undo.ts +432 -0
- package/corpus/core/src/collab/ydoc-manager.ts +68 -0
- package/corpus/core/src/connections/catalog.ts +2 -3
- package/corpus/core/src/db/create-get-db.ts +86 -1
- package/corpus/core/src/db/ensure-additive-columns.ts +430 -0
- package/corpus/core/src/db/index.ts +6 -0
- package/corpus/core/src/db/migrations.ts +236 -35
- package/corpus/core/src/jobs/scheduler.ts +17 -14
- package/corpus/core/src/observability/traces.ts +182 -1
- package/corpus/core/src/org/context.ts +79 -2
- package/corpus/core/src/server/agent-chat-plugin.ts +10 -6
- package/corpus/core/src/server/auth-marketing.ts +0 -11
- package/corpus/core/src/server/auth.ts +11 -4
- package/corpus/core/src/server/collab-plugin.ts +56 -13
- package/corpus/core/src/server/design-token-utils.ts +1 -1
- package/corpus/core/src/server/fusion-app.ts +447 -0
- package/corpus/core/src/server/index.ts +13 -0
- package/corpus/core/src/server/onboarding-html.ts +3 -4
- package/corpus/core/src/server/poll-events.ts +14 -2
- package/corpus/core/src/server/poll.ts +219 -12
- package/corpus/core/src/server/schema-prompt.ts +28 -9
- package/corpus/core/src/settings/store.ts +33 -2
- package/corpus/core/src/sharing/actions/set-resource-visibility.ts +2 -0
- package/corpus/core/src/sharing/actions/share-resource.ts +3 -0
- package/corpus/core/src/sharing/actions/unshare-resource.ts +2 -0
- package/corpus/core/src/styles/agent-native.css +0 -5
- package/corpus/core/src/templates/default/.agents/skills/real-time-collab/SKILL.md +82 -19
- package/corpus/core/src/templates/default/.agents/skills/real-time-sync/SKILL.md +15 -0
- package/corpus/core/src/templates/default/.agents/skills/storing-data/SKILL.md +8 -0
- package/corpus/core/src/templates/default/package.json +3 -3
- package/corpus/core/src/templates/workspace-core/.agents/skills/client-methods/SKILL.md +8 -0
- package/corpus/core/src/templates/workspace-core/.agents/skills/observability/SKILL.md +22 -0
- package/corpus/core/src/templates/workspace-core/.agents/skills/real-time-collab/SKILL.md +82 -19
- package/corpus/core/src/templates/workspace-core/.agents/skills/real-time-sync/SKILL.md +15 -0
- package/corpus/core/src/templates/workspace-core/.agents/skills/storing-data/SKILL.md +8 -0
- package/corpus/core/src/templates/workspace-core/.agents/skills/tracking/SKILL.md +1 -0
- package/corpus/core/src/tracking/providers.ts +22 -1
- package/corpus/templates/analytics/.agents/skills/real-time-sync/SKILL.md +15 -0
- package/corpus/templates/analytics/.agents/skills/session-replay/SKILL.md +20 -5
- package/corpus/templates/analytics/.agents/skills/storing-data/SKILL.md +8 -0
- package/corpus/templates/analytics/AGENTS.md +33 -6
- package/corpus/templates/analytics/actions/navigate.ts +14 -4
- package/corpus/templates/analytics/actions/query-agent-native-analytics.ts +1 -1
- package/corpus/templates/analytics/actions/view-screen.ts +57 -0
- package/corpus/templates/analytics/app/components/layout/CommandPalette.tsx +23 -1
- package/corpus/templates/analytics/app/components/layout/Header.tsx +1 -0
- package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +21 -0
- package/corpus/templates/analytics/app/hooks/use-navigation-state.ts +7 -0
- package/corpus/templates/analytics/app/i18n/zh-TW.ts +15 -1
- package/corpus/templates/analytics/app/i18n-data.ts +151 -10
- package/corpus/templates/analytics/app/pages/Agents.tsx +118 -0
- package/corpus/templates/analytics/app/pages/DataSources.tsx +11 -1
- package/corpus/templates/analytics/app/pages/sessions/SessionDevToolsPanel.tsx +76 -40
- package/corpus/templates/analytics/app/pages/sessions/session-replay-devtools.ts +5 -0
- package/corpus/templates/analytics/app/routes/agents.tsx +10 -0
- package/corpus/templates/analytics/changelog/2026-07-03-agent-admin-tools-page.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-03-agent-llm-observability-dashboard.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-03-session-recordings-now-include-response-bodies-for-5xx-error.md +6 -0
- package/corpus/templates/analytics/docs/schemas/first-party-analytics.md +34 -0
- package/corpus/templates/analytics/docs/schemas/tracked-events.md +6 -0
- package/corpus/templates/analytics/package.json +3 -3
- package/corpus/templates/analytics/seeds/dashboards/agent-observability-llm.json +173 -0
- package/corpus/templates/analytics/server/jobs/analytics-alerts.ts +23 -5
- package/corpus/templates/analytics/server/lib/analytics-alerts.ts +58 -13
- package/corpus/templates/analytics/server/lib/dashboard-catalog.ts +23 -0
- package/corpus/templates/analytics/server/lib/dashboard-seeds.ts +5 -0
- package/corpus/templates/analytics/server/lib/session-replay-agent-context.ts +120 -8
- package/corpus/templates/analytics/server/lib/session-replay.ts +3 -2
- package/corpus/templates/analytics/server/plugins/db.ts +93 -2
- package/corpus/templates/analytics/server/routes/api/session-replay/agent-diagnostics.json.get.ts +45 -1
- package/corpus/templates/analytics/shared/session-replay-diagnostics.ts +10 -0
- package/corpus/templates/assets/.agents/skills/real-time-collab/SKILL.md +82 -19
- package/corpus/templates/assets/.agents/skills/real-time-sync/SKILL.md +15 -0
- package/corpus/templates/assets/.agents/skills/storing-data/SKILL.md +8 -0
- package/corpus/templates/assets/package.json +3 -3
- package/corpus/templates/assets/server/plugins/db.ts +63 -2
- package/corpus/templates/brain/.agents/skills/real-time-sync/SKILL.md +15 -0
- package/corpus/templates/brain/package.json +3 -3
- package/corpus/templates/brain/server/plugins/db.ts +75 -2
- package/corpus/templates/calendar/.agents/skills/real-time-sync/SKILL.md +15 -0
- package/corpus/templates/calendar/.agents/skills/storing-data/SKILL.md +8 -0
- package/corpus/templates/calendar/package.json +3 -3
- package/corpus/templates/calendar/server/plugins/db.ts +64 -2
- package/corpus/templates/chat/.agents/skills/real-time-sync/SKILL.md +15 -0
- package/corpus/templates/chat/package.json +3 -3
- package/corpus/templates/clips/actions/finalize-recording.ts +50 -40
- package/corpus/templates/clips/app/routes/r.$recordingId.tsx +8 -6
- package/corpus/templates/clips/app/routes/share.$shareId.tsx +3 -3
- package/corpus/templates/clips/changelog/2026-07-03-mobile-recording-pages-keep-videos-in-a-16-9-player-with-act.md +6 -0
- package/corpus/templates/clips/desktop/src/lib/recorder.ts +245 -77
- package/corpus/templates/clips/package.json +3 -3
- package/corpus/templates/clips/server/lib/recording-upload-state.ts +56 -0
- package/corpus/templates/clips/server/plugins/db.ts +68 -1
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +27 -3
- package/corpus/templates/content/.agents/skills/real-time-sync/SKILL.md +15 -0
- package/corpus/templates/content/.agents/skills/storing-data/SKILL.md +8 -0
- package/corpus/templates/content/actions/edit-document.ts +48 -1
- package/corpus/templates/content/actions/list-documents.ts +101 -94
- package/corpus/templates/content/actions/search-documents.ts +19 -3
- package/corpus/templates/content/actions/update-document.ts +54 -1
- package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +127 -7
- package/corpus/templates/content/app/components/editor/VisualEditor.tsx +97 -2
- package/corpus/templates/content/changelog/2026-07-03-agent-document-edits-now-appear-as-a-live-collaborator-prese.md +6 -0
- package/corpus/templates/content/changelog/2026-07-03-document-search-and-list-views-load-faster-on-large-workspac.md +6 -0
- package/corpus/templates/content/package.json +3 -3
- package/corpus/templates/content/server/plugins/db.ts +57 -1
- package/corpus/templates/design/.agents/skills/design-generation/SKILL.md +190 -13
- package/corpus/templates/design/.agents/skills/design-systems/SKILL.md +16 -4
- package/corpus/templates/design/.agents/skills/export-handoff/SKILL.md +46 -3
- package/corpus/templates/design/.agents/skills/full-app-build/SKILL.md +140 -0
- package/corpus/templates/design/.agents/skills/visual-edit/SKILL.md +20 -0
- package/corpus/templates/design/AGENTS.md +83 -13
- package/corpus/templates/design/actions/add-fusion-screens.ts +91 -0
- package/corpus/templates/design/actions/apply-a11y-fix.ts +5 -1
- package/corpus/templates/design/actions/apply-component-prop-edit.ts +24 -69
- package/corpus/templates/design/actions/apply-fusion-edits.ts +161 -0
- package/corpus/templates/design/actions/apply-motion-edit.ts +29 -2
- package/corpus/templates/design/actions/apply-visual-edit.ts +38 -3
- package/corpus/templates/design/actions/connect-localhost.ts +25 -18
- package/corpus/templates/design/actions/create-component.ts +5 -1
- package/corpus/templates/design/actions/create-fusion-app.ts +202 -0
- package/corpus/templates/design/actions/deploy-fusion-app.ts +123 -0
- package/corpus/templates/design/actions/edit-design.ts +13 -16
- package/corpus/templates/design/actions/export-pdf.ts +8 -1
- package/corpus/templates/design/actions/export-zip.ts +10 -1
- package/corpus/templates/design/actions/get-fusion-deploy-status.ts +86 -0
- package/corpus/templates/design/actions/get-motion-timeline.ts +26 -20
- package/corpus/templates/design/actions/grant-localhost-write-consent.ts +10 -0
- package/corpus/templates/design/actions/insert-asset.ts +190 -7
- package/corpus/templates/design/actions/list-fusion-edits.ts +96 -0
- package/corpus/templates/design/actions/list-local-files.ts +133 -0
- package/corpus/templates/design/actions/open-visual-edit.ts +27 -5
- package/corpus/templates/design/actions/present-design-variants.ts +2 -2
- package/corpus/templates/design/actions/push-fusion-app.ts +62 -0
- package/corpus/templates/design/actions/queue-fusion-edit.ts +112 -0
- package/corpus/templates/design/actions/read-local-file.ts +139 -0
- package/corpus/templates/design/actions/revoke-localhost-write-consent.ts +18 -14
- package/corpus/templates/design/actions/run-design-audit.ts +7 -4
- package/corpus/templates/design/actions/send-fusion-message.ts +69 -0
- package/corpus/templates/design/actions/sync-fusion-app.ts +196 -0
- package/corpus/templates/design/actions/write-local-file.ts +162 -36
- package/corpus/templates/design/app/components/design/CanvasContextMenu.tsx +12 -0
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +600 -66
- package/corpus/templates/design/app/components/design/EditPanel.tsx +793 -304
- package/corpus/templates/design/app/components/design/FusionAppBanner.tsx +328 -0
- package/corpus/templates/design/app/components/design/LayersPanel.tsx +671 -154
- package/corpus/templates/design/app/components/design/LocalhostWriteConsentDialog.tsx +1 -1
- package/corpus/templates/design/app/components/design/MotionDock.tsx +234 -46
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +2070 -420
- package/corpus/templates/design/app/components/design/StatesPanel.tsx +25 -2
- package/corpus/templates/design/app/components/design/TweaksPanel.tsx +7 -98
- package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +640 -72
- package/corpus/templates/design/app/components/design/bridge/motion-preview.bridge.ts +605 -0
- package/corpus/templates/design/app/components/design/canvas-primitive-style.ts +50 -26
- package/corpus/templates/design/app/components/design/code-workbench/ActivityBar.tsx +85 -0
- package/corpus/templates/design/app/components/design/code-workbench/CodeWorkbench.tsx +466 -0
- package/corpus/templates/design/app/components/design/code-workbench/SideBar.tsx +53 -0
- package/corpus/templates/design/app/components/design/code-workbench/commands.ts +339 -0
- package/corpus/templates/design/app/components/design/code-workbench/editor/Breadcrumbs.tsx +42 -0
- package/corpus/templates/design/app/components/design/code-workbench/editor/BufferSync.tsx +128 -0
- package/corpus/templates/design/app/components/design/code-workbench/editor/EditorTabs.tsx +363 -0
- package/corpus/templates/design/app/components/design/code-workbench/editor/MonacoHost.tsx +268 -0
- package/corpus/templates/design/app/components/design/code-workbench/editor/StatusBar.tsx +208 -0
- package/corpus/templates/design/app/components/design/code-workbench/editor/monaco-setup.ts +61 -0
- package/corpus/templates/design/app/components/design/code-workbench/editor/monaco-theme-palette.ts +191 -0
- package/corpus/templates/design/app/components/design/code-workbench/editor/monaco-theme.ts +29 -0
- package/corpus/templates/design/app/components/design/code-workbench/editor/status-bar-lang.ts +21 -0
- package/corpus/templates/design/app/components/design/code-workbench/editor/tab-drag.ts +27 -0
- package/corpus/templates/design/app/components/design/code-workbench/explorer/ExplorerView.tsx +155 -0
- package/corpus/templates/design/app/components/design/code-workbench/explorer/FileTree.tsx +551 -0
- package/corpus/templates/design/app/components/design/code-workbench/explorer/file-icons.tsx +115 -0
- package/corpus/templates/design/app/components/design/code-workbench/format/format-on-open.ts +65 -0
- package/corpus/templates/design/app/components/design/code-workbench/format/prettier-format.ts +182 -0
- package/corpus/templates/design/app/components/design/code-workbench/model-registry.ts +133 -0
- package/corpus/templates/design/app/components/design/code-workbench/quickinput/QuickInput.tsx +563 -0
- package/corpus/templates/design/app/components/design/code-workbench/quickinput/fuzzy.ts +170 -0
- package/corpus/templates/design/app/components/design/code-workbench/search/SearchView.tsx +508 -0
- package/corpus/templates/design/app/components/design/code-workbench/search/search-engine.ts +280 -0
- package/corpus/templates/design/app/components/design/code-workbench/store.tsx +734 -0
- package/corpus/templates/design/app/components/design/code-workbench/workspace/create-providers.ts +39 -0
- package/corpus/templates/design/app/components/design/code-workbench/workspace/inline-provider.ts +196 -0
- package/corpus/templates/design/app/components/design/code-workbench/workspace/localhost-provider.ts +177 -0
- package/corpus/templates/design/app/components/design/code-workbench/workspace/tree.ts +110 -0
- package/corpus/templates/design/app/components/design/code-workbench/workspace/types.ts +103 -0
- package/corpus/templates/design/app/components/design/code-workbench-theme.ts +68 -0
- package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +16 -4
- package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +217 -113
- package/corpus/templates/design/app/components/design/inspector/GradientEditor.tsx +111 -5
- package/corpus/templates/design/app/components/design/inspector/ImageFillControls.tsx +13 -2
- package/corpus/templates/design/app/components/design/inspector/ScrubInput.tsx +79 -16
- package/corpus/templates/design/app/components/design/inspector/index.ts +9 -0
- package/corpus/templates/design/app/components/design/inspector/scrub-input-utils.ts +67 -2
- package/corpus/templates/design/app/components/design/types.ts +16 -0
- package/corpus/templates/design/app/components/editor/PromptDialog.tsx +80 -1
- package/corpus/templates/design/app/components/visual-editor/CanvasCommentPins.tsx +24 -6
- package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +15 -1
- package/corpus/templates/design/app/components/visual-editor/SaveStatusIndicator.tsx +6 -16
- package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +77 -0
- package/corpus/templates/design/app/i18n/zh-TW.ts +2 -0
- package/corpus/templates/design/app/i18n-data.ts +31 -0
- package/corpus/templates/design/app/lib/design-import.ts +95 -0
- package/corpus/templates/design/app/pages/DesignEditor.tsx +5586 -1112
- package/corpus/templates/design/app/pages/Index.tsx +90 -17
- package/corpus/templates/design/changelog/2026-07-03-canvas-interactions-now-match-figma-rotation-aware-resizing-.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-inspector-fixes-mixed-selections-show-mixed-instead-of-wrong.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-keyframe-timeline-works-reliably-drag-keyframes-smoothly-pic.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-layers-panel-matches-figma-top-layer-on-top-drag-with-auto-s.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-much-faster-editor-smooth-dragging-zooming-and-panel-updates.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-pen-tool-refinements-click-the-first-point-to-close-with-a-d.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-text-editing-enter-adds-a-line-break-international-ime-typin.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-the-ai-now-edits-like-a-collaborator-on-canvas-its-cursor-gl.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-the-code-panel-is-now-a-full-vs-code-style-editor-with-a-fil.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-undo-and-redo-are-dependable-across-agent-edits-screen-switc.md +6 -0
- package/corpus/templates/design/package.json +4 -3
- package/corpus/templates/design/server/db/schema.ts +31 -0
- package/corpus/templates/design/server/lib/design-export.ts +43 -1
- package/corpus/templates/design/server/lib/fusion-screens.ts +246 -0
- package/corpus/templates/design/server/plugins/db.ts +89 -2
- package/corpus/templates/design/shared/board-file.ts +25 -5
- package/corpus/templates/design/shared/canvas-math.ts +754 -9
- package/corpus/templates/design/shared/code-layer.ts +304 -26
- package/corpus/templates/design/shared/collab-selection.ts +52 -0
- package/corpus/templates/design/shared/color-utils.ts +84 -0
- package/corpus/templates/design/shared/design-source-capabilities.ts +16 -7
- package/corpus/templates/design/shared/full-app.ts +126 -0
- package/corpus/templates/design/shared/motion-compiler.ts +75 -31
- package/corpus/templates/design/shared/motion-timeline.ts +335 -0
- package/corpus/templates/design/shared/pen-path.ts +496 -23
- package/corpus/templates/dispatch/package.json +3 -3
- package/corpus/templates/forms/.agents/skills/real-time-sync/SKILL.md +15 -0
- package/corpus/templates/forms/.agents/skills/storing-data/SKILL.md +8 -0
- package/corpus/templates/forms/package.json +3 -3
- package/corpus/templates/forms/server/plugins/db.ts +62 -2
- package/corpus/templates/macros/package.json +3 -3
- package/corpus/templates/macros/server/plugins/db.ts +63 -2
- package/corpus/templates/mail/.agents/skills/real-time-sync/SKILL.md +15 -0
- package/corpus/templates/mail/.agents/skills/storing-data/SKILL.md +8 -0
- package/corpus/templates/mail/package.json +3 -3
- package/corpus/templates/mail/server/lib/contact-frequency.ts +42 -15
- package/corpus/templates/mail/server/plugins/db.ts +63 -2
- package/corpus/templates/plan/.agents/skills/real-time-sync/SKILL.md +15 -0
- package/corpus/templates/plan/.agents/skills/visual-plan/SKILL.md +9 -3
- package/corpus/templates/plan/.agents/skills/visualize-repo/SKILL.md +92 -0
- package/corpus/templates/plan/actions/export-visual-plan.ts +5 -0
- package/corpus/templates/plan/actions/get-local-plan-folder.ts +4 -22
- package/corpus/templates/plan/actions/get-plan-version.ts +6 -16
- package/corpus/templates/plan/actions/list-plan-versions.ts +48 -20
- package/corpus/templates/plan/actions/patch-visual-plan-source.ts +41 -1
- package/corpus/templates/plan/actions/promote-local-plan-folder.ts +4 -22
- package/corpus/templates/plan/actions/update-local-plan-comments.ts +4 -2
- package/corpus/templates/plan/actions/update-local-plan-folder.ts +4 -22
- package/corpus/templates/plan/actions/update-visual-plan.ts +60 -1
- package/corpus/templates/plan/agent-native.app-skill.json +10 -0
- package/corpus/templates/plan/app/components/editor/PlanDocumentEditor.tsx +138 -39
- package/corpus/templates/plan/app/components/editor/usePlanUndoStack.ts +165 -23
- package/corpus/templates/plan/app/components/plan/PlanContentRenderer.tsx +80 -4
- package/corpus/templates/plan/app/global.css +11 -9
- package/corpus/templates/plan/app/hooks/use-navigation-state.ts +10 -1
- package/corpus/templates/plan/app/hooks/use-plan-presence.ts +66 -0
- package/corpus/templates/plan/app/pages/PlansPage.tsx +215 -63
- package/corpus/templates/plan/changelog/2026-07-03-local-visual-plans-and-recaps-can-save-comments-without-sign.md +6 -0
- package/corpus/templates/plan/changelog/2026-07-03-plan-tabs-render-nested-text-instead-of-raw-json.md +6 -0
- package/corpus/templates/plan/changelog/2026-07-03-plans-are-now-fully-real-time-collaborative-multiple-people-.md +6 -0
- package/corpus/templates/plan/changelog/2026-07-03-plans-open-and-refresh-faster-especially-long-lived-plans-wi.md +6 -0
- package/corpus/templates/plan/changelog/2026-07-03-undo-now-survives-agent-edits-cmd-z-keeps-working-on-your-ow.md +6 -0
- package/corpus/templates/plan/package.json +5 -3
- package/corpus/templates/plan/server/db/schema.ts +12 -0
- package/corpus/templates/plan/server/lib/local-plan-kind.ts +25 -0
- package/corpus/templates/plan/server/lib/plan-versions.ts +83 -1
- package/corpus/templates/plan/server/lib/public-action-paths.ts +0 -2
- package/corpus/templates/plan/server/plans.ts +26 -3
- package/corpus/templates/plan/server/plugins/db.ts +105 -2
- package/corpus/templates/slides/.agents/skills/slide-editing/SKILL.md +8 -4
- package/corpus/templates/slides/AGENTS.md +4 -0
- package/corpus/templates/slides/actions/_agent-presence.ts +48 -0
- package/corpus/templates/slides/actions/add-slide.ts +16 -18
- package/corpus/templates/slides/actions/patch-deck.ts +4 -2
- package/corpus/templates/slides/actions/update-slide.ts +114 -77
- package/corpus/templates/slides/actions/view-screen.ts +39 -0
- package/corpus/templates/slides/app/components/editor/EditorSidebar.tsx +47 -3
- package/corpus/templates/slides/app/components/editor/EditorToolbar.tsx +32 -35
- package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +625 -199
- package/corpus/templates/slides/app/components/editor/SlideStyleInspector.tsx +342 -0
- package/corpus/templates/slides/app/components/editor/TweaksPanel.tsx +46 -96
- package/corpus/templates/slides/app/components/visual-editor/SaveStatusIndicator.tsx +8 -1
- package/corpus/templates/slides/app/context/DeckContext.tsx +589 -176
- package/corpus/templates/slides/app/hooks/use-deck-presence.ts +84 -42
- package/corpus/templates/slides/app/i18n/ar-SA.ts +46 -0
- package/corpus/templates/slides/app/i18n/de-DE.ts +46 -0
- package/corpus/templates/slides/app/i18n/en-US.ts +46 -0
- package/corpus/templates/slides/app/i18n/es-ES.ts +46 -0
- package/corpus/templates/slides/app/i18n/fr-FR.ts +46 -0
- package/corpus/templates/slides/app/i18n/hi-IN.ts +46 -0
- package/corpus/templates/slides/app/i18n/ja-JP.ts +46 -0
- package/corpus/templates/slides/app/i18n/ko-KR.ts +46 -0
- package/corpus/templates/slides/app/i18n/pt-BR.ts +46 -0
- package/corpus/templates/slides/app/i18n/zh-CN.ts +46 -0
- package/corpus/templates/slides/app/i18n/zh-TW.ts +46 -0
- package/corpus/templates/slides/app/pages/DeckEditor.tsx +25 -13
- package/corpus/templates/slides/changelog/2026-07-03-figma-style-slide-editing.md +6 -0
- package/corpus/templates/slides/changelog/2026-07-03-see-the-ai-working-with-you-when-the-agent-edits-a-slide-it-.md +6 -0
- package/corpus/templates/slides/changelog/2026-07-03-undo-redo-is-now-precise-and-safe-with-collaborators-it-only.md +6 -0
- package/corpus/templates/slides/package.json +3 -3
- package/corpus/templates/slides/server/handlers/decks.ts +31 -3
- package/corpus/templates/slides/server/plugins/db.ts +65 -2
- package/corpus/templates/videos/netlify.toml +1 -12
- package/dist/agent/run-store.js +1 -1
- package/dist/agent/run-store.js.map +1 -1
- package/dist/chat-threads/store.d.ts +9 -1
- package/dist/chat-threads/store.d.ts.map +1 -1
- package/dist/chat-threads/store.js +63 -11
- package/dist/chat-threads/store.js.map +1 -1
- package/dist/checkpoints/store.d.ts.map +1 -1
- package/dist/checkpoints/store.js +10 -2
- package/dist/checkpoints/store.js.map +1 -1
- package/dist/cli/create.d.ts.map +1 -1
- package/dist/cli/create.js +3 -5
- package/dist/cli/create.js.map +1 -1
- package/dist/cli/design-connect.d.ts +49 -1
- package/dist/cli/design-connect.d.ts.map +1 -1
- package/dist/cli/design-connect.js +414 -9
- package/dist/cli/design-connect.js.map +1 -1
- package/dist/cli/index.js +12 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/plan-local.d.ts +24 -0
- package/dist/cli/plan-local.d.ts.map +1 -1
- package/dist/cli/plan-local.js +188 -20
- package/dist/cli/plan-local.js.map +1 -1
- package/dist/cli/skills.d.ts +3 -1
- package/dist/cli/skills.d.ts.map +1 -1
- package/dist/cli/skills.js +173 -21
- package/dist/cli/skills.js.map +1 -1
- package/dist/cli/templates-meta.d.ts.map +1 -1
- package/dist/cli/templates-meta.js +0 -14
- package/dist/cli/templates-meta.js.map +1 -1
- package/dist/cli/visualize-repo.d.ts +49 -0
- package/dist/cli/visualize-repo.d.ts.map +1 -0
- package/dist/cli/visualize-repo.js +561 -0
- package/dist/cli/visualize-repo.js.map +1 -0
- package/dist/client/AssistantChat.d.ts.map +1 -1
- package/dist/client/AssistantChat.js +120 -15
- package/dist/client/AssistantChat.js.map +1 -1
- package/dist/client/agent-chat-adapter.d.ts.map +1 -1
- package/dist/client/agent-chat-adapter.js +6 -5
- package/dist/client/agent-chat-adapter.js.map +1 -1
- package/dist/client/app-providers.d.ts +2 -2
- package/dist/client/app-providers.js.map +1 -1
- package/dist/client/collab/index.d.ts +4 -1
- package/dist/client/collab/index.d.ts.map +1 -1
- package/dist/client/collab/index.js +3 -0
- package/dist/client/collab/index.js.map +1 -1
- package/dist/client/components/LiveCursorOverlay.d.ts.map +1 -1
- package/dist/client/components/LiveCursorOverlay.js +11 -3
- package/dist/client/components/LiveCursorOverlay.js.map +1 -1
- package/dist/client/components/PresenceBar.d.ts.map +1 -1
- package/dist/client/components/PresenceBar.js +6 -1
- package/dist/client/components/PresenceBar.js.map +1 -1
- package/dist/client/components/RecentEditHighlights.d.ts +43 -0
- package/dist/client/components/RecentEditHighlights.d.ts.map +1 -0
- package/dist/client/components/RecentEditHighlights.js +146 -0
- package/dist/client/components/RecentEditHighlights.js.map +1 -0
- package/dist/client/components/RemoteSelectionRings.d.ts +9 -0
- package/dist/client/components/RemoteSelectionRings.d.ts.map +1 -1
- package/dist/client/components/RemoteSelectionRings.js +22 -8
- package/dist/client/components/RemoteSelectionRings.js.map +1 -1
- package/dist/client/index.d.ts +6 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +7 -0
- package/dist/client/index.js.map +1 -1
- package/dist/client/org/OrgSwitcher.d.ts.map +1 -1
- package/dist/client/org/OrgSwitcher.js +1 -3
- package/dist/client/org/OrgSwitcher.js.map +1 -1
- package/dist/client/rich-markdown-editor/SharedRichEditor.d.ts +8 -1
- package/dist/client/rich-markdown-editor/SharedRichEditor.d.ts.map +1 -1
- package/dist/client/rich-markdown-editor/SharedRichEditor.js +8 -2
- package/dist/client/rich-markdown-editor/SharedRichEditor.js.map +1 -1
- package/dist/client/rich-markdown-editor/index.d.ts +1 -0
- package/dist/client/rich-markdown-editor/index.d.ts.map +1 -1
- package/dist/client/rich-markdown-editor/index.js +1 -0
- package/dist/client/rich-markdown-editor/index.js.map +1 -1
- package/dist/client/rich-markdown-editor/surgical-apply.d.ts +66 -0
- package/dist/client/rich-markdown-editor/surgical-apply.d.ts.map +1 -0
- package/dist/client/rich-markdown-editor/surgical-apply.js +147 -0
- package/dist/client/rich-markdown-editor/surgical-apply.js.map +1 -0
- package/dist/client/rich-markdown-editor/useCollabReconcile.d.ts +18 -2
- package/dist/client/rich-markdown-editor/useCollabReconcile.d.ts.map +1 -1
- package/dist/client/rich-markdown-editor/useCollabReconcile.js +39 -8
- package/dist/client/rich-markdown-editor/useCollabReconcile.js.map +1 -1
- package/dist/client/session-replay.d.ts +10 -1
- package/dist/client/session-replay.d.ts.map +1 -1
- package/dist/client/session-replay.js +134 -7
- package/dist/client/session-replay.js.map +1 -1
- package/dist/client/sse-event-processor.d.ts.map +1 -1
- package/dist/client/sse-event-processor.js +81 -1
- package/dist/client/sse-event-processor.js.map +1 -1
- package/dist/client/use-action.d.ts +24 -0
- package/dist/client/use-action.d.ts.map +1 -1
- package/dist/client/use-action.js +109 -27
- package/dist/client/use-action.js.map +1 -1
- package/dist/client/use-agent-engine-configured.d.ts.map +1 -1
- package/dist/client/use-agent-engine-configured.js +7 -1
- package/dist/client/use-agent-engine-configured.js.map +1 -1
- package/dist/client/use-chat-threads.d.ts.map +1 -1
- package/dist/client/use-chat-threads.js +11 -3
- package/dist/client/use-chat-threads.js.map +1 -1
- package/dist/client/use-db-sync.d.ts +36 -0
- package/dist/client/use-db-sync.d.ts.map +1 -1
- package/dist/client/use-db-sync.js +68 -12
- package/dist/client/use-db-sync.js.map +1 -1
- package/dist/client/use-pinch-zoom.d.ts.map +1 -1
- package/dist/client/use-pinch-zoom.js +76 -11
- package/dist/client/use-pinch-zoom.js.map +1 -1
- package/dist/client/visual-style-controls.d.ts +79 -0
- package/dist/client/visual-style-controls.d.ts.map +1 -0
- package/dist/client/visual-style-controls.js +150 -0
- package/dist/client/visual-style-controls.js.map +1 -0
- package/dist/collab/agent-presence.d.ts +51 -5
- package/dist/collab/agent-presence.d.ts.map +1 -1
- package/dist/collab/agent-presence.js +144 -58
- package/dist/collab/agent-presence.js.map +1 -1
- package/dist/collab/awareness-store.d.ts +32 -0
- package/dist/collab/awareness-store.d.ts.map +1 -0
- package/dist/collab/awareness-store.js +0 -0
- package/dist/collab/awareness-store.js.map +1 -0
- package/dist/collab/awareness.d.ts +14 -3
- package/dist/collab/awareness.d.ts.map +1 -1
- package/dist/collab/awareness.js +53 -5
- package/dist/collab/awareness.js.map +1 -1
- package/dist/collab/client.d.ts +2 -0
- package/dist/collab/client.d.ts.map +1 -1
- package/dist/collab/client.js +79 -119
- package/dist/collab/client.js.map +1 -1
- package/dist/collab/index.d.ts +3 -1
- package/dist/collab/index.d.ts.map +1 -1
- package/dist/collab/index.js +5 -1
- package/dist/collab/index.js.map +1 -1
- package/dist/collab/presence.d.ts.map +1 -1
- package/dist/collab/presence.js +54 -3
- package/dist/collab/presence.js.map +1 -1
- package/dist/collab/recent-edits.d.ts +87 -0
- package/dist/collab/recent-edits.d.ts.map +1 -0
- package/dist/collab/recent-edits.js +116 -0
- package/dist/collab/recent-edits.js.map +1 -0
- package/dist/collab/undo.d.ts +121 -0
- package/dist/collab/undo.d.ts.map +1 -0
- package/dist/collab/undo.js +244 -0
- package/dist/collab/undo.js.map +1 -0
- package/dist/collab/ydoc-manager.d.ts +10 -0
- package/dist/collab/ydoc-manager.d.ts.map +1 -1
- package/dist/collab/ydoc-manager.js +57 -0
- package/dist/collab/ydoc-manager.js.map +1 -1
- package/dist/connections/catalog.d.ts +2 -2
- package/dist/connections/catalog.d.ts.map +1 -1
- package/dist/connections/catalog.js +1 -1
- package/dist/connections/catalog.js.map +1 -1
- package/dist/db/create-get-db.d.ts +22 -0
- package/dist/db/create-get-db.d.ts.map +1 -1
- package/dist/db/create-get-db.js +73 -1
- package/dist/db/create-get-db.js.map +1 -1
- package/dist/db/ensure-additive-columns.d.ts +93 -0
- package/dist/db/ensure-additive-columns.d.ts.map +1 -0
- package/dist/db/ensure-additive-columns.js +355 -0
- package/dist/db/ensure-additive-columns.js.map +1 -0
- package/dist/db/index.d.ts +1 -0
- package/dist/db/index.d.ts.map +1 -1
- package/dist/db/index.js +1 -0
- package/dist/db/index.js.map +1 -1
- package/dist/db/migrations.d.ts +70 -0
- package/dist/db/migrations.d.ts.map +1 -1
- package/dist/db/migrations.js +196 -33
- package/dist/db/migrations.js.map +1 -1
- package/dist/jobs/scheduler.js +17 -10
- package/dist/jobs/scheduler.js.map +1 -1
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/observability/routes.d.ts +5 -5
- package/dist/observability/traces.d.ts.map +1 -1
- package/dist/observability/traces.js +155 -1
- package/dist/observability/traces.js.map +1 -1
- package/dist/org/context.d.ts +7 -0
- package/dist/org/context.d.ts.map +1 -1
- package/dist/org/context.js +54 -2
- package/dist/org/context.js.map +1 -1
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +3 -3
- package/dist/secrets/routes.d.ts +6 -6
- package/dist/server/agent-chat-plugin.d.ts.map +1 -1
- package/dist/server/agent-chat-plugin.js +10 -6
- package/dist/server/agent-chat-plugin.js.map +1 -1
- package/dist/server/auth-marketing.d.ts.map +1 -1
- package/dist/server/auth-marketing.js +0 -10
- package/dist/server/auth-marketing.js.map +1 -1
- package/dist/server/auth.d.ts.map +1 -1
- package/dist/server/auth.js +6 -4
- package/dist/server/auth.js.map +1 -1
- package/dist/server/collab-plugin.d.ts.map +1 -1
- package/dist/server/collab-plugin.js +45 -13
- package/dist/server/collab-plugin.js.map +1 -1
- package/dist/server/design-token-utils.d.ts +1 -1
- package/dist/server/design-token-utils.js +1 -1
- package/dist/server/design-token-utils.js.map +1 -1
- package/dist/server/fusion-app.d.ts +107 -0
- package/dist/server/fusion-app.d.ts.map +1 -0
- package/dist/server/fusion-app.js +371 -0
- package/dist/server/fusion-app.js.map +1 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +1 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/onboarding-html.d.ts.map +1 -1
- package/dist/server/onboarding-html.js +3 -4
- package/dist/server/onboarding-html.js.map +1 -1
- package/dist/server/poll-events.d.ts +2 -0
- package/dist/server/poll-events.d.ts.map +1 -1
- package/dist/server/poll-events.js +7 -2
- package/dist/server/poll-events.js.map +1 -1
- package/dist/server/poll.d.ts +47 -3
- package/dist/server/poll.d.ts.map +1 -1
- package/dist/server/poll.js +176 -10
- package/dist/server/poll.js.map +1 -1
- package/dist/server/schema-prompt.d.ts.map +1 -1
- package/dist/server/schema-prompt.js +24 -7
- package/dist/server/schema-prompt.js.map +1 -1
- package/dist/settings/store.d.ts.map +1 -1
- package/dist/settings/store.js +32 -3
- package/dist/settings/store.js.map +1 -1
- package/dist/sharing/actions/set-resource-visibility.js +2 -0
- package/dist/sharing/actions/set-resource-visibility.js.map +1 -1
- package/dist/sharing/actions/share-resource.d.ts.map +1 -1
- package/dist/sharing/actions/share-resource.js +3 -0
- package/dist/sharing/actions/share-resource.js.map +1 -1
- package/dist/sharing/actions/unshare-resource.js +2 -0
- package/dist/sharing/actions/unshare-resource.js.map +1 -1
- package/dist/styles/agent-native.css +0 -5
- package/dist/templates/default/.agents/skills/real-time-collab/SKILL.md +82 -19
- package/dist/templates/default/.agents/skills/real-time-sync/SKILL.md +15 -0
- package/dist/templates/default/.agents/skills/storing-data/SKILL.md +8 -0
- package/dist/templates/default/package.json +3 -3
- package/dist/templates/workspace-core/.agents/skills/client-methods/SKILL.md +8 -0
- package/dist/templates/workspace-core/.agents/skills/observability/SKILL.md +22 -0
- package/dist/templates/workspace-core/.agents/skills/real-time-collab/SKILL.md +82 -19
- package/dist/templates/workspace-core/.agents/skills/real-time-sync/SKILL.md +15 -0
- package/dist/templates/workspace-core/.agents/skills/storing-data/SKILL.md +8 -0
- package/dist/templates/workspace-core/.agents/skills/tracking/SKILL.md +1 -0
- package/dist/tracking/providers.d.ts.map +1 -1
- package/dist/tracking/providers.js +17 -1
- package/dist/tracking/providers.js.map +1 -1
- package/docs/content/cloneable-saas.mdx +0 -1
- package/docs/content/locales/ar-SA/cloneable-saas.mdx +0 -1
- package/docs/content/locales/ar-SA/plan-plugin.mdx +10 -9
- package/docs/content/locales/ar-SA/sharing.mdx +1 -2
- package/docs/content/locales/ar-SA/skills-guide.mdx +2 -3
- package/docs/content/locales/ar-SA/template-plan.mdx +23 -7
- package/docs/content/locales/ar-SA/tracking.mdx +1 -1
- package/docs/content/locales/ar-SA/workspace-connections.mdx +11 -11
- package/docs/content/locales/de-DE/cloneable-saas.mdx +0 -1
- package/docs/content/locales/de-DE/plan-plugin.mdx +10 -9
- package/docs/content/locales/de-DE/sharing.mdx +1 -2
- package/docs/content/locales/de-DE/skills-guide.mdx +2 -3
- package/docs/content/locales/de-DE/template-plan.mdx +23 -7
- package/docs/content/locales/de-DE/tracking.mdx +1 -1
- package/docs/content/locales/de-DE/workspace-connections.mdx +1 -1
- package/docs/content/locales/es-ES/cloneable-saas.mdx +0 -1
- package/docs/content/locales/es-ES/plan-plugin.mdx +10 -9
- package/docs/content/locales/es-ES/sharing.mdx +1 -2
- package/docs/content/locales/es-ES/skills-guide.mdx +2 -3
- package/docs/content/locales/es-ES/template-plan.mdx +23 -7
- package/docs/content/locales/es-ES/tracking.mdx +1 -1
- package/docs/content/locales/es-ES/workspace-connections.mdx +1 -1
- package/docs/content/locales/fr-FR/cloneable-saas.mdx +0 -1
- package/docs/content/locales/fr-FR/plan-plugin.mdx +10 -9
- package/docs/content/locales/fr-FR/sharing.mdx +1 -2
- package/docs/content/locales/fr-FR/skills-guide.mdx +2 -3
- package/docs/content/locales/fr-FR/template-plan.mdx +23 -7
- package/docs/content/locales/fr-FR/tracking.mdx +1 -1
- package/docs/content/locales/fr-FR/workspace-connections.mdx +1 -1
- package/docs/content/locales/hi-IN/cloneable-saas.mdx +0 -1
- package/docs/content/locales/hi-IN/plan-plugin.mdx +10 -9
- package/docs/content/locales/hi-IN/sharing.mdx +1 -2
- package/docs/content/locales/hi-IN/skills-guide.mdx +2 -3
- package/docs/content/locales/hi-IN/template-plan.mdx +23 -7
- package/docs/content/locales/hi-IN/tracking.mdx +1 -1
- package/docs/content/locales/hi-IN/workspace-connections.mdx +1 -1
- package/docs/content/locales/ja-JP/cloneable-saas.mdx +0 -1
- package/docs/content/locales/ja-JP/plan-plugin.mdx +10 -9
- package/docs/content/locales/ja-JP/sharing.mdx +1 -2
- package/docs/content/locales/ja-JP/skills-guide.mdx +2 -3
- package/docs/content/locales/ja-JP/template-plan.mdx +23 -7
- package/docs/content/locales/ja-JP/tracking.mdx +1 -1
- package/docs/content/locales/ja-JP/workspace-connections.mdx +1 -1
- package/docs/content/locales/ko-KR/cloneable-saas.mdx +0 -1
- package/docs/content/locales/ko-KR/plan-plugin.mdx +10 -9
- package/docs/content/locales/ko-KR/sharing.mdx +1 -2
- package/docs/content/locales/ko-KR/skills-guide.mdx +2 -3
- package/docs/content/locales/ko-KR/template-plan.mdx +23 -7
- package/docs/content/locales/ko-KR/tracking.mdx +1 -1
- package/docs/content/locales/ko-KR/workspace-connections.mdx +1 -1
- package/docs/content/locales/pt-BR/cloneable-saas.mdx +0 -1
- package/docs/content/locales/pt-BR/plan-plugin.mdx +10 -9
- package/docs/content/locales/pt-BR/sharing.mdx +1 -2
- package/docs/content/locales/pt-BR/skills-guide.mdx +2 -3
- package/docs/content/locales/pt-BR/template-plan.mdx +23 -7
- package/docs/content/locales/pt-BR/tracking.mdx +1 -1
- package/docs/content/locales/pt-BR/workspace-connections.mdx +1 -1
- package/docs/content/locales/zh-CN/cloneable-saas.mdx +0 -1
- package/docs/content/locales/zh-CN/plan-plugin.mdx +10 -9
- package/docs/content/locales/zh-CN/sharing.mdx +1 -2
- package/docs/content/locales/zh-CN/skills-guide.mdx +2 -3
- package/docs/content/locales/zh-CN/template-plan.mdx +23 -7
- package/docs/content/locales/zh-CN/tracking.mdx +1 -1
- package/docs/content/locales/zh-CN/workspace-connections.mdx +1 -1
- package/docs/content/locales/zh-TW/cloneable-saas.mdx +0 -1
- package/docs/content/locales/zh-TW/plan-plugin.mdx +10 -9
- package/docs/content/locales/zh-TW/sharing.mdx +1 -2
- package/docs/content/locales/zh-TW/skills-guide.mdx +2 -3
- package/docs/content/locales/zh-TW/template-plan.mdx +23 -7
- package/docs/content/locales/zh-TW/tracking.mdx +1 -1
- package/docs/content/locales/zh-TW/workspace-connections.mdx +1 -1
- package/docs/content/plan-plugin.mdx +12 -11
- package/docs/content/sharing.mdx +1 -2
- package/docs/content/skills-guide.mdx +4 -3
- package/docs/content/template-plan.mdx +37 -6
- package/docs/content/tracking.mdx +1 -1
- package/docs/content/workspace-connections.mdx +1 -1
- package/package.json +6 -6
- package/src/templates/default/.agents/skills/real-time-collab/SKILL.md +82 -19
- package/src/templates/default/.agents/skills/real-time-sync/SKILL.md +15 -0
- package/src/templates/default/.agents/skills/storing-data/SKILL.md +8 -0
- package/src/templates/default/package.json +3 -3
- package/src/templates/workspace-core/.agents/skills/client-methods/SKILL.md +8 -0
- package/src/templates/workspace-core/.agents/skills/observability/SKILL.md +22 -0
- package/src/templates/workspace-core/.agents/skills/real-time-collab/SKILL.md +82 -19
- package/src/templates/workspace-core/.agents/skills/real-time-sync/SKILL.md +15 -0
- package/src/templates/workspace-core/.agents/skills/storing-data/SKILL.md +8 -0
- package/src/templates/workspace-core/.agents/skills/tracking/SKILL.md +1 -0
- package/corpus/core/docs/content/locales/ar-SA/template-videos.mdx +0 -336
- package/corpus/core/docs/content/locales/de-DE/template-videos.mdx +0 -336
- package/corpus/core/docs/content/locales/es-ES/template-videos.mdx +0 -336
- package/corpus/core/docs/content/locales/fr-FR/template-videos.mdx +0 -336
- package/corpus/core/docs/content/locales/hi-IN/template-videos.mdx +0 -336
- package/corpus/core/docs/content/locales/ja-JP/template-videos.mdx +0 -336
- package/corpus/core/docs/content/locales/ko-KR/template-videos.mdx +0 -336
- package/corpus/core/docs/content/locales/pt-BR/template-videos.mdx +0 -336
- package/corpus/core/docs/content/locales/zh-CN/template-videos.mdx +0 -334
- package/corpus/core/docs/content/locales/zh-TW/template-videos.mdx +0 -334
- package/corpus/core/docs/content/template-videos.mdx +0 -332
- package/corpus/templates/design/app/components/design/CodeWorkbenchHost.tsx +0 -697
- package/corpus/templates/slides/app/components/editor/CodeEditor.tsx +0 -49
- package/corpus/templates/videos/.agents/skills/actions/SKILL.md +0 -432
- package/corpus/templates/videos/.agents/skills/animation-tracks/SKILL.md +0 -99
- package/corpus/templates/videos/.agents/skills/capture-learnings/SKILL.md +0 -76
- package/corpus/templates/videos/.agents/skills/composition-management/SKILL.md +0 -106
- package/corpus/templates/videos/.agents/skills/create-skill/SKILL.md +0 -221
- package/corpus/templates/videos/.agents/skills/delegate-to-agent/SKILL.md +0 -263
- package/corpus/templates/videos/.agents/skills/frontend-design/SKILL.md +0 -112
- package/corpus/templates/videos/.agents/skills/security/SKILL.md +0 -263
- package/corpus/templates/videos/.agents/skills/self-modifying-code/SKILL.md +0 -88
- package/corpus/templates/videos/.agents/skills/shadcn-ui/SKILL.md +0 -95
- package/corpus/templates/videos/.builder/ANIMATED_PROPERTIES.md +0 -335
- package/corpus/templates/videos/.builder/KEYFRAME_SYNC.md +0 -166
- package/corpus/templates/videos/.builder/SEAMLESS_SYNC.md +0 -211
- package/corpus/templates/videos/.builder/rules/deploy-app.mdc +0 -22
- package/corpus/templates/videos/.builder/rules/organize-ui.mdc +0 -9
- package/corpus/templates/videos/.builder/skills/remotion/SKILL.md +0 -29
- package/corpus/templates/videos/.builder/skills/remotion/rules/animations.md +0 -27
- package/corpus/templates/videos/.builder/skills/remotion/rules/assets.md +0 -43
- package/corpus/templates/videos/.builder/skills/remotion/rules/audio.md +0 -58
- package/corpus/templates/videos/.builder/skills/remotion/rules/compositions.md +0 -129
- package/corpus/templates/videos/.builder/skills/remotion/rules/fonts.md +0 -47
- package/corpus/templates/videos/.builder/skills/remotion/rules/images.md +0 -41
- package/corpus/templates/videos/.builder/skills/remotion/rules/parameters.md +0 -59
- package/corpus/templates/videos/.builder/skills/remotion/rules/sequencing.md +0 -54
- package/corpus/templates/videos/.builder/skills/remotion/rules/tailwind.md +0 -11
- package/corpus/templates/videos/.builder/skills/remotion/rules/text-animations.md +0 -18
- package/corpus/templates/videos/.builder/skills/remotion/rules/timing.md +0 -103
- package/corpus/templates/videos/.builder/skills/remotion/rules/transparent-videos.md +0 -35
- package/corpus/templates/videos/.builder/skills/remotion/rules/trimming.md +0 -38
- package/corpus/templates/videos/.builder/skills/remotion/rules/videos.md +0 -54
- package/corpus/templates/videos/.dockerignore +0 -62
- package/corpus/templates/videos/.env.example +0 -4
- package/corpus/templates/videos/.oxfmtrc.json +0 -8
- package/corpus/templates/videos/AGENTS.md +0 -46
- package/corpus/templates/videos/CHANGELOG.md +0 -10
- package/corpus/templates/videos/DEVELOPING.md +0 -143
- package/corpus/templates/videos/README.md +0 -33
- package/corpus/templates/videos/actions/analyze-brand-assets.ts +0 -151
- package/corpus/templates/videos/actions/apply-design-system.ts +0 -35
- package/corpus/templates/videos/actions/create-design-system.ts +0 -84
- package/corpus/templates/videos/actions/create-folder.ts +0 -48
- package/corpus/templates/videos/actions/db-connect.ts +0 -45
- package/corpus/templates/videos/actions/db-health.ts +0 -24
- package/corpus/templates/videos/actions/db-status.ts +0 -28
- package/corpus/templates/videos/actions/delete-composition.ts +0 -272
- package/corpus/templates/videos/actions/delete-folder.ts +0 -27
- package/corpus/templates/videos/actions/generate-animated-component.ts +0 -470
- package/corpus/templates/videos/actions/get-composition.ts +0 -36
- package/corpus/templates/videos/actions/get-design-system.ts +0 -35
- package/corpus/templates/videos/actions/import-code.ts +0 -67
- package/corpus/templates/videos/actions/import-design-project.ts +0 -42
- package/corpus/templates/videos/actions/import-document.ts +0 -125
- package/corpus/templates/videos/actions/import-from-url.ts +0 -18
- package/corpus/templates/videos/actions/import-github.ts +0 -182
- package/corpus/templates/videos/actions/list-compositions.ts +0 -32
- package/corpus/templates/videos/actions/list-design-systems.ts +0 -66
- package/corpus/templates/videos/actions/list-folders.ts +0 -61
- package/corpus/templates/videos/actions/move-composition-to-folder.ts +0 -59
- package/corpus/templates/videos/actions/navigate.ts +0 -40
- package/corpus/templates/videos/actions/rename-folder.ts +0 -41
- package/corpus/templates/videos/actions/run.ts +0 -2
- package/corpus/templates/videos/actions/save-composition.ts +0 -114
- package/corpus/templates/videos/actions/set-default-design-system.ts +0 -40
- package/corpus/templates/videos/actions/update-composition.ts +0 -81
- package/corpus/templates/videos/actions/update-design-system.ts +0 -68
- package/corpus/templates/videos/actions/validate-compositions.ts +0 -502
- package/corpus/templates/videos/actions/view-screen.ts +0 -48
- package/corpus/templates/videos/app/components/CameraControls.tsx +0 -442
- package/corpus/templates/videos/app/components/CameraToolbar.tsx +0 -437
- package/corpus/templates/videos/app/components/CloudUpgrade.tsx +0 -287
- package/corpus/templates/videos/app/components/CollabPresenceBar.tsx +0 -81
- package/corpus/templates/videos/app/components/CompSettingsEditor.tsx +0 -107
- package/corpus/templates/videos/app/components/ComponentLibrarySidebar.tsx +0 -457
- package/corpus/templates/videos/app/components/ComponentPropsPanel.tsx +0 -227
- package/corpus/templates/videos/app/components/ComponentsSidebar.tsx +0 -70
- package/corpus/templates/videos/app/components/CompositionCard.tsx +0 -147
- package/corpus/templates/videos/app/components/CurrentElementPanel.tsx +0 -835
- package/corpus/templates/videos/app/components/CursorControls.tsx +0 -664
- package/corpus/templates/videos/app/components/CursorPositioningOverlay.tsx +0 -238
- package/corpus/templates/videos/app/components/LibraryFolderRow.tsx +0 -235
- package/corpus/templates/videos/app/components/MotionCurveSelect.tsx +0 -61
- package/corpus/templates/videos/app/components/NewCompositionPopover.tsx +0 -192
- package/corpus/templates/videos/app/components/PropsEditor.tsx +0 -210
- package/corpus/templates/videos/app/components/QuestionFlow.tsx +0 -40
- package/corpus/templates/videos/app/components/Sidebar.tsx +0 -609
- package/corpus/templates/videos/app/components/StudioHeader.tsx +0 -109
- package/corpus/templates/videos/app/components/Timeline.tsx +0 -2067
- package/corpus/templates/videos/app/components/TrackPropertiesPanel.tsx +0 -922
- package/corpus/templates/videos/app/components/TweaksPanel.tsx +0 -277
- package/corpus/templates/videos/app/components/VideoPlayer.tsx +0 -487
- package/corpus/templates/videos/app/components/design-system/DesignSystemCard.tsx +0 -134
- package/corpus/templates/videos/app/components/design-system/DesignSystemPreview.tsx +0 -176
- package/corpus/templates/videos/app/components/design-system/DesignSystemSetup.tsx +0 -707
- package/corpus/templates/videos/app/components/keyframes/KeyframeActionButtons.tsx +0 -78
- package/corpus/templates/videos/app/components/keyframes/KeyframeNavigation.tsx +0 -59
- package/corpus/templates/videos/app/components/keyframes/PropertyNumberInput.tsx +0 -56
- package/corpus/templates/videos/app/components/layout/Header.tsx +0 -64
- package/corpus/templates/videos/app/components/layout/HeaderActions.tsx +0 -84
- package/corpus/templates/videos/app/components/layout/Layout.tsx +0 -132
- package/corpus/templates/videos/app/components/layout/NavSidebar.tsx +0 -180
- package/corpus/templates/videos/app/components/ui/accordion.tsx +0 -56
- package/corpus/templates/videos/app/components/ui/alert-dialog.tsx +0 -139
- package/corpus/templates/videos/app/components/ui/alert.tsx +0 -59
- package/corpus/templates/videos/app/components/ui/aspect-ratio.tsx +0 -5
- package/corpus/templates/videos/app/components/ui/avatar.tsx +0 -48
- package/corpus/templates/videos/app/components/ui/badge.tsx +0 -37
- package/corpus/templates/videos/app/components/ui/breadcrumb.tsx +0 -115
- package/corpus/templates/videos/app/components/ui/button.tsx +0 -57
- package/corpus/templates/videos/app/components/ui/calendar.tsx +0 -68
- package/corpus/templates/videos/app/components/ui/card.tsx +0 -86
- package/corpus/templates/videos/app/components/ui/carousel.tsx +0 -260
- package/corpus/templates/videos/app/components/ui/chart.tsx +0 -375
- package/corpus/templates/videos/app/components/ui/checkbox.tsx +0 -28
- package/corpus/templates/videos/app/components/ui/collapsible.tsx +0 -9
- package/corpus/templates/videos/app/components/ui/command.tsx +0 -154
- package/corpus/templates/videos/app/components/ui/context-menu.tsx +0 -202
- package/corpus/templates/videos/app/components/ui/dialog.tsx +0 -130
- package/corpus/templates/videos/app/components/ui/drawer.tsx +0 -116
- package/corpus/templates/videos/app/components/ui/dropdown-menu.tsx +0 -212
- package/corpus/templates/videos/app/components/ui/form.tsx +0 -177
- package/corpus/templates/videos/app/components/ui/hover-card.tsx +0 -27
- package/corpus/templates/videos/app/components/ui/input-otp.tsx +0 -69
- package/corpus/templates/videos/app/components/ui/input.tsx +0 -22
- package/corpus/templates/videos/app/components/ui/label.tsx +0 -24
- package/corpus/templates/videos/app/components/ui/menubar.tsx +0 -234
- package/corpus/templates/videos/app/components/ui/navigation-menu.tsx +0 -128
- package/corpus/templates/videos/app/components/ui/pagination.tsx +0 -121
- package/corpus/templates/videos/app/components/ui/popover.tsx +0 -44
- package/corpus/templates/videos/app/components/ui/progress.tsx +0 -26
- package/corpus/templates/videos/app/components/ui/radio-group.tsx +0 -42
- package/corpus/templates/videos/app/components/ui/resizable.tsx +0 -43
- package/corpus/templates/videos/app/components/ui/scroll-area.tsx +0 -46
- package/corpus/templates/videos/app/components/ui/select.tsx +0 -158
- package/corpus/templates/videos/app/components/ui/separator.tsx +0 -29
- package/corpus/templates/videos/app/components/ui/sheet.tsx +0 -160
- package/corpus/templates/videos/app/components/ui/sidebar.tsx +0 -782
- package/corpus/templates/videos/app/components/ui/skeleton.tsx +0 -15
- package/corpus/templates/videos/app/components/ui/slider.tsx +0 -26
- package/corpus/templates/videos/app/components/ui/sonner.tsx +0 -58
- package/corpus/templates/videos/app/components/ui/spinner.tsx +0 -17
- package/corpus/templates/videos/app/components/ui/switch.tsx +0 -27
- package/corpus/templates/videos/app/components/ui/table.tsx +0 -117
- package/corpus/templates/videos/app/components/ui/tabs.tsx +0 -53
- package/corpus/templates/videos/app/components/ui/textarea.tsx +0 -23
- package/corpus/templates/videos/app/components/ui/toast.tsx +0 -127
- package/corpus/templates/videos/app/components/ui/toaster.tsx +0 -33
- package/corpus/templates/videos/app/components/ui/toggle-group.tsx +0 -59
- package/corpus/templates/videos/app/components/ui/toggle.tsx +0 -43
- package/corpus/templates/videos/app/components/ui/tooltip.tsx +0 -30
- package/corpus/templates/videos/app/components/ui/use-toast.ts +0 -3
- package/corpus/templates/videos/app/config/constants.ts +0 -204
- package/corpus/templates/videos/app/contexts/CompositionContext.tsx +0 -437
- package/corpus/templates/videos/app/contexts/CurrentElementContext.tsx +0 -247
- package/corpus/templates/videos/app/contexts/PlaybackContext.tsx +0 -65
- package/corpus/templates/videos/app/contexts/TimelineContext.tsx +0 -433
- package/corpus/templates/videos/app/entry.client.tsx +0 -19
- package/corpus/templates/videos/app/entry.server.tsx +0 -10
- package/corpus/templates/videos/app/global.css +0 -171
- package/corpus/templates/videos/app/hooks/use-composition-collab.ts +0 -269
- package/corpus/templates/videos/app/hooks/use-composition-design-system.ts +0 -47
- package/corpus/templates/videos/app/hooks/use-database-compositions.ts +0 -144
- package/corpus/templates/videos/app/hooks/use-db-status.ts +0 -30
- package/corpus/templates/videos/app/hooks/use-design-systems.ts +0 -21
- package/corpus/templates/videos/app/hooks/use-folders.ts +0 -199
- package/corpus/templates/videos/app/hooks/use-mobile.tsx +0 -21
- package/corpus/templates/videos/app/hooks/use-navigation-state.ts +0 -52
- package/corpus/templates/videos/app/hooks/use-question-flow.ts +0 -14
- package/corpus/templates/videos/app/hooks/use-toast.ts +0 -188
- package/corpus/templates/videos/app/hooks/useUnsavedChanges.ts +0 -48
- package/corpus/templates/videos/app/i18n/ar-SA.ts +0 -446
- package/corpus/templates/videos/app/i18n/de-DE.ts +0 -470
- package/corpus/templates/videos/app/i18n/en-US.ts +0 -452
- package/corpus/templates/videos/app/i18n/es-ES.ts +0 -468
- package/corpus/templates/videos/app/i18n/fr-FR.ts +0 -469
- package/corpus/templates/videos/app/i18n/hi-IN.ts +0 -446
- package/corpus/templates/videos/app/i18n/index.ts +0 -34
- package/corpus/templates/videos/app/i18n/ja-JP.ts +0 -460
- package/corpus/templates/videos/app/i18n/ko-KR.ts +0 -453
- package/corpus/templates/videos/app/i18n/pt-BR.ts +0 -461
- package/corpus/templates/videos/app/i18n/zh-CN.ts +0 -437
- package/corpus/templates/videos/app/i18n/zh-TW.ts +0 -437
- package/corpus/templates/videos/app/lib/database-compositions.ts +0 -67
- package/corpus/templates/videos/app/lib/design-systems.ts +0 -234
- package/corpus/templates/videos/app/lib/tab-id.ts +0 -1
- package/corpus/templates/videos/app/lib/utils.ts +0 -1
- package/corpus/templates/videos/app/pages/ComponentLibrary.tsx +0 -134
- package/corpus/templates/videos/app/pages/ComponentLibraryView.tsx +0 -362
- package/corpus/templates/videos/app/pages/CompositionView.tsx +0 -715
- package/corpus/templates/videos/app/pages/DesignSystems.tsx +0 -180
- package/corpus/templates/videos/app/pages/Index.tsx +0 -258
- package/corpus/templates/videos/app/pages/NewComposition.tsx +0 -95
- package/corpus/templates/videos/app/pages/NotFound.tsx +0 -36
- package/corpus/templates/videos/app/remotion/CameraHost.tsx +0 -223
- package/corpus/templates/videos/app/remotion/animations/useElementAnimations.ts +0 -231
- package/corpus/templates/videos/app/remotion/componentRegistry.ts +0 -473
- package/corpus/templates/videos/app/remotion/components/AnimatedElement.tsx +0 -436
- package/corpus/templates/videos/app/remotion/components/InteractiveElement.tsx +0 -159
- package/corpus/templates/videos/app/remotion/components/InteractiveZoneEditor.tsx +0 -273
- package/corpus/templates/videos/app/remotion/compositions/BlankComposition.tsx +0 -204
- package/corpus/templates/videos/app/remotion/compositions/index.ts +0 -2
- package/corpus/templates/videos/app/remotion/createComponentCursorTrack.ts +0 -132
- package/corpus/templates/videos/app/remotion/docs/CURSOR_TRACKS.md +0 -285
- package/corpus/templates/videos/app/remotion/easingFunctions.ts +0 -255
- package/corpus/templates/videos/app/remotion/hooks/createInteractiveComposition.tsx +0 -283
- package/corpus/templates/videos/app/remotion/hooks/useCursorHistory.ts +0 -72
- package/corpus/templates/videos/app/remotion/hooks/useCursorTypeFromHover.ts +0 -35
- package/corpus/templates/videos/app/remotion/hooks/useEditableZones.tsx +0 -267
- package/corpus/templates/videos/app/remotion/hooks/useHoverAnimation.ts +0 -90
- package/corpus/templates/videos/app/remotion/hooks/useHoverAnimationSmooth.ts +0 -146
- package/corpus/templates/videos/app/remotion/hooks/useInteractiveComponent.ts +0 -711
- package/corpus/templates/videos/app/remotion/hooks/useRegisterInteractiveElement.ts +0 -56
- package/corpus/templates/videos/app/remotion/library-components/Button.tsx +0 -252
- package/corpus/templates/videos/app/remotion/library-components/Card.tsx +0 -241
- package/corpus/templates/videos/app/remotion/library-components/CodePanel.tsx +0 -1005
- package/corpus/templates/videos/app/remotion/library-components/FileItem.tsx +0 -196
- package/corpus/templates/videos/app/remotion/library-components/FolderItem.tsx +0 -199
- package/corpus/templates/videos/app/remotion/library-components/PrimaryButton.tsx +0 -177
- package/corpus/templates/videos/app/remotion/library-components/SecondaryButton.tsx +0 -167
- package/corpus/templates/videos/app/remotion/library-components/SectionHeader.tsx +0 -108
- package/corpus/templates/videos/app/remotion/library-components/TEMPLATE.tsx +0 -211
- package/corpus/templates/videos/app/remotion/library-components/index.ts +0 -10
- package/corpus/templates/videos/app/remotion/registry.ts +0 -91
- package/corpus/templates/videos/app/remotion/testing/animationTestUtils.ts +0 -366
- package/corpus/templates/videos/app/remotion/trackAnimation.ts +0 -179
- package/corpus/templates/videos/app/remotion/trackHelpers.ts +0 -110
- package/corpus/templates/videos/app/remotion/ui-components/Cursor.tsx +0 -182
- package/corpus/templates/videos/app/remotion/ui-components/InteractiveButton.tsx +0 -116
- package/corpus/templates/videos/app/remotion/ui-components/InteractiveCard.tsx +0 -126
- package/corpus/templates/videos/app/remotion/ui-components/index.ts +0 -16
- package/corpus/templates/videos/app/remotion/utils/animationHelpers.ts +0 -444
- package/corpus/templates/videos/app/remotion/utils/interactiveElements.ts +0 -235
- package/corpus/templates/videos/app/remotion/utils/springAnimation.ts +0 -76
- package/corpus/templates/videos/app/root.tsx +0 -165
- package/corpus/templates/videos/app/routes/$.tsx +0 -10
- package/corpus/templates/videos/app/routes/_index.tsx +0 -25
- package/corpus/templates/videos/app/routes/c.$compositionId.tsx +0 -10
- package/corpus/templates/videos/app/routes/components.tsx +0 -10
- package/corpus/templates/videos/app/routes/design-systems.tsx +0 -10
- package/corpus/templates/videos/app/routes/extensions.$id.$slug.tsx +0 -2
- package/corpus/templates/videos/app/routes/extensions.$id.tsx +0 -5
- package/corpus/templates/videos/app/routes/extensions._index.tsx +0 -5
- package/corpus/templates/videos/app/routes/extensions.tsx +0 -5
- package/corpus/templates/videos/app/routes/settings.tsx +0 -88
- package/corpus/templates/videos/app/routes/team.tsx +0 -11
- package/corpus/templates/videos/app/routes.ts +0 -4
- package/corpus/templates/videos/app/state.ts +0 -77
- package/corpus/templates/videos/app/types/elementAnimations.ts +0 -121
- package/corpus/templates/videos/app/types.ts +0 -200
- package/corpus/templates/videos/app/utils/compositionHelpers.ts +0 -445
- package/corpus/templates/videos/app/utils/debug.ts +0 -63
- package/corpus/templates/videos/app/utils/keyframeUtils.ts +0 -331
- package/corpus/templates/videos/app/utils/timelineCoordinates.ts +0 -79
- package/corpus/templates/videos/app/vite-env.d.ts +0 -6
- package/corpus/templates/videos/changelog/2026-06-24-a-new-settings-page-gives-quick-access-to-language-workspace.md +0 -6
- package/corpus/templates/videos/changelog/2026-06-24-added-a-language-picker-and-localized-app-chrome-for-support.md +0 -6
- package/corpus/templates/videos/changelog/2026-06-27-traditional-chinese-copy-uses-taiwan-terminology.md +0 -6
- package/corpus/templates/videos/changelog/2026-06-28-left-sidebar-collapses-into-an-animated-icon-rail.md +0 -6
- package/corpus/templates/videos/changelog/2026-06-29-video-layouts-adapt-when-the-agent-sidebar-is-open.md +0 -6
- package/corpus/templates/videos/components.json +0 -20
- package/corpus/templates/videos/data/sync-config.json +0 -3
- package/corpus/templates/videos/docs/examples/create-composition.example.ts +0 -128
- package/corpus/templates/videos/drizzle.config.ts +0 -3
- package/corpus/templates/videos/learnings.defaults.md +0 -5
- package/corpus/templates/videos/package.json +0 -114
- package/corpus/templates/videos/public/agent-native-icon-dark.svg +0 -10
- package/corpus/templates/videos/public/agent-native-icon-light.svg +0 -10
- package/corpus/templates/videos/public/agent-native-logo-dark.svg +0 -21
- package/corpus/templates/videos/public/agent-native-logo-light.svg +0 -21
- package/corpus/templates/videos/public/favicon.svg +0 -1
- package/corpus/templates/videos/public/icon-180.svg +0 -1
- package/corpus/templates/videos/public/icon-192.svg +0 -1
- package/corpus/templates/videos/public/icon-512.svg +0 -1
- package/corpus/templates/videos/public/manifest.json +0 -21
- package/corpus/templates/videos/public/placeholder.svg +0 -1
- package/corpus/templates/videos/public/robots.txt +0 -14
- package/corpus/templates/videos/react-router.config.ts +0 -7
- package/corpus/templates/videos/server/db/index.ts +0 -37
- package/corpus/templates/videos/server/db/schema.ts +0 -55
- package/corpus/templates/videos/server/middleware/auth.ts +0 -15
- package/corpus/templates/videos/server/plugins/agent-chat.ts +0 -29
- package/corpus/templates/videos/server/plugins/auth.ts +0 -14
- package/corpus/templates/videos/server/plugins/collab.ts +0 -11
- package/corpus/templates/videos/server/plugins/core-routes.ts +0 -22
- package/corpus/templates/videos/server/plugins/db.ts +0 -160
- package/corpus/templates/videos/server/routes/[...page].get.ts +0 -5
- package/corpus/templates/videos/server/routes/[...page].head.ts +0 -1
- package/corpus/templates/videos/server/routes/api/save-composition-defaults.post.ts +0 -274
- package/corpus/templates/videos/shared/api.ts +0 -75
- package/corpus/templates/videos/ssr-entry.ts +0 -15
- package/corpus/templates/videos/tsconfig.json +0 -21
- package/corpus/templates/videos/vite.config.ts +0 -19
- package/corpus/templates/videos/vitest.config.ts +0 -16
- package/docs/content/locales/ar-SA/template-videos.mdx +0 -336
- package/docs/content/locales/de-DE/template-videos.mdx +0 -336
- package/docs/content/locales/es-ES/template-videos.mdx +0 -336
- package/docs/content/locales/fr-FR/template-videos.mdx +0 -336
- package/docs/content/locales/hi-IN/template-videos.mdx +0 -336
- package/docs/content/locales/ja-JP/template-videos.mdx +0 -336
- package/docs/content/locales/ko-KR/template-videos.mdx +0 -336
- package/docs/content/locales/pt-BR/template-videos.mdx +0 -336
- package/docs/content/locales/zh-CN/template-videos.mdx +0 -334
- package/docs/content/locales/zh-TW/template-videos.mdx +0 -334
- package/docs/content/template-videos.mdx +0 -332
|
@@ -4,16 +4,25 @@ import {
|
|
|
4
4
|
DEFAULT_ASSIGNED_REGION_HEIGHT,
|
|
5
5
|
DEFAULT_ASSIGNED_REGION_MAX_COLUMNS,
|
|
6
6
|
DEFAULT_ASSIGNED_REGION_WIDTH,
|
|
7
|
+
DEFAULT_CANVAS_MAX_ZOOM,
|
|
8
|
+
DEFAULT_CANVAS_MIN_ZOOM,
|
|
7
9
|
DEFAULT_SNAP_THRESHOLD_SCREEN_PX,
|
|
8
|
-
|
|
10
|
+
computeEqualGapGuides,
|
|
9
11
|
computeMoveSnap,
|
|
10
12
|
computeResizeSnap,
|
|
13
|
+
type DistanceGuideBand,
|
|
14
|
+
type EqualGapGuide,
|
|
11
15
|
getDraftGeometryFromPoints,
|
|
12
16
|
getFrameGroupBounds,
|
|
13
17
|
getNudgeDelta,
|
|
14
18
|
getPanForZoomToCursor,
|
|
19
|
+
getResizeCursorForHandle,
|
|
20
|
+
getRotatedFrameAABB,
|
|
15
21
|
resizeFrameGroupFromDelta,
|
|
16
22
|
resizeFrameGroupToBounds,
|
|
23
|
+
resizeRotatedFrameFromDelta,
|
|
24
|
+
rotateFrameGroupAroundCenter,
|
|
25
|
+
rotatedRectIntersects,
|
|
17
26
|
screenToCanvasPoint,
|
|
18
27
|
type ArrowNudgeKey,
|
|
19
28
|
} from "@shared/canvas-math";
|
|
@@ -25,9 +34,15 @@ import {
|
|
|
25
34
|
createCornerNode,
|
|
26
35
|
createSmoothNode,
|
|
27
36
|
getPenPathGeometry,
|
|
37
|
+
hitTestPenAnchor,
|
|
38
|
+
hitTestPenHandle,
|
|
28
39
|
isPenCloseTarget,
|
|
40
|
+
movePenAnchor,
|
|
41
|
+
movePenHandle,
|
|
29
42
|
scalePenPathToGeometry,
|
|
30
43
|
serializePenPath,
|
|
44
|
+
setPenNodeType,
|
|
45
|
+
snapPenAnchorPoint,
|
|
31
46
|
translatePenPath,
|
|
32
47
|
type PenNode,
|
|
33
48
|
type PenPath,
|
|
@@ -392,6 +407,14 @@ interface MultiScreenCanvasProps {
|
|
|
392
407
|
info?: ElementInfo,
|
|
393
408
|
details?: { html?: string },
|
|
394
409
|
) => void;
|
|
410
|
+
/**
|
|
411
|
+
* Figma-style vector edit mode: when present, renders an interactive
|
|
412
|
+
* overlay (draggable anchors + control handles) over `path` and lets the
|
|
413
|
+
* user reshape it. When null/undefined, nothing new renders and existing
|
|
414
|
+
* pen-draw / selection / drag behavior is unaffected. The parent owns the
|
|
415
|
+
* working PenPath state, entering/exiting edit mode, and persistence.
|
|
416
|
+
*/
|
|
417
|
+
vectorEdit?: VectorEditOverlayState | null;
|
|
395
418
|
}
|
|
396
419
|
|
|
397
420
|
/**
|
|
@@ -406,6 +429,9 @@ const SCREEN_GAP = 56;
|
|
|
406
429
|
export const SURFACE_PADDING = 240;
|
|
407
430
|
const DUPLICATE_DRAG_THRESHOLD = 6;
|
|
408
431
|
const DRAG_THRESHOLD = 3;
|
|
432
|
+
/** How close two gaps must be (in screen px, converted to canvas px at the
|
|
433
|
+
* live zoom) to count as "equal" for the smart-spacing guides (CV11). */
|
|
434
|
+
const EQUAL_GAP_TOLERANCE_SCREEN_PX = 2;
|
|
409
435
|
const FRAME_LABEL_HEIGHT = 28;
|
|
410
436
|
const FRAME_HEADER_BUTTON_OUTSIDE_WIDTH = 260;
|
|
411
437
|
const FRAME_HEADER_BUTTON_RESERVE = 116;
|
|
@@ -414,8 +440,21 @@ const TRANSFORM_BADGE_EDGE_PADDING = 8;
|
|
|
414
440
|
const TRANSFORM_BADGE_HEIGHT = 28;
|
|
415
441
|
const TRANSFORM_BADGE_MIN_WIDTH = 64;
|
|
416
442
|
const TRANSFORM_BADGE_MAX_WIDTH = 180;
|
|
417
|
-
|
|
418
|
-
|
|
443
|
+
// Additive zIndex boost for the current "top" screen (selected, else active,
|
|
444
|
+
// else the first frame — see topScreenId). Screens are keyed by screen.id in
|
|
445
|
+
// stable DOM order (see PF16): reordering the top screen's key to the end of
|
|
446
|
+
// the array to win the paint stacking order forced React to move that
|
|
447
|
+
// iframe's DOM node, which reloads its document (a visible white flash).
|
|
448
|
+
// zIndex alone can express "renders above its siblings" without touching DOM
|
|
449
|
+
// order, as long as the boost is large enough to beat any real geometry.z
|
|
450
|
+
// (frame z-order is a small per-design integer) while staying well under the
|
|
451
|
+
// reserved resize-handle stacking range (999_999+).
|
|
452
|
+
const TOP_SCREEN_Z_BOOST = 100_000;
|
|
453
|
+
// Shared with canvas-math.ts (DEFAULT_CANVAS_MIN_ZOOM/DEFAULT_CANVAS_MAX_ZOOM)
|
|
454
|
+
// so this surface's zoom clamp lives in one place instead of being
|
|
455
|
+
// redeclared locally and drifting from the shared constant.
|
|
456
|
+
const MIN_ZOOM = DEFAULT_CANVAS_MIN_ZOOM;
|
|
457
|
+
const MAX_ZOOM = DEFAULT_CANVAS_MAX_ZOOM;
|
|
419
458
|
const ZOOM_SENSITIVITY = 0.01;
|
|
420
459
|
const MAX_WHEEL_ZOOM_DELTA = 120;
|
|
421
460
|
const MAX_WHEEL_PAN_DELTA = 140;
|
|
@@ -722,8 +761,14 @@ const DRAFT_TEXT_WIDTH = 180;
|
|
|
722
761
|
const DRAFT_TEXT_HEIGHT = 48;
|
|
723
762
|
const DRAFT_LINE_WIDTH = 160;
|
|
724
763
|
const DRAFT_PATH_MIN_SIZE = 12;
|
|
725
|
-
const PEN_SAMPLE_DISTANCE_SCREEN_PX = 5;
|
|
726
764
|
const PEN_CLOSE_HIT_RADIUS_SCREEN_PX = 10;
|
|
765
|
+
/** Screen-space hit radius for vector-edit anchor/handle pointer targets,
|
|
766
|
+
* independent of PEN_CLOSE_HIT_RADIUS_SCREEN_PX (that one gates the pen
|
|
767
|
+
* tool's close-path affordance while drawing, not editing an existing
|
|
768
|
+
* path). Converted to canvas px via `screenPxToCanvasPx` before being
|
|
769
|
+
* passed to hitTestPenAnchor/hitTestPenHandle, which operate in canvas
|
|
770
|
+
* space. */
|
|
771
|
+
const VECTOR_EDIT_HIT_RADIUS_SCREEN_PX = 8;
|
|
727
772
|
|
|
728
773
|
interface ResolvedScreenMetadata {
|
|
729
774
|
source: ScreenSourceType;
|
|
@@ -766,6 +811,56 @@ export interface Point {
|
|
|
766
811
|
y: number;
|
|
767
812
|
}
|
|
768
813
|
|
|
814
|
+
/**
|
|
815
|
+
* Interactive vector-edit overlay state, supplied by the parent
|
|
816
|
+
* (DesignEditor) whenever the user is editing an existing path's anchors and
|
|
817
|
+
* control handles on the canvas. `path` is expressed in path-local
|
|
818
|
+
* coordinates (the same space `pen-path.ts` helpers operate in); `originCanvas`
|
|
819
|
+
* is where that path's local origin (0,0) sits in canvas space, so a given
|
|
820
|
+
* local point's canvas position is simply `originCanvas + localPoint`
|
|
821
|
+
* (see `vectorEditLocalToCanvasPoint`). The parent owns the working PenPath
|
|
822
|
+
* state, entering/exiting edit mode, and persistence — this component only
|
|
823
|
+
* renders the overlay and reports pointer interaction back through
|
|
824
|
+
* `onChange`/`onExit`.
|
|
825
|
+
*/
|
|
826
|
+
export interface VectorEditOverlayState {
|
|
827
|
+
path: PenPath;
|
|
828
|
+
originCanvas: Point;
|
|
829
|
+
onChange: (nextPath: PenPath, phase: "preview" | "commit") => void;
|
|
830
|
+
onExit: () => void;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/** Path-local point -> canvas-space point, given the path's canvas origin. */
|
|
834
|
+
export function vectorEditLocalToCanvasPoint(
|
|
835
|
+
local: Point,
|
|
836
|
+
originCanvas: Point,
|
|
837
|
+
): Point {
|
|
838
|
+
return { x: originCanvas.x + local.x, y: originCanvas.y + local.y };
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/** Canvas-space point -> path-local point, given the path's canvas origin.
|
|
842
|
+
* Inverse of `vectorEditLocalToCanvasPoint`. */
|
|
843
|
+
export function vectorEditCanvasToLocalPoint(
|
|
844
|
+
canvasPoint: Point,
|
|
845
|
+
originCanvas: Point,
|
|
846
|
+
): Point {
|
|
847
|
+
return {
|
|
848
|
+
x: canvasPoint.x - originCanvas.x,
|
|
849
|
+
y: canvasPoint.y - originCanvas.y,
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/** Converts a constant on-screen pixel radius (e.g. a hit-test tolerance
|
|
854
|
+
* that should feel the same size regardless of zoom) into canvas px, the
|
|
855
|
+
* space hitTestPenAnchor/hitTestPenHandle and PenPath geometry operate in.
|
|
856
|
+
* `zoom` is the same 0-based-at-100 percentage used throughout this file
|
|
857
|
+
* (100 = 1:1). Mirrors the `screenPx / (zoom / 100)` conversion already
|
|
858
|
+
* used by the pen tool's close-hit-target radius. */
|
|
859
|
+
export function screenPxToCanvasPx(screenPx: number, zoom: number): number {
|
|
860
|
+
const scale = zoom / 100;
|
|
861
|
+
return scale > 0 ? screenPx / scale : screenPx;
|
|
862
|
+
}
|
|
863
|
+
|
|
769
864
|
export type CrossScreenDropPlacement = "before" | "after" | "inside";
|
|
770
865
|
export type CrossScreenDropAxis = "x" | "y";
|
|
771
866
|
export type CrossScreenDropMode = "flow-insert" | "absolute-container";
|
|
@@ -851,6 +946,59 @@ function isCrossScreenHitTestAnchorRect(
|
|
|
851
946
|
);
|
|
852
947
|
}
|
|
853
948
|
|
|
949
|
+
// Snap/guide recompute runs every rAF-coalesced mousemove during a drag and
|
|
950
|
+
// always returns a freshly-allocated array, so referential-equality bail in
|
|
951
|
+
// setState never fires even when the guides are unchanged frame-to-frame
|
|
952
|
+
// (e.g. holding steady mid-drag, or dragging along an axis with no new
|
|
953
|
+
// alignment). Shallow field-compare avoids the wasted re-render (PF15).
|
|
954
|
+
function alignmentGuidesEqual(a: AlignmentGuide[], b: AlignmentGuide[]) {
|
|
955
|
+
if (a === b) return true;
|
|
956
|
+
if (a.length !== b.length) return false;
|
|
957
|
+
for (let i = 0; i < a.length; i++) {
|
|
958
|
+
const x = a[i];
|
|
959
|
+
const y = b[i];
|
|
960
|
+
if (
|
|
961
|
+
x.orientation !== y.orientation ||
|
|
962
|
+
x.position !== y.position ||
|
|
963
|
+
x.start !== y.start ||
|
|
964
|
+
x.end !== y.end
|
|
965
|
+
) {
|
|
966
|
+
return false;
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
return true;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function distanceGuideBandEqual(
|
|
973
|
+
a: DistanceGuideBand,
|
|
974
|
+
b: DistanceGuideBand,
|
|
975
|
+
): boolean {
|
|
976
|
+
return (
|
|
977
|
+
a.gapStart === b.gapStart &&
|
|
978
|
+
a.gapEnd === b.gapEnd &&
|
|
979
|
+
a.crossStart === b.crossStart &&
|
|
980
|
+
a.crossEnd === b.crossEnd
|
|
981
|
+
);
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
function equalGapGuidesEqual(a: EqualGapGuide[], b: EqualGapGuide[]) {
|
|
985
|
+
if (a === b) return true;
|
|
986
|
+
if (a.length !== b.length) return false;
|
|
987
|
+
for (let i = 0; i < a.length; i++) {
|
|
988
|
+
const x = a[i];
|
|
989
|
+
const y = b[i];
|
|
990
|
+
if (
|
|
991
|
+
x.orientation !== y.orientation ||
|
|
992
|
+
x.gap !== y.gap ||
|
|
993
|
+
!distanceGuideBandEqual(x.bands[0], y.bands[0]) ||
|
|
994
|
+
!distanceGuideBandEqual(x.bands[1], y.bands[1])
|
|
995
|
+
) {
|
|
996
|
+
return false;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
return true;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
854
1002
|
export function getCrossScreenDropGuideForHitTest(args: {
|
|
855
1003
|
hit: CrossScreenHitTestResult;
|
|
856
1004
|
targetGeometry: FrameGeometry;
|
|
@@ -963,13 +1111,21 @@ interface DraftPrimitive {
|
|
|
963
1111
|
|
|
964
1112
|
type DraftPrimitiveById = Record<string, DraftPrimitive>;
|
|
965
1113
|
|
|
1114
|
+
/** Live keyboard modifiers for shape-drawing tools: shift constrains rect/
|
|
1115
|
+
* ellipse to a square/circle (and lines/arrows to 45deg increments); alt
|
|
1116
|
+
* draws outward from the start point as the shape's center. */
|
|
1117
|
+
interface DraftGeometryModifiers {
|
|
1118
|
+
shiftKey?: boolean;
|
|
1119
|
+
altKey?: boolean;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
966
1122
|
interface DraftPrimitiveInput {
|
|
967
1123
|
tool: DraftCreationTool;
|
|
968
1124
|
start: Point;
|
|
969
1125
|
end: Point;
|
|
970
|
-
points?: Point[];
|
|
971
1126
|
moved: boolean;
|
|
972
1127
|
toolProps?: CanvasToolProps;
|
|
1128
|
+
modifiers?: DraftGeometryModifiers;
|
|
973
1129
|
}
|
|
974
1130
|
|
|
975
1131
|
interface MarqueeRect {
|
|
@@ -1017,6 +1173,21 @@ interface RotateDragState {
|
|
|
1017
1173
|
hasMoved: boolean;
|
|
1018
1174
|
}
|
|
1019
1175
|
|
|
1176
|
+
/** Multi-selection rotate (CV14): rotates every selected frame together
|
|
1177
|
+
* around the group's own center, using rotateFrameGroupAroundCenter. Kept
|
|
1178
|
+
* as a separate drag-state type from the single-frame RotateDragState above
|
|
1179
|
+
* (rather than extending it to optionally hold multiple ids) so the
|
|
1180
|
+
* existing, already-correct single-frame rotate path is never touched. */
|
|
1181
|
+
interface GroupRotateDragState {
|
|
1182
|
+
type: "group-rotate";
|
|
1183
|
+
originClient: Point;
|
|
1184
|
+
originFrames: FrameGeometryById;
|
|
1185
|
+
targetIds: string[];
|
|
1186
|
+
groupCenter: Point;
|
|
1187
|
+
originPointerAngle: number;
|
|
1188
|
+
hasMoved: boolean;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1020
1191
|
interface MarqueeDragState {
|
|
1021
1192
|
type: "marquee";
|
|
1022
1193
|
originClient: Point;
|
|
@@ -1068,6 +1239,40 @@ interface PenNodeDragState {
|
|
|
1068
1239
|
anchor: Point;
|
|
1069
1240
|
pathBefore: PenPath | null;
|
|
1070
1241
|
hasMoved: boolean;
|
|
1242
|
+
/**
|
|
1243
|
+
* True when this drag started on the close-hit-target (the path's first
|
|
1244
|
+
* anchor) rather than adding a new node. Figma defers the close commit
|
|
1245
|
+
* until mouseup so a drag on the closing click can shape the closing
|
|
1246
|
+
* segment's curve (the first anchor's handleIn) instead of the click
|
|
1247
|
+
* being an instant, undraggable straight-line close.
|
|
1248
|
+
*/
|
|
1249
|
+
closing?: boolean;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
/** Dragging an anchor square of a `vectorEdit` overlay path (P-VE1). Anchor
|
|
1253
|
+
* drags move the whole node (point + handles, via movePenAnchor's default
|
|
1254
|
+
* moveHandlesWithAnchor:true) rather than reshaping a single handle. */
|
|
1255
|
+
interface VectorEditAnchorDragState {
|
|
1256
|
+
type: "vector-anchor";
|
|
1257
|
+
originClient: Point;
|
|
1258
|
+
nodeIndex: number;
|
|
1259
|
+
/** Path snapshot (local coords) from just before this drag began, restored
|
|
1260
|
+
* on cancel. */
|
|
1261
|
+
pathBefore: PenPath;
|
|
1262
|
+
hasMoved: boolean;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
/** Dragging a control-handle circle of a `vectorEdit` overlay path (P-VE1).
|
|
1266
|
+
* Alt/Option held during the drag breaks handle symmetry into a cusp
|
|
1267
|
+
* (movePenHandle's breakSymmetry), matching the pen tool's own alt
|
|
1268
|
+
* behavior while placing a fresh anchor. */
|
|
1269
|
+
interface VectorEditHandleDragState {
|
|
1270
|
+
type: "vector-handle";
|
|
1271
|
+
originClient: Point;
|
|
1272
|
+
nodeIndex: number;
|
|
1273
|
+
which: "in" | "out";
|
|
1274
|
+
pathBefore: PenPath;
|
|
1275
|
+
hasMoved: boolean;
|
|
1071
1276
|
}
|
|
1072
1277
|
|
|
1073
1278
|
interface DraftCreationPreview {
|
|
@@ -1080,12 +1285,15 @@ type DragState =
|
|
|
1080
1285
|
| MoveDragState
|
|
1081
1286
|
| ResizeDragState
|
|
1082
1287
|
| RotateDragState
|
|
1288
|
+
| GroupRotateDragState
|
|
1083
1289
|
| MarqueeDragState
|
|
1084
1290
|
| PanDragState
|
|
1085
1291
|
| DraftMoveDragState
|
|
1086
1292
|
| DraftResizeDragState
|
|
1087
1293
|
| DraftCreateDragState
|
|
1088
|
-
| PenNodeDragState
|
|
1294
|
+
| PenNodeDragState
|
|
1295
|
+
| VectorEditAnchorDragState
|
|
1296
|
+
| VectorEditHandleDragState;
|
|
1089
1297
|
|
|
1090
1298
|
type PendingWheelGesture =
|
|
1091
1299
|
| {
|
|
@@ -1101,7 +1309,14 @@ type PendingWheelGesture =
|
|
|
1101
1309
|
deltaY: number;
|
|
1102
1310
|
};
|
|
1103
1311
|
|
|
1104
|
-
|
|
1312
|
+
// PF8: DesignEditor re-renders on nearly every interaction (drag, hover,
|
|
1313
|
+
// zoom, selection). All call-site props are stabilized in DesignEditor.tsx
|
|
1314
|
+
// (useMemo/useCallback hoists, cached getLayerSelectorsForFile, hoisted
|
|
1315
|
+
// renderScreenContent, hoisted board-surface callbacks — see the "PF8"
|
|
1316
|
+
// comments near those definitions), so a default shallow comparator is
|
|
1317
|
+
// sufficient and preferred: it will correctly re-render whenever a real
|
|
1318
|
+
// data/callback identity changes instead of silently masking one.
|
|
1319
|
+
export const MultiScreenCanvas = memo(function MultiScreenCanvas({
|
|
1105
1320
|
screens,
|
|
1106
1321
|
zoom,
|
|
1107
1322
|
activeId,
|
|
@@ -1164,6 +1379,7 @@ export function MultiScreenCanvas({
|
|
|
1164
1379
|
onBoardVisualStyleChange,
|
|
1165
1380
|
onBoardVisualDuplicateChange,
|
|
1166
1381
|
onBoardTextContentChange,
|
|
1382
|
+
vectorEdit,
|
|
1167
1383
|
}: MultiScreenCanvasProps) {
|
|
1168
1384
|
const t = useT();
|
|
1169
1385
|
const surfaceRef = useRef<HTMLDivElement>(null);
|
|
@@ -1189,6 +1405,16 @@ export function MultiScreenCanvas({
|
|
|
1189
1405
|
);
|
|
1190
1406
|
const [penPointer, setPenPointer] = useState<Point | null>(null);
|
|
1191
1407
|
const [penCloseHover, setPenCloseHover] = useState(false);
|
|
1408
|
+
// Last raw client point the pen ghost/close-hover preview was computed
|
|
1409
|
+
// from (P18). A wheel pan/zoom gesture mutates pan/zoom every animation
|
|
1410
|
+
// frame via applyViewToDom without the mouse itself moving, so the
|
|
1411
|
+
// screen->canvas mapping used for the ghost segment goes stale unless we
|
|
1412
|
+
// re-derive it from this remembered client point after each such change.
|
|
1413
|
+
const lastPenClientPointRef = useRef<{
|
|
1414
|
+
clientX: number;
|
|
1415
|
+
clientY: number;
|
|
1416
|
+
shiftKey: boolean;
|
|
1417
|
+
} | null>(null);
|
|
1192
1418
|
const [localActiveTool, setLocalActiveTool] =
|
|
1193
1419
|
useState<MultiScreenCanvasTool>("move");
|
|
1194
1420
|
const [selectedIds, setSelectedIds] = useState<string[]>(
|
|
@@ -1203,7 +1429,25 @@ export function MultiScreenCanvas({
|
|
|
1203
1429
|
const [isDragging, setIsDragging] = useState(false);
|
|
1204
1430
|
const [isPanning, setIsPanning] = useState(false);
|
|
1205
1431
|
const [marquee, setMarquee] = useState<MarqueeRect | null>(null);
|
|
1206
|
-
const
|
|
1432
|
+
const marqueeRef = useRef<MarqueeRect | null>(marquee);
|
|
1433
|
+
const [alignmentGuides, setAlignmentGuidesRaw] = useState<AlignmentGuide[]>(
|
|
1434
|
+
[],
|
|
1435
|
+
);
|
|
1436
|
+
const [equalGapGuides, setEqualGapGuidesRaw] = useState<EqualGapGuide[]>([]);
|
|
1437
|
+
// Guides are recomputed into a brand-new array on every rAF-coalesced
|
|
1438
|
+
// mousemove during a drag; without a value-equality bail, React commits a
|
|
1439
|
+
// state update (and a re-render) every frame even when the guides drawn on
|
|
1440
|
+
// screen haven't actually changed (PF15). Bail before calling setState.
|
|
1441
|
+
const setAlignmentGuides = useCallback((next: AlignmentGuide[]) => {
|
|
1442
|
+
setAlignmentGuidesRaw((current) =>
|
|
1443
|
+
alignmentGuidesEqual(current, next) ? current : next,
|
|
1444
|
+
);
|
|
1445
|
+
}, []);
|
|
1446
|
+
const setEqualGapGuides = useCallback((next: EqualGapGuide[]) => {
|
|
1447
|
+
setEqualGapGuidesRaw((current) =>
|
|
1448
|
+
equalGapGuidesEqual(current, next) ? current : next,
|
|
1449
|
+
);
|
|
1450
|
+
}, []);
|
|
1207
1451
|
const [duplicatePreview, setDuplicatePreview] =
|
|
1208
1452
|
useState<DuplicatePreview | null>(null);
|
|
1209
1453
|
const [transformBadge, setTransformBadge] = useState<TransformBadge | null>(
|
|
@@ -1214,6 +1458,12 @@ export function MultiScreenCanvas({
|
|
|
1214
1458
|
useState<PrimitiveDropTarget | null>(null);
|
|
1215
1459
|
const primitiveDropTargetRef = useRef<PrimitiveDropTarget | null>(null);
|
|
1216
1460
|
const onPrimitiveReparentRef = useRef(onPrimitiveReparent);
|
|
1461
|
+
// Mirrors the `vectorEdit` prop so long-lived mousemove/mouseup closures
|
|
1462
|
+
// created at drag-start (beginVectorAnchorDrag/beginVectorHandleDrag) always
|
|
1463
|
+
// read the current path/onChange even if the prop identity changes mid-drag
|
|
1464
|
+
// (e.g. a re-render from the preview onChange itself), rather than closing
|
|
1465
|
+
// over a snapshot from the moment the drag began.
|
|
1466
|
+
const vectorEditRef = useRef(vectorEdit);
|
|
1217
1467
|
|
|
1218
1468
|
// Cross-screen element drag state — driven by postMessage from the source iframe.
|
|
1219
1469
|
interface CrossScreenDragGhost {
|
|
@@ -1254,17 +1504,47 @@ export function MultiScreenCanvas({
|
|
|
1254
1504
|
const crossScreenLastBoardPointRef = useRef<{ x: number; y: number } | null>(
|
|
1255
1505
|
null,
|
|
1256
1506
|
);
|
|
1507
|
+
/** rAF handle for throttling drop-guide hit-tests during the parent-window
|
|
1508
|
+
* mousemove fallback drag (see activateParentDrag) — a hit-test is a
|
|
1509
|
+
* postMessage round-trip to the target iframe, so firing one per raw
|
|
1510
|
+
* mousemove event (which can be dozens per frame) floods the message
|
|
1511
|
+
* channel for no visual benefit beyond one update per animation frame. */
|
|
1512
|
+
const crossScreenMoveRafRef = useRef<number | null>(null);
|
|
1513
|
+
const crossScreenPendingMoveRef = useRef<{
|
|
1514
|
+
boardPoint: Point;
|
|
1515
|
+
sourceScreenId: string;
|
|
1516
|
+
} | null>(null);
|
|
1517
|
+
/** Last successful hit-test result per target screen id, so a timed-out
|
|
1518
|
+
* request (bridge script briefly busy, iframe still loading, etc.) can
|
|
1519
|
+
* fall back to the previous guide instead of resolving empty and making
|
|
1520
|
+
* the drop guide flicker away every time a single hit-test is slow. */
|
|
1521
|
+
const crossScreenLastHitResultRef = useRef<
|
|
1522
|
+
Map<string, CrossScreenHitTestResult>
|
|
1523
|
+
>(new Map());
|
|
1257
1524
|
const onCrossScreenElementDropRef = useRef(onCrossScreenElementDrop);
|
|
1258
1525
|
const onBoardDrawPrimitiveRef = useRef(onBoardDrawPrimitive);
|
|
1259
1526
|
// Ref wrapper for finishDrag so callbacks declared before finishDrag can
|
|
1260
1527
|
// reference it via the ref without hitting the const TDZ.
|
|
1261
1528
|
const finishDragRef = useRef<() => void>(() => {});
|
|
1529
|
+
// Ref wrappers for applyViewToDom/scheduleViewCommit (defined later, near
|
|
1530
|
+
// the wheel/pinch gesture path) so beginPan — declared earlier — can reuse
|
|
1531
|
+
// the same imperative-transform-during-gesture pattern without a TDZ error.
|
|
1532
|
+
const applyViewToDomRef = useRef<() => void>(() => {});
|
|
1533
|
+
const scheduleViewCommitRef = useRef<
|
|
1534
|
+
(options?: { settleChrome?: boolean }) => void
|
|
1535
|
+
>(() => {});
|
|
1536
|
+
// Ref wrapper for recomputePenPointerForViewChange (P18, defined later
|
|
1537
|
+
// near updatePenPointer) so the external `zoom` prop sync effect —
|
|
1538
|
+
// declared earlier — can resync the pen ghost preview after an
|
|
1539
|
+
// externally-driven (toolbar/keyboard) zoom change without a TDZ error.
|
|
1540
|
+
const recomputePenPointerForViewChangeRef = useRef<() => void>(() => {});
|
|
1262
1541
|
const suppressNextPick = useRef(false);
|
|
1263
1542
|
const feedbackTimerRef = useRef<number | null>(null);
|
|
1264
1543
|
const pendingWheelGestureRef = useRef<PendingWheelGesture | null>(null);
|
|
1265
1544
|
const wheelGestureFrameRef = useRef<number | null>(null);
|
|
1266
1545
|
const worldRef = useRef<HTMLDivElement>(null);
|
|
1267
1546
|
const pixelGridRef = useRef<HTMLDivElement>(null);
|
|
1547
|
+
const marqueeOverlayRef = useRef<HTMLSpanElement>(null);
|
|
1268
1548
|
const viewCommitTimerRef = useRef<number | null>(null);
|
|
1269
1549
|
const pendingChromeSettleRef = useRef(false);
|
|
1270
1550
|
const chromeSettleTimerRef = useRef<number | null>(null);
|
|
@@ -1309,6 +1589,10 @@ export function MultiScreenCanvas({
|
|
|
1309
1589
|
onPrimitiveReparentRef.current = onPrimitiveReparent;
|
|
1310
1590
|
}, [onPrimitiveReparent]);
|
|
1311
1591
|
|
|
1592
|
+
useEffect(() => {
|
|
1593
|
+
vectorEditRef.current = vectorEdit;
|
|
1594
|
+
}, [vectorEdit]);
|
|
1595
|
+
|
|
1312
1596
|
useEffect(() => {
|
|
1313
1597
|
onCrossScreenElementDropRef.current = onCrossScreenElementDrop;
|
|
1314
1598
|
}, [onCrossScreenElementDrop]);
|
|
@@ -1327,12 +1611,18 @@ export function MultiScreenCanvas({
|
|
|
1327
1611
|
|
|
1328
1612
|
const updateFrameGeometry = useCallback(
|
|
1329
1613
|
(updater: (current: FrameGeometryById) => FrameGeometryById) => {
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1614
|
+
// Compute the next value from the ref (kept in sync below and by the
|
|
1615
|
+
// frameGeometry-mirroring effect) and call the onGeometryChange side
|
|
1616
|
+
// effect *after* setFrameGeometry, not inside the updater passed to
|
|
1617
|
+
// it. React (especially StrictMode, which double-invokes state
|
|
1618
|
+
// updaters to surface impure updates) may call an updater function
|
|
1619
|
+
// more than once per commit — doing the ref write + external callback
|
|
1620
|
+
// inside it would double-fire onGeometryChange for a single logical
|
|
1621
|
+
// geometry change.
|
|
1622
|
+
const next = updater(frameGeometryRef.current);
|
|
1623
|
+
frameGeometryRef.current = next;
|
|
1624
|
+
setFrameGeometry(next);
|
|
1625
|
+
onGeometryChangeRef.current?.(next);
|
|
1336
1626
|
},
|
|
1337
1627
|
[],
|
|
1338
1628
|
);
|
|
@@ -1387,6 +1677,10 @@ export function MultiScreenCanvas({
|
|
|
1387
1677
|
panRef.current = pan;
|
|
1388
1678
|
}, [pan]);
|
|
1389
1679
|
|
|
1680
|
+
useEffect(() => {
|
|
1681
|
+
marqueeRef.current = marquee;
|
|
1682
|
+
}, [marquee]);
|
|
1683
|
+
|
|
1390
1684
|
useEffect(() => {
|
|
1391
1685
|
zoomRef.current = canvasZoom;
|
|
1392
1686
|
}, [canvasZoom]);
|
|
@@ -1436,8 +1730,39 @@ export function MultiScreenCanvas({
|
|
|
1436
1730
|
}, [selectedDraftIds]);
|
|
1437
1731
|
|
|
1438
1732
|
useEffect(() => {
|
|
1733
|
+
// zoomRef.current is the canvas's own last-known zoom, kept in sync
|
|
1734
|
+
// synchronously by every internal zoom path (wheel/pinch commitView,
|
|
1735
|
+
// fit-to-screen) *before* those paths call onZoomChange. So if it
|
|
1736
|
+
// already matches the incoming prop, this change originated from our own
|
|
1737
|
+
// gesture round-tripping back through a controlled `zoom` prop — that
|
|
1738
|
+
// path already applied its own (cursor-anchored) pan compensation, and
|
|
1739
|
+
// redoing it here with a surface-center anchor would double-shift pan.
|
|
1740
|
+
// Only compensate when this is a genuinely external change (toolbar
|
|
1741
|
+
// buttons, keyboard shortcuts) that never touched zoomRef/panRef.
|
|
1742
|
+
const previousZoom = zoomRef.current;
|
|
1743
|
+
if (zoom === previousZoom) return;
|
|
1744
|
+
// External zoom changes otherwise anchor at world origin (0,0) since
|
|
1745
|
+
// only canvasZoom is updated here — content visibly jumps diagonally
|
|
1746
|
+
// instead of zooming in place. Mirror the wheel/pinch cursor-anchored
|
|
1747
|
+
// compensation using the surface's own center as the anchor, since
|
|
1748
|
+
// there's no cursor position for a toolbar/keyboard-driven zoom change.
|
|
1749
|
+
const rect = surfaceRef.current?.getBoundingClientRect();
|
|
1750
|
+
const cursor = rect
|
|
1751
|
+
? { x: rect.width / 2, y: rect.height / 2 }
|
|
1752
|
+
: { x: 0, y: 0 };
|
|
1753
|
+
const nextPan = getPanForZoomToCursor({
|
|
1754
|
+
pan: panRef.current,
|
|
1755
|
+
cursor,
|
|
1756
|
+
oldZoom: previousZoom,
|
|
1757
|
+
nextZoom: zoom,
|
|
1758
|
+
});
|
|
1759
|
+
panRef.current = nextPan;
|
|
1760
|
+
setPan(nextPan);
|
|
1439
1761
|
setCanvasZoom(zoom);
|
|
1440
1762
|
zoomRef.current = zoom;
|
|
1763
|
+
// P18: an externally-driven zoom change (toolbar/keyboard) also moves
|
|
1764
|
+
// the canvas-space mapping the pen ghost preview was computed from.
|
|
1765
|
+
recomputePenPointerForViewChangeRef.current();
|
|
1441
1766
|
}, [zoom]);
|
|
1442
1767
|
|
|
1443
1768
|
useEffect(() => {
|
|
@@ -1703,8 +2028,12 @@ export function MultiScreenCanvas({
|
|
|
1703
2028
|
|
|
1704
2029
|
const postHitTestPreviewClear = (targetId: string | null | undefined) => {
|
|
1705
2030
|
if (!targetId) return;
|
|
2031
|
+
const targetScreen = screensRef.current.find((s) => s.id === targetId);
|
|
2032
|
+
const iframeId = targetScreen
|
|
2033
|
+
? getActiveScreenIframeId(targetScreen)
|
|
2034
|
+
: targetId;
|
|
1706
2035
|
const targetIframe = surfaceRef.current?.querySelector<HTMLIFrameElement>(
|
|
1707
|
-
`[data-screen-iframe-id="${CSS.escape(
|
|
2036
|
+
`[data-screen-iframe-id="${CSS.escape(iframeId)}"]`,
|
|
1708
2037
|
);
|
|
1709
2038
|
targetIframe?.contentWindow?.postMessage(
|
|
1710
2039
|
{ type: "agent-native:hit-test-preview-clear" },
|
|
@@ -1736,6 +2065,9 @@ export function MultiScreenCanvas({
|
|
|
1736
2065
|
clearCrossScreenDropGuide();
|
|
1737
2066
|
crossScreenTargetRef.current = null;
|
|
1738
2067
|
crossScreenDragMsgRef.current = null;
|
|
2068
|
+
// Drop the timeout-fallback cache so a future, unrelated drag session
|
|
2069
|
+
// never shows a guide left over from this one.
|
|
2070
|
+
crossScreenLastHitResultRef.current.clear();
|
|
1739
2071
|
};
|
|
1740
2072
|
|
|
1741
2073
|
const runHitTest = (
|
|
@@ -1748,8 +2080,9 @@ export function MultiScreenCanvas({
|
|
|
1748
2080
|
);
|
|
1749
2081
|
if (!targetScreen) return Promise.resolve({});
|
|
1750
2082
|
const targetGeometry = candidate.geometry;
|
|
2083
|
+
const targetIframeId = CSS.escape(getActiveScreenIframeId(targetScreen));
|
|
1751
2084
|
const targetIframe = surfaceRef.current?.querySelector<HTMLIFrameElement>(
|
|
1752
|
-
`[data-screen-iframe-id="${
|
|
2085
|
+
`[data-screen-iframe-id="${targetIframeId}"]`,
|
|
1753
2086
|
);
|
|
1754
2087
|
const targetContentWindow = targetIframe?.contentWindow;
|
|
1755
2088
|
if (!targetContentWindow) return Promise.resolve({});
|
|
@@ -1769,20 +2102,28 @@ export function MultiScreenCanvas({
|
|
|
1769
2102
|
return new Promise((resolve) => {
|
|
1770
2103
|
const timer = window.setTimeout(() => {
|
|
1771
2104
|
window.removeEventListener("message", hitListener);
|
|
1772
|
-
|
|
1773
|
-
|
|
2105
|
+
// Fall back to the last successful result for this target instead
|
|
2106
|
+
// of resolving empty — a single slow reply (bridge script briefly
|
|
2107
|
+
// busy, iframe mid-navigation, etc.) shouldn't make the drop guide
|
|
2108
|
+
// flicker away and immediately reappear on the next hit-test.
|
|
2109
|
+
resolve(crossScreenLastHitResultRef.current.get(candidate.id) ?? {});
|
|
2110
|
+
}, 250);
|
|
1774
2111
|
|
|
1775
2112
|
const hitListener = (ev: MessageEvent) => {
|
|
1776
2113
|
if (
|
|
1777
2114
|
!ev.data ||
|
|
1778
2115
|
ev.data.type !== "agent-native:hit-test-result" ||
|
|
1779
|
-
ev.data.correlationId !== correlationId
|
|
2116
|
+
ev.data.correlationId !== correlationId ||
|
|
2117
|
+
// Require the reply to actually come from the iframe we asked,
|
|
2118
|
+
// not just any window that happens to observe/guess the
|
|
2119
|
+
// correlationId and reply with a matching payload shape.
|
|
2120
|
+
ev.source !== targetContentWindow
|
|
1780
2121
|
) {
|
|
1781
2122
|
return;
|
|
1782
2123
|
}
|
|
1783
2124
|
window.clearTimeout(timer);
|
|
1784
2125
|
window.removeEventListener("message", hitListener);
|
|
1785
|
-
|
|
2126
|
+
const result: CrossScreenHitTestResult = {
|
|
1786
2127
|
anchorNodeId:
|
|
1787
2128
|
typeof ev.data.anchorNodeId === "string"
|
|
1788
2129
|
? ev.data.anchorNodeId
|
|
@@ -1799,7 +2140,9 @@ export function MultiScreenCanvas({
|
|
|
1799
2140
|
anchorRect: isCrossScreenHitTestAnchorRect(ev.data.anchorRect)
|
|
1800
2141
|
? ev.data.anchorRect
|
|
1801
2142
|
: undefined,
|
|
1802
|
-
}
|
|
2143
|
+
};
|
|
2144
|
+
crossScreenLastHitResultRef.current.set(candidate.id, result);
|
|
2145
|
+
resolve(result);
|
|
1803
2146
|
};
|
|
1804
2147
|
window.addEventListener("message", hitListener);
|
|
1805
2148
|
|
|
@@ -1828,8 +2171,9 @@ export function MultiScreenCanvas({
|
|
|
1828
2171
|
(s) => s.id === candidate.id,
|
|
1829
2172
|
);
|
|
1830
2173
|
if (!targetScreen) return null;
|
|
2174
|
+
const targetIframeId = CSS.escape(getActiveScreenIframeId(targetScreen));
|
|
1831
2175
|
const targetIframe = surfaceRef.current?.querySelector<HTMLIFrameElement>(
|
|
1832
|
-
`[data-screen-iframe-id="${
|
|
2176
|
+
`[data-screen-iframe-id="${targetIframeId}"]`,
|
|
1833
2177
|
);
|
|
1834
2178
|
const targetViewportWidth =
|
|
1835
2179
|
targetIframe?.clientWidth || getResolvedMetadata(targetScreen).width;
|
|
@@ -1978,6 +2322,31 @@ export function MultiScreenCanvas({
|
|
|
1978
2322
|
if (!event.data || event.data.type !== "agent-native:cross-screen-drag") {
|
|
1979
2323
|
return;
|
|
1980
2324
|
}
|
|
2325
|
+
// Resolve which of our own embedded design-preview iframes actually
|
|
2326
|
+
// posted this message — postMessage's origin/source aren't otherwise
|
|
2327
|
+
// checked here, so without this any window (including a spoofed one)
|
|
2328
|
+
// could claim to be a screen. Same pattern as
|
|
2329
|
+
// handleEmbeddedWheelMessage below. Bind to the matched iframe element
|
|
2330
|
+
// itself rather than trusting the payload: a compromised/owned preview
|
|
2331
|
+
// iframe can put any `screenId` it wants in the message body, so the
|
|
2332
|
+
// sender's true screen identity must come from the DOM, not the data.
|
|
2333
|
+
const surfaceForSourceCheck = surfaceRef.current;
|
|
2334
|
+
const sourcePreviewIframe = surfaceForSourceCheck
|
|
2335
|
+
? Array.from(
|
|
2336
|
+
surfaceForSourceCheck.querySelectorAll<HTMLIFrameElement>(
|
|
2337
|
+
"iframe[data-design-preview-iframe]",
|
|
2338
|
+
),
|
|
2339
|
+
).find((iframe) => iframe.contentWindow === event.source)
|
|
2340
|
+
: undefined;
|
|
2341
|
+
if (!sourcePreviewIframe) return;
|
|
2342
|
+
// The board's DesignCanvas renders with `boardSurface` set, which
|
|
2343
|
+
// deliberately omits `data-screen-iframe-id` (see DesignCanvas.tsx), so
|
|
2344
|
+
// an unset attribute here means the message came from the board
|
|
2345
|
+
// surface iframe rather than a per-screen one.
|
|
2346
|
+
const domScreenId =
|
|
2347
|
+
sourcePreviewIframe.getAttribute("data-screen-iframe-id") ??
|
|
2348
|
+
boardFileId ??
|
|
2349
|
+
undefined;
|
|
1981
2350
|
const msg = event.data as {
|
|
1982
2351
|
type: string;
|
|
1983
2352
|
phase: "start" | "move" | "end" | "cancel";
|
|
@@ -2012,12 +2381,17 @@ export function MultiScreenCanvas({
|
|
|
2012
2381
|
return;
|
|
2013
2382
|
}
|
|
2014
2383
|
|
|
2015
|
-
//
|
|
2016
|
-
//
|
|
2384
|
+
// Attribute the drag to the DOM-verified source iframe's own screen id,
|
|
2385
|
+
// never the payload's claimed `msg.screenId` — the message body is
|
|
2386
|
+
// authored by the (sandboxed but same-origin-capable) preview iframe
|
|
2387
|
+
// content and must not be trusted to identify itself. Fall back to
|
|
2388
|
+
// activeId only when the matched iframe unexpectedly has no resolvable
|
|
2389
|
+
// screen id (e.g. a stale/unknown screen), matching prior behavior for
|
|
2390
|
+
// that edge case.
|
|
2017
2391
|
const sourceScreenId =
|
|
2018
|
-
|
|
2019
|
-
(
|
|
2020
|
-
?
|
|
2392
|
+
domScreenId &&
|
|
2393
|
+
(domScreenId === boardFileId || frameGeometryRef.current[domScreenId])
|
|
2394
|
+
? domScreenId
|
|
2021
2395
|
: activeId;
|
|
2022
2396
|
if (!sourceScreenId) {
|
|
2023
2397
|
// Always clear visual state (ghost + highlight) when we have no active
|
|
@@ -2042,6 +2416,23 @@ export function MultiScreenCanvas({
|
|
|
2042
2416
|
surfaceRef.current,
|
|
2043
2417
|
);
|
|
2044
2418
|
let didCleanup = false;
|
|
2419
|
+
const cancelPendingParentDrag = () => {
|
|
2420
|
+
if (crossScreenMoveRafRef.current !== null) {
|
|
2421
|
+
window.cancelAnimationFrame(crossScreenMoveRafRef.current);
|
|
2422
|
+
crossScreenMoveRafRef.current = null;
|
|
2423
|
+
}
|
|
2424
|
+
crossScreenPendingMoveRef.current = null;
|
|
2425
|
+
};
|
|
2426
|
+
const flushPendingParentDrag = () => {
|
|
2427
|
+
crossScreenMoveRafRef.current = null;
|
|
2428
|
+
const pending = crossScreenPendingMoveRef.current;
|
|
2429
|
+
crossScreenPendingMoveRef.current = null;
|
|
2430
|
+
if (!pending) return;
|
|
2431
|
+
updateCrossScreenTargetFromBoardPoint(
|
|
2432
|
+
pending.boardPoint,
|
|
2433
|
+
pending.sourceScreenId,
|
|
2434
|
+
);
|
|
2435
|
+
};
|
|
2045
2436
|
const activateParentDrag = (ev: MouseEvent) => {
|
|
2046
2437
|
ev.preventDefault();
|
|
2047
2438
|
updateCrossScreenTargetFromBoardPoint(
|
|
@@ -2050,9 +2441,26 @@ export function MultiScreenCanvas({
|
|
|
2050
2441
|
);
|
|
2051
2442
|
};
|
|
2052
2443
|
const handleParentMouseMove = (ev: MouseEvent) => {
|
|
2053
|
-
|
|
2444
|
+
ev.preventDefault();
|
|
2445
|
+
// Each drop-guide update is a postMessage round-trip to the target
|
|
2446
|
+
// iframe (see requestCrossScreenDropGuide/runHitTest). Coalesce
|
|
2447
|
+
// rapid mousemove events down to one hit-test per animation frame
|
|
2448
|
+
// instead of firing one per raw event.
|
|
2449
|
+
crossScreenPendingMoveRef.current = {
|
|
2450
|
+
boardPoint: getCanvasPoint(ev.clientX, ev.clientY),
|
|
2451
|
+
sourceScreenId,
|
|
2452
|
+
};
|
|
2453
|
+
if (crossScreenMoveRafRef.current === null) {
|
|
2454
|
+
crossScreenMoveRafRef.current = window.requestAnimationFrame(
|
|
2455
|
+
flushPendingParentDrag,
|
|
2456
|
+
);
|
|
2457
|
+
}
|
|
2054
2458
|
};
|
|
2055
2459
|
const handleParentMouseUp = (ev: MouseEvent) => {
|
|
2460
|
+
// Flush synchronously with the true final pointer position on
|
|
2461
|
+
// release — don't wait for a throttled rAF that may not run
|
|
2462
|
+
// before finalizeCrossScreenDrop reads crossScreenTargetRef.
|
|
2463
|
+
cancelPendingParentDrag();
|
|
2056
2464
|
activateParentDrag(ev);
|
|
2057
2465
|
const candidate = crossScreenTargetRef.current;
|
|
2058
2466
|
const payload = crossScreenDragMsgRef.current ?? {
|
|
@@ -2071,11 +2479,13 @@ export function MultiScreenCanvas({
|
|
|
2071
2479
|
);
|
|
2072
2480
|
};
|
|
2073
2481
|
const handleParentWindowBlur = () => {
|
|
2482
|
+
cancelPendingParentDrag();
|
|
2074
2483
|
clearCrossScreenDrag();
|
|
2075
2484
|
};
|
|
2076
2485
|
const cleanup = () => {
|
|
2077
2486
|
if (didCleanup) return;
|
|
2078
2487
|
didCleanup = true;
|
|
2488
|
+
cancelPendingParentDrag();
|
|
2079
2489
|
window.removeEventListener("mousemove", handleParentMouseMove, true);
|
|
2080
2490
|
window.removeEventListener("mouseup", handleParentMouseUp, true);
|
|
2081
2491
|
window.removeEventListener("blur", handleParentWindowBlur, true);
|
|
@@ -2265,24 +2675,57 @@ export function MultiScreenCanvas({
|
|
|
2265
2675
|
surfaceRef.current,
|
|
2266
2676
|
);
|
|
2267
2677
|
let lastMouseEvent: MouseEvent | null = null;
|
|
2678
|
+
// rAF-coalesce raw mousemove: a drag/pan/marquee gesture can fire many
|
|
2679
|
+
// mousemove events per frame, but the handler recomputes snap/geometry
|
|
2680
|
+
// and commits React state — doing that per-event (rather than per
|
|
2681
|
+
// frame) is the dominant cost during a drag (see PF15 in perf report).
|
|
2682
|
+
// We keep only the latest event and flush it once per animation frame
|
|
2683
|
+
// (latest-wins). Flushing is forced synchronously before mouseup/blur
|
|
2684
|
+
// so the gesture always ends on the true final pointer position.
|
|
2685
|
+
let pendingMoveFrame: number | null = null;
|
|
2686
|
+
const flushPendingMove = () => {
|
|
2687
|
+
if (pendingMoveFrame !== null) {
|
|
2688
|
+
window.cancelAnimationFrame(pendingMoveFrame);
|
|
2689
|
+
pendingMoveFrame = null;
|
|
2690
|
+
}
|
|
2691
|
+
if (lastMouseEvent) {
|
|
2692
|
+
handleMouseMove(lastMouseEvent);
|
|
2693
|
+
}
|
|
2694
|
+
};
|
|
2268
2695
|
const move = (ev: MouseEvent) => {
|
|
2269
2696
|
lastMouseEvent = ev;
|
|
2270
2697
|
ev.preventDefault();
|
|
2271
|
-
|
|
2698
|
+
if (pendingMoveFrame !== null) return;
|
|
2699
|
+
pendingMoveFrame = window.requestAnimationFrame(() => {
|
|
2700
|
+
pendingMoveFrame = null;
|
|
2701
|
+
if (lastMouseEvent) handleMouseMove(lastMouseEvent);
|
|
2702
|
+
});
|
|
2272
2703
|
};
|
|
2273
2704
|
const up = (ev: MouseEvent) => {
|
|
2274
2705
|
lastMouseEvent = ev;
|
|
2275
2706
|
ev.preventDefault();
|
|
2707
|
+
// Flush any coalesced move first so the final drop position reflects
|
|
2708
|
+
// this exact event, then run the up handler with it.
|
|
2709
|
+
flushPendingMove();
|
|
2276
2710
|
handleMouseUp(ev);
|
|
2277
2711
|
};
|
|
2278
2712
|
const cleanupOnBlur = () => {
|
|
2279
2713
|
if (handleCancel) {
|
|
2714
|
+
if (pendingMoveFrame !== null) {
|
|
2715
|
+
window.cancelAnimationFrame(pendingMoveFrame);
|
|
2716
|
+
pendingMoveFrame = null;
|
|
2717
|
+
}
|
|
2280
2718
|
handleCancel();
|
|
2281
2719
|
return;
|
|
2282
2720
|
}
|
|
2721
|
+
flushPendingMove();
|
|
2283
2722
|
handleMouseUp(lastMouseEvent ?? new MouseEvent("mouseup"));
|
|
2284
2723
|
};
|
|
2285
2724
|
dragCleanup.current = () => {
|
|
2725
|
+
if (pendingMoveFrame !== null) {
|
|
2726
|
+
window.cancelAnimationFrame(pendingMoveFrame);
|
|
2727
|
+
pendingMoveFrame = null;
|
|
2728
|
+
}
|
|
2286
2729
|
window.removeEventListener("mousemove", move);
|
|
2287
2730
|
window.removeEventListener("mouseup", up);
|
|
2288
2731
|
window.removeEventListener("blur", cleanupOnBlur);
|
|
@@ -2298,8 +2741,12 @@ export function MultiScreenCanvas({
|
|
|
2298
2741
|
|
|
2299
2742
|
const requestSelectableElementInfos = useCallback(
|
|
2300
2743
|
(screenId: string): Promise<ElementInfo[]> => {
|
|
2744
|
+
const targetScreen = screensRef.current.find((s) => s.id === screenId);
|
|
2745
|
+
const iframeId = targetScreen
|
|
2746
|
+
? getActiveScreenIframeId(targetScreen)
|
|
2747
|
+
: screenId;
|
|
2301
2748
|
const targetIframe = surfaceRef.current?.querySelector<HTMLIFrameElement>(
|
|
2302
|
-
`[data-screen-iframe-id="${CSS.escape(
|
|
2749
|
+
`[data-screen-iframe-id="${CSS.escape(iframeId)}"]`,
|
|
2303
2750
|
);
|
|
2304
2751
|
const targetContentWindow = targetIframe?.contentWindow;
|
|
2305
2752
|
if (!targetContentWindow) return Promise.resolve([]);
|
|
@@ -2315,7 +2762,11 @@ export function MultiScreenCanvas({
|
|
|
2315
2762
|
if (
|
|
2316
2763
|
!event.data ||
|
|
2317
2764
|
event.data.type !== "agent-native:selectable-rects-result" ||
|
|
2318
|
-
event.data.correlationId !== correlationId
|
|
2765
|
+
event.data.correlationId !== correlationId ||
|
|
2766
|
+
// Require the reply to actually come from the iframe we asked,
|
|
2767
|
+
// not just any window that happens to observe/guess the
|
|
2768
|
+
// correlationId and reply with a matching payload shape.
|
|
2769
|
+
event.source !== targetContentWindow
|
|
2319
2770
|
) {
|
|
2320
2771
|
return;
|
|
2321
2772
|
}
|
|
@@ -2347,58 +2798,76 @@ export function MultiScreenCanvas({
|
|
|
2347
2798
|
[],
|
|
2348
2799
|
);
|
|
2349
2800
|
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
info,
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2801
|
+
/** Collects marquee-selectable layer candidates. Each screen requires an
|
|
2802
|
+
* async postMessage round-trip into its iframe (requestSelectableElementInfos),
|
|
2803
|
+
* so collecting for every screen on the board unconditionally at marquee
|
|
2804
|
+
* mousedown (PF20) is expensive for boards with many screens — most of
|
|
2805
|
+
* which the marquee rect will never touch. `screenIds`, when given, scopes
|
|
2806
|
+
* collection to just those frame entries (plus the board, which spans the
|
|
2807
|
+
* whole surface so it's included whenever explicitly requested); omit it
|
|
2808
|
+
* to collect every screen. */
|
|
2809
|
+
const collectLayerMarqueeCandidates = useCallback(
|
|
2810
|
+
async (screenIds?: Set<string>) => {
|
|
2811
|
+
const frameEntries = getCurrentFrameEntries().filter(
|
|
2812
|
+
(entry) => !screenIds || screenIds.has(entry.id),
|
|
2813
|
+
);
|
|
2814
|
+
const frameCandidates = await Promise.all(
|
|
2815
|
+
frameEntries.map(async (entry) => {
|
|
2816
|
+
const screen = screensRef.current.find(
|
|
2817
|
+
(item) => item.id === entry.id,
|
|
2818
|
+
);
|
|
2819
|
+
if (!screen) return [] as CanvasLayerMarqueeCandidate[];
|
|
2820
|
+
const iframe = surfaceRef.current?.querySelector<HTMLIFrameElement>(
|
|
2821
|
+
`[data-screen-iframe-id="${CSS.escape(getActiveScreenIframeId(screen))}"]`,
|
|
2822
|
+
);
|
|
2823
|
+
const metadata = getResolvedMetadata(screen);
|
|
2824
|
+
const viewportWidth = iframe?.clientWidth || metadata.width;
|
|
2825
|
+
const viewportHeight = iframe?.clientHeight || metadata.height;
|
|
2826
|
+
const scaleX = entry.geometry.width / Math.max(1, viewportWidth);
|
|
2827
|
+
const scaleY = entry.geometry.height / Math.max(1, viewportHeight);
|
|
2828
|
+
const infos = await requestSelectableElementInfos(entry.id);
|
|
2829
|
+
return infos.map((info) => ({
|
|
2830
|
+
screenId: entry.id,
|
|
2831
|
+
info,
|
|
2832
|
+
geometry: {
|
|
2833
|
+
x: entry.geometry.x + info.boundingRect.x * scaleX,
|
|
2834
|
+
y: entry.geometry.y + info.boundingRect.y * scaleY,
|
|
2835
|
+
width: info.boundingRect.width * scaleX,
|
|
2836
|
+
height: info.boundingRect.height * scaleY,
|
|
2837
|
+
},
|
|
2838
|
+
frameGeometry: entry.geometry,
|
|
2839
|
+
}));
|
|
2840
|
+
}),
|
|
2841
|
+
);
|
|
2842
|
+
const boardCandidates =
|
|
2843
|
+
boardFileId &&
|
|
2844
|
+
boardFrameGeometry &&
|
|
2845
|
+
(!screenIds || screenIds.has(boardFileId))
|
|
2846
|
+
? await (async () => {
|
|
2847
|
+
const infos = await requestSelectableElementInfos(boardFileId);
|
|
2848
|
+
return infos.map((info) => ({
|
|
2849
|
+
screenId: boardFileId,
|
|
2850
|
+
info,
|
|
2851
|
+
geometry: {
|
|
2852
|
+
x: boardFrameGeometry.x + info.boundingRect.x,
|
|
2853
|
+
y: boardFrameGeometry.y + info.boundingRect.y,
|
|
2854
|
+
width: info.boundingRect.width,
|
|
2855
|
+
height: info.boundingRect.height,
|
|
2856
|
+
},
|
|
2857
|
+
frameGeometry: boardFrameGeometry,
|
|
2858
|
+
}));
|
|
2859
|
+
})()
|
|
2860
|
+
: [];
|
|
2861
|
+
return [...frameCandidates.flat(), ...boardCandidates];
|
|
2862
|
+
},
|
|
2863
|
+
[
|
|
2864
|
+
boardFileId,
|
|
2865
|
+
boardFrameGeometry,
|
|
2866
|
+
getCurrentFrameEntries,
|
|
2867
|
+
getResolvedMetadata,
|
|
2868
|
+
requestSelectableElementInfos,
|
|
2869
|
+
],
|
|
2870
|
+
);
|
|
2402
2871
|
|
|
2403
2872
|
const scheduleFeedbackClear = useCallback(() => {
|
|
2404
2873
|
if (feedbackTimerRef.current !== null) {
|
|
@@ -2437,11 +2906,20 @@ export function MultiScreenCanvas({
|
|
|
2437
2906
|
window.innerHeight - TRANSFORM_BADGE_EDGE_PADDING
|
|
2438
2907
|
? clientY + TRANSFORM_BADGE_OFFSET
|
|
2439
2908
|
: clientY - TRANSFORM_BADGE_HEIGHT - TRANSFORM_BADGE_OFFSET;
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2909
|
+
const nextX = clampNumber(preferredX, TRANSFORM_BADGE_EDGE_PADDING, maxX);
|
|
2910
|
+
const nextY = clampNumber(preferredY, TRANSFORM_BADGE_EDGE_PADDING, maxY);
|
|
2911
|
+
// Equality-bail: called from the rAF-coalesced mousemove handler, so
|
|
2912
|
+
// this runs at most once per frame already, but a steady drag (e.g.
|
|
2913
|
+
// pinned against a snap axis) can still repeat the identical badge
|
|
2914
|
+
// text/position — skip the setState in that case (PF15).
|
|
2915
|
+
setTransformBadge((current) =>
|
|
2916
|
+
current &&
|
|
2917
|
+
current.text === text &&
|
|
2918
|
+
current.x === nextX &&
|
|
2919
|
+
current.y === nextY
|
|
2920
|
+
? current
|
|
2921
|
+
: { text, x: nextX, y: nextY },
|
|
2922
|
+
);
|
|
2445
2923
|
},
|
|
2446
2924
|
[],
|
|
2447
2925
|
);
|
|
@@ -2529,6 +3007,7 @@ export function MultiScreenCanvas({
|
|
|
2529
3007
|
setMarquee(null);
|
|
2530
3008
|
setCreationPreview(null);
|
|
2531
3009
|
setAlignmentGuides([]);
|
|
3010
|
+
setEqualGapGuides([]);
|
|
2532
3011
|
setTransformBadge(null);
|
|
2533
3012
|
setDragCursor(null);
|
|
2534
3013
|
primitiveDropTargetRef.current = null;
|
|
@@ -2546,7 +3025,11 @@ export function MultiScreenCanvas({
|
|
|
2546
3025
|
|
|
2547
3026
|
if (state) {
|
|
2548
3027
|
cancelled = true;
|
|
2549
|
-
if (
|
|
3028
|
+
if (
|
|
3029
|
+
state.type === "move" ||
|
|
3030
|
+
state.type === "resize" ||
|
|
3031
|
+
state.type === "group-rotate"
|
|
3032
|
+
) {
|
|
2550
3033
|
updateFrameGeometry((current) =>
|
|
2551
3034
|
frameGeometryWithOverrides(current, state.originFrames),
|
|
2552
3035
|
);
|
|
@@ -2574,6 +3057,14 @@ export function MultiScreenCanvas({
|
|
|
2574
3057
|
setPenGesturePreview(null);
|
|
2575
3058
|
setPenPointer(null);
|
|
2576
3059
|
setPenCloseHover(false);
|
|
3060
|
+
} else if (
|
|
3061
|
+
state.type === "vector-anchor" ||
|
|
3062
|
+
state.type === "vector-handle"
|
|
3063
|
+
) {
|
|
3064
|
+
// vectorEdit's path is parent-owned (unlike activePenPath above),
|
|
3065
|
+
// so reverting on cancel means reporting the pre-drag snapshot back
|
|
3066
|
+
// as a commit rather than mutating any local state here.
|
|
3067
|
+
vectorEdit?.onChange(clonePenPath(state.pathBefore), "commit");
|
|
2577
3068
|
}
|
|
2578
3069
|
}
|
|
2579
3070
|
|
|
@@ -2593,22 +3084,33 @@ export function MultiScreenCanvas({
|
|
|
2593
3084
|
updateFrameGeometry,
|
|
2594
3085
|
updateSelectedDraftIds,
|
|
2595
3086
|
updateSelectedIds,
|
|
3087
|
+
vectorEdit,
|
|
2596
3088
|
]);
|
|
2597
3089
|
|
|
2598
3090
|
useEffect(() => {
|
|
2599
3091
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
2600
3092
|
if (event.key !== "Escape") return;
|
|
2601
|
-
if (
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
3093
|
+
if (cancelActiveDrag()) {
|
|
3094
|
+
event.preventDefault();
|
|
3095
|
+
event.stopPropagation();
|
|
3096
|
+
event.stopImmediatePropagation();
|
|
3097
|
+
return;
|
|
3098
|
+
}
|
|
3099
|
+
// No in-flight drag to cancel: Escape while in vector edit mode exits
|
|
3100
|
+
// the mode entirely (matches Figma), rather than being a no-op.
|
|
3101
|
+
if (vectorEdit) {
|
|
3102
|
+
vectorEdit.onExit();
|
|
3103
|
+
event.preventDefault();
|
|
3104
|
+
event.stopPropagation();
|
|
3105
|
+
event.stopImmediatePropagation();
|
|
3106
|
+
}
|
|
2605
3107
|
};
|
|
2606
3108
|
|
|
2607
3109
|
window.addEventListener("keydown", handleKeyDown, true);
|
|
2608
3110
|
return () => {
|
|
2609
3111
|
window.removeEventListener("keydown", handleKeyDown, true);
|
|
2610
3112
|
};
|
|
2611
|
-
}, [cancelActiveDrag]);
|
|
3113
|
+
}, [cancelActiveDrag, vectorEdit]);
|
|
2612
3114
|
|
|
2613
3115
|
const beginPan = useCallback(
|
|
2614
3116
|
(e: React.MouseEvent) => {
|
|
@@ -2629,10 +3131,25 @@ export function MultiScreenCanvas({
|
|
|
2629
3131
|
y: state.originPan.y + ev.clientY - state.originClient.y,
|
|
2630
3132
|
};
|
|
2631
3133
|
panRef.current = nextPan;
|
|
2632
|
-
|
|
3134
|
+
// Mirror the wheel/pinch path (applyViewToDom): mutate the transform
|
|
3135
|
+
// directly during the gesture and only reconcile React state once the
|
|
3136
|
+
// gesture settles, so a mouse-pan produces zero re-renders per move.
|
|
3137
|
+
applyViewToDomRef.current();
|
|
3138
|
+
scheduleViewCommitRef.current();
|
|
2633
3139
|
};
|
|
2634
3140
|
|
|
2635
|
-
|
|
3141
|
+
const handlePanEnd = () => {
|
|
3142
|
+
// Ensure React state reflects the true final pan immediately on
|
|
3143
|
+
// release rather than waiting for the debounced commit timer.
|
|
3144
|
+
setPan(panRef.current);
|
|
3145
|
+
// P18: a middle-mouse-button pan while a pen path is active also
|
|
3146
|
+
// moves the canvas-space mapping the ghost preview was computed
|
|
3147
|
+
// from — resync it now that pan has settled.
|
|
3148
|
+
recomputePenPointerForViewChangeRef.current();
|
|
3149
|
+
finishDrag();
|
|
3150
|
+
};
|
|
3151
|
+
|
|
3152
|
+
installDragListeners(handleMouseMove, handlePanEnd);
|
|
2636
3153
|
},
|
|
2637
3154
|
[finishDrag, installDragListeners],
|
|
2638
3155
|
);
|
|
@@ -2644,14 +3161,25 @@ export function MultiScreenCanvas({
|
|
|
2644
3161
|
const originCanvas = getCanvasPoint(e.clientX, e.clientY);
|
|
2645
3162
|
let latestRect = normalizeRectFromPoints(originCanvas, originCanvas);
|
|
2646
3163
|
let layerCandidates: CanvasLayerMarqueeCandidate[] = [];
|
|
3164
|
+
// PF20: collecting selectable layer info for every screen on the board
|
|
3165
|
+
// requires one async postMessage round-trip per iframe. Doing that
|
|
3166
|
+
// eagerly for the whole board on marquee mousedown is wasted work for
|
|
3167
|
+
// any screen the marquee rect never reaches. Instead, lazily collect
|
|
3168
|
+
// only screens the rect currently intersects, growing the collected
|
|
3169
|
+
// set incrementally as the drag expands the rect. `collectingScreenIds`
|
|
3170
|
+
// guards against re-requesting a screen whose collection is already
|
|
3171
|
+
// in flight or done.
|
|
3172
|
+
const collectedScreenIds = new Set<string>();
|
|
3173
|
+
const collectingScreenIds = new Set<string>();
|
|
2647
3174
|
const reportLayerSelection = (rect: MarqueeRect) => {
|
|
2648
3175
|
const state = dragState.current;
|
|
2649
3176
|
if (!state || state.type !== "marquee") return;
|
|
3177
|
+
const chromeScale = chromeScaleFromZoom(zoomRef.current);
|
|
2650
3178
|
const selection = layerCandidates
|
|
2651
3179
|
.filter((candidate) =>
|
|
2652
3180
|
rotatedRectIntersects(
|
|
2653
3181
|
rect,
|
|
2654
|
-
getSelectableBounds(candidate.geometry),
|
|
3182
|
+
getSelectableBounds(candidate.geometry, chromeScale),
|
|
2655
3183
|
getFrameCenter(candidate.frameGeometry),
|
|
2656
3184
|
candidate.frameGeometry.rotation ?? 0,
|
|
2657
3185
|
),
|
|
@@ -2666,6 +3194,34 @@ export function MultiScreenCanvas({
|
|
|
2666
3194
|
shiftKey: state.additive,
|
|
2667
3195
|
});
|
|
2668
3196
|
};
|
|
3197
|
+
const collectForIntersectedScreens = (hitIds: string[]) => {
|
|
3198
|
+
const newIds = hitIds.filter(
|
|
3199
|
+
(id) => !collectedScreenIds.has(id) && !collectingScreenIds.has(id),
|
|
3200
|
+
);
|
|
3201
|
+
// The board spans the whole surface, so include it once anything
|
|
3202
|
+
// intersects (or immediately, for the initial zero-size rect) rather
|
|
3203
|
+
// than trying to hit-test its own — usually oversized — geometry.
|
|
3204
|
+
if (
|
|
3205
|
+
boardFileId &&
|
|
3206
|
+
boardFrameGeometry &&
|
|
3207
|
+
!collectedScreenIds.has(boardFileId) &&
|
|
3208
|
+
!collectingScreenIds.has(boardFileId)
|
|
3209
|
+
) {
|
|
3210
|
+
newIds.push(boardFileId);
|
|
3211
|
+
}
|
|
3212
|
+
if (newIds.length === 0) return;
|
|
3213
|
+
const requestIds = new Set(newIds);
|
|
3214
|
+
newIds.forEach((id) => collectingScreenIds.add(id));
|
|
3215
|
+
void collectLayerMarqueeCandidates(requestIds).then((candidates) => {
|
|
3216
|
+
newIds.forEach((id) => {
|
|
3217
|
+
collectingScreenIds.delete(id);
|
|
3218
|
+
collectedScreenIds.add(id);
|
|
3219
|
+
});
|
|
3220
|
+
if (dragState.current?.type !== "marquee") return;
|
|
3221
|
+
layerCandidates = [...layerCandidates, ...candidates];
|
|
3222
|
+
reportLayerSelection(latestRect);
|
|
3223
|
+
});
|
|
3224
|
+
};
|
|
2669
3225
|
dragState.current = {
|
|
2670
3226
|
type: "marquee",
|
|
2671
3227
|
originClient: { x: e.clientX, y: e.clientY },
|
|
@@ -2686,11 +3242,10 @@ export function MultiScreenCanvas({
|
|
|
2686
3242
|
});
|
|
2687
3243
|
}
|
|
2688
3244
|
setIsDragging(true);
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
});
|
|
3245
|
+
// Seed collection with whatever the zero-size origin rect already
|
|
3246
|
+
// touches (typically just the board, if present) so a click-without-
|
|
3247
|
+
// drag still reports a correct (likely empty) selection on mouseup.
|
|
3248
|
+
collectForIntersectedScreens([]);
|
|
2694
3249
|
|
|
2695
3250
|
const handleMouseMove = (ev: MouseEvent) => {
|
|
2696
3251
|
const state = dragState.current;
|
|
@@ -2709,11 +3264,12 @@ export function MultiScreenCanvas({
|
|
|
2709
3264
|
}
|
|
2710
3265
|
setMarquee(rect);
|
|
2711
3266
|
|
|
3267
|
+
const chromeScale = chromeScaleFromZoom(zoomRef.current);
|
|
2712
3268
|
const hitIds = getCurrentFrameEntries()
|
|
2713
3269
|
.filter((entry) =>
|
|
2714
3270
|
rotatedRectIntersects(
|
|
2715
3271
|
rect,
|
|
2716
|
-
getSelectableBounds(entry.geometry),
|
|
3272
|
+
getSelectableBounds(entry.geometry, chromeScale),
|
|
2717
3273
|
getFrameCenter(entry.geometry),
|
|
2718
3274
|
entry.geometry.rotation ?? 0,
|
|
2719
3275
|
),
|
|
@@ -2723,21 +3279,23 @@ export function MultiScreenCanvas({
|
|
|
2723
3279
|
.filter((entry) =>
|
|
2724
3280
|
rotatedRectIntersects(
|
|
2725
3281
|
rect,
|
|
2726
|
-
getSelectableBounds(entry.geometry),
|
|
3282
|
+
getSelectableBounds(entry.geometry, chromeScale),
|
|
2727
3283
|
getFrameCenter(entry.geometry),
|
|
2728
3284
|
entry.geometry.rotation ?? 0,
|
|
2729
3285
|
),
|
|
2730
3286
|
)
|
|
2731
3287
|
.map((entry) => entry.id);
|
|
2732
3288
|
|
|
3289
|
+
collectForIntersectedScreens(hitIds);
|
|
3290
|
+
|
|
2733
3291
|
updateSelectedIds(() =>
|
|
2734
3292
|
state.additive
|
|
2735
|
-
?
|
|
3293
|
+
? xorMarqueeSelection(state.baseSelectedIds, hitIds)
|
|
2736
3294
|
: hitIds,
|
|
2737
3295
|
);
|
|
2738
3296
|
updateSelectedDraftIds(() =>
|
|
2739
3297
|
state.additive
|
|
2740
|
-
?
|
|
3298
|
+
? xorMarqueeSelection(state.baseSelectedDraftIds, hitDraftIds)
|
|
2741
3299
|
: hitDraftIds,
|
|
2742
3300
|
);
|
|
2743
3301
|
reportLayerSelection(rect);
|
|
@@ -2760,6 +3318,8 @@ export function MultiScreenCanvas({
|
|
|
2760
3318
|
installDragListeners(handleMouseMove, handleMouseUp);
|
|
2761
3319
|
},
|
|
2762
3320
|
[
|
|
3321
|
+
boardFileId,
|
|
3322
|
+
boardFrameGeometry,
|
|
2763
3323
|
collectLayerMarqueeCandidates,
|
|
2764
3324
|
finishDrag,
|
|
2765
3325
|
getCanvasPoint,
|
|
@@ -3029,15 +3589,24 @@ export function MultiScreenCanvas({
|
|
|
3029
3589
|
) => {
|
|
3030
3590
|
const rawPoint = getCanvasPoint(clientX, clientY);
|
|
3031
3591
|
const lastAnchor = path?.nodes[path.nodes.length - 1]?.point;
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3592
|
+
const constrainedPoint =
|
|
3593
|
+
shiftKey && lastAnchor
|
|
3594
|
+
? constrainPointTo45Degrees(lastAnchor, rawPoint)
|
|
3595
|
+
: rawPoint;
|
|
3596
|
+
// Light anchor snapping (P15): snap onto an existing anchor of the
|
|
3597
|
+
// path being drawn (so you can precisely re-hit a prior point), else
|
|
3598
|
+
// round to integer canvas px once zoomed to 100% or more.
|
|
3599
|
+
return snapPenAnchorPoint(constrainedPoint, path, {
|
|
3600
|
+
hitRadius: PEN_CLOSE_HIT_RADIUS_SCREEN_PX / (zoomRef.current / 100),
|
|
3601
|
+
zoom: zoomRef.current,
|
|
3602
|
+
});
|
|
3035
3603
|
},
|
|
3036
3604
|
[getCanvasPoint],
|
|
3037
3605
|
);
|
|
3038
3606
|
|
|
3039
3607
|
const updatePenPointer = useCallback(
|
|
3040
3608
|
(clientX: number, clientY: number, shiftKey: boolean) => {
|
|
3609
|
+
lastPenClientPointRef.current = { clientX, clientY, shiftKey };
|
|
3041
3610
|
const path = activePenPathRef.current;
|
|
3042
3611
|
if (!path || path.closed) {
|
|
3043
3612
|
setPenPointer(null);
|
|
@@ -3061,35 +3630,54 @@ export function MultiScreenCanvas({
|
|
|
3061
3630
|
[getCanvasPoint, getPenAnchorPoint],
|
|
3062
3631
|
);
|
|
3063
3632
|
|
|
3633
|
+
// P18: a wheel pan/zoom gesture moves pan/zoom every animation frame
|
|
3634
|
+
// (applyViewToDom, mutated imperatively — see its comment) without any
|
|
3635
|
+
// mousemove event firing, so the pen ghost/ close-hover preview — derived
|
|
3636
|
+
// from screen->canvas conversion of the last known client point — goes
|
|
3637
|
+
// stale and visibly detaches from the cursor mid-gesture. Recompute it
|
|
3638
|
+
// from the remembered client point whenever pan/zoom changes.
|
|
3639
|
+
const recomputePenPointerForViewChange = useCallback(() => {
|
|
3640
|
+
const last = lastPenClientPointRef.current;
|
|
3641
|
+
if (!last || !activePenPathRef.current) return;
|
|
3642
|
+
updatePenPointer(last.clientX, last.clientY, last.shiftKey);
|
|
3643
|
+
}, [updatePenPointer]);
|
|
3644
|
+
recomputePenPointerForViewChangeRef.current =
|
|
3645
|
+
recomputePenPointerForViewChange;
|
|
3646
|
+
|
|
3064
3647
|
const beginPenNodeCreation = useCallback(
|
|
3065
3648
|
(e: React.MouseEvent) => {
|
|
3066
3649
|
if (e.button !== 0) return;
|
|
3067
3650
|
e.preventDefault();
|
|
3068
3651
|
e.stopPropagation();
|
|
3652
|
+
// Double (or further) click ends the path instead of adding another
|
|
3653
|
+
// duplicate coincident anchor at the same point (P7).
|
|
3654
|
+
if (e.detail > 1) {
|
|
3655
|
+
finishPenPath();
|
|
3656
|
+
return;
|
|
3657
|
+
}
|
|
3069
3658
|
suppressNextPick.current = true;
|
|
3070
3659
|
|
|
3071
3660
|
const pathBefore = activePenPathRef.current?.closed
|
|
3072
3661
|
? null
|
|
3073
3662
|
: activePenPathRef.current;
|
|
3074
3663
|
const rawPoint = getCanvasPoint(e.clientX, e.clientY);
|
|
3075
|
-
|
|
3664
|
+
const closing = Boolean(
|
|
3076
3665
|
pathBefore &&
|
|
3077
3666
|
isPenCloseTarget(
|
|
3078
3667
|
pathBefore,
|
|
3079
3668
|
rawPoint,
|
|
3080
3669
|
PEN_CLOSE_HIT_RADIUS_SCREEN_PX / (zoomRef.current / 100),
|
|
3081
|
-
)
|
|
3082
|
-
) {
|
|
3083
|
-
finishPenPath(closePenPath(pathBefore));
|
|
3084
|
-
return;
|
|
3085
|
-
}
|
|
3086
|
-
|
|
3087
|
-
const anchor = getPenAnchorPoint(
|
|
3088
|
-
e.clientX,
|
|
3089
|
-
e.clientY,
|
|
3090
|
-
e.shiftKey,
|
|
3091
|
-
pathBefore,
|
|
3670
|
+
),
|
|
3092
3671
|
);
|
|
3672
|
+
|
|
3673
|
+
// Figma defers the close commit to mouseup rather than closing
|
|
3674
|
+
// instantly on mousedown, so a drag on the closing click can shape
|
|
3675
|
+
// the closing segment's curve. Anchor the drag at the path's first
|
|
3676
|
+
// point (rather than the raw cursor position) so a click-only close
|
|
3677
|
+
// (no drag) still closes exactly on the start anchor.
|
|
3678
|
+
const anchor = closing
|
|
3679
|
+
? pathBefore!.nodes[0].point
|
|
3680
|
+
: getPenAnchorPoint(e.clientX, e.clientY, e.shiftKey, pathBefore);
|
|
3093
3681
|
const pathSnapshot = pathBefore ? clonePenPath(pathBefore) : null;
|
|
3094
3682
|
dragState.current = {
|
|
3095
3683
|
type: "pen-node",
|
|
@@ -3097,13 +3685,18 @@ export function MultiScreenCanvas({
|
|
|
3097
3685
|
anchor,
|
|
3098
3686
|
pathBefore: pathSnapshot,
|
|
3099
3687
|
hasMoved: false,
|
|
3688
|
+
closing,
|
|
3100
3689
|
};
|
|
3101
|
-
const initialPath =
|
|
3690
|
+
const initialPath = closing
|
|
3691
|
+
? (pathSnapshot as PenPath)
|
|
3692
|
+
: appendPenNode(pathSnapshot, createCornerNode(anchor));
|
|
3102
3693
|
activePenPathRef.current = initialPath;
|
|
3103
3694
|
setActivePenPath(initialPath);
|
|
3104
|
-
setPenGesturePreview(
|
|
3695
|
+
setPenGesturePreview(
|
|
3696
|
+
closing ? closePenPath(pathSnapshot as PenPath) : initialPath,
|
|
3697
|
+
);
|
|
3105
3698
|
setPenPointer(null);
|
|
3106
|
-
setPenCloseHover(
|
|
3699
|
+
setPenCloseHover(closing);
|
|
3107
3700
|
setIsDragging(true);
|
|
3108
3701
|
setDragCursor("crosshair");
|
|
3109
3702
|
|
|
@@ -3121,13 +3714,31 @@ export function MultiScreenCanvas({
|
|
|
3121
3714
|
}
|
|
3122
3715
|
|
|
3123
3716
|
const handlePoint = getCanvasPoint(ev.clientX, ev.clientY);
|
|
3717
|
+
const handleOut = ev.shiftKey
|
|
3718
|
+
? constrainPointTo45Degrees(state.anchor, handlePoint)
|
|
3719
|
+
: handlePoint;
|
|
3720
|
+
|
|
3721
|
+
if (state.closing) {
|
|
3722
|
+
// Shape the closing segment: drag the first anchor's handleIn
|
|
3723
|
+
// (mirrored across the anchor from the drag point, matching how
|
|
3724
|
+
// every other smooth anchor's handles work) without appending a
|
|
3725
|
+
// new node — the path is still just previewed as closed.
|
|
3726
|
+
const closedPreviewPath = shapeClosingHandles(
|
|
3727
|
+
state.pathBefore as PenPath,
|
|
3728
|
+
state.hasMoved ? handleOut : null,
|
|
3729
|
+
);
|
|
3730
|
+
setPenGesturePreview(closedPreviewPath);
|
|
3731
|
+
return;
|
|
3732
|
+
}
|
|
3733
|
+
|
|
3124
3734
|
const node = state.hasMoved
|
|
3125
|
-
? createSmoothNode(
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3735
|
+
? createSmoothNode(state.anchor, handleOut, {
|
|
3736
|
+
// Alt/Option while dragging a new anchor's handle breaks
|
|
3737
|
+
// symmetry into a cusp (P8): read the live event's altKey on
|
|
3738
|
+
// every move so toggling Alt mid-drag updates immediately,
|
|
3739
|
+
// rather than latching whatever it was when the drag started.
|
|
3740
|
+
breakSymmetry: ev.altKey,
|
|
3741
|
+
})
|
|
3131
3742
|
: createCornerNode(state.anchor);
|
|
3132
3743
|
const nextPath = appendPenNode(state.pathBefore, node);
|
|
3133
3744
|
activePenPathRef.current = nextPath;
|
|
@@ -3143,13 +3754,27 @@ export function MultiScreenCanvas({
|
|
|
3143
3754
|
}
|
|
3144
3755
|
|
|
3145
3756
|
const handlePoint = getCanvasPoint(ev.clientX, ev.clientY);
|
|
3757
|
+
const handleOut = ev.shiftKey
|
|
3758
|
+
? constrainPointTo45Degrees(state.anchor, handlePoint)
|
|
3759
|
+
: handlePoint;
|
|
3760
|
+
|
|
3761
|
+
if (state.closing) {
|
|
3762
|
+
const closedPath = shapeClosingHandles(
|
|
3763
|
+
state.pathBefore as PenPath,
|
|
3764
|
+
state.hasMoved ? handleOut : null,
|
|
3765
|
+
);
|
|
3766
|
+
setPenGesturePreview(null);
|
|
3767
|
+
setPenPointer(null);
|
|
3768
|
+
setPenCloseHover(false);
|
|
3769
|
+
finishPenPath(closedPath);
|
|
3770
|
+
finishDrag();
|
|
3771
|
+
return;
|
|
3772
|
+
}
|
|
3773
|
+
|
|
3146
3774
|
const node: PenNode = state.hasMoved
|
|
3147
|
-
? createSmoothNode(
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
? constrainPointTo45Degrees(state.anchor, handlePoint)
|
|
3151
|
-
: handlePoint,
|
|
3152
|
-
)
|
|
3775
|
+
? createSmoothNode(state.anchor, handleOut, {
|
|
3776
|
+
breakSymmetry: ev.altKey,
|
|
3777
|
+
})
|
|
3153
3778
|
: createCornerNode(state.anchor);
|
|
3154
3779
|
const nextPath = appendPenNode(state.pathBefore, node);
|
|
3155
3780
|
activePenPathRef.current = nextPath;
|
|
@@ -3185,6 +3810,220 @@ export function MultiScreenCanvas({
|
|
|
3185
3810
|
],
|
|
3186
3811
|
);
|
|
3187
3812
|
|
|
3813
|
+
// ── Vector edit mode (P-VE1): drag an existing path's anchors/handles ────
|
|
3814
|
+
// (see VectorEditOverlayState / VectorEditOverlay). The overlay itself
|
|
3815
|
+
// resolves hit-tests and starts these; both gestures follow the same
|
|
3816
|
+
// installDragListeners/dragState pattern as every other drag above, with
|
|
3817
|
+
// the parent-owned `vectorEdit.path` (not local React state) as the
|
|
3818
|
+
// source of truth: every move reports an updated path via
|
|
3819
|
+
// `vectorEdit.onChange(next, "preview" | "commit")` instead of setting
|
|
3820
|
+
// local state.
|
|
3821
|
+
const beginVectorAnchorDrag = useCallback(
|
|
3822
|
+
(nodeIndex: number, e: React.MouseEvent) => {
|
|
3823
|
+
if (!vectorEdit || e.button !== 0) return;
|
|
3824
|
+
e.preventDefault();
|
|
3825
|
+
e.stopPropagation();
|
|
3826
|
+
claimKeyboardFocus();
|
|
3827
|
+
|
|
3828
|
+
const pathBefore = clonePenPath(vectorEdit.path);
|
|
3829
|
+
dragState.current = {
|
|
3830
|
+
type: "vector-anchor",
|
|
3831
|
+
originClient: { x: e.clientX, y: e.clientY },
|
|
3832
|
+
nodeIndex,
|
|
3833
|
+
pathBefore,
|
|
3834
|
+
hasMoved: false,
|
|
3835
|
+
};
|
|
3836
|
+
setIsDragging(true);
|
|
3837
|
+
setDragCursor("move");
|
|
3838
|
+
|
|
3839
|
+
const handleMouseMove = (ev: MouseEvent) => {
|
|
3840
|
+
const state = dragState.current;
|
|
3841
|
+
if (!state || state.type !== "vector-anchor") return;
|
|
3842
|
+
const active = vectorEditRef.current;
|
|
3843
|
+
if (!active) return;
|
|
3844
|
+
if (
|
|
3845
|
+
!state.hasMoved &&
|
|
3846
|
+
Math.hypot(
|
|
3847
|
+
ev.clientX - state.originClient.x,
|
|
3848
|
+
ev.clientY - state.originClient.y,
|
|
3849
|
+
) >= DRAG_THRESHOLD
|
|
3850
|
+
) {
|
|
3851
|
+
state.hasMoved = true;
|
|
3852
|
+
}
|
|
3853
|
+
const canvasPoint = getCanvasPoint(ev.clientX, ev.clientY);
|
|
3854
|
+
const localPoint = vectorEditCanvasToLocalPoint(
|
|
3855
|
+
canvasPoint,
|
|
3856
|
+
active.originCanvas,
|
|
3857
|
+
);
|
|
3858
|
+
const nextPath = movePenAnchor(
|
|
3859
|
+
state.pathBefore,
|
|
3860
|
+
state.nodeIndex,
|
|
3861
|
+
localPoint,
|
|
3862
|
+
);
|
|
3863
|
+
active.onChange(nextPath, "preview");
|
|
3864
|
+
};
|
|
3865
|
+
|
|
3866
|
+
const handleMouseUp = (ev: MouseEvent) => {
|
|
3867
|
+
const state = dragState.current;
|
|
3868
|
+
if (!state || state.type !== "vector-anchor") {
|
|
3869
|
+
finishDrag();
|
|
3870
|
+
return;
|
|
3871
|
+
}
|
|
3872
|
+
const active = vectorEditRef.current;
|
|
3873
|
+
if (!active) {
|
|
3874
|
+
finishDrag();
|
|
3875
|
+
return;
|
|
3876
|
+
}
|
|
3877
|
+
const canvasPoint = getCanvasPoint(ev.clientX, ev.clientY);
|
|
3878
|
+
const localPoint = vectorEditCanvasToLocalPoint(
|
|
3879
|
+
canvasPoint,
|
|
3880
|
+
active.originCanvas,
|
|
3881
|
+
);
|
|
3882
|
+
const nextPath = state.hasMoved
|
|
3883
|
+
? movePenAnchor(state.pathBefore, state.nodeIndex, localPoint)
|
|
3884
|
+
: state.pathBefore;
|
|
3885
|
+
active.onChange(nextPath, "commit");
|
|
3886
|
+
finishDrag();
|
|
3887
|
+
};
|
|
3888
|
+
|
|
3889
|
+
const cancelGesture = () => {
|
|
3890
|
+
const state = dragState.current;
|
|
3891
|
+
const active = vectorEditRef.current;
|
|
3892
|
+
if (state?.type === "vector-anchor" && active) {
|
|
3893
|
+
active.onChange(clonePenPath(state.pathBefore), "commit");
|
|
3894
|
+
}
|
|
3895
|
+
finishDrag();
|
|
3896
|
+
};
|
|
3897
|
+
|
|
3898
|
+
installDragListeners(handleMouseMove, handleMouseUp, cancelGesture);
|
|
3899
|
+
},
|
|
3900
|
+
[
|
|
3901
|
+
claimKeyboardFocus,
|
|
3902
|
+
finishDrag,
|
|
3903
|
+
getCanvasPoint,
|
|
3904
|
+
installDragListeners,
|
|
3905
|
+
vectorEdit,
|
|
3906
|
+
],
|
|
3907
|
+
);
|
|
3908
|
+
|
|
3909
|
+
const beginVectorHandleDrag = useCallback(
|
|
3910
|
+
(nodeIndex: number, which: "in" | "out", e: React.MouseEvent) => {
|
|
3911
|
+
if (!vectorEdit || e.button !== 0) return;
|
|
3912
|
+
e.preventDefault();
|
|
3913
|
+
e.stopPropagation();
|
|
3914
|
+
claimKeyboardFocus();
|
|
3915
|
+
|
|
3916
|
+
const pathBefore = clonePenPath(vectorEdit.path);
|
|
3917
|
+
dragState.current = {
|
|
3918
|
+
type: "vector-handle",
|
|
3919
|
+
originClient: { x: e.clientX, y: e.clientY },
|
|
3920
|
+
nodeIndex,
|
|
3921
|
+
which,
|
|
3922
|
+
pathBefore,
|
|
3923
|
+
hasMoved: false,
|
|
3924
|
+
};
|
|
3925
|
+
setIsDragging(true);
|
|
3926
|
+
setDragCursor("crosshair");
|
|
3927
|
+
|
|
3928
|
+
const handleMouseMove = (ev: MouseEvent) => {
|
|
3929
|
+
const state = dragState.current;
|
|
3930
|
+
if (!state || state.type !== "vector-handle") return;
|
|
3931
|
+
const active = vectorEditRef.current;
|
|
3932
|
+
if (!active) return;
|
|
3933
|
+
if (
|
|
3934
|
+
!state.hasMoved &&
|
|
3935
|
+
Math.hypot(
|
|
3936
|
+
ev.clientX - state.originClient.x,
|
|
3937
|
+
ev.clientY - state.originClient.y,
|
|
3938
|
+
) >= DRAG_THRESHOLD
|
|
3939
|
+
) {
|
|
3940
|
+
state.hasMoved = true;
|
|
3941
|
+
}
|
|
3942
|
+
const canvasPoint = getCanvasPoint(ev.clientX, ev.clientY);
|
|
3943
|
+
const localPoint = vectorEditCanvasToLocalPoint(
|
|
3944
|
+
canvasPoint,
|
|
3945
|
+
active.originCanvas,
|
|
3946
|
+
);
|
|
3947
|
+
// Alt/Option held mid-drag breaks handle symmetry into a cusp,
|
|
3948
|
+
// matching the pen tool's own alt behavior (read live on every move
|
|
3949
|
+
// so toggling Alt mid-drag updates immediately).
|
|
3950
|
+
const nextPath = movePenHandle(
|
|
3951
|
+
state.pathBefore,
|
|
3952
|
+
state.nodeIndex,
|
|
3953
|
+
state.which,
|
|
3954
|
+
localPoint,
|
|
3955
|
+
{ breakSymmetry: ev.altKey },
|
|
3956
|
+
);
|
|
3957
|
+
active.onChange(nextPath, "preview");
|
|
3958
|
+
};
|
|
3959
|
+
|
|
3960
|
+
const handleMouseUp = (ev: MouseEvent) => {
|
|
3961
|
+
const state = dragState.current;
|
|
3962
|
+
if (!state || state.type !== "vector-handle") {
|
|
3963
|
+
finishDrag();
|
|
3964
|
+
return;
|
|
3965
|
+
}
|
|
3966
|
+
const active = vectorEditRef.current;
|
|
3967
|
+
if (!active) {
|
|
3968
|
+
finishDrag();
|
|
3969
|
+
return;
|
|
3970
|
+
}
|
|
3971
|
+
const canvasPoint = getCanvasPoint(ev.clientX, ev.clientY);
|
|
3972
|
+
const localPoint = vectorEditCanvasToLocalPoint(
|
|
3973
|
+
canvasPoint,
|
|
3974
|
+
active.originCanvas,
|
|
3975
|
+
);
|
|
3976
|
+
const nextPath = state.hasMoved
|
|
3977
|
+
? movePenHandle(
|
|
3978
|
+
state.pathBefore,
|
|
3979
|
+
state.nodeIndex,
|
|
3980
|
+
state.which,
|
|
3981
|
+
localPoint,
|
|
3982
|
+
{ breakSymmetry: ev.altKey },
|
|
3983
|
+
)
|
|
3984
|
+
: state.pathBefore;
|
|
3985
|
+
active.onChange(nextPath, "commit");
|
|
3986
|
+
finishDrag();
|
|
3987
|
+
};
|
|
3988
|
+
|
|
3989
|
+
const cancelGesture = () => {
|
|
3990
|
+
const state = dragState.current;
|
|
3991
|
+
const active = vectorEditRef.current;
|
|
3992
|
+
if (state?.type === "vector-handle" && active) {
|
|
3993
|
+
active.onChange(clonePenPath(state.pathBefore), "commit");
|
|
3994
|
+
}
|
|
3995
|
+
finishDrag();
|
|
3996
|
+
};
|
|
3997
|
+
|
|
3998
|
+
installDragListeners(handleMouseMove, handleMouseUp, cancelGesture);
|
|
3999
|
+
},
|
|
4000
|
+
[
|
|
4001
|
+
claimKeyboardFocus,
|
|
4002
|
+
finishDrag,
|
|
4003
|
+
getCanvasPoint,
|
|
4004
|
+
installDragListeners,
|
|
4005
|
+
vectorEdit,
|
|
4006
|
+
],
|
|
4007
|
+
);
|
|
4008
|
+
|
|
4009
|
+
/** Toggle corner<->smooth on double-click of an anchor (P-VE1). Always
|
|
4010
|
+
* commits immediately (no preview phase — there's no drag to preview). */
|
|
4011
|
+
const toggleVectorNodeType = useCallback(
|
|
4012
|
+
(nodeIndex: number) => {
|
|
4013
|
+
if (!vectorEdit) return;
|
|
4014
|
+
const node = vectorEdit.path.nodes[nodeIndex];
|
|
4015
|
+
if (!node) return;
|
|
4016
|
+
const isCorner = !node.handleIn && !node.handleOut;
|
|
4017
|
+
const nextPath = setPenNodeType(
|
|
4018
|
+
vectorEdit.path,
|
|
4019
|
+
nodeIndex,
|
|
4020
|
+
isCorner ? "smooth" : "corner",
|
|
4021
|
+
);
|
|
4022
|
+
vectorEdit.onChange(nextPath, "commit");
|
|
4023
|
+
},
|
|
4024
|
+
[vectorEdit],
|
|
4025
|
+
);
|
|
4026
|
+
|
|
3188
4027
|
const beginDraftCreation = useCallback(
|
|
3189
4028
|
(tool: DraftCreationTool, e: React.MouseEvent) => {
|
|
3190
4029
|
if (e.button !== 0) return;
|
|
@@ -3200,14 +4039,12 @@ export function MultiScreenCanvas({
|
|
|
3200
4039
|
false,
|
|
3201
4040
|
);
|
|
3202
4041
|
const initialPoints =
|
|
3203
|
-
tool === "
|
|
3204
|
-
? [
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
]
|
|
3210
|
-
: undefined;
|
|
4042
|
+
tool === "line" || tool === "arrow"
|
|
4043
|
+
? [
|
|
4044
|
+
originCanvas,
|
|
4045
|
+
{ x: originCanvas.x + DRAFT_LINE_WIDTH, y: originCanvas.y },
|
|
4046
|
+
]
|
|
4047
|
+
: undefined;
|
|
3211
4048
|
dragState.current = {
|
|
3212
4049
|
type: "draft-create",
|
|
3213
4050
|
tool,
|
|
@@ -3223,7 +4060,7 @@ export function MultiScreenCanvas({
|
|
|
3223
4060
|
points: initialPoints,
|
|
3224
4061
|
});
|
|
3225
4062
|
setIsDragging(true);
|
|
3226
|
-
setDragCursor(
|
|
4063
|
+
setDragCursor("crosshair");
|
|
3227
4064
|
|
|
3228
4065
|
const handleMouseMove = (ev: MouseEvent) => {
|
|
3229
4066
|
const state = dragState.current;
|
|
@@ -3239,22 +4076,15 @@ export function MultiScreenCanvas({
|
|
|
3239
4076
|
state.hasMoved = true;
|
|
3240
4077
|
}
|
|
3241
4078
|
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
tool,
|
|
3252
|
-
geometry: getPathGeometry(state.points),
|
|
3253
|
-
points: state.points,
|
|
3254
|
-
});
|
|
3255
|
-
return;
|
|
3256
|
-
}
|
|
3257
|
-
|
|
4079
|
+
const modifiers: DraftGeometryModifiers = {
|
|
4080
|
+
shiftKey: ev.shiftKey,
|
|
4081
|
+
altKey: ev.altKey,
|
|
4082
|
+
};
|
|
4083
|
+
const isLineTool = state.tool === "line" || state.tool === "arrow";
|
|
4084
|
+
const previewEnd =
|
|
4085
|
+
isLineTool && ev.shiftKey
|
|
4086
|
+
? constrainPointTo45Degrees(state.originCanvas, nextCanvas)
|
|
4087
|
+
: nextCanvas;
|
|
3258
4088
|
setCreationPreview({
|
|
3259
4089
|
tool,
|
|
3260
4090
|
geometry: getDraftPreviewGeometryForTool(
|
|
@@ -3262,11 +4092,9 @@ export function MultiScreenCanvas({
|
|
|
3262
4092
|
state.originCanvas,
|
|
3263
4093
|
nextCanvas,
|
|
3264
4094
|
state.hasMoved,
|
|
4095
|
+
modifiers,
|
|
3265
4096
|
),
|
|
3266
|
-
points:
|
|
3267
|
-
state.tool === "line" || state.tool === "arrow"
|
|
3268
|
-
? [state.originCanvas, nextCanvas]
|
|
3269
|
-
: undefined,
|
|
4097
|
+
points: isLineTool ? [state.originCanvas, previewEnd] : undefined,
|
|
3270
4098
|
});
|
|
3271
4099
|
};
|
|
3272
4100
|
|
|
@@ -3277,7 +4105,7 @@ export function MultiScreenCanvas({
|
|
|
3277
4105
|
return;
|
|
3278
4106
|
}
|
|
3279
4107
|
|
|
3280
|
-
|
|
4108
|
+
const endCanvas = getCanvasPoint(ev.clientX, ev.clientY);
|
|
3281
4109
|
const canvasMoved =
|
|
3282
4110
|
Math.hypot(
|
|
3283
4111
|
endCanvas.x - state.originCanvas.x,
|
|
@@ -3291,18 +4119,18 @@ export function MultiScreenCanvas({
|
|
|
3291
4119
|
) >= DRAG_THRESHOLD ||
|
|
3292
4120
|
canvasMoved;
|
|
3293
4121
|
state.hasMoved = releaseMoved;
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
finishDrag();
|
|
3299
|
-
return;
|
|
3300
|
-
}
|
|
3301
|
-
endCanvas = points[points.length - 1] ?? endCanvas;
|
|
3302
|
-
}
|
|
4122
|
+
const modifiers: DraftGeometryModifiers = {
|
|
4123
|
+
shiftKey: ev.shiftKey,
|
|
4124
|
+
altKey: ev.altKey,
|
|
4125
|
+
};
|
|
3303
4126
|
if (state.tool === "frame" && onCreateScreenFrame) {
|
|
3304
4127
|
onCreateScreenFrame(
|
|
3305
|
-
getDraftGeometryForTool(
|
|
4128
|
+
getDraftGeometryForTool(
|
|
4129
|
+
state.tool,
|
|
4130
|
+
state.originCanvas,
|
|
4131
|
+
endCanvas,
|
|
4132
|
+
modifiers,
|
|
4133
|
+
),
|
|
3306
4134
|
);
|
|
3307
4135
|
if (activeTool === undefined) {
|
|
3308
4136
|
setLocalActiveTool("move");
|
|
@@ -3315,22 +4143,15 @@ export function MultiScreenCanvas({
|
|
|
3315
4143
|
tool: state.tool,
|
|
3316
4144
|
start: state.originCanvas,
|
|
3317
4145
|
end: endCanvas,
|
|
3318
|
-
points,
|
|
3319
4146
|
moved: releaseMoved,
|
|
3320
4147
|
toolProps,
|
|
4148
|
+
modifiers,
|
|
3321
4149
|
});
|
|
3322
4150
|
commitDraftPrimitive(nextDraft, state.originFrameId);
|
|
3323
4151
|
if (activeTool === undefined) {
|
|
3324
4152
|
setLocalActiveTool("move");
|
|
3325
4153
|
}
|
|
3326
|
-
|
|
3327
|
-
if (activeTool === undefined) {
|
|
3328
|
-
setLocalActiveTool("pen");
|
|
3329
|
-
}
|
|
3330
|
-
onActiveToolChange?.("pen");
|
|
3331
|
-
} else {
|
|
3332
|
-
onActiveToolChange?.("move");
|
|
3333
|
-
}
|
|
4154
|
+
onActiveToolChange?.("move");
|
|
3334
4155
|
finishDrag();
|
|
3335
4156
|
};
|
|
3336
4157
|
|
|
@@ -3726,8 +4547,8 @@ export function MultiScreenCanvas({
|
|
|
3726
4547
|
const state = dragState.current;
|
|
3727
4548
|
if (!state || state.type !== "move") return;
|
|
3728
4549
|
const scale = zoomRef.current / 100;
|
|
3729
|
-
|
|
3730
|
-
|
|
4550
|
+
let dx = (ev.clientX - state.originClient.x) / scale;
|
|
4551
|
+
let dy = (ev.clientY - state.originClient.y) / scale;
|
|
3731
4552
|
if (
|
|
3732
4553
|
!state.hasMoved &&
|
|
3733
4554
|
Math.hypot(
|
|
@@ -3738,6 +4559,26 @@ export function MultiScreenCanvas({
|
|
|
3738
4559
|
state.hasMoved = true;
|
|
3739
4560
|
}
|
|
3740
4561
|
|
|
4562
|
+
// Never commit a live transform before the drag threshold is crossed:
|
|
4563
|
+
// otherwise 1-2px of click jitter nudges the frame, that nudge is
|
|
4564
|
+
// never reverted (mouseup below only restores origin when the whole
|
|
4565
|
+
// gesture never moved past threshold — see below), and the next drag
|
|
4566
|
+
// reads its origin from the already-nudged geometry.
|
|
4567
|
+
if (!state.hasMoved) return;
|
|
4568
|
+
|
|
4569
|
+
// Shift held mid-move (not at mousedown — that path is shift-click
|
|
4570
|
+
// multi-select and never reaches here, see the guard above) locks
|
|
4571
|
+
// movement to a single axis, matching Figma. Zero the smaller-
|
|
4572
|
+
// magnitude axis before snapping so snap candidates on the locked
|
|
4573
|
+
// axis can't reintroduce drift on it.
|
|
4574
|
+
if (ev.shiftKey) {
|
|
4575
|
+
if (Math.abs(dx) >= Math.abs(dy)) {
|
|
4576
|
+
dy = 0;
|
|
4577
|
+
} else {
|
|
4578
|
+
dx = 0;
|
|
4579
|
+
}
|
|
4580
|
+
}
|
|
4581
|
+
|
|
3741
4582
|
const movingEntries = state.targetIds.map((targetId) => ({
|
|
3742
4583
|
id: targetId,
|
|
3743
4584
|
geometry: {
|
|
@@ -3769,6 +4610,51 @@ export function MultiScreenCanvas({
|
|
|
3769
4610
|
});
|
|
3770
4611
|
setAlignmentGuides(snap.guides);
|
|
3771
4612
|
|
|
4613
|
+
// Smart-spacing guides (CV11) — only meaningful for a single moving
|
|
4614
|
+
// frame (matches Figma, which only shows equal-gap guides while
|
|
4615
|
+
// dragging one object, not a multi-select group).
|
|
4616
|
+
if (state.targetIds.length === 1) {
|
|
4617
|
+
const primaryId = state.targetIds[0];
|
|
4618
|
+
const movedGeometry = movingEntries.find(
|
|
4619
|
+
(entry) => entry.id === primaryId,
|
|
4620
|
+
)?.geometry;
|
|
4621
|
+
if (movedGeometry) {
|
|
4622
|
+
// Same screen-px-to-canvas-px conversion computeMoveSnap uses
|
|
4623
|
+
// for its own threshold, so the equal-gap tolerance also stays
|
|
4624
|
+
// a constant few screen pixels regardless of zoom level.
|
|
4625
|
+
const tolerance =
|
|
4626
|
+
EQUAL_GAP_TOLERANCE_SCREEN_PX /
|
|
4627
|
+
Math.max(0.01, zoomRef.current / 100);
|
|
4628
|
+
setEqualGapGuides(
|
|
4629
|
+
computeEqualGapGuides(
|
|
4630
|
+
{
|
|
4631
|
+
...movedGeometry,
|
|
4632
|
+
x: movedGeometry.x + snap.dx,
|
|
4633
|
+
y: movedGeometry.y + snap.dy,
|
|
4634
|
+
},
|
|
4635
|
+
stationaryEntries,
|
|
4636
|
+
{ toleranceCanvasPx: tolerance },
|
|
4637
|
+
),
|
|
4638
|
+
);
|
|
4639
|
+
}
|
|
4640
|
+
} else {
|
|
4641
|
+
setEqualGapGuides([]);
|
|
4642
|
+
}
|
|
4643
|
+
|
|
4644
|
+
// Resize shows a W x H badge and rotate shows a degrees badge — move
|
|
4645
|
+
// was the one transform with no live feedback at all. Show the
|
|
4646
|
+
// primary frame's new (rounded) position, matching resize/rotate's
|
|
4647
|
+
// convention of displaying the current absolute value rather than a
|
|
4648
|
+
// delta.
|
|
4649
|
+
const primaryOrigin = state.originFrames[state.primaryId];
|
|
4650
|
+
if (primaryOrigin) {
|
|
4651
|
+
showTransformFeedback(
|
|
4652
|
+
`${Math.round(primaryOrigin.x + dx + snap.dx)}, ${Math.round(primaryOrigin.y + dy + snap.dy)}`,
|
|
4653
|
+
ev.clientX,
|
|
4654
|
+
ev.clientY,
|
|
4655
|
+
);
|
|
4656
|
+
}
|
|
4657
|
+
|
|
3772
4658
|
// When all dragged ids are committed primitive nodeIds (not screen
|
|
3773
4659
|
// frames), check for a container primitive drop target to highlight.
|
|
3774
4660
|
const currentFrameIds = Object.keys(frameGeometryRef.current);
|
|
@@ -3786,6 +4672,15 @@ export function MultiScreenCanvas({
|
|
|
3786
4672
|
const handleMouseUp = () => {
|
|
3787
4673
|
const state = dragState.current;
|
|
3788
4674
|
const dropTarget = primitiveDropTargetRef.current;
|
|
4675
|
+
if (state?.type === "move" && !state.hasMoved) {
|
|
4676
|
+
// Belt-and-braces: the live transform above already skips committing
|
|
4677
|
+
// until hasMoved, but restore origin here too in case any geometry
|
|
4678
|
+
// slipped through (e.g. a future code path that writes frameGeometry
|
|
4679
|
+
// directly) so a below-threshold click never leaves a phantom nudge.
|
|
4680
|
+
updateFrameGeometry((current) =>
|
|
4681
|
+
frameGeometryWithOverrides(current, state.originFrames),
|
|
4682
|
+
);
|
|
4683
|
+
}
|
|
3789
4684
|
if (state?.type === "move" && state.hasMoved) {
|
|
3790
4685
|
// If all dragged ids are committed primitive nodeIds (not screen
|
|
3791
4686
|
// frames), attempt a primitive reparent on drop.
|
|
@@ -3831,6 +4726,7 @@ export function MultiScreenCanvas({
|
|
|
3831
4726
|
installDragListeners,
|
|
3832
4727
|
onPick,
|
|
3833
4728
|
resolvePrimitiveScreenId,
|
|
4729
|
+
showTransformFeedback,
|
|
3834
4730
|
updateFrameGeometry,
|
|
3835
4731
|
updatePrimitiveDropTarget,
|
|
3836
4732
|
updateSelectedDraftIds,
|
|
@@ -3872,7 +4768,19 @@ export function MultiScreenCanvas({
|
|
|
3872
4768
|
hasMoved: false,
|
|
3873
4769
|
};
|
|
3874
4770
|
setIsDragging(true);
|
|
3875
|
-
|
|
4771
|
+
// Rotation-aware cursor: a static per-handle cursor is only correct
|
|
4772
|
+
// when the frame isn't rotated. For a single selected (possibly
|
|
4773
|
+
// rotated) frame, quantize the handle's rotated visual angle to the
|
|
4774
|
+
// nearest 45deg to pick the matching cursor; group resizes keep the
|
|
4775
|
+
// unrotated cursor, matching the group's own unrotated resize math.
|
|
4776
|
+
setDragCursor(
|
|
4777
|
+
originEntries.length === 1
|
|
4778
|
+
? getResizeCursorForHandle(
|
|
4779
|
+
handle,
|
|
4780
|
+
originEntries[0].geometry.rotation ?? 0,
|
|
4781
|
+
)
|
|
4782
|
+
: getResizeCursor(handle),
|
|
4783
|
+
);
|
|
3876
4784
|
|
|
3877
4785
|
const handleMouseMove = (ev: MouseEvent) => {
|
|
3878
4786
|
const state = dragState.current;
|
|
@@ -3890,10 +4798,58 @@ export function MultiScreenCanvas({
|
|
|
3890
4798
|
state.hasMoved = true;
|
|
3891
4799
|
}
|
|
3892
4800
|
|
|
4801
|
+
// Skip committing any transform until the drag threshold is crossed —
|
|
4802
|
+
// see the matching comment in beginFrameDrag's move handler.
|
|
4803
|
+
if (!state.hasMoved) return;
|
|
4804
|
+
|
|
3893
4805
|
const originEntries = state.targetIds.map((targetId) => ({
|
|
3894
4806
|
id: targetId,
|
|
3895
4807
|
geometry: state.originFrames[targetId],
|
|
3896
4808
|
}));
|
|
4809
|
+
|
|
4810
|
+
// A single rotated frame needs rotation-aware resize math: the handle
|
|
4811
|
+
// follows the frame's own rotated axes (matching how the handles
|
|
4812
|
+
// render, rotated with the frame) and the opposite anchor edge/corner
|
|
4813
|
+
// stays fixed in WORLD space, not just in the unrotated local frame.
|
|
4814
|
+
// Multi-select group resize with rotated members keeps the prior
|
|
4815
|
+
// (unrotated-bounds) behavior — extending this to groups would need
|
|
4816
|
+
// per-member rotation handling around a shared group anchor, which is
|
|
4817
|
+
// a larger change than this fix covers.
|
|
4818
|
+
const singleRotatedFrame =
|
|
4819
|
+
originEntries.length === 1 &&
|
|
4820
|
+
(originEntries[0].geometry.rotation ?? 0)
|
|
4821
|
+
? originEntries[0]
|
|
4822
|
+
: null;
|
|
4823
|
+
|
|
4824
|
+
if (singleRotatedFrame) {
|
|
4825
|
+
const resizedGeometry = resizeRotatedFrameFromDelta(
|
|
4826
|
+
singleRotatedFrame.geometry,
|
|
4827
|
+
state.handle,
|
|
4828
|
+
dx,
|
|
4829
|
+
dy,
|
|
4830
|
+
{
|
|
4831
|
+
preserveAspectRatio: ev.shiftKey,
|
|
4832
|
+
resizeFromCenter: ev.altKey,
|
|
4833
|
+
minWidth: 1,
|
|
4834
|
+
minHeight: 1,
|
|
4835
|
+
},
|
|
4836
|
+
);
|
|
4837
|
+
updateFrameGeometry((current) => ({
|
|
4838
|
+
...current,
|
|
4839
|
+
[singleRotatedFrame.id]: {
|
|
4840
|
+
...state.originFrames[singleRotatedFrame.id],
|
|
4841
|
+
...resizedGeometry,
|
|
4842
|
+
},
|
|
4843
|
+
}));
|
|
4844
|
+
setAlignmentGuides([]);
|
|
4845
|
+
showTransformFeedback(
|
|
4846
|
+
`${Math.round(resizedGeometry.width)} x ${Math.round(resizedGeometry.height)}`,
|
|
4847
|
+
ev.clientX,
|
|
4848
|
+
ev.clientY,
|
|
4849
|
+
);
|
|
4850
|
+
return;
|
|
4851
|
+
}
|
|
4852
|
+
|
|
3897
4853
|
const resized = resizeFrameGroupFromDelta(
|
|
3898
4854
|
originEntries,
|
|
3899
4855
|
state.originBounds,
|
|
@@ -3917,6 +4873,11 @@ export function MultiScreenCanvas({
|
|
|
3917
4873
|
thresholdScreenPx: DEFAULT_SNAP_THRESHOLD_SCREEN_PX,
|
|
3918
4874
|
zoom: zoomRef.current,
|
|
3919
4875
|
bypass: ev.metaKey || ev.ctrlKey,
|
|
4876
|
+
// Snapping x and y independently can each pull toward a
|
|
4877
|
+
// different sibling edge, which would distort a shift-held
|
|
4878
|
+
// (aspect-locked) resize away from its ratio — see
|
|
4879
|
+
// computeAspectPreservingResizeSnap in canvas-math.ts.
|
|
4880
|
+
preserveAspectRatio: ev.shiftKey,
|
|
3920
4881
|
},
|
|
3921
4882
|
);
|
|
3922
4883
|
const resizedEntries = resizeFrameGroupToBounds(
|
|
@@ -3944,6 +4905,12 @@ export function MultiScreenCanvas({
|
|
|
3944
4905
|
|
|
3945
4906
|
const handleMouseUp = () => {
|
|
3946
4907
|
const state = dragState.current;
|
|
4908
|
+
if (state?.type === "resize" && !state.hasMoved) {
|
|
4909
|
+
// Belt-and-braces restore, matching the move handler.
|
|
4910
|
+
updateFrameGeometry((current) =>
|
|
4911
|
+
frameGeometryWithOverrides(current, state.originFrames),
|
|
4912
|
+
);
|
|
4913
|
+
}
|
|
3947
4914
|
if (state?.type === "resize" && state.hasMoved) {
|
|
3948
4915
|
const after = cloneFrameGeometryById(frameGeometryRef.current);
|
|
3949
4916
|
onGeometryCommitRef.current?.(
|
|
@@ -4020,6 +4987,10 @@ export function MultiScreenCanvas({
|
|
|
4020
4987
|
) {
|
|
4021
4988
|
state.hasMoved = true;
|
|
4022
4989
|
}
|
|
4990
|
+
// Skip committing any transform until the drag threshold is crossed —
|
|
4991
|
+
// see the matching comment in beginFrameDrag's move handler.
|
|
4992
|
+
if (!state.hasMoved) return;
|
|
4993
|
+
|
|
4023
4994
|
const pointer = getCanvasPoint(ev.clientX, ev.clientY);
|
|
4024
4995
|
const center = getFrameCenter(state.originFrame);
|
|
4025
4996
|
const raw =
|
|
@@ -4043,12 +5014,135 @@ export function MultiScreenCanvas({
|
|
|
4043
5014
|
|
|
4044
5015
|
const handleMouseUp = () => {
|
|
4045
5016
|
const state = dragState.current;
|
|
5017
|
+
if (state?.type === "rotate" && !state.hasMoved) {
|
|
5018
|
+
// Belt-and-braces restore, matching the move handler.
|
|
5019
|
+
updateFrameGeometry((current) => ({
|
|
5020
|
+
...current,
|
|
5021
|
+
[state.frameId]: { ...state.originFrame },
|
|
5022
|
+
}));
|
|
5023
|
+
}
|
|
4046
5024
|
if (state?.type === "rotate" && state.hasMoved) {
|
|
4047
5025
|
const after = cloneFrameGeometryById(frameGeometryRef.current);
|
|
4048
5026
|
onGeometryCommitRef.current?.(
|
|
4049
|
-
frameGeometryWithOverrides(after, {
|
|
4050
|
-
[state.frameId]: state.originFrame,
|
|
4051
|
-
}),
|
|
5027
|
+
frameGeometryWithOverrides(after, {
|
|
5028
|
+
[state.frameId]: state.originFrame,
|
|
5029
|
+
}),
|
|
5030
|
+
after,
|
|
5031
|
+
);
|
|
5032
|
+
}
|
|
5033
|
+
suppressNextPick.current = true;
|
|
5034
|
+
finishDrag();
|
|
5035
|
+
};
|
|
5036
|
+
|
|
5037
|
+
installDragListeners(handleMouseMove, handleMouseUp);
|
|
5038
|
+
},
|
|
5039
|
+
[
|
|
5040
|
+
activeId,
|
|
5041
|
+
finishDrag,
|
|
5042
|
+
getCanvasPoint,
|
|
5043
|
+
getCurrentFrameEntries,
|
|
5044
|
+
installDragListeners,
|
|
5045
|
+
onPick,
|
|
5046
|
+
showTransformFeedback,
|
|
5047
|
+
updateFrameGeometry,
|
|
5048
|
+
updateSelectedIds,
|
|
5049
|
+
],
|
|
5050
|
+
);
|
|
5051
|
+
|
|
5052
|
+
// Multi-selection rotate (CV14): rotates every currently-selected frame
|
|
5053
|
+
// together around the group's own center. Kept entirely separate from
|
|
5054
|
+
// beginRotate above — single-frame rotate is unaffected.
|
|
5055
|
+
const beginGroupRotate = useCallback(
|
|
5056
|
+
(e: React.MouseEvent) => {
|
|
5057
|
+
if (e.button !== 0) return;
|
|
5058
|
+
e.preventDefault();
|
|
5059
|
+
e.stopPropagation();
|
|
5060
|
+
suppressNextPick.current = true;
|
|
5061
|
+
|
|
5062
|
+
const targetIds = selectedIdsRef.current;
|
|
5063
|
+
if (targetIds.length < 2) return;
|
|
5064
|
+
const originEntries = getCurrentFrameEntries().filter((entry) =>
|
|
5065
|
+
targetIds.includes(entry.id),
|
|
5066
|
+
);
|
|
5067
|
+
if (originEntries.length < 2) return;
|
|
5068
|
+
const groupBounds = getFrameGroupBounds(originEntries);
|
|
5069
|
+
if (!groupBounds) return;
|
|
5070
|
+
const groupCenter = { x: groupBounds.centerX, y: groupBounds.centerY };
|
|
5071
|
+
|
|
5072
|
+
const pointer = getCanvasPoint(e.clientX, e.clientY);
|
|
5073
|
+
dragState.current = {
|
|
5074
|
+
type: "group-rotate",
|
|
5075
|
+
originClient: { x: e.clientX, y: e.clientY },
|
|
5076
|
+
originFrames: Object.fromEntries(
|
|
5077
|
+
originEntries.map((entry) => [entry.id, entry.geometry]),
|
|
5078
|
+
) as FrameGeometryById,
|
|
5079
|
+
targetIds: originEntries.map((entry) => entry.id),
|
|
5080
|
+
groupCenter,
|
|
5081
|
+
originPointerAngle: angleBetween(groupCenter, pointer),
|
|
5082
|
+
hasMoved: false,
|
|
5083
|
+
};
|
|
5084
|
+
setIsDragging(true);
|
|
5085
|
+
setDragCursor("grabbing");
|
|
5086
|
+
|
|
5087
|
+
const handleMouseMove = (ev: MouseEvent) => {
|
|
5088
|
+
const state = dragState.current;
|
|
5089
|
+
if (!state || state.type !== "group-rotate") return;
|
|
5090
|
+
if (
|
|
5091
|
+
!state.hasMoved &&
|
|
5092
|
+
Math.hypot(
|
|
5093
|
+
ev.clientX - state.originClient.x,
|
|
5094
|
+
ev.clientY - state.originClient.y,
|
|
5095
|
+
) >= DRAG_THRESHOLD
|
|
5096
|
+
) {
|
|
5097
|
+
state.hasMoved = true;
|
|
5098
|
+
}
|
|
5099
|
+
// Skip committing any transform until the drag threshold is crossed —
|
|
5100
|
+
// see the matching comment in beginFrameDrag's move handler.
|
|
5101
|
+
if (!state.hasMoved) return;
|
|
5102
|
+
|
|
5103
|
+
const pointer = getCanvasPoint(ev.clientX, ev.clientY);
|
|
5104
|
+
const currentAngle = angleBetween(state.groupCenter, pointer);
|
|
5105
|
+
const rawDelta = currentAngle - state.originPointerAngle;
|
|
5106
|
+
const delta = ev.shiftKey ? Math.round(rawDelta / 15) * 15 : rawDelta;
|
|
5107
|
+
|
|
5108
|
+
const originEntriesForRotate = state.targetIds.map((targetId) => ({
|
|
5109
|
+
id: targetId,
|
|
5110
|
+
geometry: state.originFrames[targetId],
|
|
5111
|
+
}));
|
|
5112
|
+
const rotated = rotateFrameGroupAroundCenter(
|
|
5113
|
+
originEntriesForRotate,
|
|
5114
|
+
state.groupCenter,
|
|
5115
|
+
delta,
|
|
5116
|
+
);
|
|
5117
|
+
updateFrameGeometry((current) => {
|
|
5118
|
+
const next = { ...current };
|
|
5119
|
+
rotated.forEach((entry) => {
|
|
5120
|
+
next[entry.id] = {
|
|
5121
|
+
...state.originFrames[entry.id],
|
|
5122
|
+
...entry.geometry,
|
|
5123
|
+
};
|
|
5124
|
+
});
|
|
5125
|
+
return next;
|
|
5126
|
+
});
|
|
5127
|
+
showTransformFeedback(
|
|
5128
|
+
`${Math.round(delta)}deg`,
|
|
5129
|
+
ev.clientX,
|
|
5130
|
+
ev.clientY,
|
|
5131
|
+
);
|
|
5132
|
+
};
|
|
5133
|
+
|
|
5134
|
+
const handleMouseUp = () => {
|
|
5135
|
+
const state = dragState.current;
|
|
5136
|
+
if (state?.type === "group-rotate" && !state.hasMoved) {
|
|
5137
|
+
// Belt-and-braces restore, matching the move handler.
|
|
5138
|
+
updateFrameGeometry((current) =>
|
|
5139
|
+
frameGeometryWithOverrides(current, state.originFrames),
|
|
5140
|
+
);
|
|
5141
|
+
}
|
|
5142
|
+
if (state?.type === "group-rotate" && state.hasMoved) {
|
|
5143
|
+
const after = cloneFrameGeometryById(frameGeometryRef.current);
|
|
5144
|
+
onGeometryCommitRef.current?.(
|
|
5145
|
+
frameGeometryWithOverrides(after, state.originFrames),
|
|
4052
5146
|
after,
|
|
4053
5147
|
);
|
|
4054
5148
|
}
|
|
@@ -4059,15 +5153,12 @@ export function MultiScreenCanvas({
|
|
|
4059
5153
|
installDragListeners(handleMouseMove, handleMouseUp);
|
|
4060
5154
|
},
|
|
4061
5155
|
[
|
|
4062
|
-
activeId,
|
|
4063
5156
|
finishDrag,
|
|
4064
5157
|
getCanvasPoint,
|
|
4065
5158
|
getCurrentFrameEntries,
|
|
4066
5159
|
installDragListeners,
|
|
4067
|
-
onPick,
|
|
4068
5160
|
showTransformFeedback,
|
|
4069
5161
|
updateFrameGeometry,
|
|
4070
|
-
updateSelectedIds,
|
|
4071
5162
|
],
|
|
4072
5163
|
);
|
|
4073
5164
|
|
|
@@ -4153,6 +5244,11 @@ export function MultiScreenCanvas({
|
|
|
4153
5244
|
canDuplicate: !!onDuplicate,
|
|
4154
5245
|
moved: false,
|
|
4155
5246
|
});
|
|
5247
|
+
// Mount the interaction shield and mute preview-iframe pointer events for
|
|
5248
|
+
// the duration of the gesture, same as every other drag — otherwise the
|
|
5249
|
+
// pointer freezes crossing a live embedded iframe and a release over a
|
|
5250
|
+
// screen never reaches handleMouseUp.
|
|
5251
|
+
setIsDragging(true);
|
|
4156
5252
|
|
|
4157
5253
|
const handleMouseMove = (ev: MouseEvent) => {
|
|
4158
5254
|
const dx = ev.clientX - origin.x;
|
|
@@ -4165,22 +5261,24 @@ export function MultiScreenCanvas({
|
|
|
4165
5261
|
y: rect ? ev.clientY - rect.top + 16 : ev.clientY,
|
|
4166
5262
|
width: previewWidth,
|
|
4167
5263
|
height: previewHeight,
|
|
4168
|
-
|
|
5264
|
+
// Live alt state, not just capability: if the user releases alt
|
|
5265
|
+
// mid-drag the preview should visibly fall back to its "not armed"
|
|
5266
|
+
// dashed/preview styling, matching that mouseup will then cancel
|
|
5267
|
+
// the duplicate instead of creating one (see handleMouseUp below).
|
|
5268
|
+
canDuplicate: !!onDuplicate && ev.altKey,
|
|
4169
5269
|
moved,
|
|
4170
5270
|
});
|
|
4171
5271
|
};
|
|
4172
5272
|
|
|
4173
5273
|
const cleanupDuplicateGesture = () => {
|
|
4174
5274
|
setDuplicatePreview(null);
|
|
4175
|
-
window.removeEventListener("mousemove", handleMouseMove);
|
|
4176
|
-
window.removeEventListener("mouseup", handleMouseUp);
|
|
4177
|
-
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
|
4178
|
-
window.removeEventListener("blur", handleBlur);
|
|
4179
5275
|
duplicateCleanup.current = null;
|
|
4180
|
-
|
|
4181
|
-
|
|
4182
|
-
|
|
4183
|
-
|
|
5276
|
+
// finishDrag clears isDragging, unmounts the shield, and — critically —
|
|
5277
|
+
// runs dragCleanup.current() to detach the window listeners installed
|
|
5278
|
+
// by installDragListeners and restore preview-iframe pointer events.
|
|
5279
|
+
// dragState.current was never set for this gesture, so finishDrag's
|
|
5280
|
+
// other resets (marquee/creation-preview/etc.) are no-ops here.
|
|
5281
|
+
finishDrag();
|
|
4184
5282
|
};
|
|
4185
5283
|
|
|
4186
5284
|
const handleMouseUp = (ev: MouseEvent) => {
|
|
@@ -4188,22 +5286,27 @@ export function MultiScreenCanvas({
|
|
|
4188
5286
|
Math.hypot(ev.clientX - origin.x, ev.clientY - origin.y) >=
|
|
4189
5287
|
DUPLICATE_DRAG_THRESHOLD;
|
|
4190
5288
|
const mode = moved ? "alt-drag" : "alt-click";
|
|
4191
|
-
|
|
4192
|
-
|
|
5289
|
+
// Figma semantics: a plain alt-click (no drag) never duplicates —
|
|
5290
|
+
// only an actual alt-drag does. And alt is evaluated live: releasing
|
|
5291
|
+
// it before mouseup cancels the pending duplicate rather than
|
|
5292
|
+
// creating one anyway (this gesture never moves the original frame
|
|
5293
|
+
// during the drag — it only shows a floating ghost preview — so
|
|
5294
|
+
// "cancel" here means no-op, not handing off to a live move).
|
|
5295
|
+
const shouldDuplicate = moved && ev.altKey;
|
|
5296
|
+
|
|
5297
|
+
if (onDuplicate && shouldDuplicate) {
|
|
4193
5298
|
const dropCanvasPosition = canvasPointFromClient(
|
|
4194
5299
|
ev.clientX,
|
|
4195
5300
|
ev.clientY,
|
|
4196
5301
|
);
|
|
4197
|
-
|
|
4198
|
-
|
|
4199
|
-
|
|
4200
|
-
|
|
4201
|
-
|
|
4202
|
-
|
|
4203
|
-
|
|
4204
|
-
|
|
4205
|
-
y: sourceFrame.geometry.y + SCREEN_GAP / 2,
|
|
4206
|
-
};
|
|
5302
|
+
// shouldDuplicate implies moved, so the drop position is always
|
|
5303
|
+
// relative to the pointer's offset into the source frame (the
|
|
5304
|
+
// "snap next to source" placement only applied to the old
|
|
5305
|
+
// zero-move alt-click case, which no longer duplicates at all).
|
|
5306
|
+
const canvasPosition = {
|
|
5307
|
+
x: dropCanvasPosition.x - pointerOffset.x,
|
|
5308
|
+
y: dropCanvasPosition.y - pointerOffset.y,
|
|
5309
|
+
};
|
|
4207
5310
|
onDuplicate(screen.id, {
|
|
4208
5311
|
mode,
|
|
4209
5312
|
screen,
|
|
@@ -4219,11 +5322,20 @@ export function MultiScreenCanvas({
|
|
|
4219
5322
|
};
|
|
4220
5323
|
|
|
4221
5324
|
duplicateCleanup.current = cleanupDuplicateGesture;
|
|
4222
|
-
|
|
4223
|
-
|
|
4224
|
-
|
|
5325
|
+
installDragListeners(
|
|
5326
|
+
handleMouseMove,
|
|
5327
|
+
handleMouseUp,
|
|
5328
|
+
cleanupDuplicateGesture,
|
|
5329
|
+
);
|
|
4225
5330
|
},
|
|
4226
|
-
[
|
|
5331
|
+
[
|
|
5332
|
+
canvasPointFromClient,
|
|
5333
|
+
finishDrag,
|
|
5334
|
+
getCurrentFrameEntries,
|
|
5335
|
+
installDragListeners,
|
|
5336
|
+
onDuplicate,
|
|
5337
|
+
onPick,
|
|
5338
|
+
],
|
|
4227
5339
|
);
|
|
4228
5340
|
|
|
4229
5341
|
const handleMouseDown = useCallback(
|
|
@@ -4236,6 +5348,52 @@ export function MultiScreenCanvas({
|
|
|
4236
5348
|
const target = e.target as HTMLElement;
|
|
4237
5349
|
const onFrame = !!target.closest("[data-frame-shell]");
|
|
4238
5350
|
const tool = normalizeCanvasTool(activeTool ?? localActiveTool);
|
|
5351
|
+
if (vectorEdit) {
|
|
5352
|
+
if (e.button !== 0) return;
|
|
5353
|
+
e.preventDefault();
|
|
5354
|
+
// Hit-test the click directly against the path's anchors/handles
|
|
5355
|
+
// (rather than relying on per-element DOM handlers), reusing the
|
|
5356
|
+
// same pure hitTestPenAnchor/hitTestPenHandle helpers pen-path.ts
|
|
5357
|
+
// exports. A screen-space radius keeps the hit target a constant
|
|
5358
|
+
// physical size regardless of zoom, matching PEN_CLOSE_HIT_RADIUS
|
|
5359
|
+
// above. Handles take priority over anchors when both are in range
|
|
5360
|
+
// (checked first, below).
|
|
5361
|
+
const canvasPoint = getCanvasPoint(e.clientX, e.clientY);
|
|
5362
|
+
const localPoint = vectorEditCanvasToLocalPoint(
|
|
5363
|
+
canvasPoint,
|
|
5364
|
+
vectorEdit.originCanvas,
|
|
5365
|
+
);
|
|
5366
|
+
const hitRadius = screenPxToCanvasPx(
|
|
5367
|
+
VECTOR_EDIT_HIT_RADIUS_SCREEN_PX,
|
|
5368
|
+
zoomRef.current,
|
|
5369
|
+
);
|
|
5370
|
+
const handleHit = hitTestPenHandle(
|
|
5371
|
+
vectorEdit.path,
|
|
5372
|
+
localPoint,
|
|
5373
|
+
hitRadius,
|
|
5374
|
+
);
|
|
5375
|
+
if (handleHit) {
|
|
5376
|
+
beginVectorHandleDrag(handleHit.nodeIndex, handleHit.which, e);
|
|
5377
|
+
return;
|
|
5378
|
+
}
|
|
5379
|
+
const anchorHit = hitTestPenAnchor(
|
|
5380
|
+
vectorEdit.path,
|
|
5381
|
+
localPoint,
|
|
5382
|
+
hitRadius,
|
|
5383
|
+
);
|
|
5384
|
+
if (anchorHit) {
|
|
5385
|
+
if (e.detail > 1) {
|
|
5386
|
+
toggleVectorNodeType(anchorHit.nodeIndex);
|
|
5387
|
+
return;
|
|
5388
|
+
}
|
|
5389
|
+
beginVectorAnchorDrag(anchorHit.nodeIndex, e);
|
|
5390
|
+
return;
|
|
5391
|
+
}
|
|
5392
|
+
// Missed everything: an empty-canvas click while in vector edit mode
|
|
5393
|
+
// exits the mode, matching Figma.
|
|
5394
|
+
vectorEdit.onExit();
|
|
5395
|
+
return;
|
|
5396
|
+
}
|
|
4239
5397
|
if (e.button === 1) {
|
|
4240
5398
|
beginPan(e);
|
|
4241
5399
|
return;
|
|
@@ -4263,8 +5421,13 @@ export function MultiScreenCanvas({
|
|
|
4263
5421
|
beginMarquee,
|
|
4264
5422
|
beginPan,
|
|
4265
5423
|
beginPenNodeCreation,
|
|
5424
|
+
beginVectorAnchorDrag,
|
|
5425
|
+
beginVectorHandleDrag,
|
|
4266
5426
|
claimKeyboardFocus,
|
|
5427
|
+
getCanvasPoint,
|
|
4267
5428
|
localActiveTool,
|
|
5429
|
+
toggleVectorNodeType,
|
|
5430
|
+
vectorEdit,
|
|
4268
5431
|
],
|
|
4269
5432
|
);
|
|
4270
5433
|
|
|
@@ -4298,6 +5461,20 @@ export function MultiScreenCanvas({
|
|
|
4298
5461
|
grid.style.backgroundPosition = `${p.x}px ${p.y}px`;
|
|
4299
5462
|
grid.style.backgroundSize = `${nextScale}px ${nextScale}px`;
|
|
4300
5463
|
}
|
|
5464
|
+
// A marquee-select drag can run concurrently with a wheel/trackpad pan
|
|
5465
|
+
// gesture (e.g. two-finger scroll while left-mouse-dragging a marquee).
|
|
5466
|
+
// Without this, the marquee overlay's position/size — computed from
|
|
5467
|
+
// React `pan`/`scale` in its inline style — would only catch up once the
|
|
5468
|
+
// gesture settles and scheduleViewCommit() re-renders, visibly lagging
|
|
5469
|
+
// behind the frames it's supposed to be selecting against.
|
|
5470
|
+
const marqueeOverlay = marqueeOverlayRef.current;
|
|
5471
|
+
const activeMarquee = marqueeRef.current;
|
|
5472
|
+
if (marqueeOverlay && activeMarquee) {
|
|
5473
|
+
marqueeOverlay.style.left = `${p.x + (SURFACE_PADDING + activeMarquee.x) * nextScale}px`;
|
|
5474
|
+
marqueeOverlay.style.top = `${p.y + (SURFACE_PADDING + activeMarquee.y) * nextScale}px`;
|
|
5475
|
+
marqueeOverlay.style.width = `${Math.max(1, activeMarquee.width * nextScale)}px`;
|
|
5476
|
+
marqueeOverlay.style.height = `${Math.max(1, activeMarquee.height * nextScale)}px`;
|
|
5477
|
+
}
|
|
4301
5478
|
}, []);
|
|
4302
5479
|
|
|
4303
5480
|
const startChromeSettle = useCallback(() => {
|
|
@@ -4319,7 +5496,11 @@ export function MultiScreenCanvas({
|
|
|
4319
5496
|
setCanvasZoom(zoomRef.current);
|
|
4320
5497
|
setPan(panRef.current);
|
|
4321
5498
|
onZoomChange?.(zoomRef.current);
|
|
4322
|
-
|
|
5499
|
+
// P18: the wheel/pinch gesture just settled (pan/zoom state is
|
|
5500
|
+
// reconciled into React here) — resync the pen ghost preview from the
|
|
5501
|
+
// last known cursor position now that the canvas-space mapping changed.
|
|
5502
|
+
recomputePenPointerForViewChange();
|
|
5503
|
+
}, [onZoomChange, recomputePenPointerForViewChange, startChromeSettle]);
|
|
4323
5504
|
|
|
4324
5505
|
// Debounced: only commit to React state once the gesture has been idle for a
|
|
4325
5506
|
// beat, so a continuous pinch produces zero re-renders until the user pauses.
|
|
@@ -4335,6 +5516,8 @@ export function MultiScreenCanvas({
|
|
|
4335
5516
|
},
|
|
4336
5517
|
[commitView],
|
|
4337
5518
|
);
|
|
5519
|
+
applyViewToDomRef.current = applyViewToDom;
|
|
5520
|
+
scheduleViewCommitRef.current = scheduleViewCommit;
|
|
4338
5521
|
|
|
4339
5522
|
const flushPendingWheelGesture = useCallback(() => {
|
|
4340
5523
|
wheelGestureFrameRef.current = null;
|
|
@@ -4462,6 +5645,15 @@ export function MultiScreenCanvas({
|
|
|
4462
5645
|
|
|
4463
5646
|
const handleWheelEvent = useCallback(
|
|
4464
5647
|
(event: WheelEvent) => {
|
|
5648
|
+
// DesignCanvas re-dispatches embedded-canvas-wheel messages as a
|
|
5649
|
+
// synthetic (non-isTrusted) WheelEvent on its own iframe element so its
|
|
5650
|
+
// own listeners can reuse one code path. That synthetic event bubbles
|
|
5651
|
+
// up through this surface's capture listener too, so without this guard
|
|
5652
|
+
// every embedded-screen wheel gesture gets processed twice: once via
|
|
5653
|
+
// the postMessage handler below, and again here from the re-dispatch.
|
|
5654
|
+
// Real user wheel input is always isTrusted, so this only filters out
|
|
5655
|
+
// the synthetic replay.
|
|
5656
|
+
if (!event.isTrusted) return;
|
|
4465
5657
|
event.preventDefault();
|
|
4466
5658
|
event.stopPropagation();
|
|
4467
5659
|
enqueueWheelGestureFromClient({
|
|
@@ -4663,6 +5855,19 @@ export function MultiScreenCanvas({
|
|
|
4663
5855
|
|
|
4664
5856
|
const primaryKey = event.metaKey || event.ctrlKey;
|
|
4665
5857
|
if (primaryKey && !event.shiftKey && event.key.toLowerCase() === "z") {
|
|
5858
|
+
// While a pen-node drag is actively in progress (mouse down, still
|
|
5859
|
+
// dragging the handle for the anchor being placed), the path in
|
|
5860
|
+
// activePenPathRef already includes that in-progress node — undoing
|
|
5861
|
+
// here would pop it out from under the live drag, then the drag's
|
|
5862
|
+
// own mousemove/mouseup handlers would immediately re-add it from
|
|
5863
|
+
// their closed-over `state`, producing a no-op flicker. Ignore the
|
|
5864
|
+
// shortcut until the drag settles (mouseup/cancel).
|
|
5865
|
+
if (dragState.current?.type === "pen-node") {
|
|
5866
|
+
event.preventDefault();
|
|
5867
|
+
event.stopPropagation();
|
|
5868
|
+
event.stopImmediatePropagation();
|
|
5869
|
+
return;
|
|
5870
|
+
}
|
|
4666
5871
|
event.preventDefault();
|
|
4667
5872
|
event.stopPropagation();
|
|
4668
5873
|
event.stopImmediatePropagation();
|
|
@@ -4684,7 +5889,12 @@ export function MultiScreenCanvas({
|
|
|
4684
5889
|
event.preventDefault();
|
|
4685
5890
|
event.stopPropagation();
|
|
4686
5891
|
event.stopImmediatePropagation();
|
|
4687
|
-
|
|
5892
|
+
// Figma: Escape ends the path in progress and keeps what's drawn so
|
|
5893
|
+
// far (no data loss), rather than discarding the whole path.
|
|
5894
|
+
// finishPenPath already falls back to a discard for a path with
|
|
5895
|
+
// fewer than 2 nodes (P16), where there's nothing meaningful to
|
|
5896
|
+
// commit.
|
|
5897
|
+
finishPenPath(path);
|
|
4688
5898
|
return;
|
|
4689
5899
|
}
|
|
4690
5900
|
|
|
@@ -4700,15 +5910,28 @@ export function MultiScreenCanvas({
|
|
|
4700
5910
|
return () => {
|
|
4701
5911
|
window.removeEventListener("keydown", handleKeyDown, true);
|
|
4702
5912
|
};
|
|
4703
|
-
}, [
|
|
5913
|
+
}, [finishPenPath, undoActivePenPathSegment]);
|
|
4704
5914
|
|
|
4705
5915
|
useEffect(() => {
|
|
4706
5916
|
const tool = normalizeCanvasTool(activeTool ?? localActiveTool);
|
|
4707
|
-
|
|
4708
|
-
|
|
4709
|
-
|
|
4710
|
-
|
|
4711
|
-
|
|
5917
|
+
// Switching tools mid-path (toolbar click or a single-letter shortcut
|
|
5918
|
+
// reaching the editor's handlers) used to silently discard the path in
|
|
5919
|
+
// progress. Figma commits it instead — finishPenPath already discards
|
|
5920
|
+
// for a sub-2-node path (P16), so this only ever loses genuinely empty
|
|
5921
|
+
// in-progress state.
|
|
5922
|
+
if (tool !== "pen") finishPenPath();
|
|
5923
|
+
}, [activeTool, finishPenPath, localActiveTool]);
|
|
5924
|
+
|
|
5925
|
+
// Cmd+D / Ctrl+D: duplicate every selected frame (not just the first) with
|
|
5926
|
+
// a visible offset so each copy doesn't land exactly on top of its
|
|
5927
|
+
// original (Figma-style behaviour). `onDuplicate` is fire-and-forget
|
|
5928
|
+
// (`(id, request) => void`) and the actual new file id is only known
|
|
5929
|
+
// asynchronously by the caller (DesignEditor's createFileMutation
|
|
5930
|
+
// onSuccess), so this component has no id to add to its own selection
|
|
5931
|
+
// state. Known limitation: after a multi-duplicate, selection isn't
|
|
5932
|
+
// reprogrammed to the new copies (the caller instead makes its own last
|
|
5933
|
+
// duplicate the active file) — promoting the duplicates to the new
|
|
5934
|
+
// selection would require `onDuplicate` to return/callback the created id.
|
|
4712
5935
|
useEffect(() => {
|
|
4713
5936
|
if (!onDuplicate) return;
|
|
4714
5937
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
@@ -4720,31 +5943,39 @@ export function MultiScreenCanvas({
|
|
|
4720
5943
|
) {
|
|
4721
5944
|
return;
|
|
4722
5945
|
}
|
|
5946
|
+
// Always suppress the browser default (bookmark dialog) — but leave
|
|
5947
|
+
// propagation intact until we know a frame is duplicable here, so the
|
|
5948
|
+
// global hotkey hook can still duplicate non-frame layer selections.
|
|
5949
|
+
event.preventDefault();
|
|
4723
5950
|
// Only act on frame IDs — filter out canvas primitives (sub-elements).
|
|
4724
5951
|
const frameIds = selectedIdsRef.current.filter(
|
|
4725
5952
|
(id) => frameGeometryRef.current[id],
|
|
4726
5953
|
);
|
|
4727
|
-
|
|
4728
|
-
if (!targetId) return;
|
|
4729
|
-
const screen = screens.find((s) => s.id === targetId);
|
|
4730
|
-
if (!screen) return;
|
|
4731
|
-
const sourceGeometry = frameGeometryRef.current[targetId];
|
|
4732
|
-
if (!sourceGeometry) return;
|
|
4733
|
-
event.preventDefault();
|
|
5954
|
+
if (frameIds.length === 0) return;
|
|
4734
5955
|
event.stopPropagation();
|
|
4735
5956
|
event.stopImmediatePropagation();
|
|
4736
|
-
//
|
|
4737
|
-
//
|
|
4738
|
-
|
|
4739
|
-
|
|
4740
|
-
|
|
4741
|
-
|
|
4742
|
-
|
|
4743
|
-
|
|
4744
|
-
|
|
4745
|
-
|
|
4746
|
-
|
|
4747
|
-
|
|
5957
|
+
// Duplicate every selected frame, not just the first — each duplicate
|
|
5958
|
+
// is offset relative to its OWN source geometry (not chained off the
|
|
5959
|
+
// previous duplicate), mirroring how a multi-select alt-drag would
|
|
5960
|
+
// offset each frame independently.
|
|
5961
|
+
for (const targetId of frameIds) {
|
|
5962
|
+
const screen = screens.find((s) => s.id === targetId);
|
|
5963
|
+
if (!screen) continue;
|
|
5964
|
+
const sourceGeometry = frameGeometryRef.current[targetId];
|
|
5965
|
+
if (!sourceGeometry) continue;
|
|
5966
|
+
// Offset the duplicate by one grid gap to the right (and slightly down)
|
|
5967
|
+
// so it is visually distinct from the original, mirroring Figma's behaviour.
|
|
5968
|
+
const canvasPosition = {
|
|
5969
|
+
x: sourceGeometry.x + sourceGeometry.width + SCREEN_GAP,
|
|
5970
|
+
y: sourceGeometry.y,
|
|
5971
|
+
};
|
|
5972
|
+
onDuplicate(targetId, {
|
|
5973
|
+
mode: "alt-click",
|
|
5974
|
+
screen,
|
|
5975
|
+
canvasPosition,
|
|
5976
|
+
dropCanvasPosition: canvasPosition,
|
|
5977
|
+
});
|
|
5978
|
+
}
|
|
4748
5979
|
};
|
|
4749
5980
|
|
|
4750
5981
|
window.addEventListener("keydown", handleKeyDown, true);
|
|
@@ -4785,29 +6016,43 @@ export function MultiScreenCanvas({
|
|
|
4785
6016
|
: effectiveTool === "hand"
|
|
4786
6017
|
? "grab"
|
|
4787
6018
|
: "default";
|
|
4788
|
-
|
|
4789
|
-
|
|
4790
|
-
|
|
4791
|
-
|
|
4792
|
-
|
|
4793
|
-
|
|
4794
|
-
|
|
4795
|
-
|
|
4796
|
-
|
|
4797
|
-
|
|
4798
|
-
|
|
4799
|
-
|
|
4800
|
-
|
|
4801
|
-
|
|
4802
|
-
|
|
4803
|
-
|
|
4804
|
-
|
|
4805
|
-
|
|
4806
|
-
|
|
4807
|
-
|
|
4808
|
-
|
|
4809
|
-
|
|
4810
|
-
|
|
6019
|
+
// PF19: canvasFrames (and everything derived from it below) used to be
|
|
6020
|
+
// plain per-render recomputation over `screens`/`frameGeometry`, which are
|
|
6021
|
+
// large arrays/maps for boards with many screens. Memoize so a render that
|
|
6022
|
+
// doesn't touch screens/geometry (e.g. a hover-only or unrelated state
|
|
6023
|
+
// update) doesn't re-walk and re-allocate the whole frame list.
|
|
6024
|
+
const canvasFrames = useMemo(
|
|
6025
|
+
() =>
|
|
6026
|
+
screens.map((screen, index) => {
|
|
6027
|
+
const metadata = getResolvedMetadata(screen);
|
|
6028
|
+
return {
|
|
6029
|
+
screen,
|
|
6030
|
+
metadata,
|
|
6031
|
+
geometry:
|
|
6032
|
+
frameGeometry[screen.id] ??
|
|
6033
|
+
getInitialFrameGeometry(index, metadata),
|
|
6034
|
+
};
|
|
6035
|
+
}),
|
|
6036
|
+
[frameGeometry, getResolvedMetadata, screens],
|
|
6037
|
+
);
|
|
6038
|
+
const topScreenId = useMemo(
|
|
6039
|
+
() =>
|
|
6040
|
+
selectedIds.find((id) =>
|
|
6041
|
+
canvasFrames.some(({ screen }) => screen.id === id),
|
|
6042
|
+
) ??
|
|
6043
|
+
(activeId && canvasFrames.some(({ screen }) => screen.id === activeId)
|
|
6044
|
+
? activeId
|
|
6045
|
+
: canvasFrames[0]?.screen.id),
|
|
6046
|
+
[activeId, canvasFrames, selectedIds],
|
|
6047
|
+
);
|
|
6048
|
+
// PF16: previously this reordered canvasFrames so the top screen's keyed
|
|
6049
|
+
// entry moved to the end of the array. Since screens are keyed by
|
|
6050
|
+
// screen.id, moving a key's position in a mapped list makes React move
|
|
6051
|
+
// that DOM node (iframe) to match — which reloads the iframe's document,
|
|
6052
|
+
// producing a visible white flash every time selection changes. zIndex
|
|
6053
|
+
// (boosted for the top screen in Screen's root style, see
|
|
6054
|
+
// TOP_SCREEN_Z_BOOST) already expresses "paint above its siblings" without
|
|
6055
|
+
// needing to touch DOM order, so render canvasFrames in stable order.
|
|
4811
6056
|
const screenContentById = useMemo(() => {
|
|
4812
6057
|
if (!renderScreenContent) return new Map<string, ReactNode>();
|
|
4813
6058
|
return new Map(
|
|
@@ -4822,21 +6067,41 @@ export function MultiScreenCanvas({
|
|
|
4822
6067
|
}),
|
|
4823
6068
|
);
|
|
4824
6069
|
}, [frameGeometry, getResolvedMetadata, renderScreenContent, screens]);
|
|
4825
|
-
|
|
4826
|
-
|
|
4827
|
-
|
|
4828
|
-
|
|
4829
|
-
|
|
4830
|
-
|
|
4831
|
-
|
|
6070
|
+
// PF19: filters/maps over canvasFrames + a getFrameGroupBounds pass — cheap
|
|
6071
|
+
// for a handful of screens, but this runs on every render (hover, hint
|
|
6072
|
+
// text, unrelated state), not just selection changes. Memoize keyed on the
|
|
6073
|
+
// actual selection + frame list so unrelated renders reuse the prior arrays
|
|
6074
|
+
// (and downstream consumers like SelectionBox/GroupSelectionBox, which take
|
|
6075
|
+
// these by reference, skip re-rendering too).
|
|
6076
|
+
const selectedFrameEntries = useMemo(
|
|
6077
|
+
() =>
|
|
6078
|
+
canvasFrames
|
|
6079
|
+
.filter(({ screen }) => selectedIdSet.has(screen.id))
|
|
6080
|
+
.map(({ screen, geometry }) => ({ id: screen.id, geometry })),
|
|
6081
|
+
[canvasFrames, selectedIdSet],
|
|
6082
|
+
);
|
|
6083
|
+
const selectedGroupBounds = useMemo(
|
|
6084
|
+
() =>
|
|
6085
|
+
selectedFrameEntries.length > 1
|
|
6086
|
+
? getFrameGroupBounds(selectedFrameEntries)
|
|
6087
|
+
: null,
|
|
6088
|
+
[selectedFrameEntries],
|
|
6089
|
+
);
|
|
4832
6090
|
const hasGroupSelection = !!selectedGroupBounds;
|
|
4833
|
-
const selectedDraftEntries =
|
|
4834
|
-
|
|
4835
|
-
|
|
4836
|
-
|
|
4837
|
-
|
|
4838
|
-
|
|
4839
|
-
|
|
6091
|
+
const selectedDraftEntries = useMemo(
|
|
6092
|
+
() =>
|
|
6093
|
+
draftPrimitives
|
|
6094
|
+
.filter((draft) => selectedDraftIdSet.has(draft.id))
|
|
6095
|
+
.map((draft) => ({ id: draft.id, geometry: draft.geometry })),
|
|
6096
|
+
[draftPrimitives, selectedDraftIdSet],
|
|
6097
|
+
);
|
|
6098
|
+
const selectedDraftGroupBounds = useMemo(
|
|
6099
|
+
() =>
|
|
6100
|
+
selectedDraftEntries.length > 1
|
|
6101
|
+
? getFrameGroupBounds(selectedDraftEntries)
|
|
6102
|
+
: null,
|
|
6103
|
+
[selectedDraftEntries],
|
|
6104
|
+
);
|
|
4840
6105
|
const singleSelectedFrame =
|
|
4841
6106
|
selectedFrameEntries.length === 1 && !selectedGroupBounds
|
|
4842
6107
|
? selectedFrameEntries[0]
|
|
@@ -4993,7 +6258,7 @@ export function MultiScreenCanvas({
|
|
|
4993
6258
|
);
|
|
4994
6259
|
})()}
|
|
4995
6260
|
|
|
4996
|
-
{
|
|
6261
|
+
{canvasFrames.map(({ screen, metadata, geometry }) => {
|
|
4997
6262
|
return (
|
|
4998
6263
|
<Screen
|
|
4999
6264
|
key={screen.id}
|
|
@@ -5002,6 +6267,7 @@ export function MultiScreenCanvas({
|
|
|
5002
6267
|
geometry={geometry}
|
|
5003
6268
|
screenContent={screenContentById.get(screen.id)}
|
|
5004
6269
|
isActive={screen.id === activeId}
|
|
6270
|
+
isTopScreen={screen.id === topScreenId}
|
|
5005
6271
|
isSelected={selectedIdSet.has(screen.id)}
|
|
5006
6272
|
showFullView={fullViewIdSet.has(screen.id)}
|
|
5007
6273
|
isDirectlyHovered={screen.id === directlyHoveredScreenId}
|
|
@@ -5022,16 +6288,13 @@ export function MultiScreenCanvas({
|
|
|
5022
6288
|
onStartResize={beginResize}
|
|
5023
6289
|
onStartRotate={beginRotate}
|
|
5024
6290
|
onStartDuplicateGesture={beginDuplicateGesture}
|
|
5025
|
-
|
|
5026
|
-
|
|
5027
|
-
|
|
5028
|
-
|
|
5029
|
-
|
|
5030
|
-
|
|
5031
|
-
|
|
5032
|
-
? (widthPx) => onActiveBreakpointChange(screen.id, widthPx)
|
|
5033
|
-
: undefined
|
|
5034
|
-
}
|
|
6291
|
+
// Pass the id-first callbacks straight through (PF18): Screen
|
|
6292
|
+
// itself binds screen.id when it calls these, so every screen
|
|
6293
|
+
// instance gets the exact same stable function reference here
|
|
6294
|
+
// instead of a fresh per-screen closure allocated on every
|
|
6295
|
+
// MultiScreenCanvas render, which used to defeat memo(Screen).
|
|
6296
|
+
onAddBreakpoint={onAddBreakpoint}
|
|
6297
|
+
onActiveBreakpointChange={onActiveBreakpointChange}
|
|
5035
6298
|
/>
|
|
5036
6299
|
);
|
|
5037
6300
|
})}
|
|
@@ -5089,7 +6352,18 @@ export function MultiScreenCanvas({
|
|
|
5089
6352
|
: null}
|
|
5090
6353
|
|
|
5091
6354
|
{displayedPenPath ? (
|
|
5092
|
-
<PenPathOverlay
|
|
6355
|
+
<PenPathOverlay
|
|
6356
|
+
path={displayedPenPath}
|
|
6357
|
+
closeHover={penCloseHover}
|
|
6358
|
+
chromeScale={chromeScale}
|
|
6359
|
+
/>
|
|
6360
|
+
) : null}
|
|
6361
|
+
|
|
6362
|
+
{vectorEdit ? (
|
|
6363
|
+
<VectorEditOverlay
|
|
6364
|
+
vectorEdit={vectorEdit}
|
|
6365
|
+
chromeScale={chromeScale}
|
|
6366
|
+
/>
|
|
5093
6367
|
) : null}
|
|
5094
6368
|
|
|
5095
6369
|
{singleSelectedFrame ? (
|
|
@@ -5126,6 +6400,7 @@ export function MultiScreenCanvas({
|
|
|
5126
6400
|
chromeScale={chromeScale}
|
|
5127
6401
|
chromeSettling={chromeSettling}
|
|
5128
6402
|
onStartResize={beginGroupResize}
|
|
6403
|
+
onStartRotate={beginGroupRotate}
|
|
5129
6404
|
/>
|
|
5130
6405
|
) : null}
|
|
5131
6406
|
|
|
@@ -5159,8 +6434,63 @@ export function MultiScreenCanvas({
|
|
|
5159
6434
|
}
|
|
5160
6435
|
/>
|
|
5161
6436
|
))}
|
|
6437
|
+
|
|
6438
|
+
{/* Smart-spacing guides (CV11): highlight both equal-sized gaps
|
|
6439
|
+
around the moving frame, with one label showing the shared
|
|
6440
|
+
distance. */}
|
|
6441
|
+
{equalGapGuides.map((guide, index) =>
|
|
6442
|
+
guide.bands.map((band, bandIndex) => (
|
|
6443
|
+
<span
|
|
6444
|
+
key={`equal-gap-${guide.orientation}-${index}-${bandIndex}`}
|
|
6445
|
+
className="pointer-events-none absolute z-30 bg-[var(--design-editor-accent-color)]/25"
|
|
6446
|
+
style={
|
|
6447
|
+
guide.orientation === "vertical"
|
|
6448
|
+
? {
|
|
6449
|
+
left: SURFACE_PADDING + band.gapStart,
|
|
6450
|
+
top: SURFACE_PADDING + band.crossStart,
|
|
6451
|
+
width: Math.max(1, band.gapEnd - band.gapStart),
|
|
6452
|
+
height: Math.max(1, band.crossEnd - band.crossStart),
|
|
6453
|
+
}
|
|
6454
|
+
: {
|
|
6455
|
+
left: SURFACE_PADDING + band.crossStart,
|
|
6456
|
+
top: SURFACE_PADDING + band.gapStart,
|
|
6457
|
+
width: Math.max(1, band.crossEnd - band.crossStart),
|
|
6458
|
+
height: Math.max(1, band.gapEnd - band.gapStart),
|
|
6459
|
+
}
|
|
6460
|
+
}
|
|
6461
|
+
/>
|
|
6462
|
+
)),
|
|
6463
|
+
)}
|
|
5162
6464
|
</div>
|
|
5163
6465
|
|
|
6466
|
+
{/* Equal-gap distance labels render outside the pan/scale-transformed
|
|
6467
|
+
world container (same reasoning as the marquee/duplicate-preview
|
|
6468
|
+
overlays above it) so they need the explicit
|
|
6469
|
+
pan + (SURFACE_PADDING + canvasCoord) * scale conversion instead of
|
|
6470
|
+
the raw canvas coordinates the bands above use inside that
|
|
6471
|
+
container. */}
|
|
6472
|
+
{equalGapGuides.map((guide, index) => {
|
|
6473
|
+
const band = guide.bands[0];
|
|
6474
|
+
const crossMid = (band.crossStart + band.crossEnd) / 2;
|
|
6475
|
+
const gapMid = (band.gapStart + band.gapEnd) / 2;
|
|
6476
|
+
const labelCanvasPoint =
|
|
6477
|
+
guide.orientation === "vertical"
|
|
6478
|
+
? { x: gapMid, y: crossMid }
|
|
6479
|
+
: { x: crossMid, y: gapMid };
|
|
6480
|
+
return (
|
|
6481
|
+
<span
|
|
6482
|
+
key={`equal-gap-label-${guide.orientation}-${index}`}
|
|
6483
|
+
className="pointer-events-none absolute z-40 -translate-x-1/2 -translate-y-1/2 rounded bg-[var(--design-editor-accent-color)] px-1 py-0.5 text-[10px] font-medium leading-none text-[var(--design-editor-accent-contrast-color)] shadow-sm"
|
|
6484
|
+
style={{
|
|
6485
|
+
left: pan.x + (SURFACE_PADDING + labelCanvasPoint.x) * scale,
|
|
6486
|
+
top: pan.y + (SURFACE_PADDING + labelCanvasPoint.y) * scale,
|
|
6487
|
+
}}
|
|
6488
|
+
>
|
|
6489
|
+
{Math.round(guide.gap)}
|
|
6490
|
+
</span>
|
|
6491
|
+
);
|
|
6492
|
+
})}
|
|
6493
|
+
|
|
5164
6494
|
{penActive || creationToolActive ? (
|
|
5165
6495
|
<div
|
|
5166
6496
|
data-canvas-creation-shield
|
|
@@ -5171,11 +6501,18 @@ export function MultiScreenCanvas({
|
|
|
5171
6501
|
|
|
5172
6502
|
{marquee ? (
|
|
5173
6503
|
<span
|
|
6504
|
+
ref={marqueeOverlayRef}
|
|
5174
6505
|
className="pointer-events-none absolute z-40 border border-[var(--design-editor-accent-color)] bg-[var(--design-editor-selection-color)]"
|
|
5175
6506
|
style={{
|
|
5176
6507
|
// Convert canvas-space marquee to surface-space so this overlay
|
|
5177
6508
|
// is never clipped or hidden by the canvas transform container.
|
|
5178
6509
|
// Surface position = pan + (SURFACE_PADDING + canvasCoord) * scale
|
|
6510
|
+
// NOTE: this uses React `pan`/`scale`, which only update on the
|
|
6511
|
+
// debounced view-commit — during an active wheel/pinch gesture
|
|
6512
|
+
// (e.g. two-finger pan while marquee-dragging) applyViewToDom
|
|
6513
|
+
// keeps this element's position in sync imperatively via
|
|
6514
|
+
// marqueeOverlayRef, the same way it already does for the world
|
|
6515
|
+
// transform and pixel grid.
|
|
5179
6516
|
left: pan.x + (SURFACE_PADDING + marquee.x) * scale,
|
|
5180
6517
|
top: pan.y + (SURFACE_PADDING + marquee.y) * scale,
|
|
5181
6518
|
width: Math.max(1, marquee.width * scale),
|
|
@@ -5288,7 +6625,7 @@ export function MultiScreenCanvas({
|
|
|
5288
6625
|
) : null}
|
|
5289
6626
|
</div>
|
|
5290
6627
|
);
|
|
5291
|
-
}
|
|
6628
|
+
});
|
|
5292
6629
|
|
|
5293
6630
|
function DraftPrimitiveLayer({
|
|
5294
6631
|
draft,
|
|
@@ -5374,6 +6711,7 @@ function DraftPrimitiveLayer({
|
|
|
5374
6711
|
showRotate={false}
|
|
5375
6712
|
chromeScale={chromeScale}
|
|
5376
6713
|
chromeSettling={chromeSettling}
|
|
6714
|
+
rotationDeg={draft.geometry.rotation ?? 0}
|
|
5377
6715
|
onStartResize={(handle, event) =>
|
|
5378
6716
|
onStartResize(draft.id, handle, event)
|
|
5379
6717
|
}
|
|
@@ -5512,12 +6850,27 @@ function DraftPrimitiveContent({
|
|
|
5512
6850
|
function PenPathOverlay({
|
|
5513
6851
|
path,
|
|
5514
6852
|
closeHover,
|
|
6853
|
+
chromeScale,
|
|
5515
6854
|
}: {
|
|
5516
6855
|
path: PenPath;
|
|
5517
6856
|
closeHover: boolean;
|
|
6857
|
+
chromeScale: number;
|
|
5518
6858
|
}) {
|
|
5519
6859
|
const geometry = getPenPathGeometry(path);
|
|
5520
6860
|
const pathData = serializePenPath(path);
|
|
6861
|
+
// PenPathOverlay lives inside the pan/zoom-scaled world container, so raw
|
|
6862
|
+
// px sizes here would shrink to specks at low zoom and blow up into blobs
|
|
6863
|
+
// at high zoom. Scale every screen-space size (anchor/handle boxes,
|
|
6864
|
+
// stroke widths) by chromeScale (= 1 / zoomScale) the same way
|
|
6865
|
+
// SelectionBox's resize/rotate handles do, so they stay a constant size
|
|
6866
|
+
// on screen regardless of canvas zoom.
|
|
6867
|
+
const anchorSize = 8 * chromeScale;
|
|
6868
|
+
const handleSize = 6 * chromeScale;
|
|
6869
|
+
const anchorBorderWidth = Math.max(1, chromeScale);
|
|
6870
|
+
const handleBorderWidth = Math.max(1, chromeScale);
|
|
6871
|
+
const outlineStrokeWidth = 5 * chromeScale;
|
|
6872
|
+
const strokeWidth = 2 * chromeScale;
|
|
6873
|
+
const handleLineStrokeWidth = Math.max(1, chromeScale);
|
|
5521
6874
|
return (
|
|
5522
6875
|
<div
|
|
5523
6876
|
data-pen-path-overlay
|
|
@@ -5543,7 +6896,7 @@ function PenPathOverlay({
|
|
|
5543
6896
|
y2={node.handleIn.y}
|
|
5544
6897
|
stroke="var(--design-editor-accent-color)"
|
|
5545
6898
|
strokeDasharray="3 3"
|
|
5546
|
-
strokeWidth={
|
|
6899
|
+
strokeWidth={handleLineStrokeWidth}
|
|
5547
6900
|
/>
|
|
5548
6901
|
) : null}
|
|
5549
6902
|
{node.handleOut ? (
|
|
@@ -5554,7 +6907,7 @@ function PenPathOverlay({
|
|
|
5554
6907
|
y2={node.handleOut.y}
|
|
5555
6908
|
stroke="var(--design-editor-accent-color)"
|
|
5556
6909
|
strokeDasharray="3 3"
|
|
5557
|
-
strokeWidth={
|
|
6910
|
+
strokeWidth={handleLineStrokeWidth}
|
|
5558
6911
|
/>
|
|
5559
6912
|
) : null}
|
|
5560
6913
|
</g>
|
|
@@ -5565,7 +6918,7 @@ function PenPathOverlay({
|
|
|
5565
6918
|
stroke="rgba(255,255,255,0.95)"
|
|
5566
6919
|
strokeLinecap="round"
|
|
5567
6920
|
strokeLinejoin="round"
|
|
5568
|
-
strokeWidth={
|
|
6921
|
+
strokeWidth={outlineStrokeWidth}
|
|
5569
6922
|
/>
|
|
5570
6923
|
<path
|
|
5571
6924
|
d={pathData}
|
|
@@ -5573,7 +6926,7 @@ function PenPathOverlay({
|
|
|
5573
6926
|
stroke="var(--design-editor-accent-color)"
|
|
5574
6927
|
strokeLinecap="round"
|
|
5575
6928
|
strokeLinejoin="round"
|
|
5576
|
-
strokeWidth={
|
|
6929
|
+
strokeWidth={strokeWidth}
|
|
5577
6930
|
/>
|
|
5578
6931
|
</svg>
|
|
5579
6932
|
{path.nodes.map((node, index) => (
|
|
@@ -5581,14 +6934,17 @@ function PenPathOverlay({
|
|
|
5581
6934
|
key={`anchor-${index}`}
|
|
5582
6935
|
data-pen-anchor
|
|
5583
6936
|
className={cn(
|
|
5584
|
-
"absolute
|
|
6937
|
+
"absolute rounded-[2px] border shadow-sm",
|
|
5585
6938
|
index === 0 && closeHover
|
|
5586
6939
|
? "scale-125 border-[var(--design-editor-accent-color)] bg-[var(--design-editor-accent-color)] ring-4 ring-[var(--design-editor-selection-color)]"
|
|
5587
6940
|
: "border-[var(--design-editor-accent-color)] bg-[var(--design-editor-accent-contrast-color)]",
|
|
5588
6941
|
)}
|
|
5589
6942
|
style={{
|
|
5590
|
-
left: node.point.x - geometry.x -
|
|
5591
|
-
top: node.point.y - geometry.y -
|
|
6943
|
+
left: node.point.x - geometry.x - anchorSize / 2,
|
|
6944
|
+
top: node.point.y - geometry.y - anchorSize / 2,
|
|
6945
|
+
width: anchorSize,
|
|
6946
|
+
height: anchorSize,
|
|
6947
|
+
borderWidth: anchorBorderWidth,
|
|
5592
6948
|
}}
|
|
5593
6949
|
/>
|
|
5594
6950
|
))}
|
|
@@ -5599,10 +6955,13 @@ function PenPathOverlay({
|
|
|
5599
6955
|
<span
|
|
5600
6956
|
key={`handle-${index}-${handleIndex}`}
|
|
5601
6957
|
data-pen-handle
|
|
5602
|
-
className="absolute
|
|
6958
|
+
className="absolute rounded-full border border-[var(--design-editor-accent-color)] bg-background shadow-sm"
|
|
5603
6959
|
style={{
|
|
5604
|
-
left: handle.x - geometry.x -
|
|
5605
|
-
top: handle.y - geometry.y -
|
|
6960
|
+
left: handle.x - geometry.x - handleSize / 2,
|
|
6961
|
+
top: handle.y - geometry.y - handleSize / 2,
|
|
6962
|
+
width: handleSize,
|
|
6963
|
+
height: handleSize,
|
|
6964
|
+
borderWidth: handleBorderWidth,
|
|
5606
6965
|
}}
|
|
5607
6966
|
/>
|
|
5608
6967
|
)),
|
|
@@ -5615,6 +6974,154 @@ function isPoint(point: Point | undefined): point is Point {
|
|
|
5615
6974
|
return !!point;
|
|
5616
6975
|
}
|
|
5617
6976
|
|
|
6977
|
+
/**
|
|
6978
|
+
* Interactive counterpart of `PenPathOverlay` (P-VE1): renders the same
|
|
6979
|
+
* anchor-square / handle-circle / dashed-connector visual language, sized by
|
|
6980
|
+
* `chromeScale` so it stays a constant screen size at any zoom — like
|
|
6981
|
+
* `SelectionBox`'s resize handles, not like `PenPathOverlay`'s fixed-size
|
|
6982
|
+
* (non-interactive) chrome. Lives inside the pan/zoom-scaled `world`
|
|
6983
|
+
* container alongside `PenPathOverlay`/`SelectionBox`, positioned in canvas
|
|
6984
|
+
* space via `originCanvas + local point` (`vectorEditLocalToCanvasPoint`).
|
|
6985
|
+
*
|
|
6986
|
+
* Purely visual: pointer interaction (hit-testing + drag) is owned entirely
|
|
6987
|
+
* by the parent's `handleMouseDown`, which runs on the capture phase and
|
|
6988
|
+
* resolves hitTestPenHandle/hitTestPenAnchor itself against the raw click
|
|
6989
|
+
* point (handles take priority over anchors when both are in range) — see
|
|
6990
|
+
* the `vectorEdit` branch there. This mirrors how `PenPathOverlay` is a pure
|
|
6991
|
+
* render of `activePenPath`/`penGesturePreview` state owned by the pen-tool
|
|
6992
|
+
* gesture handlers rather than an independently-interactive component.
|
|
6993
|
+
*/
|
|
6994
|
+
function VectorEditOverlay({
|
|
6995
|
+
vectorEdit,
|
|
6996
|
+
chromeScale,
|
|
6997
|
+
}: {
|
|
6998
|
+
vectorEdit: VectorEditOverlayState;
|
|
6999
|
+
chromeScale: number;
|
|
7000
|
+
}) {
|
|
7001
|
+
const { path, originCanvas } = vectorEdit;
|
|
7002
|
+
// Render in canvas space: every local point is offset by the path's
|
|
7003
|
+
// canvas origin before being laid out, so the overlay's own geometry/
|
|
7004
|
+
// pathData stay in the same canvas coordinate frame PenPathOverlay uses.
|
|
7005
|
+
const canvasPath = useMemo<PenPath>(
|
|
7006
|
+
() => translatePenPath(path, originCanvas.x, originCanvas.y),
|
|
7007
|
+
[path, originCanvas.x, originCanvas.y],
|
|
7008
|
+
);
|
|
7009
|
+
const geometry = getPenPathGeometry(canvasPath);
|
|
7010
|
+
const pathData = serializePenPath(canvasPath);
|
|
7011
|
+
|
|
7012
|
+
const anchorSize = 9 * chromeScale;
|
|
7013
|
+
const handleSize = 7 * chromeScale;
|
|
7014
|
+
const anchorBorderWidth = Math.max(1, 1.5 * chromeScale);
|
|
7015
|
+
const handleBorderWidth = Math.max(1, chromeScale);
|
|
7016
|
+
const outlineStrokeWidth = 5 * chromeScale;
|
|
7017
|
+
const strokeWidth = 2 * chromeScale;
|
|
7018
|
+
const handleLineStrokeWidth = Math.max(1, chromeScale);
|
|
7019
|
+
|
|
7020
|
+
return (
|
|
7021
|
+
<div
|
|
7022
|
+
data-vector-edit-overlay
|
|
7023
|
+
// pointer-events:auto on the overlay's own bounding box (not each
|
|
7024
|
+
// anchor/handle) so a click anywhere within it is captured by the
|
|
7025
|
+
// surface's onMouseDownCapture handler for hit-testing, while empty
|
|
7026
|
+
// space *outside* this box still reaches the surface as a background
|
|
7027
|
+
// click (which exits vector edit mode) via CSS containment rather than
|
|
7028
|
+
// per-element listeners.
|
|
7029
|
+
className="pointer-events-auto absolute z-[95]"
|
|
7030
|
+
style={{
|
|
7031
|
+
left: SURFACE_PADDING + geometry.x,
|
|
7032
|
+
top: SURFACE_PADDING + geometry.y,
|
|
7033
|
+
width: geometry.width,
|
|
7034
|
+
height: geometry.height,
|
|
7035
|
+
}}
|
|
7036
|
+
>
|
|
7037
|
+
<svg
|
|
7038
|
+
className="pointer-events-none absolute inset-0 size-full overflow-visible"
|
|
7039
|
+
viewBox={`${geometry.x} ${geometry.y} ${geometry.width} ${geometry.height}`}
|
|
7040
|
+
>
|
|
7041
|
+
{canvasPath.nodes.map((node, index) => (
|
|
7042
|
+
<g key={`vector-handle-lines-${index}`}>
|
|
7043
|
+
{node.handleIn ? (
|
|
7044
|
+
<line
|
|
7045
|
+
x1={node.point.x}
|
|
7046
|
+
y1={node.point.y}
|
|
7047
|
+
x2={node.handleIn.x}
|
|
7048
|
+
y2={node.handleIn.y}
|
|
7049
|
+
stroke="var(--design-editor-accent-color)"
|
|
7050
|
+
strokeDasharray="3 3"
|
|
7051
|
+
strokeWidth={handleLineStrokeWidth}
|
|
7052
|
+
/>
|
|
7053
|
+
) : null}
|
|
7054
|
+
{node.handleOut ? (
|
|
7055
|
+
<line
|
|
7056
|
+
x1={node.point.x}
|
|
7057
|
+
y1={node.point.y}
|
|
7058
|
+
x2={node.handleOut.x}
|
|
7059
|
+
y2={node.handleOut.y}
|
|
7060
|
+
stroke="var(--design-editor-accent-color)"
|
|
7061
|
+
strokeDasharray="3 3"
|
|
7062
|
+
strokeWidth={handleLineStrokeWidth}
|
|
7063
|
+
/>
|
|
7064
|
+
) : null}
|
|
7065
|
+
</g>
|
|
7066
|
+
))}
|
|
7067
|
+
<path
|
|
7068
|
+
d={pathData}
|
|
7069
|
+
fill="none"
|
|
7070
|
+
stroke="rgba(255,255,255,0.95)"
|
|
7071
|
+
strokeLinecap="round"
|
|
7072
|
+
strokeLinejoin="round"
|
|
7073
|
+
strokeWidth={outlineStrokeWidth}
|
|
7074
|
+
/>
|
|
7075
|
+
<path
|
|
7076
|
+
d={pathData}
|
|
7077
|
+
fill="none"
|
|
7078
|
+
stroke="var(--design-editor-accent-color)"
|
|
7079
|
+
strokeLinecap="round"
|
|
7080
|
+
strokeLinejoin="round"
|
|
7081
|
+
strokeWidth={strokeWidth}
|
|
7082
|
+
/>
|
|
7083
|
+
</svg>
|
|
7084
|
+
{canvasPath.nodes.map((node, index) => (
|
|
7085
|
+
<span
|
|
7086
|
+
key={`vector-anchor-${index}`}
|
|
7087
|
+
data-vector-anchor
|
|
7088
|
+
className="pointer-events-none absolute rounded-[2px] border shadow-sm border-[var(--design-editor-accent-color)] bg-[var(--design-editor-accent-contrast-color)]"
|
|
7089
|
+
style={{
|
|
7090
|
+
left: node.point.x - geometry.x - anchorSize / 2,
|
|
7091
|
+
top: node.point.y - geometry.y - anchorSize / 2,
|
|
7092
|
+
width: anchorSize,
|
|
7093
|
+
height: anchorSize,
|
|
7094
|
+
borderWidth: anchorBorderWidth,
|
|
7095
|
+
}}
|
|
7096
|
+
/>
|
|
7097
|
+
))}
|
|
7098
|
+
{canvasPath.nodes.flatMap((node, index) =>
|
|
7099
|
+
(
|
|
7100
|
+
[
|
|
7101
|
+
["in", node.handleIn] as const,
|
|
7102
|
+
["out", node.handleOut] as const,
|
|
7103
|
+
] as const
|
|
7104
|
+
)
|
|
7105
|
+
.filter((entry): entry is ["in" | "out", Point] => isPoint(entry[1]))
|
|
7106
|
+
.map(([which, handle]) => (
|
|
7107
|
+
<span
|
|
7108
|
+
key={`vector-handle-${index}-${which}`}
|
|
7109
|
+
data-vector-handle
|
|
7110
|
+
className="pointer-events-none absolute rounded-full border border-[var(--design-editor-accent-color)] bg-background shadow-sm"
|
|
7111
|
+
style={{
|
|
7112
|
+
left: handle.x - geometry.x - handleSize / 2,
|
|
7113
|
+
top: handle.y - geometry.y - handleSize / 2,
|
|
7114
|
+
width: handleSize,
|
|
7115
|
+
height: handleSize,
|
|
7116
|
+
borderWidth: handleBorderWidth,
|
|
7117
|
+
}}
|
|
7118
|
+
/>
|
|
7119
|
+
)),
|
|
7120
|
+
)}
|
|
7121
|
+
</div>
|
|
7122
|
+
);
|
|
7123
|
+
}
|
|
7124
|
+
|
|
5618
7125
|
/** Standard Tailwind breakpoint widths, mobile-first (base / md: / lg: / xl:). */
|
|
5619
7126
|
const STANDARD_BREAKPOINT_WIDTHS = [390, 768, 1280] as const;
|
|
5620
7127
|
|
|
@@ -5630,12 +7137,57 @@ function nextBreakpointWidth(existing: number[]): number | undefined {
|
|
|
5630
7137
|
return STANDARD_BREAKPOINT_WIDTHS.find((w) => !existing.includes(w));
|
|
5631
7138
|
}
|
|
5632
7139
|
|
|
7140
|
+
/**
|
|
7141
|
+
* The `data-screen-iframe-id` DOM attribute value for a screen's PRIMARY
|
|
7142
|
+
* iframe. Breakpoint sub-frames (see BreakpointPreviewRow) get their own
|
|
7143
|
+
* distinct suffixed id via `getBreakpointIframeId` so `querySelector`
|
|
7144
|
+
* (which always returns the first DOM match) can't silently collide two
|
|
7145
|
+
* different iframes onto the same id.
|
|
7146
|
+
*/
|
|
7147
|
+
export function getPrimaryIframeId(screenId: string): string {
|
|
7148
|
+
return screenId;
|
|
7149
|
+
}
|
|
7150
|
+
|
|
7151
|
+
/** The `data-screen-iframe-id` value for one specific breakpoint sub-frame. */
|
|
7152
|
+
export function getBreakpointIframeId(
|
|
7153
|
+
screenId: string,
|
|
7154
|
+
widthPx: number,
|
|
7155
|
+
): string {
|
|
7156
|
+
return `${screenId}::bp-${widthPx}`;
|
|
7157
|
+
}
|
|
7158
|
+
|
|
7159
|
+
/**
|
|
7160
|
+
* Resolves which iframe DOM id actually represents `screen` right now for
|
|
7161
|
+
* hit-test / drag / wheel bridge lookups: the currently active breakpoint
|
|
7162
|
+
* sub-frame if one is selected, otherwise the primary iframe. Every
|
|
7163
|
+
* `querySelector('[data-screen-iframe-id="…"]')` call site that resolves a
|
|
7164
|
+
* screen id to a live iframe must go through this so an active breakpoint
|
|
7165
|
+
* scope actually targets the frame the user is looking at, instead of
|
|
7166
|
+
* always silently resolving to the primary frame regardless of which one is
|
|
7167
|
+
* selected.
|
|
7168
|
+
*/
|
|
7169
|
+
export function getActiveScreenIframeId(screen: {
|
|
7170
|
+
id: string;
|
|
7171
|
+
activeBreakpointWidth?: number;
|
|
7172
|
+
breakpointWidths?: number[];
|
|
7173
|
+
}): string {
|
|
7174
|
+
const activeWidth = screen.activeBreakpointWidth;
|
|
7175
|
+
if (
|
|
7176
|
+
activeWidth !== undefined &&
|
|
7177
|
+
screen.breakpointWidths?.includes(activeWidth)
|
|
7178
|
+
) {
|
|
7179
|
+
return getBreakpointIframeId(screen.id, activeWidth);
|
|
7180
|
+
}
|
|
7181
|
+
return getPrimaryIframeId(screen.id);
|
|
7182
|
+
}
|
|
7183
|
+
|
|
5633
7184
|
interface ScreenProps {
|
|
5634
7185
|
screen: ScreenFile;
|
|
5635
7186
|
metadata: ResolvedScreenMetadata;
|
|
5636
7187
|
geometry: FrameGeometry;
|
|
5637
7188
|
isActive: boolean;
|
|
5638
7189
|
isSelected: boolean;
|
|
7190
|
+
isTopScreen: boolean;
|
|
5639
7191
|
showFullView: boolean;
|
|
5640
7192
|
isDirectlyHovered: boolean;
|
|
5641
7193
|
hasHoveredChild: boolean;
|
|
@@ -5661,8 +7213,17 @@ interface ScreenProps {
|
|
|
5661
7213
|
display: string,
|
|
5662
7214
|
e: React.MouseEvent<HTMLElement>,
|
|
5663
7215
|
) => void;
|
|
5664
|
-
|
|
5665
|
-
onActiveBreakpointChange
|
|
7216
|
+
// Id-first (screenId, widthPx) shape, same as MultiScreenCanvas's own
|
|
7217
|
+
// onAddBreakpoint/onActiveBreakpointChange props (PF18): Screen binds
|
|
7218
|
+
// screen.id itself when calling these, so the parent can pass the same
|
|
7219
|
+
// stable function reference for every screen instead of allocating a new
|
|
7220
|
+
// per-screen `(widthPx) => onAddBreakpoint(screen.id, widthPx)` closure on
|
|
7221
|
+
// every render, which defeated memo(Screen) for every screen every time.
|
|
7222
|
+
onAddBreakpoint?: (screenId: string, widthPx: number) => void;
|
|
7223
|
+
onActiveBreakpointChange?: (
|
|
7224
|
+
screenId: string,
|
|
7225
|
+
widthPx: number | undefined,
|
|
7226
|
+
) => void;
|
|
5666
7227
|
}
|
|
5667
7228
|
|
|
5668
7229
|
const Screen = memo(function Screen({
|
|
@@ -5671,6 +7232,7 @@ const Screen = memo(function Screen({
|
|
|
5671
7232
|
geometry,
|
|
5672
7233
|
isActive,
|
|
5673
7234
|
isSelected,
|
|
7235
|
+
isTopScreen,
|
|
5674
7236
|
showFullView,
|
|
5675
7237
|
isDirectlyHovered,
|
|
5676
7238
|
hasHoveredChild,
|
|
@@ -5760,7 +7322,9 @@ const Screen = memo(function Screen({
|
|
|
5760
7322
|
? `rotate(${geometry.rotation}deg)`
|
|
5761
7323
|
: undefined,
|
|
5762
7324
|
transformOrigin: `${geometry.width / 2}px ${frameLabelHeight + geometry.height / 2}px`,
|
|
5763
|
-
zIndex:
|
|
7325
|
+
zIndex: isTopScreen
|
|
7326
|
+
? (geometry.z ?? 0) + TOP_SCREEN_Z_BOOST
|
|
7327
|
+
: geometry.z,
|
|
5764
7328
|
}}
|
|
5765
7329
|
>
|
|
5766
7330
|
<div
|
|
@@ -5784,6 +7348,11 @@ const Screen = memo(function Screen({
|
|
|
5784
7348
|
if (e.button !== 0) return;
|
|
5785
7349
|
if (penActive || creationToolActive) return;
|
|
5786
7350
|
if (e.altKey) {
|
|
7351
|
+
// Matches the data-screen-card mousedown handler below: without
|
|
7352
|
+
// this, a trailing click after the alt-drag/duplicate gesture
|
|
7353
|
+
// ends falls through to this row's onClick and steals selection
|
|
7354
|
+
// away from the newly created duplicate.
|
|
7355
|
+
suppressNextClick.current = true;
|
|
5787
7356
|
onStartDuplicateGesture(screen, display, e);
|
|
5788
7357
|
return;
|
|
5789
7358
|
}
|
|
@@ -5827,6 +7396,19 @@ const Screen = memo(function Screen({
|
|
|
5827
7396
|
>
|
|
5828
7397
|
{display}
|
|
5829
7398
|
</span>
|
|
7399
|
+
{metadata.source === "fusion" ? (
|
|
7400
|
+
<span
|
|
7401
|
+
data-frame-source-badge="fusion"
|
|
7402
|
+
className="shrink-0 rounded-sm bg-muted-foreground/15 px-1 !text-[9px] font-medium uppercase tracking-wide text-muted-foreground"
|
|
7403
|
+
title={
|
|
7404
|
+
"Backed by a running app" /* i18n-ignore short frame badge, mirrors other frame-chrome literals in this file */
|
|
7405
|
+
}
|
|
7406
|
+
>
|
|
7407
|
+
{
|
|
7408
|
+
"App" /* i18n-ignore short frame badge, mirrors other frame-chrome literals in this file */
|
|
7409
|
+
}
|
|
7410
|
+
</span>
|
|
7411
|
+
) : null}
|
|
5830
7412
|
</div>
|
|
5831
7413
|
<button
|
|
5832
7414
|
type="button"
|
|
@@ -6008,6 +7590,7 @@ const Screen = memo(function Screen({
|
|
|
6008
7590
|
showRotate
|
|
6009
7591
|
chromeScale={chromeScale}
|
|
6010
7592
|
chromeSettling={chromeSettling}
|
|
7593
|
+
rotationDeg={geometry.rotation ?? 0}
|
|
6011
7594
|
onStartResize={(handle, e) => onStartResize(screen.id, handle, e)}
|
|
6012
7595
|
onStartRotate={(e) => onStartRotate(screen.id, e)}
|
|
6013
7596
|
/>
|
|
@@ -6028,8 +7611,16 @@ const Screen = memo(function Screen({
|
|
|
6028
7611
|
penActive={penActive}
|
|
6029
7612
|
creationToolActive={creationToolActive}
|
|
6030
7613
|
chromeScale={chromeScale}
|
|
6031
|
-
onActiveBreakpointChange={
|
|
6032
|
-
|
|
7614
|
+
onActiveBreakpointChange={
|
|
7615
|
+
onActiveBreakpointChange
|
|
7616
|
+
? (widthPx) => onActiveBreakpointChange(screen.id, widthPx)
|
|
7617
|
+
: undefined
|
|
7618
|
+
}
|
|
7619
|
+
onAddBreakpoint={
|
|
7620
|
+
onAddBreakpoint
|
|
7621
|
+
? (widthPx) => onAddBreakpoint(screen.id, widthPx)
|
|
7622
|
+
: undefined
|
|
7623
|
+
}
|
|
6033
7624
|
/>
|
|
6034
7625
|
) : null}
|
|
6035
7626
|
</div>
|
|
@@ -6044,6 +7635,7 @@ function areScreenPropsEqual(prev: ScreenProps, next: ScreenProps) {
|
|
|
6044
7635
|
sameFrameGeometry(prev.geometry, next.geometry) &&
|
|
6045
7636
|
prev.isActive === next.isActive &&
|
|
6046
7637
|
prev.isSelected === next.isSelected &&
|
|
7638
|
+
prev.isTopScreen === next.isTopScreen &&
|
|
6047
7639
|
prev.showFullView === next.showFullView &&
|
|
6048
7640
|
prev.isDirectlyHovered === next.isDirectlyHovered &&
|
|
6049
7641
|
prev.hasHoveredChild === next.hasHoveredChild &&
|
|
@@ -6059,7 +7651,13 @@ function areScreenPropsEqual(prev: ScreenProps, next: ScreenProps) {
|
|
|
6059
7651
|
prev.onStartFrameDrag === next.onStartFrameDrag &&
|
|
6060
7652
|
prev.onStartResize === next.onStartResize &&
|
|
6061
7653
|
prev.onStartRotate === next.onStartRotate &&
|
|
6062
|
-
prev.onStartDuplicateGesture === next.onStartDuplicateGesture
|
|
7654
|
+
prev.onStartDuplicateGesture === next.onStartDuplicateGesture &&
|
|
7655
|
+
// Now id-first (screenId, widthPx) callbacks passed straight through
|
|
7656
|
+
// from MultiScreenCanvas's own props (PF18) instead of a fresh
|
|
7657
|
+
// per-screen arrow allocated in the render loop, so these are expected
|
|
7658
|
+
// to be referentially stable across renders and are safe to compare.
|
|
7659
|
+
prev.onAddBreakpoint === next.onAddBreakpoint &&
|
|
7660
|
+
prev.onActiveBreakpointChange === next.onActiveBreakpointChange
|
|
6063
7661
|
);
|
|
6064
7662
|
}
|
|
6065
7663
|
|
|
@@ -6087,7 +7685,9 @@ function BreakpointPreviewRow({
|
|
|
6087
7685
|
* The primary screen's srcdoc with the lightweight hit-test responder already
|
|
6088
7686
|
* injected (memoised in the parent Screen component). Passed down so
|
|
6089
7687
|
* breakpoint sub-iframes carry the same responder and can be found via
|
|
6090
|
-
* [data-screen-iframe-id]
|
|
7688
|
+
* their own distinct [data-screen-iframe-id] (see getBreakpointIframeId)
|
|
7689
|
+
* by the cross-screen drop-into-container handler when that breakpoint is
|
|
7690
|
+
* the active edit scope (see getActiveScreenIframeId).
|
|
6091
7691
|
*/
|
|
6092
7692
|
srcdocWithHitTest: string;
|
|
6093
7693
|
activeBreakpointWidth: number | undefined;
|
|
@@ -6178,7 +7778,19 @@ function BreakpointPreviewRow({
|
|
|
6178
7778
|
style={{ width: frameWidth, height: frameHeight }}
|
|
6179
7779
|
>
|
|
6180
7780
|
<iframe
|
|
6181
|
-
|
|
7781
|
+
// Distinct id per breakpoint sub-frame — the primary iframe
|
|
7782
|
+
// above uses the bare screen id, so without a suffix here
|
|
7783
|
+
// every breakpoint sub-frame's iframe would share the exact
|
|
7784
|
+
// same [data-screen-iframe-id] as the primary, and
|
|
7785
|
+
// querySelector (which returns only the first DOM match)
|
|
7786
|
+
// would always resolve hit-test/drag/wheel bridge lookups to
|
|
7787
|
+
// the primary frame regardless of which breakpoint is
|
|
7788
|
+
// active. getActiveScreenIframeId resolves the correct one
|
|
7789
|
+
// to query at lookup time.
|
|
7790
|
+
data-screen-iframe-id={getBreakpointIframeId(
|
|
7791
|
+
screen.id,
|
|
7792
|
+
widthPx,
|
|
7793
|
+
)}
|
|
6182
7794
|
src={previewUrl}
|
|
6183
7795
|
srcDoc={previewUrl ? undefined : srcdocWithHitTest}
|
|
6184
7796
|
sandbox="allow-scripts"
|
|
@@ -6245,11 +7857,16 @@ function GroupSelectionBox({
|
|
|
6245
7857
|
chromeScale,
|
|
6246
7858
|
chromeSettling,
|
|
6247
7859
|
onStartResize,
|
|
7860
|
+
onStartRotate,
|
|
6248
7861
|
}: {
|
|
6249
7862
|
bounds: NonNullable<ReturnType<typeof getFrameGroupBounds>>;
|
|
6250
7863
|
chromeScale: number;
|
|
6251
7864
|
chromeSettling: boolean;
|
|
6252
7865
|
onStartResize: (handle: ResizeHandle, e: React.MouseEvent) => void;
|
|
7866
|
+
/** Multi-selection rotate (CV14). Omit to keep the previous behavior (no
|
|
7867
|
+
* rotate handle shown) — used by callers whose selection kind doesn't
|
|
7868
|
+
* support group rotate yet (e.g. draft primitives). */
|
|
7869
|
+
onStartRotate?: (e: React.MouseEvent) => void;
|
|
6253
7870
|
}) {
|
|
6254
7871
|
return (
|
|
6255
7872
|
<SelectionBox
|
|
@@ -6261,10 +7878,10 @@ function GroupSelectionBox({
|
|
|
6261
7878
|
}}
|
|
6262
7879
|
chromeScale={chromeScale}
|
|
6263
7880
|
chromeSettling={chromeSettling}
|
|
6264
|
-
showRotate={
|
|
7881
|
+
showRotate={!!onStartRotate}
|
|
6265
7882
|
filled
|
|
6266
7883
|
onStartResize={onStartResize}
|
|
6267
|
-
onStartRotate={() => {}}
|
|
7884
|
+
onStartRotate={onStartRotate ?? (() => {})}
|
|
6268
7885
|
/>
|
|
6269
7886
|
);
|
|
6270
7887
|
}
|
|
@@ -6360,6 +7977,7 @@ function SelectionBox({
|
|
|
6360
7977
|
showRotate={showRotate}
|
|
6361
7978
|
chromeScale={chromeScale}
|
|
6362
7979
|
chromeSettling={chromeSettling}
|
|
7980
|
+
rotationDeg={geometry.rotation ?? 0}
|
|
6363
7981
|
onStartResize={onStartResize}
|
|
6364
7982
|
onStartRotate={onStartRotate}
|
|
6365
7983
|
/>
|
|
@@ -6374,6 +7992,7 @@ function ResizeHandles({
|
|
|
6374
7992
|
showRotate = true,
|
|
6375
7993
|
chromeScale = 1,
|
|
6376
7994
|
chromeSettling = false,
|
|
7995
|
+
rotationDeg = 0,
|
|
6377
7996
|
onStartResize,
|
|
6378
7997
|
onStartRotate,
|
|
6379
7998
|
}: {
|
|
@@ -6383,6 +8002,10 @@ function ResizeHandles({
|
|
|
6383
8002
|
showRotate?: boolean;
|
|
6384
8003
|
chromeScale?: number;
|
|
6385
8004
|
chromeSettling?: boolean;
|
|
8005
|
+
/** The frame's own rotation, so hover cursors match the handle's rotated
|
|
8006
|
+
* visual direction instead of a static unrotated cursor (CSS `cursor` is
|
|
8007
|
+
* never itself rotated by a transform on the element). */
|
|
8008
|
+
rotationDeg?: number;
|
|
6386
8009
|
onStartResize: (handle: ResizeHandle, e: React.MouseEvent) => void;
|
|
6387
8010
|
onStartRotate: (e: React.MouseEvent) => void;
|
|
6388
8011
|
}) {
|
|
@@ -6410,7 +8033,7 @@ function ResizeHandles({
|
|
|
6410
8033
|
className={edgeHandleClass}
|
|
6411
8034
|
style={edgeHandleStyle(
|
|
6412
8035
|
config.handle,
|
|
6413
|
-
config.
|
|
8036
|
+
getResizeCursorForHandle(config.handle, rotationDeg),
|
|
6414
8037
|
chromeScale,
|
|
6415
8038
|
chromeSettling,
|
|
6416
8039
|
)}
|
|
@@ -6424,7 +8047,7 @@ function ResizeHandles({
|
|
|
6424
8047
|
className={visibleHandleClass}
|
|
6425
8048
|
style={cornerHandleStyle(
|
|
6426
8049
|
config.handle,
|
|
6427
|
-
config.
|
|
8050
|
+
getResizeCursorForHandle(config.handle, rotationDeg),
|
|
6428
8051
|
chromeScale,
|
|
6429
8052
|
chromeSettling,
|
|
6430
8053
|
)}
|
|
@@ -6660,6 +8283,29 @@ function dedupeIds(ids: string[]) {
|
|
|
6660
8283
|
return [...new Set(ids)];
|
|
6661
8284
|
}
|
|
6662
8285
|
|
|
8286
|
+
/** Matches the render-time `chromeScale = scale > 0 ? 1 / scale : 1` used to
|
|
8287
|
+
* keep on-screen chrome (labels, handles) a constant pixel size regardless
|
|
8288
|
+
* of canvas zoom. Callbacks that hit-test against the rendered label band
|
|
8289
|
+
* (e.g. marquee selection) must use the same conversion from the live zoom
|
|
8290
|
+
* ref instead of a fixed constant. */
|
|
8291
|
+
function chromeScaleFromZoom(zoom: number) {
|
|
8292
|
+
const scale = zoom / 100;
|
|
8293
|
+
return scale > 0 ? 1 / scale : 1;
|
|
8294
|
+
}
|
|
8295
|
+
|
|
8296
|
+
/** Shift-marquee selection combine, matching Figma: items currently swept by
|
|
8297
|
+
* the marquee toggle relative to the selection the gesture started with —
|
|
8298
|
+
* already-selected items under the marquee are deselected, not re-added.
|
|
8299
|
+
* Items outside the base selection AND not currently under the marquee are
|
|
8300
|
+
* left untouched. A plain union (the previous behavior) can only ever grow
|
|
8301
|
+
* the selection, so it never lets a shift-marquee deselect anything. */
|
|
8302
|
+
function xorMarqueeSelection(baseIds: string[], hitIds: string[]) {
|
|
8303
|
+
const hitSet = new Set(hitIds);
|
|
8304
|
+
const kept = baseIds.filter((id) => !hitSet.has(id));
|
|
8305
|
+
const added = hitIds.filter((id) => !baseIds.includes(id));
|
|
8306
|
+
return dedupeIds([...kept, ...added]);
|
|
8307
|
+
}
|
|
8308
|
+
|
|
6663
8309
|
function sameIds(a: string[], b: string[]) {
|
|
6664
8310
|
return a.length === b.length && a.every((id, index) => id === b[index]);
|
|
6665
8311
|
}
|
|
@@ -6759,17 +8405,6 @@ function normalizeRectFromPoints(start: Point, end: Point): MarqueeRect {
|
|
|
6759
8405
|
};
|
|
6760
8406
|
}
|
|
6761
8407
|
|
|
6762
|
-
function rectIntersects(rect: MarqueeRect, bounds: BoundsRect) {
|
|
6763
|
-
const right = rect.x + rect.width;
|
|
6764
|
-
const bottom = rect.y + rect.height;
|
|
6765
|
-
return (
|
|
6766
|
-
rect.x <= bounds.right &&
|
|
6767
|
-
right >= bounds.left &&
|
|
6768
|
-
rect.y <= bounds.bottom &&
|
|
6769
|
-
bottom >= bounds.top
|
|
6770
|
-
);
|
|
6771
|
-
}
|
|
6772
|
-
|
|
6773
8408
|
function rectContainsPoint(bounds: BoundsRect, point: Point) {
|
|
6774
8409
|
return (
|
|
6775
8410
|
point.x >= bounds.left &&
|
|
@@ -6800,63 +8435,6 @@ function rotatePointAroundCenter(
|
|
|
6800
8435
|
};
|
|
6801
8436
|
}
|
|
6802
8437
|
|
|
6803
|
-
/** Test whether a marquee rect (in canvas space) intersects an OBB described by
|
|
6804
|
-
* `bounds` (unrotated AABB) that has been rotated `degrees` around `center`.
|
|
6805
|
-
* We map the four marquee corners into the frame's local space and check
|
|
6806
|
-
* whether any corner is inside the bounds; we also check the reverse (any
|
|
6807
|
-
* frame corner inside the marquee) to handle the case where the marquee is
|
|
6808
|
-
* entirely contained within the rotated frame. */
|
|
6809
|
-
function rotatedRectIntersects(
|
|
6810
|
-
rect: MarqueeRect,
|
|
6811
|
-
bounds: BoundsRect,
|
|
6812
|
-
center: Point,
|
|
6813
|
-
degrees: number,
|
|
6814
|
-
): boolean {
|
|
6815
|
-
if (!degrees) {
|
|
6816
|
-
return rectIntersects(rect, bounds);
|
|
6817
|
-
}
|
|
6818
|
-
// Four corners of the marquee rect in canvas space.
|
|
6819
|
-
const marqueeCorners: Point[] = [
|
|
6820
|
-
{ x: rect.x, y: rect.y },
|
|
6821
|
-
{ x: rect.x + rect.width, y: rect.y },
|
|
6822
|
-
{ x: rect.x + rect.width, y: rect.y + rect.height },
|
|
6823
|
-
{ x: rect.x, y: rect.y + rect.height },
|
|
6824
|
-
];
|
|
6825
|
-
// Test: any marquee corner inside the unrotated frame bounds?
|
|
6826
|
-
for (const corner of marqueeCorners) {
|
|
6827
|
-
const local = rotatePointAroundCenter(corner, center, degrees);
|
|
6828
|
-
if (rectContainsPoint(bounds, local)) return true;
|
|
6829
|
-
}
|
|
6830
|
-
// Four corners of the unrotated frame in canvas space (rotate them outward).
|
|
6831
|
-
const frameCorners: Point[] = [
|
|
6832
|
-
{ x: bounds.left, y: bounds.top },
|
|
6833
|
-
{ x: bounds.right, y: bounds.top },
|
|
6834
|
-
{ x: bounds.right, y: bounds.bottom },
|
|
6835
|
-
{ x: bounds.left, y: bounds.bottom },
|
|
6836
|
-
];
|
|
6837
|
-
const rad = (degrees * Math.PI) / 180;
|
|
6838
|
-
const cos = Math.cos(rad);
|
|
6839
|
-
const sin = Math.sin(rad);
|
|
6840
|
-
const marqueeRight = rect.x + rect.width;
|
|
6841
|
-
const marqueeBottom = rect.y + rect.height;
|
|
6842
|
-
// Test: any rotated frame corner inside the marquee rect?
|
|
6843
|
-
for (const fc of frameCorners) {
|
|
6844
|
-
const dx = fc.x - center.x;
|
|
6845
|
-
const dy = fc.y - center.y;
|
|
6846
|
-
const wx = center.x + dx * cos - dy * sin;
|
|
6847
|
-
const wy = center.y + dx * sin + dy * cos;
|
|
6848
|
-
if (
|
|
6849
|
-
wx >= rect.x &&
|
|
6850
|
-
wx <= marqueeRight &&
|
|
6851
|
-
wy >= rect.y &&
|
|
6852
|
-
wy <= marqueeBottom
|
|
6853
|
-
) {
|
|
6854
|
-
return true;
|
|
6855
|
-
}
|
|
6856
|
-
}
|
|
6857
|
-
return false;
|
|
6858
|
-
}
|
|
6859
|
-
|
|
6860
8438
|
function sameFrameGeometry(a: FrameGeometry, b: FrameGeometry) {
|
|
6861
8439
|
return (
|
|
6862
8440
|
a.x === b.x &&
|
|
@@ -6912,25 +8490,36 @@ export function getDraftPreviewGeometryForTool(
|
|
|
6912
8490
|
start: Point,
|
|
6913
8491
|
end: Point,
|
|
6914
8492
|
hasMoved: boolean,
|
|
8493
|
+
modifiers?: DraftGeometryModifiers,
|
|
6915
8494
|
): FrameGeometry {
|
|
6916
|
-
|
|
6917
|
-
|
|
8495
|
+
// Note: "pen" is a valid DraftCreationTool value for board-surface/cursor
|
|
8496
|
+
// purposes (see getDraftCreationTool), but the pen tool never actually
|
|
8497
|
+
// drives this creation-drag geometry path — beginPenNodeCreation handles
|
|
8498
|
+
// pen gestures entirely separately via activePenPath/PenNodeDragState.
|
|
8499
|
+
if (tool === "line" || tool === "arrow") {
|
|
8500
|
+
return getDraftGeometryForTool(tool, start, end, modifiers);
|
|
6918
8501
|
}
|
|
6919
8502
|
|
|
6920
8503
|
if (!hasMoved) {
|
|
6921
8504
|
return { x: start.x, y: start.y, width: 0, height: 0 };
|
|
6922
8505
|
}
|
|
6923
8506
|
|
|
6924
|
-
return getDraftGeometryForTool(tool, start, end);
|
|
8507
|
+
return getDraftGeometryForTool(tool, start, end, modifiers);
|
|
6925
8508
|
}
|
|
6926
8509
|
|
|
6927
8510
|
function getDraftGeometryForTool(
|
|
6928
8511
|
tool: DraftCreationTool,
|
|
6929
8512
|
start: Point,
|
|
6930
8513
|
end: Point,
|
|
8514
|
+
modifiers?: DraftGeometryModifiers,
|
|
6931
8515
|
): FrameGeometry {
|
|
6932
|
-
if (tool === "
|
|
6933
|
-
|
|
8516
|
+
if (tool === "line" || tool === "arrow") {
|
|
8517
|
+
// Shift constrains the line/arrow to 45deg increments, reusing the same
|
|
8518
|
+
// helper the pen tool already uses for constrained segments.
|
|
8519
|
+
const effectiveEnd = modifiers?.shiftKey
|
|
8520
|
+
? constrainPointTo45Degrees(start, end)
|
|
8521
|
+
: end;
|
|
8522
|
+
return getPathGeometry([start, effectiveEnd]);
|
|
6934
8523
|
}
|
|
6935
8524
|
const options =
|
|
6936
8525
|
tool === "frame"
|
|
@@ -6953,7 +8542,11 @@ function getDraftGeometryForTool(
|
|
|
6953
8542
|
defaultWidth: DRAFT_RECT_WIDTH,
|
|
6954
8543
|
defaultHeight: DRAFT_RECT_HEIGHT,
|
|
6955
8544
|
};
|
|
6956
|
-
return getDraftGeometryFromPoints(start, end,
|
|
8545
|
+
return getDraftGeometryFromPoints(start, end, {
|
|
8546
|
+
...options,
|
|
8547
|
+
square: modifiers?.shiftKey,
|
|
8548
|
+
fromCenter: modifiers?.altKey,
|
|
8549
|
+
});
|
|
6957
8550
|
}
|
|
6958
8551
|
|
|
6959
8552
|
function getPathGeometry(points: readonly Point[]): FrameGeometry {
|
|
@@ -6983,35 +8576,19 @@ function createDraftPrimitive({
|
|
|
6983
8576
|
tool,
|
|
6984
8577
|
start,
|
|
6985
8578
|
end,
|
|
6986
|
-
points,
|
|
6987
8579
|
moved,
|
|
6988
8580
|
toolProps,
|
|
8581
|
+
modifiers,
|
|
6989
8582
|
}: DraftPrimitiveInput): DraftPrimitive {
|
|
6990
8583
|
const id = createDraftId(tool);
|
|
6991
8584
|
const geometry = moved
|
|
6992
|
-
? getDraftGeometryForTool(tool, start, end)
|
|
6993
|
-
: getDraftGeometryForTool(tool, start, start);
|
|
6994
|
-
|
|
6995
|
-
|
|
6996
|
-
|
|
6997
|
-
|
|
6998
|
-
|
|
6999
|
-
start,
|
|
7000
|
-
{ x: start.x + 64, y: start.y + 24 },
|
|
7001
|
-
{ x: start.x + 128, y: start.y },
|
|
7002
|
-
];
|
|
7003
|
-
return createPenDraftPrimitive(
|
|
7004
|
-
{
|
|
7005
|
-
nodes: pathPoints.map(createCornerNode),
|
|
7006
|
-
closed: false,
|
|
7007
|
-
},
|
|
7008
|
-
{
|
|
7009
|
-
id,
|
|
7010
|
-
stroke: toolProps?.stroke,
|
|
7011
|
-
strokeWidth: toolProps?.strokeWidth,
|
|
7012
|
-
},
|
|
7013
|
-
);
|
|
7014
|
-
}
|
|
8585
|
+
? getDraftGeometryForTool(tool, start, end, modifiers)
|
|
8586
|
+
: getDraftGeometryForTool(tool, start, start, modifiers);
|
|
8587
|
+
// Note: pen never reaches createDraftPrimitive — beginPenNodeCreation
|
|
8588
|
+
// commits pen paths directly via createPenDraftPrimitive (see
|
|
8589
|
+
// finishPenPath). The old fallback here (a hardcoded 3-point zigzag
|
|
8590
|
+
// placeholder path used when no freehand samples existed) was unreachable
|
|
8591
|
+
// dead code and has been removed.
|
|
7015
8592
|
if (tool === "text") {
|
|
7016
8593
|
return {
|
|
7017
8594
|
id,
|
|
@@ -7024,8 +8601,11 @@ function createDraftPrimitive({
|
|
|
7024
8601
|
};
|
|
7025
8602
|
}
|
|
7026
8603
|
if (tool === "line" || tool === "arrow") {
|
|
8604
|
+
const effectiveEnd = modifiers?.shiftKey
|
|
8605
|
+
? constrainPointTo45Degrees(start, end)
|
|
8606
|
+
: end;
|
|
7027
8607
|
const pathPoints = moved
|
|
7028
|
-
? [start,
|
|
8608
|
+
? [start, effectiveEnd]
|
|
7029
8609
|
: [start, { x: start.x + DRAFT_LINE_WIDTH, y: start.y }];
|
|
7030
8610
|
return {
|
|
7031
8611
|
id,
|
|
@@ -7051,6 +8631,31 @@ function createDraftPrimitive({
|
|
|
7051
8631
|
};
|
|
7052
8632
|
}
|
|
7053
8633
|
|
|
8634
|
+
/**
|
|
8635
|
+
* Builds the closed preview/commit path for a click-or-drag close gesture
|
|
8636
|
+
* (P6). Closing on the first anchor is deferred to mouseup so a drag can
|
|
8637
|
+
* shape the closing segment's curve: dragging sets the first anchor's
|
|
8638
|
+
* handleIn (mirrored across that anchor, like every other smooth-node
|
|
8639
|
+
* handle) so the curve eases into the start point instead of always
|
|
8640
|
+
* closing with a hard straight/corner segment.
|
|
8641
|
+
*/
|
|
8642
|
+
function shapeClosingHandles(
|
|
8643
|
+
pathBefore: PenPath,
|
|
8644
|
+
dragPoint: Point | null,
|
|
8645
|
+
): PenPath {
|
|
8646
|
+
const closed = closePenPath(pathBefore);
|
|
8647
|
+
if (!dragPoint || closed.nodes.length === 0) return closed;
|
|
8648
|
+
|
|
8649
|
+
const first = closed.nodes[0];
|
|
8650
|
+
const handleIn = {
|
|
8651
|
+
x: first.point.x - (dragPoint.x - first.point.x),
|
|
8652
|
+
y: first.point.y - (dragPoint.y - first.point.y),
|
|
8653
|
+
};
|
|
8654
|
+
const nodes = closed.nodes.slice();
|
|
8655
|
+
nodes[0] = { ...first, handleIn };
|
|
8656
|
+
return { nodes, closed: true };
|
|
8657
|
+
}
|
|
8658
|
+
|
|
7054
8659
|
function createPenDraftPrimitive(
|
|
7055
8660
|
path: PenPath,
|
|
7056
8661
|
{
|
|
@@ -7301,10 +8906,19 @@ function angleBetween(center: Point, point: Point) {
|
|
|
7301
8906
|
return (Math.atan2(point.y - center.y, point.x - center.x) * 180) / Math.PI;
|
|
7302
8907
|
}
|
|
7303
8908
|
|
|
7304
|
-
function getSelectableBounds(
|
|
8909
|
+
function getSelectableBounds(
|
|
8910
|
+
geometry: FrameGeometry,
|
|
8911
|
+
chromeScale = 1,
|
|
8912
|
+
): BoundsRect {
|
|
7305
8913
|
return {
|
|
7306
8914
|
left: geometry.x,
|
|
7307
|
-
|
|
8915
|
+
// The rendered frame-label band is FRAME_LABEL_HEIGHT * chromeScale (see
|
|
8916
|
+
// frameLabelHeight in the Screen renderer) — chromeScale grows as you
|
|
8917
|
+
// zoom out (chromeScale = 1 / scale) so the label stays a constant
|
|
8918
|
+
// on-screen size. Marquee/layer hit-testing must match that rendered
|
|
8919
|
+
// band, not a fixed canvas-space height, or the clickable label area
|
|
8920
|
+
// drifts out of sync with what's drawn at any zoom other than 100%.
|
|
8921
|
+
top: geometry.y - FRAME_LABEL_HEIGHT * chromeScale,
|
|
7308
8922
|
right: geometry.x + geometry.width,
|
|
7309
8923
|
bottom: geometry.y + geometry.height,
|
|
7310
8924
|
};
|
|
@@ -7510,12 +9124,44 @@ function hashString(s: string): string {
|
|
|
7510
9124
|
export const primitiveParseCache = new Map<string, ParsedScreenPrimitive[]>();
|
|
7511
9125
|
const PRIMITIVE_PARSE_CACHE_MAX = 64;
|
|
7512
9126
|
|
|
9127
|
+
/** First-level identity cache: most callers (e.g. drag/marquee mousemove
|
|
9128
|
+
* handlers) call parsePrimitivesFromScreen repeatedly per frame against the
|
|
9129
|
+
* *same* ScreenFile object — `screen.content` hasn't changed at all between
|
|
9130
|
+
* calls. Hashing the full content string on every call to build the
|
|
9131
|
+
* second-level cache key is wasted work in that case (PF17). Keep one most-
|
|
9132
|
+
* recent `{content, result}` pair per screen id and skip straight past the
|
|
9133
|
+
* hash when the content reference is unchanged. Only falls through to the
|
|
9134
|
+
* hash-keyed Map (and a real re-parse) when content has actually changed. */
|
|
9135
|
+
const primitiveParseIdentityCache = new Map<
|
|
9136
|
+
string,
|
|
9137
|
+
{ content: string; result: ParsedScreenPrimitive[] }
|
|
9138
|
+
>();
|
|
9139
|
+
|
|
9140
|
+
/** Test-only escape hatch: clears both cache levels. Production code never
|
|
9141
|
+
* needs to call this — the identity cache self-invalidates whenever
|
|
9142
|
+
* `screen.content` changes, and the hash-keyed cache is bounded/LRU'd. */
|
|
9143
|
+
export function __clearPrimitiveParseCachesForTests() {
|
|
9144
|
+
primitiveParseCache.clear();
|
|
9145
|
+
primitiveParseIdentityCache.clear();
|
|
9146
|
+
}
|
|
9147
|
+
|
|
7513
9148
|
export function parsePrimitivesFromScreen(
|
|
7514
9149
|
screen: ScreenFile,
|
|
7515
9150
|
): ParsedScreenPrimitive[] {
|
|
9151
|
+
const identityEntry = primitiveParseIdentityCache.get(screen.id);
|
|
9152
|
+
if (identityEntry && identityEntry.content === screen.content) {
|
|
9153
|
+
return identityEntry.result;
|
|
9154
|
+
}
|
|
9155
|
+
|
|
7516
9156
|
const cacheKey = `${screen.id}:${screen.content.length}:${hashString(screen.content)}`;
|
|
7517
9157
|
const cached = primitiveParseCache.get(cacheKey);
|
|
7518
|
-
if (cached)
|
|
9158
|
+
if (cached) {
|
|
9159
|
+
primitiveParseIdentityCache.set(screen.id, {
|
|
9160
|
+
content: screen.content,
|
|
9161
|
+
result: cached,
|
|
9162
|
+
});
|
|
9163
|
+
return cached;
|
|
9164
|
+
}
|
|
7519
9165
|
|
|
7520
9166
|
const result: ParsedScreenPrimitive[] = [];
|
|
7521
9167
|
if (typeof DOMParser === "undefined" || !screen.content) {
|
|
@@ -7589,6 +9235,10 @@ export function parsePrimitivesFromScreen(
|
|
|
7589
9235
|
if (firstKey !== undefined) primitiveParseCache.delete(firstKey);
|
|
7590
9236
|
}
|
|
7591
9237
|
primitiveParseCache.set(cacheKey, result);
|
|
9238
|
+
primitiveParseIdentityCache.set(screen.id, {
|
|
9239
|
+
content: screen.content,
|
|
9240
|
+
result,
|
|
9241
|
+
});
|
|
7592
9242
|
return result;
|
|
7593
9243
|
}
|
|
7594
9244
|
|