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