@volter/sdk 0.0.0-stage → 0.5.204
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/LICENSE +202 -0
- package/NOTICE +20 -0
- package/README.md +38 -3
- package/package.json +510 -4
- package/src/account.ts +210 -0
- package/src/chrome.ts +88 -0
- package/src/client.ts +1646 -0
- package/src/commands.ts +66 -0
- package/src/contributions.ts +619 -0
- package/src/css-numeric-style.ts +97 -0
- package/src/document-probe.ts +282 -0
- package/src/editor-view.ts +225 -0
- package/src/extension.ts +40 -0
- package/src/generations.ts +178 -0
- package/src/host.ts +1157 -0
- package/src/http-transport.browser.ts +14 -0
- package/src/http-transport.node.ts +19 -0
- package/src/index.ts +131 -0
- package/src/kit/CapabilityCoverageSection.tsx +185 -0
- package/src/kit/account-client.ts +333 -0
- package/src/kit/action-registry.ts +317 -0
- package/src/kit/active-product.ts +76 -0
- package/src/kit/active-project.ts +155 -0
- package/src/kit/adapter-editor-config.ts +25 -0
- package/src/kit/adapter-module.ts +7 -0
- package/src/kit/adapter-observation.ts +49 -0
- package/src/kit/animation/animation-clock.ts +479 -0
- package/src/kit/animation/stage-transport.ts +385 -0
- package/src/kit/api/assets.ts +365 -0
- package/src/kit/api/project-open.ts +355 -0
- package/src/kit/api/project-source.ts +180 -0
- package/src/kit/api/project-state.ts +110 -0
- package/src/kit/api/relay.ts +270 -0
- package/src/kit/api/themes.ts +45 -0
- package/src/kit/api-asset-library-wire.ts +45 -0
- package/src/kit/api-base.ts +10 -0
- package/src/kit/api-build.ts +99 -0
- package/src/kit/api-git-wire.ts +56 -0
- package/src/kit/api-logs.ts +92 -0
- package/src/kit/api-project-identity.ts +74 -0
- package/src/kit/api-settings.ts +36 -0
- package/src/kit/api-worktrees.ts +205 -0
- package/src/kit/asset-capabilities.ts +344 -0
- package/src/kit/asset-compare-core.ts +171 -0
- package/src/kit/asset-editor-context.tsx +101 -0
- package/src/kit/asset-events.ts +96 -0
- package/src/kit/asset-inspector-actions.ts +87 -0
- package/src/kit/asset-selection-viewer-registry.ts +113 -0
- package/src/kit/asset-selection.ts +146 -0
- package/src/kit/asset-thumbnails.ts +25 -0
- package/src/kit/asset-viewers.ts +115 -0
- package/src/kit/asset-workflow/asset-import-jobs.ts +106 -0
- package/src/kit/asset-workflow/asset-ledger-backend.ts +126 -0
- package/src/kit/asset-workflow/asset-ledger.ts +156 -0
- package/src/kit/asset-workflow/asset-materialization-report.ts +140 -0
- package/src/kit/asset-workflow/asset-pack-manifest.ts +320 -0
- package/src/kit/asset-workflow/asset-types.ts +142 -0
- package/src/kit/asset-workflow/audio-preview-player.ts +193 -0
- package/src/kit/asset-workflow/audio-waveform.ts +22 -0
- package/src/kit/asset-workflow/cloud-asset-client.ts +263 -0
- package/src/kit/asset-workflow/hosted-asset-materialization.ts +236 -0
- package/src/kit/asset-workflow/image-view-scale.ts +31 -0
- package/src/kit/asset-workflow/import-contract.ts +124 -0
- package/src/kit/asset-workflow/ledger-write-lock.ts +244 -0
- package/src/kit/asset-workflow/pixi-spritesheet.ts +197 -0
- package/src/kit/asset-workflow/preview-resource-lifetime.ts +44 -0
- package/src/kit/asset-workflow/project-asset-commands.ts +20 -0
- package/src/kit/asset-workflow/project-content.ts +288 -0
- package/src/kit/asset-workflow/project-source-index.ts +545 -0
- package/src/kit/asset-workflow/thumbnail-system.ts +256 -0
- package/src/kit/authoring/active-adapter.ts +200 -0
- package/src/kit/authoring/active-systems.ts +422 -0
- package/src/kit/authoring/adapter-key.ts +18 -0
- package/src/kit/authoring/authoring-asset-url.ts +27 -0
- package/src/kit/authoring/bootstrap-state.ts +49 -0
- package/src/kit/authoring/boundary-authoring-adapter.ts +189 -0
- package/src/kit/authoring/canvas-scene-guides.ts +84 -0
- package/src/kit/authoring/composite-authoring-adapter.ts +2109 -0
- package/src/kit/authoring/consumer-actions.ts +531 -0
- package/src/kit/authoring/design-time-layers.ts +852 -0
- package/src/kit/authoring/design-time-mount-registry.ts +244 -0
- package/src/kit/authoring/edit-mode-authoring.ts +637 -0
- package/src/kit/authoring/empty-project-authoring.ts +22 -0
- package/src/kit/authoring/instance-source-menu.ts +135 -0
- package/src/kit/authoring/layered-pick.ts +185 -0
- package/src/kit/authoring/mounted-root-subjects.ts +146 -0
- package/src/kit/authoring/no-authoring-adapter.ts +59 -0
- package/src/kit/authoring/object3d-document-persistence.ts +122 -0
- package/src/kit/authoring/panel-authoring.ts +121 -0
- package/src/kit/authoring/project-authoring-session.ts +105 -0
- package/src/kit/authoring/provenance.ts +99 -0
- package/src/kit/authoring/react-canvas-navigation.ts +259 -0
- package/src/kit/authoring/react-design-canvas-style.ts +20 -0
- package/src/kit/authoring/react-story-board.ts +917 -0
- package/src/kit/authoring/selection-scope.ts +195 -0
- package/src/kit/authoring/shell-document-ops.ts +169 -0
- package/src/kit/authoring/story-board-chrome-fit.ts +107 -0
- package/src/kit/authoring/story-board-presentation.ts +111 -0
- package/src/kit/authoring/three-root.ts +67 -0
- package/src/kit/authoring/viewport-tool-context.ts +73 -0
- package/src/kit/authoring/viewport-tool-owner.ts +38 -0
- package/src/kit/authoring/world-session-state.ts +101 -0
- package/src/kit/authoring-seam-evidence.ts +300 -0
- package/src/kit/availability-tick.ts +66 -0
- package/src/kit/bitmap-label.ts +120 -0
- package/src/kit/boot-routing.ts +392 -0
- package/src/kit/breakpoint-state.ts +43 -0
- package/src/kit/build-identity.ts +16 -0
- package/src/kit/bytes-codec.ts +62 -0
- package/src/kit/cancellation-reason.ts +58 -0
- package/src/kit/canvas-frames.ts +88 -0
- package/src/kit/capture-camera-pose.ts +77 -0
- package/src/kit/capture-size.ts +88 -0
- package/src/kit/chrome-registry.ts +159 -0
- package/src/kit/chrome-slot-registry.ts +91 -0
- package/src/kit/collaboration-client.ts +264 -0
- package/src/kit/collaboration-presence.ts +41 -0
- package/src/kit/command-dispatch.ts +19 -0
- package/src/kit/command-listener.ts +2182 -0
- package/src/kit/command-registry.ts +71 -0
- package/src/kit/component-board-registry.ts +205 -0
- package/src/kit/component-states-registry.ts +199 -0
- package/src/kit/components/AlignToolbar.tsx +204 -0
- package/src/kit/components/ApplicationMenus.tsx +372 -0
- package/src/kit/components/AssetEditorShell.tsx +216 -0
- package/src/kit/components/AssetInspectorToolSection.tsx +124 -0
- package/src/kit/components/BoardRulers.tsx +354 -0
- package/src/kit/components/CanvasAddNodeDialogs.tsx +529 -0
- package/src/kit/components/CanvasSceneViewport.tsx +1195 -0
- package/src/kit/components/ChromeSlot.tsx +20 -0
- package/src/kit/components/CodeView.tsx +470 -0
- package/src/kit/components/CompactInspectorShell.tsx +39 -0
- package/src/kit/components/ConsolePanel.tsx +273 -0
- package/src/kit/components/GameplaySessionTimeline.tsx +295 -0
- package/src/kit/components/InspectionProjection.tsx +932 -0
- package/src/kit/components/Inspector.tsx +270 -0
- package/src/kit/components/InspectorCanvasPreview.tsx +35 -0
- package/src/kit/components/InspectorFieldsSection.tsx +290 -0
- package/src/kit/components/InspectorStoriesSection.tsx +92 -0
- package/src/kit/components/InspectorToolSection.tsx +96 -0
- package/src/kit/components/InspectorTransformSection.tsx +245 -0
- package/src/kit/components/LightExplorerPanel.tsx +433 -0
- package/src/kit/components/MediaProperties.tsx +145 -0
- package/src/kit/components/ProjectHeader.tsx +328 -0
- package/src/kit/components/ReactCanvasControls.tsx +358 -0
- package/src/kit/components/RootSelectionOverlay.tsx +3688 -0
- package/src/kit/components/RootTextEditor.tsx +79 -0
- package/src/kit/components/SaveStatus.tsx +70 -0
- package/src/kit/components/SurfaceCrashBoundary.tsx +105 -0
- package/src/kit/components/SurfaceStateOverlay.tsx +24 -0
- package/src/kit/components/ToolContributionSurfaces.tsx +49 -0
- package/src/kit/components/ToolHost.tsx +380 -0
- package/src/kit/components/Toolbar.tsx +811 -0
- package/src/kit/components/TransientHint.tsx +44 -0
- package/src/kit/components/VersionControlSection.tsx +470 -0
- package/src/kit/components/ViewportOverlaysMenu.tsx +177 -0
- package/src/kit/components/VolterLogo.tsx +18 -0
- package/src/kit/components/WorktreeSwitcher.tsx +712 -0
- package/src/kit/components/account-documents.tsx +1162 -0
- package/src/kit/components/asset-documents.tsx +794 -0
- package/src/kit/components/asset-editor-persistence.ts +216 -0
- package/src/kit/components/asset-selection-section.tsx +545 -0
- package/src/kit/components/asset-thumbnails.tsx +307 -0
- package/src/kit/components/asset-viewers/AudioViewer.tsx +201 -0
- package/src/kit/components/asset-viewers/GenericJsonViewer.tsx +102 -0
- package/src/kit/components/asset-viewers/ImageViewer.tsx +300 -0
- package/src/kit/components/asset-viewers/JsonAssetDocument.tsx +98 -0
- package/src/kit/components/asset-viewers/OnlineAssetDetail.tsx +426 -0
- package/src/kit/components/asset-viewers/SourceAssetViewer.tsx +356 -0
- package/src/kit/components/asset-viewers/SpritesheetSpriteView.tsx +102 -0
- package/src/kit/components/asset-viewers/VideoViewer.tsx +101 -0
- package/src/kit/components/asset-viewers/shader-source.ts +144 -0
- package/src/kit/components/board-guides.ts +150 -0
- package/src/kit/components/canvas-scene-hotkeys.ts +37 -0
- package/src/kit/components/canvas-temporary-pivot.ts +34 -0
- package/src/kit/components/core-utilities.tsx +94 -0
- package/src/kit/components/inspector-preview-section.tsx +223 -0
- package/src/kit/components/inspector-revert-label.ts +20 -0
- package/src/kit/components/inspector-selection.ts +42 -0
- package/src/kit/components/inspector-stories-gating.ts +171 -0
- package/src/kit/components/inspector-transform-subject.ts +11 -0
- package/src/kit/components/inspector-transform.ts +88 -0
- package/src/kit/components/kind-documents.tsx +544 -0
- package/src/kit/components/primitives/DraftColorInput.tsx +74 -0
- package/src/kit/components/project-tool-documents.tsx +402 -0
- package/src/kit/components/scene-documents.tsx +221 -0
- package/src/kit/components/status-contributions.tsx +407 -0
- package/src/kit/components/tool-documents.tsx +302 -0
- package/src/kit/components/tool-schema-form.tsx +262 -0
- package/src/kit/components/use-after-paint.ts +41 -0
- package/src/kit/components/use-project-image-assets.ts +86 -0
- package/src/kit/components/workspace-history.ts +32 -0
- package/src/kit/components/world-documents.tsx +570 -0
- package/src/kit/components/world-overlay-gestures.ts +1939 -0
- package/src/kit/composite-screenshot.ts +2238 -0
- package/src/kit/content-entry-source-registry.ts +184 -0
- package/src/kit/contribution-surfaces.ts +48 -0
- package/src/kit/coverage/canvas-reveal.ts +192 -0
- package/src/kit/coverage/design-time-surfaces.ts +101 -0
- package/src/kit/coverage/ontology-invariants.ts +466 -0
- package/src/kit/coverage/session-vitals.ts +503 -0
- package/src/kit/crash-null-boundary.ts +36 -0
- package/src/kit/creation-site-edit.ts +1491 -0
- package/src/kit/creation-site-registry.ts +160 -0
- package/src/kit/delegate-harness-registry.ts +134 -0
- package/src/kit/document-areas.ts +70 -0
- package/src/kit/document-context-registry.ts +193 -0
- package/src/kit/document-open-registry.ts +200 -0
- package/src/kit/document-play-extension.ts +221 -0
- package/src/kit/document-preview-source.ts +20 -0
- package/src/kit/document-renderer-session.ts +138 -0
- package/src/kit/document-stage-sessions.ts +26 -0
- package/src/kit/document-viewports.ts +120 -0
- package/src/kit/editor-api.ts +46 -0
- package/src/kit/editor-chrome-capture.ts +136 -0
- package/src/kit/editor-commands.ts +176 -0
- package/src/kit/editor-console.ts +580 -0
- package/src/kit/editor-current-view.ts +56 -0
- package/src/kit/editor-document-probe.ts +1168 -0
- package/src/kit/editor-git-client.ts +115 -0
- package/src/kit/editor-hotkeys.ts +728 -0
- package/src/kit/editor-lease-view.ts +39 -0
- package/src/kit/editor-lease.ts +415 -0
- package/src/kit/editor-mode.ts +19 -0
- package/src/kit/editor-notifications.ts +140 -0
- package/src/kit/editor-presence.ts +563 -0
- package/src/kit/editor-presentation-activity.ts +58 -0
- package/src/kit/editor-presentation-notice.ts +42 -0
- package/src/kit/editor-runtime.tsx +147 -0
- package/src/kit/editor-server-response.ts +86 -0
- package/src/kit/editor-session-attribution.ts +85 -0
- package/src/kit/editor-session-mode.ts +54 -0
- package/src/kit/editor-state-facets.ts +74 -0
- package/src/kit/editor-view-presentation.ts +777 -0
- package/src/kit/environment-images.ts +58 -0
- package/src/kit/eyedropper-session.ts +60 -0
- package/src/kit/files/file-provider.ts +62 -0
- package/src/kit/files/project-files.ts +270 -0
- package/src/kit/finders/index.ts +137 -0
- package/src/kit/finders/scenes-from-entrypoint-selection.ts +387 -0
- package/src/kit/frame/frame-parts.ts +30 -0
- package/src/kit/framed-document-capture.ts +34 -0
- package/src/kit/game-globals-prelude.ts +143 -0
- package/src/kit/game-surface-defaults.ts +33 -0
- package/src/kit/gameplay-dom-recording.ts +318 -0
- package/src/kit/gameplay-export-state.ts +14 -0
- package/src/kit/gameplay-replay.ts +417 -0
- package/src/kit/gameplay-session-time.ts +9 -0
- package/src/kit/gameplay-sessions.ts +204 -0
- package/src/kit/hierarchy-component-marks.ts +298 -0
- package/src/kit/hierarchy-internals.ts +197 -0
- package/src/kit/hierarchy-kind-icon.ts +217 -0
- package/src/kit/hierarchy-menu-registry.ts +67 -0
- package/src/kit/hierarchy-node-rows.ts +307 -0
- package/src/kit/hierarchy-panel-view.ts +280 -0
- package/src/kit/hierarchy-projection.ts +76 -0
- package/src/kit/hierarchy-row-media.ts +45 -0
- package/src/kit/hierarchy-row-model.ts +308 -0
- package/src/kit/hierarchy-rows.ts +11 -0
- package/src/kit/hierarchy-walk.ts +86 -0
- package/src/kit/history/editor-session.ts +25 -0
- package/src/kit/history/history-commands.ts +147 -0
- package/src/kit/history/history-delegate.ts +187 -0
- package/src/kit/history/history-limit-notices.ts +43 -0
- package/src/kit/history/history-service.ts +1189 -0
- package/src/kit/history/persistence-coordinator.ts +35 -0
- package/src/kit/history/project-file-history.ts +386 -0
- package/src/kit/history/project-root-history-backends.ts +139 -0
- package/src/kit/history/resource-registry.ts +209 -0
- package/src/kit/history/snapshot-store.ts +103 -0
- package/src/kit/history/source-history-backend.ts +546 -0
- package/src/kit/history-types.ts +124 -0
- package/src/kit/hmr-registration-group.ts +67 -0
- package/src/kit/hmr-stable-react-context.ts +23 -0
- package/src/kit/hotkeys.ts +190 -0
- package/src/kit/inference-diagnostics.ts +69 -0
- package/src/kit/initial-project.ts +80 -0
- package/src/kit/inspection/active-subject.ts +571 -0
- package/src/kit/inspection/active-surface.ts +142 -0
- package/src/kit/inspection/compose-subject.ts +1055 -0
- package/src/kit/inspection/compose.ts +7 -0
- package/src/kit/inspection/display.ts +171 -0
- package/src/kit/inspection/document-subject.ts +109 -0
- package/src/kit/inspection/game-subject.ts +85 -0
- package/src/kit/inspection/null-subject.ts +119 -0
- package/src/kit/inspection/serialize.ts +357 -0
- package/src/kit/inspection/use-active-inspection.ts +180 -0
- package/src/kit/inspection-model.ts +542 -0
- package/src/kit/inspection-node-media.ts +58 -0
- package/src/kit/inspector-presentation.ts +203 -0
- package/src/kit/inspector-property-grouping.ts +64 -0
- package/src/kit/inspector-section-registry.ts +221 -0
- package/src/kit/instance-source-actions.ts +163 -0
- package/src/kit/js-heap.ts +71 -0
- package/src/kit/key-actions.ts +91 -0
- package/src/kit/keymap-presets.ts +428 -0
- package/src/kit/layout-policy.ts +31 -0
- package/src/kit/light-explorer-model.ts +134 -0
- package/src/kit/live-canvas-frame.ts +55 -0
- package/src/kit/live-document.ts +296 -0
- package/src/kit/live-gesture-lock.ts +50 -0
- package/src/kit/live-seam-evidence.ts +11 -0
- package/src/kit/live-session-registry.ts +220 -0
- package/src/kit/live-transition.ts +391 -0
- package/src/kit/manifest-project.ts +107 -0
- package/src/kit/module-fetch-diagnosis.ts +192 -0
- package/src/kit/mount-failure-report.ts +154 -0
- package/src/kit/native-selection-style.ts +497 -0
- package/src/kit/object3d-document-write-policy.ts +137 -0
- package/src/kit/packaged-runtime.ts +108 -0
- package/src/kit/palettes/maya.palette.json +57 -0
- package/src/kit/palettes/substance.palette.json +57 -0
- package/src/kit/performance-profiler.ts +367 -0
- package/src/kit/performance-sources.ts +69 -0
- package/src/kit/photograph-notice.ts +141 -0
- package/src/kit/play-boot-phase.ts +166 -0
- package/src/kit/play-camera-flight.ts +35 -0
- package/src/kit/png-encode.worker.ts +26 -0
- package/src/kit/presentation-surface.ts +248 -0
- package/src/kit/product-command.ts +90 -0
- package/src/kit/project-adapter.ts +1140 -0
- package/src/kit/project-asset-refresh.ts +23 -0
- package/src/kit/project-asset-roots.ts +68 -0
- package/src/kit/project-local-state.ts +151 -0
- package/src/kit/project-manager.ts +243 -0
- package/src/kit/project-module-changes.ts +201 -0
- package/src/kit/project-module-split.ts +270 -0
- package/src/kit/project-play-layers.ts +25 -0
- package/src/kit/project-provenance.ts +115 -0
- package/src/kit/project-ready.ts +42 -0
- package/src/kit/project-shape.ts +68 -0
- package/src/kit/project-tools.ts +107 -0
- package/src/kit/projection-types.ts +44 -0
- package/src/kit/readiness.ts +113 -0
- package/src/kit/renderer-resource-counts.ts +27 -0
- package/src/kit/reported-play-state.ts +90 -0
- package/src/kit/resolve-contributed-command.ts +14 -0
- package/src/kit/resolve-relative-specifier.ts +33 -0
- package/src/kit/retained-document-states.ts +91 -0
- package/src/kit/scene-document-plan.ts +320 -0
- package/src/kit/scene-live-open.ts +210 -0
- package/src/kit/scoped-game-css.ts +152 -0
- package/src/kit/served-url.ts +5 -0
- package/src/kit/session-close.ts +17 -0
- package/src/kit/session-tombstone.ts +127 -0
- package/src/kit/settings/settings-provider.ts +82 -0
- package/src/kit/settings-store.ts +348 -0
- package/src/kit/shell-document-state.ts +27 -0
- package/src/kit/shell-store-door.ts +45 -0
- package/src/kit/shell-store.ts +722 -0
- package/src/kit/source-conflict.ts +122 -0
- package/src/kit/stage-context.ts +377 -0
- package/src/kit/stage-invalidation.ts +25 -0
- package/src/kit/stage-store-registry.ts +69 -0
- package/src/kit/startup-failure.ts +80 -0
- package/src/kit/state-report-deferral.ts +73 -0
- package/src/kit/storage/host-files-storage.ts +97 -0
- package/src/kit/storage/http-storage.ts +174 -0
- package/src/kit/storage/index.ts +75 -0
- package/src/kit/storage/mem-storage.ts +158 -0
- package/src/kit/storage/path-lock.ts +44 -0
- package/src/kit/storage/paths.ts +26 -0
- package/src/kit/storage-types.ts +127 -0
- package/src/kit/stories/StoryPreviewMount.tsx +306 -0
- package/src/kit/stories/compose-project-stories.ts +255 -0
- package/src/kit/stories/prefabs-finder.ts +54 -0
- package/src/kit/stories/prefabs-from-stories.ts +182 -0
- package/src/kit/stories/project-story-regions.ts +24 -0
- package/src/kit/stories/story-capture.ts +579 -0
- package/src/kit/stories/story-declared-medium.ts +126 -0
- package/src/kit/stories/story-discovery.ts +176 -0
- package/src/kit/stories/story-dom-runtime.ts +78 -0
- package/src/kit/stories/story-grouping.ts +111 -0
- package/src/kit/stories/story-mount-turn.ts +27 -0
- package/src/kit/stories/story-presentation.ts +215 -0
- package/src/kit/stories/story-preview-component.ts +7 -0
- package/src/kit/stories/story-registry.ts +530 -0
- package/src/kit/stories-scope.ts +35 -0
- package/src/kit/story-document-openers.ts +36 -0
- package/src/kit/story-thumbnails.ts +47 -0
- package/src/kit/surface-keyboard.ts +101 -0
- package/src/kit/surface-state.ts +135 -0
- package/src/kit/system-seam-evidence.ts +72 -0
- package/src/kit/tab-census.ts +202 -0
- package/src/kit/tab-lifecycle-client.ts +227 -0
- package/src/kit/theme-library.ts +897 -0
- package/src/kit/theme-preference.ts +429 -0
- package/src/kit/three-viewport-presentation.ts +23 -0
- package/src/kit/tool-contribution-play.ts +74 -0
- package/src/kit/tool-loader.ts +1918 -0
- package/src/kit/transform-mode-request.ts +66 -0
- package/src/kit/transient-hint.ts +78 -0
- package/src/kit/transport-strip.tsx +174 -0
- package/src/kit/ui-source/adapter-region-includes.ts +238 -0
- package/src/kit/ui-source/file-region-resolver.ts +302 -0
- package/src/kit/ui-source/inspect.ts +775 -0
- package/src/kit/ui-source/source-write-backend.ts +605 -0
- package/src/kit/ui-source/tier-source-write-backend.ts +279 -0
- package/src/kit/user-local-state.ts +105 -0
- package/src/kit/viewport-activation-timings.ts +840 -0
- package/src/kit/viewport-editor-controls.ts +22 -0
- package/src/kit/viewport-presentation.ts +668 -0
- package/src/kit/viewport-surface-status.tsx +55 -0
- package/src/kit/wait-until.ts +37 -0
- package/src/kit/worker-call-metrics.ts +166 -0
- package/src/kit/workspace-areas.ts +191 -0
- package/src/kit/workspace-aux-commands.ts +11 -0
- package/src/kit/workspace-available-documents.ts +142 -0
- package/src/kit/workspace-core-utilities.ts +31 -0
- package/src/kit/workspace-document-ids.ts +59 -0
- package/src/kit/workspace-document-registry.ts +624 -0
- package/src/kit/workspace-document-restore.ts +146 -0
- package/src/kit/workspace-host-commands.ts +141 -0
- package/src/kit/workspace-persistence-gate.ts +40 -0
- package/src/kit/workspace-play-utilities.ts +44 -0
- package/src/kit/workspace-presets.ts +446 -0
- package/src/kit/workspace-regions.ts +276 -0
- package/src/kit/workspace-static-panels.ts +73 -0
- package/src/kit/workspace-status-registry.ts +121 -0
- package/src/kit/workspace-storage.ts +35 -0
- package/src/kit/workspace-style.ts +226 -0
- package/src/kit/workspace-utility-commands.ts +74 -0
- package/src/kit/workspace-utility-registry.ts +263 -0
- package/src/kit/world-adoption-event.ts +23 -0
- package/src/kit/world-adoption.ts +115 -0
- package/src/kit/world-canvas-viewport-state.ts +35 -0
- package/src/kit/world-document-routing.ts +104 -0
- package/src/kit/world-pan-state.ts +198 -0
- package/src/kit/write-pipe.ts +173 -0
- package/src/layout-arrangements.ts +5 -0
- package/src/layouts.tsx +108 -0
- package/src/looks.ts +16 -0
- package/src/project/output-roots.ts +73 -0
- package/src/project/tab-census.ts +155 -0
- package/src/project-tool-catalog.ts +104 -0
- package/src/selection.tsx +107 -0
- package/src/services.ts +18 -0
- package/src/session/build-report.ts +22 -0
- package/src/session/collaboration-types.ts +262 -0
- package/src/session/command-table.ts +327 -0
- package/src/session/discovery.ts +100 -0
- package/src/session/editor-brand.ts +48 -0
- package/src/session/editor-compatibility.ts +329 -0
- package/src/session/editor-control-lifecycle.ts +68 -0
- package/src/session/editor-control-protocol.ts +5 -0
- package/src/session/entrypoint-selection-readers.ts +66 -0
- package/src/session/entrypoint-selection-source.ts +120 -0
- package/src/session/game-css-scope.ts +30 -0
- package/src/session/hosted-attachment.ts +225 -0
- package/src/session/limited-view.ts +82 -0
- package/src/session/product-create.ts +24 -0
- package/src/session/product-locator.ts +478 -0
- package/src/session/project-module-url.ts +242 -0
- package/src/session/project-serving.ts +164 -0
- package/src/session/project-upgrade.ts +669 -0
- package/src/session/registry-format.ts +210 -0
- package/src/session/relative-path-guard.ts +56 -0
- package/src/session/scoped-game-css.ts +461 -0
- package/src/session/source-glob.ts +15 -0
- package/src/session/tool-contribution-convention.ts +123 -0
- package/src/session/workbench-locator.ts +712 -0
- package/src/session.ts +41 -0
- package/src/share.ts +160 -0
- package/src/source-analysis.ts +28 -0
- package/src/source-authoring.ts +439 -0
- package/src/tools/errors.ts +91 -0
- package/src/tools/provider-execution.ts +70 -0
- package/src/tools/registry.ts +341 -0
- package/src/tools/types.ts +159 -0
- package/src/transport.ts +100 -0
- package/src/types.ts +1693 -0
- package/src/views.ts +164 -0
- package/src/widgets/design-system.ts +93 -0
- package/src/widgets/editor-appearance.ts +151 -0
- package/src/widgets/editor-material.ts +83 -0
- package/src/widgets/icon-set-registry.ts +105 -0
- package/src/widgets/index.ts +71 -0
- package/src/widgets/inspector-widgets/AlignmentGrid.tsx +182 -0
- package/src/widgets/inspector-widgets/AssetSlotPicker.tsx +123 -0
- package/src/widgets/inspector-widgets/BorderEditor.tsx +309 -0
- package/src/widgets/inspector-widgets/ColorPicker.tsx +549 -0
- package/src/widgets/inspector-widgets/CurveEditor.tsx +359 -0
- package/src/widgets/inspector-widgets/FilterEditor.tsx +108 -0
- package/src/widgets/inspector-widgets/FontPicker.tsx +191 -0
- package/src/widgets/inspector-widgets/GradientEditor.tsx +623 -0
- package/src/widgets/inspector-widgets/ScrubbableInput.tsx +180 -0
- package/src/widgets/inspector-widgets/ShadowEditor.tsx +319 -0
- package/src/widgets/inspector-widgets/color-utils.ts +201 -0
- package/src/widgets/inspector-widgets/curve-utils.ts +212 -0
- package/src/widgets/inspector-widgets/index.ts +25 -0
- package/src/widgets/inspector-widgets/shared.tsx +140 -0
- package/src/widgets/interactive-edit-scope.ts +33 -0
- package/src/widgets/patterns/Dialog.tsx +140 -0
- package/src/widgets/patterns/Fields.tsx +44 -0
- package/src/widgets/patterns/List.tsx +25 -0
- package/src/widgets/patterns/StateSurface.tsx +40 -0
- package/src/widgets/patterns/Surfaces.tsx +122 -0
- package/src/widgets/patterns/Tabs.tsx +80 -0
- package/src/widgets/patterns/Toolbar.tsx +72 -0
- package/src/widgets/patterns/Tree.tsx +72 -0
- package/src/widgets/primitives/AnchoredMenu.tsx +260 -0
- package/src/widgets/primitives/Button.tsx +62 -0
- package/src/widgets/primitives/ColorInput.tsx +78 -0
- package/src/widgets/primitives/DraftTextInput.tsx +63 -0
- package/src/widgets/primitives/EditorIcon.tsx +157 -0
- package/src/widgets/primitives/FormControls.tsx +88 -0
- package/src/widgets/primitives/HoverPreview.tsx +96 -0
- package/src/widgets/primitives/JsonInput.tsx +113 -0
- package/src/widgets/primitives/Layout.tsx +100 -0
- package/src/widgets/primitives/Menu.tsx +161 -0
- package/src/widgets/primitives/NumberInput.tsx +169 -0
- package/src/widgets/primitives/Panel.tsx +80 -0
- package/src/widgets/primitives/SectionHeader.tsx +77 -0
- package/src/widgets/primitives/Text.tsx +54 -0
- package/src/widgets/primitives/ThemeRootPortal.tsx +52 -0
- package/src/widgets/primitives/Tooltip.tsx +204 -0
- package/src/widgets/primitives/Vec3Input.tsx +70 -0
- package/src/widgets/primitives/banner-tones.ts +32 -0
- package/src/widgets/primitives/clamp-to-viewport.ts +44 -0
- package/src/widgets/primitives/editor-icons.ts +254 -0
- package/src/widgets/primitives/panel-header-styles.ts +42 -0
- package/src/widgets/theme.ts +2841 -0
- package/src/widgets/z-index.ts +25 -0
|
@@ -0,0 +1,3688 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RootSelectionOverlay — spec 27 §3 A3, MIGRATE target of
|
|
3
|
+
* `ui-editor/overlay.tsx`'s `SelectionOverlay` (§9's consolidation table),
|
|
4
|
+
* generalized off that file's editor-store-and-stage-ref coupling onto the
|
|
5
|
+
* format-neutral authoring contract so it works over ANY DOM-capable world
|
|
6
|
+
* (react `data-oid`, ingested `react-dom`), not just the scene-UI silo.
|
|
7
|
+
*
|
|
8
|
+
* MOUNT SITE / COORDINATES (critical): DOM worlds render this component as a
|
|
9
|
+
* child of their world document's host — the EXACT SAME DOM element
|
|
10
|
+
* `design-time-layers.ts` appends that world's `position:absolute; inset:0`
|
|
11
|
+
* layer into. The native Three scene retains a separate instance inside the
|
|
12
|
+
* STAGE HOST's own container (`StageHost.tsx`, beside its canvas, mounted by
|
|
13
|
+
* `stage-overlay-set.tsx`). This component's own outer wrapper is
|
|
14
|
+
* ALSO `position:absolute; inset:0` inside that SAME parent, so its
|
|
15
|
+
* `getBoundingClientRect()` is IDENTICAL to every world layer's — a
|
|
16
|
+
* HOST-RELATIVE rect from `RectProvider.rect(id)` (host = the owning
|
|
17
|
+
* adapter's own `this.root`, which IS one of those layer divs) lands on this
|
|
18
|
+
* overlay's local coordinate space with ZERO correction. Deliberately NOT
|
|
19
|
+
* wired one level up in `DefaultEditorLayout.tsx` (a sibling of the stage
|
|
20
|
+
* there sits in a DIFFERENT, merely same-SIZED parent — co-location would be
|
|
21
|
+
* incidental, not structural); mounting inside the stage's own container
|
|
22
|
+
* makes the co-location a DOM fact, not a CSS coincidence.
|
|
23
|
+
*
|
|
24
|
+
* `PickProvider.pick` (unlike `RectProvider.rect`) takes CLIENT (viewport) px
|
|
25
|
+
* per its own contract doc comment — pointer handlers below pass
|
|
26
|
+
* `e.clientX`/`e.clientY` straight through, no conversion.
|
|
27
|
+
*
|
|
28
|
+
* Driven ONLY by the authoring contract (`getActiveAuthoring(store)` +
|
|
29
|
+
* `store.selectedEntityIds` + `pickTopmost` + `CompositeAuthoringAdapter
|
|
30
|
+
* .childAdapters()/ownerOf()`) — rule zero (spec §0): this file talks to no
|
|
31
|
+
* concrete document/rendering library and no other editor silo's store or
|
|
32
|
+
* DOM ref directly; hit-testing goes through the contract's own pick
|
|
33
|
+
* provider, never a raw point-under-cursor DOM query.
|
|
34
|
+
*
|
|
35
|
+
* Scope: A3's hover highlight, selection box + `name·kind` label, and marquee
|
|
36
|
+
* are unchanged below. Also in this same file: 8 resize handles + a
|
|
37
|
+
* rotate handle + a dimension label (B2), drag-to-move for
|
|
38
|
+
* absolutely/fixed-positioned nodes (B2), and padding/margin spacing bands
|
|
39
|
+
* (B3) — all gated on EXACTLY one node selected whose OWNER exposes `boxEdit`
|
|
40
|
+
* (see `world-overlay-gestures.ts`'s `boxEditForId`; multi-select keeps only
|
|
41
|
+
* the plain A3 boxes, no handles). The gesture PATCH MATH itself lives in the
|
|
42
|
+
* sibling `world-overlay-gestures.ts` module (pure, unit-tested); this file
|
|
43
|
+
* owns only the impure orchestration — reading the live rect/spacing values,
|
|
44
|
+
* driving the `begin`/`apply`/`end` bracket, and rendering.
|
|
45
|
+
*
|
|
46
|
+
* Phase D2 (§6) adds, also in this file: a drag-ghost (a semi-transparent
|
|
47
|
+
* follow-cursor copy of the dragged element, live during a B2 move gesture OR
|
|
48
|
+
* a D2.b reorder drag) and the reorder clone-preview itself — dragging an
|
|
49
|
+
* already-selected FLOW/LIST child (no `boxEdit` move applies to it; it's
|
|
50
|
+
* neither absolute nor fixed) among ≥3 total siblings live-previews the
|
|
51
|
+
* insertion gap the drop would land in and commits ONE `structure.reorder(id,
|
|
52
|
+
* beforeSiblingId)` call on drop (capability-gated on the owner exposing
|
|
53
|
+
* `structure.reorder` — absent for the ingested react-dom adapter, so no
|
|
54
|
+
* preview/commit there; the drag-ghost is visual-only and may still show).
|
|
55
|
+
* The gap math (`computeReorderGap`) and ghost math (`computeDragGhost`) are
|
|
56
|
+
* pure, in `world-overlay-gestures.ts`, same split as B2/B3.
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
import { Menu, MenuItem, ThemeRootPortal, themeVars, zIndex } from '@volter/sdk/widgets';
|
|
60
|
+
import type {
|
|
61
|
+
AssetDropContext,
|
|
62
|
+
AuthoringAdapter,
|
|
63
|
+
BoxEditProvider,
|
|
64
|
+
BoxEditReferencePoint,
|
|
65
|
+
DOMRectLike,
|
|
66
|
+
FrameCorners,
|
|
67
|
+
SpatialDragHandle,
|
|
68
|
+
SpatialHandlesProvider,
|
|
69
|
+
} from '@volter/project/adapter';
|
|
70
|
+
import {
|
|
71
|
+
type DragEvent as ReactDragEvent,
|
|
72
|
+
type MouseEvent as ReactMouseEvent,
|
|
73
|
+
type PointerEvent as ReactPointerEvent,
|
|
74
|
+
useCallback,
|
|
75
|
+
useEffect,
|
|
76
|
+
useRef,
|
|
77
|
+
useState,
|
|
78
|
+
useSyncExternalStore,
|
|
79
|
+
} from 'react';
|
|
80
|
+
import { collectAllNodeIds, getActiveAuthoring } from '@volter/sdk/kit/authoring/active-adapter';
|
|
81
|
+
import {
|
|
82
|
+
canvasSceneGuideRevision,
|
|
83
|
+
canvasSceneGuides,
|
|
84
|
+
subscribeCanvasSceneGuides,
|
|
85
|
+
} from '@volter/sdk/kit/authoring/canvas-scene-guides';
|
|
86
|
+
import { CompositeAuthoringAdapter } from '@volter/sdk/kit/authoring/composite-authoring-adapter';
|
|
87
|
+
import {
|
|
88
|
+
dropAuthoringAsset,
|
|
89
|
+
reorderAuthoringNode,
|
|
90
|
+
setAuthoringSelection,
|
|
91
|
+
} from '@volter/sdk/kit/authoring/consumer-actions';
|
|
92
|
+
import {
|
|
93
|
+
isEyedropperSessionActive,
|
|
94
|
+
resolveEyedropperSession,
|
|
95
|
+
subscribeEyedropperSession,
|
|
96
|
+
} from '@volter/sdk/kit/eyedropper-session';
|
|
97
|
+
import { pickCandidates, pickTopmost } from '@volter/sdk/kit/authoring/layered-pick';
|
|
98
|
+
import { AddNodeHere, type AddNodeHereState } from './CanvasAddNodeDialogs';
|
|
99
|
+
import {
|
|
100
|
+
setTemporaryPivot,
|
|
101
|
+
subscribeTemporaryPivot,
|
|
102
|
+
temporaryPivot,
|
|
103
|
+
temporaryPivotVersion,
|
|
104
|
+
} from './canvas-temporary-pivot';
|
|
105
|
+
import {
|
|
106
|
+
subscribeViewportPresentation,
|
|
107
|
+
viewDrafting,
|
|
108
|
+
viewportPresentationVersion,
|
|
109
|
+
} from '@volter/sdk/kit/viewport-presentation';
|
|
110
|
+
import {
|
|
111
|
+
selectReactStoryFrameAtPoint,
|
|
112
|
+
zoomReactStoryFrameAtPoint,
|
|
113
|
+
} from '@volter/sdk/kit/authoring/react-story-board';
|
|
114
|
+
import { viewportEditorControls } from '@volter/sdk/kit/viewport-editor-controls';
|
|
115
|
+
import {
|
|
116
|
+
panTransformValue,
|
|
117
|
+
type RootViewController,
|
|
118
|
+
sharedRootViewController,
|
|
119
|
+
} from '@volter/sdk/kit/world-pan-state';
|
|
120
|
+
import { isRootHidden } from '@volter/sdk/kit/authoring/world-session-state';
|
|
121
|
+
import { useEditorStore } from '@volter/sdk/kit/editor-runtime';
|
|
122
|
+
import { anyLiveSessionMounted } from '@volter/sdk/kit/live-session-registry';
|
|
123
|
+
import { effectiveColorFromChain } from '@volter/sdk/kit/ui-source/inspect';
|
|
124
|
+
import { AlignToolbar } from './AlignToolbar';
|
|
125
|
+
import { transformDimensionsFor } from '@volter/sdk/kit/components/inspector-transform-subject';
|
|
126
|
+
import { RootTextEditor } from './RootTextEditor';
|
|
127
|
+
import {
|
|
128
|
+
type AlignEntry,
|
|
129
|
+
boxEditForId,
|
|
130
|
+
colorSampleForId,
|
|
131
|
+
computeDragGhost,
|
|
132
|
+
computeMeasureLines,
|
|
133
|
+
computeMovePatch,
|
|
134
|
+
computeMoveSnapGuides,
|
|
135
|
+
computeNativeMoveSnap,
|
|
136
|
+
computePointSnap,
|
|
137
|
+
computeReorderGap,
|
|
138
|
+
computeResizePatch,
|
|
139
|
+
frameAngle,
|
|
140
|
+
frameForId,
|
|
141
|
+
rectFrame,
|
|
142
|
+
snapPivotPoint,
|
|
143
|
+
frameHandlePosition,
|
|
144
|
+
frameIsTurned,
|
|
145
|
+
frameResizePatch,
|
|
146
|
+
proportionalResize,
|
|
147
|
+
computeResizeSnapGuides,
|
|
148
|
+
computeRotatePatch,
|
|
149
|
+
computeSpacingBands,
|
|
150
|
+
computeSpacingPatch,
|
|
151
|
+
contextRectsForId,
|
|
152
|
+
EDGE_SNAP_THRESHOLD_PX,
|
|
153
|
+
emptyContainerHintsFor,
|
|
154
|
+
type HandlePos,
|
|
155
|
+
handlePosition,
|
|
156
|
+
type MeasureLine,
|
|
157
|
+
type PositionBadgeInfo,
|
|
158
|
+
RESIZE_HANDLES,
|
|
159
|
+
type ReorderGap,
|
|
160
|
+
readSpacingValues,
|
|
161
|
+
// D4 — moved here from a local definition in THIS file (was `export
|
|
162
|
+
// function rectForId`): `editor-hotkeys.ts`'s D4.c arrow-nudge action
|
|
163
|
+
// needed it too, and importing it off THIS component file pulled the
|
|
164
|
+
// whole `RootSelectionOverlay`/postprocessing-viewport dependency graph
|
|
165
|
+
// into `editor-hotkeys.ts` (breaks a headless/vitest unit test of the
|
|
166
|
+
// hotkey actions, which has no business mounting a viewport). Re-exported
|
|
167
|
+
// below for the existing import site.
|
|
168
|
+
rectForId,
|
|
169
|
+
resolvePositionBadge,
|
|
170
|
+
type SiblingRectEntry,
|
|
171
|
+
type SnapGuide,
|
|
172
|
+
type SpacingBand,
|
|
173
|
+
type SpacingSide,
|
|
174
|
+
siblingIdsForId,
|
|
175
|
+
spatialHandlesForId,
|
|
176
|
+
structureForId,
|
|
177
|
+
textForId,
|
|
178
|
+
} from './world-overlay-gestures';
|
|
179
|
+
|
|
180
|
+
export { rectForId } from './world-overlay-gestures';
|
|
181
|
+
|
|
182
|
+
// Canvas selection is an editor action state, so it follows the selected
|
|
183
|
+
// palette's semantic accent just like toolbars and drop overlays. Material
|
|
184
|
+
// changes never inject a hue of their own.
|
|
185
|
+
const ACCENT = themeVars.accent.default;
|
|
186
|
+
/** Strictly below `ToolStrip`+`ViewportOverlay`'s z1000 — mirrors
|
|
187
|
+
* `design-time-layers.ts`'s own `BASE_Z_INDEX` doc comment ("so canvas-local
|
|
188
|
+
* editing chrome is never occluded") — and strictly
|
|
189
|
+
* above the design-time layers themselves (`BASE_Z_INDEX + i`, small N),
|
|
190
|
+
* so this overlay's boxes/marquee always paint over the world content they
|
|
191
|
+
* describe. V-12 — pure rename onto the canonical z-index scale
|
|
192
|
+
* (`zIndex.overlayLow`), same numeric value (50), no behavior change. */
|
|
193
|
+
const Z_INDEX = zIndex.overlayLow;
|
|
194
|
+
const DRAG_THRESHOLD_PX = 5;
|
|
195
|
+
|
|
196
|
+
type EditorControlCursor = 'pointer' | 'not-allowed' | null;
|
|
197
|
+
|
|
198
|
+
function activateViewportEditorControl(
|
|
199
|
+
scopedAdapter: AuthoringAdapter | undefined,
|
|
200
|
+
clientX: number,
|
|
201
|
+
clientY: number,
|
|
202
|
+
): boolean {
|
|
203
|
+
if (scopedAdapter) return false;
|
|
204
|
+
return viewportEditorControls()?.activate(clientX, clientY) ?? false;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function updateViewportEditorControlHover(
|
|
208
|
+
scopedAdapter: AuthoringAdapter | undefined,
|
|
209
|
+
clientX: number,
|
|
210
|
+
clientY: number,
|
|
211
|
+
pickAt: (clientX: number, clientY: number) => string | null,
|
|
212
|
+
setCursor: (cursor: EditorControlCursor) => void,
|
|
213
|
+
setHoverId: (id: string | null) => void,
|
|
214
|
+
): void {
|
|
215
|
+
const cursor = scopedAdapter
|
|
216
|
+
? null
|
|
217
|
+
: (viewportEditorControls()?.hover(clientX, clientY) ?? null);
|
|
218
|
+
setCursor(cursor);
|
|
219
|
+
setHoverId(cursor ? null : pickAt(clientX, clientY));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
interface Rect {
|
|
223
|
+
x: number;
|
|
224
|
+
y: number;
|
|
225
|
+
width: number;
|
|
226
|
+
height: number;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** `name·kind` label for a node. `hierarchy.node` IS merged/routed on the
|
|
230
|
+
* composite (unlike `rects`/`pickable`), so no owner lookup is needed here. */
|
|
231
|
+
export function labelForId(adapter: AuthoringAdapter, id: string): string {
|
|
232
|
+
const node = adapter.hierarchy.node(id);
|
|
233
|
+
return node ? `${node.label}·${node.kind}` : id;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* True when `adapter` (or, for a composite, ANY of its children) exposes a
|
|
238
|
+
* `RectProvider` — the DOM-overlay capability signal (the contract's own doc
|
|
239
|
+
* comment on `AuthoringAdapter.rects`: "Absent ⇒ this adapter produces no
|
|
240
|
+
* rects (no overlay)"). Gates whether `RootSelectionOverlay` renders
|
|
241
|
+
* anything at all, so a pure-3D session (no react/pixi/DOM world in the
|
|
242
|
+
* active composite) is completely unaffected — matches spec §3 A3's wiring
|
|
243
|
+
* instruction ("gate on the active adapter exposing rects/pickable on any
|
|
244
|
+
* child, so 3D-only sessions are unaffected").
|
|
245
|
+
*/
|
|
246
|
+
export function hasRectCapableChild(adapter: AuthoringAdapter): boolean {
|
|
247
|
+
if (adapter instanceof CompositeAuthoringAdapter) {
|
|
248
|
+
return adapter.childAdapters().some((c) => !!c.adapter.rects);
|
|
249
|
+
}
|
|
250
|
+
return !!adapter.rects;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Every (id, rect) pair reachable from `adapter`'s own `rects` provider — or,
|
|
255
|
+
* for a composite, from each VISIBLE child's own `rects` provider — the
|
|
256
|
+
* marquee's candidate pool. Walks each CHILD's OWN hierarchy
|
|
257
|
+
* (`collectAllNodeIds`), never the composite's synthetic `world:<id>`
|
|
258
|
+
* wrapper, so a group/organization id is never a candidate. A session-hidden
|
|
259
|
+
* world (`world-session-state.ts`'s eye toggle, B1) contributes no
|
|
260
|
+
* candidates — consistent with it being invisible/unpickable.
|
|
261
|
+
*
|
|
262
|
+
* D4.R1 — a LOCKED node (`child.inspector.get(id, 'locked') === true`, the
|
|
263
|
+
* same session-local `lockedIds` the layer-tree lock toggle writes) is also
|
|
264
|
+
* excluded: a marquee that merely overlaps a locked node's rect must not
|
|
265
|
+
* sweep it into the selection, matching the click-pick gate each override
|
|
266
|
+
* adapter's own `pickable.pick` now applies (`ReactRootAuthoringAdapter`/
|
|
267
|
+
* `DomAuthoringAdapter`/`UIAuthoringAdapter`). The first-party three
|
|
268
|
+
* adapter has no `rects` provider, so it never reaches this function at all
|
|
269
|
+
* — its own locked-skip lives entirely in `viewport-raycast.ts`.
|
|
270
|
+
*/
|
|
271
|
+
export function collectMarqueeCandidates(
|
|
272
|
+
adapter: AuthoringAdapter,
|
|
273
|
+
): Array<{ id: string; rect: DOMRectLike }> {
|
|
274
|
+
const children: ReadonlyArray<{ worldId: string; adapter: AuthoringAdapter }> =
|
|
275
|
+
adapter instanceof CompositeAuthoringAdapter
|
|
276
|
+
? adapter.childAdapters()
|
|
277
|
+
: [{ worldId: '', adapter }];
|
|
278
|
+
const out: Array<{ id: string; rect: DOMRectLike }> = [];
|
|
279
|
+
for (const { worldId, adapter: child } of children) {
|
|
280
|
+
if (!child.rects) continue;
|
|
281
|
+
if (worldId && isRootHidden(worldId)) continue;
|
|
282
|
+
for (const id of collectAllNodeIds(child)) {
|
|
283
|
+
if (child.inspector?.get(id, 'locked') === true) continue;
|
|
284
|
+
const r = child.rects.rect(id);
|
|
285
|
+
if (r) out.push({ id, rect: r });
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return out;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Axis-aligned overlap test (ANY overlap selects — not full containment;
|
|
292
|
+
* spec §3 A3: "select all nodes whose rect intersects"). */
|
|
293
|
+
export function rectsIntersect(a: Rect, b: DOMRectLike): boolean {
|
|
294
|
+
return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Pure selection-set math for a click (not drag) on a hit node — a plain
|
|
298
|
+
* click REPLACES the selection with just `hitId`; shift-click ADDS it if
|
|
299
|
+
* absent or TOGGLES it off if already selected. Factored out of the
|
|
300
|
+
* pointer-up handler so it's directly unit-testable (spec §3 A3's own
|
|
301
|
+
* testing note: "shift-click add/toggle vs replace"). */
|
|
302
|
+
export function resolveClickSelection(
|
|
303
|
+
current: ReadonlySet<string>,
|
|
304
|
+
hitId: string,
|
|
305
|
+
shiftKey: boolean,
|
|
306
|
+
): string[] {
|
|
307
|
+
if (!shiftKey) return [hitId];
|
|
308
|
+
const next = new Set(current);
|
|
309
|
+
if (next.has(hitId)) next.delete(hitId);
|
|
310
|
+
else next.add(hitId);
|
|
311
|
+
return [...next];
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Pure selection resolution for a gesture that STARTED on empty space (no
|
|
316
|
+
* pick at pointerdown): a real marquee drag (past the drag threshold)
|
|
317
|
+
* selects every candidate whose rect intersects the rubber-band — possibly
|
|
318
|
+
* none, which clears; a plain click (no meaningful drag) clears the
|
|
319
|
+
* selection, UNLESS shift is held, in which case it's a no-op (returns
|
|
320
|
+
* `null` — "leave the selection untouched" — so an accidental shift-click on
|
|
321
|
+
* empty space doesn't wipe out an existing multi-select).
|
|
322
|
+
*/
|
|
323
|
+
export function resolveEmptySpaceSelection(
|
|
324
|
+
active: AuthoringAdapter,
|
|
325
|
+
moved: boolean,
|
|
326
|
+
marquee: Rect | null,
|
|
327
|
+
shiftKey: boolean,
|
|
328
|
+
): string[] | null {
|
|
329
|
+
const isRealDrag =
|
|
330
|
+
moved && !!marquee && (marquee.width > DRAG_THRESHOLD_PX || marquee.height > DRAG_THRESHOLD_PX);
|
|
331
|
+
if (isRealDrag && marquee) {
|
|
332
|
+
const candidates = collectMarqueeCandidates(active);
|
|
333
|
+
return candidates.filter((c) => rectsIntersect(marquee, c.rect)).map((c) => c.id);
|
|
334
|
+
}
|
|
335
|
+
return shiftKey ? null : [];
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** D4 (spec27 §8 "space-pan" row) — the `interactionRef` cursor for the
|
|
339
|
+
* current pan/eyedropper/marquee state, pure (a plain priority chain, no
|
|
340
|
+
* DOM/store reads) — factored out purely to keep the component's own
|
|
341
|
+
* cognitive complexity down, same reason `computeSingleSelectionBoxEdit`/
|
|
342
|
+
* `computeD1Affordances` above are factored out. */
|
|
343
|
+
function interactionCursor(
|
|
344
|
+
isPanning: boolean,
|
|
345
|
+
spaceHeld: boolean,
|
|
346
|
+
eyedropperActive: boolean,
|
|
347
|
+
hasMarquee: boolean,
|
|
348
|
+
): React.CSSProperties['cursor'] {
|
|
349
|
+
if (isPanning) return 'grabbing';
|
|
350
|
+
if (spaceHeld) return 'grab';
|
|
351
|
+
if (eyedropperActive || hasMarquee) return 'crosshair';
|
|
352
|
+
return 'default';
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Pointer capture is unavailable for synthetic accessibility/test events and
|
|
356
|
+
* can also be rejected when a browser has already cancelled the native
|
|
357
|
+
* pointer. Gesture ownership still works while the pointer stays in bounds,
|
|
358
|
+
* so capture is a best-effort enhancement rather than a reason to abort. */
|
|
359
|
+
function capturePointer(e: ReactPointerEvent<Element>): void {
|
|
360
|
+
try {
|
|
361
|
+
e.currentTarget.setPointerCapture?.(e.pointerId);
|
|
362
|
+
} catch {
|
|
363
|
+
// Continue without capture; pointerup/cancel still completes in bounds.
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Release an interaction pointer without adding another decision branch to
|
|
368
|
+
* each already-dense gesture completion path. */
|
|
369
|
+
function releaseCapturedPointer(e: ReactPointerEvent<HTMLElement>): void {
|
|
370
|
+
if (e.currentTarget.hasPointerCapture?.(e.pointerId)) {
|
|
371
|
+
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Resolve the board-level target that exists outside the active adapter's
|
|
376
|
+
* deliberately single-story DOM projection. */
|
|
377
|
+
function selectInactiveStoryFrame(
|
|
378
|
+
e: ReactPointerEvent<HTMLElement>,
|
|
379
|
+
hitAtDown: string | null,
|
|
380
|
+
): boolean {
|
|
381
|
+
if (hitAtDown !== null) return false;
|
|
382
|
+
const overlayContainer = e.currentTarget.parentElement?.parentElement ?? null;
|
|
383
|
+
return selectReactStoryFrameAtPoint(overlayContainer, e.clientX, e.clientY);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Whether the armed tool carries `part`'s handles. Without transform-mode
|
|
387
|
+
* awareness every part is armed; `combined` (Transform, all handles) arms all
|
|
388
|
+
* three, as it does on the 3D stage. */
|
|
389
|
+
function transformArms(
|
|
390
|
+
aware: boolean,
|
|
391
|
+
mode: string,
|
|
392
|
+
part: 'translate' | 'rotate' | 'scale',
|
|
393
|
+
): boolean {
|
|
394
|
+
// Godot's 2D Select mode is its handle mode: on a native 2D surface Select arms the box's
|
|
395
|
+
// handles as the combined tool does.
|
|
396
|
+
return !aware || mode === part || mode === 'combined' || mode === 'select';
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function boxStyle(r: DOMRectLike, solid: boolean): React.CSSProperties {
|
|
400
|
+
return {
|
|
401
|
+
position: 'absolute',
|
|
402
|
+
left: r.x,
|
|
403
|
+
top: r.y,
|
|
404
|
+
width: r.width,
|
|
405
|
+
height: r.height,
|
|
406
|
+
// Longhands, never the `border` shorthand: callers override `borderWidth`
|
|
407
|
+
// per zoom, and React drops a shorthand's colour and style when a longhand
|
|
408
|
+
// beside it changes (measured on a canvas Scene: the selection box kept a
|
|
409
|
+
// 0.3 px width with an empty colour and style, so nothing was drawn).
|
|
410
|
+
borderStyle: 'solid',
|
|
411
|
+
borderColor: ACCENT,
|
|
412
|
+
borderWidth: solid ? 2 : 1,
|
|
413
|
+
background: solid ? `color-mix(in srgb, ${ACCENT} 6%, transparent)` : 'transparent',
|
|
414
|
+
boxSizing: 'border-box',
|
|
415
|
+
pointerEvents: 'none',
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** The rotate handle above the box's top edge, along the box's own up when it is turned. */
|
|
420
|
+
function turnedRotateHandlePosition(
|
|
421
|
+
frame: FrameCorners | null,
|
|
422
|
+
rect: DOMRectLike,
|
|
423
|
+
offset: number,
|
|
424
|
+
): { left: number; top: number } {
|
|
425
|
+
if (!frame) return { left: rect.x + rect.width / 2 - ROTATE_SIZE / 2, top: rect.y - offset - ROTATE_SIZE / 2 };
|
|
426
|
+
const topX = (frame.tl.x + frame.tr.x) / 2;
|
|
427
|
+
const topY = (frame.tl.y + frame.tr.y) / 2;
|
|
428
|
+
const upX = frame.tl.x - frame.bl.x;
|
|
429
|
+
const upY = frame.tl.y - frame.bl.y;
|
|
430
|
+
const length = Math.hypot(upX, upY) || 1;
|
|
431
|
+
return {
|
|
432
|
+
left: topX + (upX / length) * offset - ROTATE_SIZE / 2,
|
|
433
|
+
top: topY + (upY / length) * offset - ROTATE_SIZE / 2,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** A turned node's box: its own size, turned to its angle about its centre (Godot's 2D frame). */
|
|
438
|
+
function turnedBoxStyle(frame: FrameCorners, borderWidth: number): React.CSSProperties {
|
|
439
|
+
const width = Math.hypot(frame.tr.x - frame.tl.x, frame.tr.y - frame.tl.y);
|
|
440
|
+
const height = Math.hypot(frame.bl.x - frame.tl.x, frame.bl.y - frame.tl.y);
|
|
441
|
+
const centerX = (frame.tl.x + frame.br.x) / 2;
|
|
442
|
+
const centerY = (frame.tl.y + frame.br.y) / 2;
|
|
443
|
+
return {
|
|
444
|
+
position: 'absolute',
|
|
445
|
+
left: centerX - width / 2,
|
|
446
|
+
top: centerY - height / 2,
|
|
447
|
+
width,
|
|
448
|
+
height,
|
|
449
|
+
transform: `rotate(${frameAngle(frame)}rad)`,
|
|
450
|
+
transformOrigin: 'center',
|
|
451
|
+
borderStyle: 'solid',
|
|
452
|
+
borderColor: ACCENT,
|
|
453
|
+
borderWidth,
|
|
454
|
+
background: 'transparent',
|
|
455
|
+
boxSizing: 'border-box',
|
|
456
|
+
pointerEvents: 'none',
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function labelStyle(r: DOMRectLike): React.CSSProperties {
|
|
461
|
+
return {
|
|
462
|
+
position: 'absolute',
|
|
463
|
+
left: r.x,
|
|
464
|
+
top: Math.max(0, r.y - 18),
|
|
465
|
+
padding: '2px 6px',
|
|
466
|
+
fontSize: 11,
|
|
467
|
+
fontFamily: "'SF Mono', 'Fira Code', Menlo, Consolas, monospace",
|
|
468
|
+
fontWeight: 600,
|
|
469
|
+
color: '#fff',
|
|
470
|
+
background: ACCENT,
|
|
471
|
+
borderRadius: 3,
|
|
472
|
+
pointerEvents: 'none',
|
|
473
|
+
whiteSpace: 'nowrap',
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const HANDLE_SIZE = 10;
|
|
478
|
+
const NATIVE_GIZMO_LENGTH_PX = 48;
|
|
479
|
+
|
|
480
|
+
/** One of the 8 B2 resize handles — a small white/themeVars.accent.default square centered on
|
|
481
|
+
* its position on the selection rect (spec:317/§5 :465). */
|
|
482
|
+
function handleStyle(center: { x: number; y: number }, cursor: string): React.CSSProperties {
|
|
483
|
+
return {
|
|
484
|
+
position: 'absolute',
|
|
485
|
+
left: center.x - HANDLE_SIZE / 2,
|
|
486
|
+
top: center.y - HANDLE_SIZE / 2,
|
|
487
|
+
width: HANDLE_SIZE,
|
|
488
|
+
height: HANDLE_SIZE,
|
|
489
|
+
background: '#fff',
|
|
490
|
+
border: `1px solid ${ACCENT}`,
|
|
491
|
+
boxSizing: 'border-box',
|
|
492
|
+
cursor,
|
|
493
|
+
pointerEvents: 'auto',
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const ROTATE_SIZE = 12;
|
|
498
|
+
const ROTATE_OFFSET = 24;
|
|
499
|
+
|
|
500
|
+
/** The rotate handle — a small circle ~24px above top-center (spec:322/§5
|
|
501
|
+
* :465 "rotate"). */
|
|
502
|
+
function rotateHandleStyle(rect: DOMRectLike): React.CSSProperties {
|
|
503
|
+
return {
|
|
504
|
+
position: 'absolute',
|
|
505
|
+
left: rect.x + rect.width / 2 - ROTATE_SIZE / 2,
|
|
506
|
+
top: rect.y - ROTATE_OFFSET - ROTATE_SIZE / 2,
|
|
507
|
+
width: ROTATE_SIZE,
|
|
508
|
+
height: ROTATE_SIZE,
|
|
509
|
+
borderRadius: '50%',
|
|
510
|
+
background: '#fff',
|
|
511
|
+
border: `1px solid ${ACCENT}`,
|
|
512
|
+
boxSizing: 'border-box',
|
|
513
|
+
cursor: 'grab',
|
|
514
|
+
padding: 0,
|
|
515
|
+
pointerEvents: 'auto',
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const SPATIAL_HANDLE_SIZE = 12;
|
|
520
|
+
|
|
521
|
+
/** One adapter-owned spatial handle: a small ring in the COLOR THE ADAPTER
|
|
522
|
+
* CHOSE, centered on the point it reported. The shell picks none of that —
|
|
523
|
+
* it draws the contract's own data — and a non-writable handle is drawn
|
|
524
|
+
* hollow and inert rather than hidden, because "this exists and you cannot
|
|
525
|
+
* move it" is the honest state (its `reason` is the title). */
|
|
526
|
+
function spatialHandleStyle(handle: SpatialDragHandle): React.CSSProperties {
|
|
527
|
+
return {
|
|
528
|
+
position: 'absolute',
|
|
529
|
+
left: handle.position[0],
|
|
530
|
+
top: handle.position[1],
|
|
531
|
+
width: SPATIAL_HANDLE_SIZE,
|
|
532
|
+
height: SPATIAL_HANDLE_SIZE,
|
|
533
|
+
borderRadius: '50%',
|
|
534
|
+
background: handle.writable ? handle.color : 'transparent',
|
|
535
|
+
border: `2px solid ${handle.color}`,
|
|
536
|
+
boxSizing: 'border-box',
|
|
537
|
+
cursor: handle.writable ? 'grab' : 'not-allowed',
|
|
538
|
+
padding: 0,
|
|
539
|
+
pointerEvents: handle.writable ? 'auto' : 'none',
|
|
540
|
+
touchAction: 'none',
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function referencePointStyle(point: BoxEditReferencePoint): React.CSSProperties {
|
|
545
|
+
return {
|
|
546
|
+
position: 'absolute',
|
|
547
|
+
left: point.x - 8,
|
|
548
|
+
top: point.y - 8,
|
|
549
|
+
width: 16,
|
|
550
|
+
height: 16,
|
|
551
|
+
display: 'grid',
|
|
552
|
+
placeItems: 'center',
|
|
553
|
+
borderRadius: point.kind === 'anchor' ? '50%' : 2,
|
|
554
|
+
background: '#fff',
|
|
555
|
+
border: `1px solid ${ACCENT}`,
|
|
556
|
+
boxSizing: 'border-box',
|
|
557
|
+
color: ACCENT,
|
|
558
|
+
cursor: 'crosshair',
|
|
559
|
+
fontSize: 9,
|
|
560
|
+
fontFamily: "'SF Mono', 'Fira Code', Menlo, Consolas, monospace",
|
|
561
|
+
fontWeight: 700,
|
|
562
|
+
lineHeight: 1,
|
|
563
|
+
padding: 0,
|
|
564
|
+
pointerEvents: 'auto',
|
|
565
|
+
touchAction: 'none',
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** The B2 dimension label (spec:460 "dimension label") — `W×H`, centered
|
|
570
|
+
* below the selection box (reuses `labelStyle`'s look, different anchor). */
|
|
571
|
+
function dimensionLabelStyle(rect: DOMRectLike): React.CSSProperties {
|
|
572
|
+
return {
|
|
573
|
+
position: 'absolute',
|
|
574
|
+
left: rect.x,
|
|
575
|
+
top: rect.y + rect.height + 4,
|
|
576
|
+
padding: '2px 6px',
|
|
577
|
+
fontSize: 11,
|
|
578
|
+
fontFamily: "'SF Mono', 'Fira Code', Menlo, Consolas, monospace",
|
|
579
|
+
fontWeight: 600,
|
|
580
|
+
color: '#fff',
|
|
581
|
+
background: ACCENT,
|
|
582
|
+
borderRadius: 3,
|
|
583
|
+
pointerEvents: 'none',
|
|
584
|
+
whiteSpace: 'nowrap',
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Deliberate literals (U6 census): the padding/margin band pair, their label
|
|
589
|
+
// inks, the measurement pinks (`#ff2d92`/`#ff5fa2`) and the snap-guide red
|
|
590
|
+
// (`#f24822`) below are the industry-convention CANVAS-GUIDE language (green
|
|
591
|
+
// padding / orange margin / pink measure / red snap — Figma & devtools
|
|
592
|
+
// grammar), each deliberately distinct from the semantic selection accent so
|
|
593
|
+
// guides never read as selection or chrome.
|
|
594
|
+
const PADDING_BAND_COLOR = 'rgba(46, 204, 113, 0.25)';
|
|
595
|
+
const MARGIN_BAND_COLOR = 'rgba(230, 126, 34, 0.25)';
|
|
596
|
+
|
|
597
|
+
/** One B3 padding/margin spacing band (spec:332-335/§5 :460/:465-466). */
|
|
598
|
+
function spacingBandStyle(band: SpacingBand): React.CSSProperties {
|
|
599
|
+
return {
|
|
600
|
+
position: 'absolute',
|
|
601
|
+
left: band.x,
|
|
602
|
+
top: band.y,
|
|
603
|
+
width: band.width,
|
|
604
|
+
height: band.height,
|
|
605
|
+
background: band.kind === 'padding' ? PADDING_BAND_COLOR : MARGIN_BAND_COLOR,
|
|
606
|
+
boxSizing: 'border-box',
|
|
607
|
+
cursor: band.cursor,
|
|
608
|
+
pointerEvents: 'auto',
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** The B3 numeric label centered in a nonzero band — monospace ~10px,
|
|
613
|
+
* porting `ui-editor/overlay.tsx`'s dashed gap-measure label styling. */
|
|
614
|
+
function spacingLabelStyle(band: SpacingBand): React.CSSProperties {
|
|
615
|
+
return {
|
|
616
|
+
position: 'absolute',
|
|
617
|
+
left: band.labelX,
|
|
618
|
+
top: band.labelY,
|
|
619
|
+
transform: 'translate(-50%, -50%)',
|
|
620
|
+
fontSize: 10,
|
|
621
|
+
fontFamily: 'monospace',
|
|
622
|
+
color: band.kind === 'padding' ? '#1b7a3d' : '#a35a12',
|
|
623
|
+
background: 'rgba(255,255,255,0.85)',
|
|
624
|
+
padding: '0 2px',
|
|
625
|
+
borderRadius: 2,
|
|
626
|
+
pointerEvents: 'none',
|
|
627
|
+
whiteSpace: 'nowrap',
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/** D2.a — the drag-ghost: a semi-transparent, INERT copy of the dragged
|
|
632
|
+
* element at its ghost rect (spec:399). Deliberately dashed + low-opacity
|
|
633
|
+
* fill so it reads as a "preview copy", never confusable with the live
|
|
634
|
+
* selection box (`boxStyle`'s solid ACCENT border) it's drawn alongside. */
|
|
635
|
+
function dragGhostStyle(rect: Rect): React.CSSProperties {
|
|
636
|
+
return {
|
|
637
|
+
position: 'absolute',
|
|
638
|
+
left: rect.x,
|
|
639
|
+
top: rect.y,
|
|
640
|
+
width: rect.width,
|
|
641
|
+
height: rect.height,
|
|
642
|
+
border: `2px dashed ${ACCENT}`,
|
|
643
|
+
background: `color-mix(in srgb, ${ACCENT} 15%, transparent)`,
|
|
644
|
+
boxSizing: 'border-box',
|
|
645
|
+
pointerEvents: 'none',
|
|
646
|
+
opacity: 0.7,
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/** D2.b — the live reorder-preview: a thin marker line at the gap the drop
|
|
651
|
+
* would land in (spec:398-400). Distinct color from the drag-ghost/ACCENT
|
|
652
|
+
* selection chrome so the two affordances (which render SIMULTANEOUSLY
|
|
653
|
+
* during a reorder drag) stay visually distinguishable. */
|
|
654
|
+
function reorderMarkerStyle(marker: Rect): React.CSSProperties {
|
|
655
|
+
return {
|
|
656
|
+
position: 'absolute',
|
|
657
|
+
left: marker.x,
|
|
658
|
+
top: marker.y,
|
|
659
|
+
width: marker.width,
|
|
660
|
+
height: marker.height,
|
|
661
|
+
background: '#ff5fa2',
|
|
662
|
+
borderRadius: 2,
|
|
663
|
+
pointerEvents: 'none',
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// --- D1 — measurement + snap + alignment guides + badges (spec §6 D1)
|
|
668
|
+
// ---------------------------------------------------------------------
|
|
669
|
+
|
|
670
|
+
const MEASURE_COLOR = '#ff2d92';
|
|
671
|
+
const MEASURE_CAP_PX = 6;
|
|
672
|
+
|
|
673
|
+
/** D1.a — the measure line itself: a 1px pink segment (figma "hold-and-
|
|
674
|
+
* measure" affordance, spec §6 "pink, end-caps, value labels"). */
|
|
675
|
+
function measureLineStyle(line: MeasureLine): React.CSSProperties {
|
|
676
|
+
const horizontal = line.orientation === 'horizontal';
|
|
677
|
+
return {
|
|
678
|
+
position: 'absolute',
|
|
679
|
+
left: Math.min(line.x1, line.x2),
|
|
680
|
+
top: Math.min(line.y1, line.y2),
|
|
681
|
+
width: horizontal ? Math.abs(line.x2 - line.x1) : 1,
|
|
682
|
+
height: horizontal ? 1 : Math.abs(line.y2 - line.y1),
|
|
683
|
+
background: MEASURE_COLOR,
|
|
684
|
+
pointerEvents: 'none',
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/** D1.a — one of the measure line's two end-caps: a short perpendicular
|
|
689
|
+
* tick at `(x,y)`. */
|
|
690
|
+
function measureCapStyle(x: number, y: number, horizontal: boolean): React.CSSProperties {
|
|
691
|
+
return {
|
|
692
|
+
position: 'absolute',
|
|
693
|
+
left: horizontal ? x : x - MEASURE_CAP_PX / 2,
|
|
694
|
+
top: horizontal ? y - MEASURE_CAP_PX / 2 : y,
|
|
695
|
+
width: horizontal ? 1 : MEASURE_CAP_PX,
|
|
696
|
+
height: horizontal ? MEASURE_CAP_PX : 1,
|
|
697
|
+
background: MEASURE_COLOR,
|
|
698
|
+
pointerEvents: 'none',
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** D1.a — the measure line's px value label, centered on the line. */
|
|
703
|
+
function measureLabelStyle(line: MeasureLine): React.CSSProperties {
|
|
704
|
+
return {
|
|
705
|
+
position: 'absolute',
|
|
706
|
+
left: line.labelX,
|
|
707
|
+
top: line.labelY,
|
|
708
|
+
transform: 'translate(-50%, -50%)',
|
|
709
|
+
padding: '1px 4px',
|
|
710
|
+
fontSize: 10,
|
|
711
|
+
fontFamily: 'monospace',
|
|
712
|
+
fontWeight: 600,
|
|
713
|
+
color: '#fff',
|
|
714
|
+
background: MEASURE_COLOR,
|
|
715
|
+
borderRadius: 2,
|
|
716
|
+
pointerEvents: 'none',
|
|
717
|
+
whiteSpace: 'nowrap',
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const SNAP_GUIDE_COLOR = '#f24822';
|
|
722
|
+
|
|
723
|
+
/** D1.b — one active alignment/snap guide, live only while the underlying
|
|
724
|
+
* gesture's own edge-snap is engaged (spec §6 "guides appear only within
|
|
725
|
+
* the snap threshold; disappear when not snapping"). Dashed so it reads as
|
|
726
|
+
* a transient alignment aid, distinct from the solid pink measure line. */
|
|
727
|
+
function snapGuideStyle(guide: SnapGuide): React.CSSProperties {
|
|
728
|
+
const vertical = guide.orientation === 'vertical';
|
|
729
|
+
return {
|
|
730
|
+
position: 'absolute',
|
|
731
|
+
left: vertical ? guide.at : guide.start,
|
|
732
|
+
top: vertical ? guide.start : guide.at,
|
|
733
|
+
width: vertical ? 1 : guide.end - guide.start,
|
|
734
|
+
height: vertical ? guide.end - guide.start : 1,
|
|
735
|
+
borderLeft: vertical ? `1px dashed ${SNAP_GUIDE_COLOR}` : undefined,
|
|
736
|
+
borderTop: !vertical ? `1px dashed ${SNAP_GUIDE_COLOR}` : undefined,
|
|
737
|
+
pointerEvents: 'none',
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/** D1.c — the position/parent-layout badge, anchored just above-left of the
|
|
742
|
+
* selection (mirrors `labelStyle`'s anchor, offset further up so it never
|
|
743
|
+
* overlaps the `name·kind` label A3 already renders there). */
|
|
744
|
+
function positionBadgeStyle(r: DOMRectLike): React.CSSProperties {
|
|
745
|
+
return {
|
|
746
|
+
position: 'absolute',
|
|
747
|
+
left: r.x,
|
|
748
|
+
top: Math.max(0, r.y - 36),
|
|
749
|
+
display: 'flex',
|
|
750
|
+
gap: 4,
|
|
751
|
+
pointerEvents: 'none',
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const BADGE_SEGMENT_STYLE: React.CSSProperties = {
|
|
756
|
+
padding: '2px 6px',
|
|
757
|
+
fontSize: 10,
|
|
758
|
+
fontFamily: "'SF Mono', 'Fira Code', Menlo, Consolas, monospace",
|
|
759
|
+
fontWeight: 600,
|
|
760
|
+
color: themeVars.content.onAccent,
|
|
761
|
+
background: `color-mix(in srgb, ${ACCENT} 85%, transparent)`,
|
|
762
|
+
borderRadius: 3,
|
|
763
|
+
whiteSpace: 'nowrap',
|
|
764
|
+
};
|
|
765
|
+
|
|
766
|
+
/** D1.c — the parent-container outline: a subtle dashed stroke around
|
|
767
|
+
* `rect` (spec §6 "outline the parent container rect ... with a subtle
|
|
768
|
+
* stroke"). */
|
|
769
|
+
function parentOutlineStyle(r: DOMRectLike): React.CSSProperties {
|
|
770
|
+
return {
|
|
771
|
+
position: 'absolute',
|
|
772
|
+
left: r.x,
|
|
773
|
+
top: r.y,
|
|
774
|
+
width: r.width,
|
|
775
|
+
height: r.height,
|
|
776
|
+
border: `1px dashed color-mix(in srgb, ${ACCENT} 45%, transparent)`,
|
|
777
|
+
boxSizing: 'border-box',
|
|
778
|
+
pointerEvents: 'none',
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* A single active B2/B3 gesture — resize/move/rotate/spacing — bracketed by
|
|
784
|
+
* `ownerBoxEdit.begin/end`. Kept SEPARATE from {@link DragState} (A3's click/
|
|
785
|
+
* marquee tracking): a gesture and a click/marquee-drag are mutually
|
|
786
|
+
* exclusive per pointer session and want independent lifecycles (spec §4 B2
|
|
787
|
+
* step 4's own instruction).
|
|
788
|
+
*/
|
|
789
|
+
interface GestureState {
|
|
790
|
+
kind: 'resize' | 'move' | 'native-scale' | 'reference' | 'rotate' | 'spacing';
|
|
791
|
+
id: string;
|
|
792
|
+
ownerBoxEdit: BoxEditProvider;
|
|
793
|
+
/** Gesture start point, in the SAME coordinate frame as `origRect`/`center`
|
|
794
|
+
* (host-local) — see `computeRotatePatch`'s doc comment on frame
|
|
795
|
+
* consistency. Resize/move only need the DELTA (frame-invariant under a
|
|
796
|
+
* fixed additive offset), so they tolerate either frame; rotate does not,
|
|
797
|
+
* so this file always seeds it host-local for every kind, uniformly. */
|
|
798
|
+
startLocal: { x: number; y: number };
|
|
799
|
+
origRect: DOMRectLike;
|
|
800
|
+
pos?: HandlePos;
|
|
801
|
+
/** A turned node's box at the gesture's start, and its origin: a resize then works along the
|
|
802
|
+
* node's own axes (`frameResizePatch`). */
|
|
803
|
+
frame?: FrameCorners;
|
|
804
|
+
frameOrigin?: { x: number; y: number };
|
|
805
|
+
isPositioned?: boolean;
|
|
806
|
+
center?: { x: number; y: number };
|
|
807
|
+
context?: ReturnType<typeof contextRectsForId>;
|
|
808
|
+
side?: SpacingSide;
|
|
809
|
+
origValue?: number;
|
|
810
|
+
pointerId?: number;
|
|
811
|
+
referencePoint?: BoxEditReferencePoint;
|
|
812
|
+
/** Native 2D move gizmo constraint. Board drags omit it and move freely. */
|
|
813
|
+
moveAxis?: 'x' | 'y' | 'both';
|
|
814
|
+
/** Native display-object position in stage space. Unlike a visual bounds
|
|
815
|
+
* center, this remains the actual rotate/scale origin for anchored sprites
|
|
816
|
+
* and pivoted containers. */
|
|
817
|
+
nativeOrigin?: { x: number; y: number };
|
|
818
|
+
nativeScaleAxis?: 'x' | 'y' | 'both';
|
|
819
|
+
nativeHandleSpan?: number;
|
|
820
|
+
/** A native rotate's starting angle on screen, degrees: Godot snaps the angle itself (step and
|
|
821
|
+
* offset), not the turn, unless Snap Relative. */
|
|
822
|
+
startAngleDeg?: number;
|
|
823
|
+
/** Other selected nodes moved by the same direct-manipulation gesture. */
|
|
824
|
+
movePeers?: readonly MovePeer[];
|
|
825
|
+
/** A 2D group rotate or scale: the point every node turns or scales about (the temporary pivot,
|
|
826
|
+
* else the selection's centre). Each peer's origin moves about it with the primary's. */
|
|
827
|
+
groupCenter?: { x: number; y: number };
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
interface MovePeer {
|
|
831
|
+
readonly id: string;
|
|
832
|
+
readonly ownerBoxEdit: BoxEditProvider;
|
|
833
|
+
readonly origRect: DOMRectLike;
|
|
834
|
+
readonly nativeOrigin?: { x: number; y: number };
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function snapNativeTransform(value: number, enabled: boolean, step: number, offset = 0): number {
|
|
838
|
+
return enabled && step > 0 ? Math.round((value - offset) / step) * step + offset : value;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function nativeScalePatch(
|
|
842
|
+
gesture: GestureState,
|
|
843
|
+
dx: number,
|
|
844
|
+
dy: number,
|
|
845
|
+
snapEnabled: boolean,
|
|
846
|
+
altKey: boolean,
|
|
847
|
+
scaleStep: number,
|
|
848
|
+
proportional = false,
|
|
849
|
+
): Record<string, number> {
|
|
850
|
+
const span = gesture.nativeHandleSpan ?? 1;
|
|
851
|
+
// Shift scales proportionally (Godot's Scale mode): an axis handle moves both axes together.
|
|
852
|
+
const axis = proportional ? 'both' : (gesture.nativeScaleAxis ?? 'both');
|
|
853
|
+
const along = proportional && gesture.nativeScaleAxis === 'x' ? dx : proportional && gesture.nativeScaleAxis === 'y' ? dy : null;
|
|
854
|
+
let scaleXFactor: number;
|
|
855
|
+
let scaleYFactor: number;
|
|
856
|
+
if (axis === 'x') {
|
|
857
|
+
scaleXFactor = 1 + dx / span;
|
|
858
|
+
scaleYFactor = 1;
|
|
859
|
+
} else if (axis === 'y') {
|
|
860
|
+
scaleXFactor = 1;
|
|
861
|
+
scaleYFactor = 1 + dy / span;
|
|
862
|
+
} else if (along !== null) {
|
|
863
|
+
scaleXFactor = 1 + along / span;
|
|
864
|
+
scaleYFactor = scaleXFactor;
|
|
865
|
+
} else if (gesture.center) {
|
|
866
|
+
// Godot's free scale: each axis scales by the ratio of where the pointer is to where it
|
|
867
|
+
// pressed, both measured from the pivot (the temporary pivot, else the node's origin) along the
|
|
868
|
+
// node's own axes; Shift takes their mean.
|
|
869
|
+
const a = ((gesture.startAngleDeg ?? 0) * Math.PI) / 180;
|
|
870
|
+
const fx = gesture.startLocal.x - gesture.center.x;
|
|
871
|
+
const fy = gesture.startLocal.y - gesture.center.y;
|
|
872
|
+
const fu = fx * Math.cos(a) + fy * Math.sin(a);
|
|
873
|
+
const fv = -fx * Math.sin(a) + fy * Math.cos(a);
|
|
874
|
+
const tu = (fx + dx) * Math.cos(a) + (fy + dy) * Math.sin(a);
|
|
875
|
+
const tv = -(fx + dx) * Math.sin(a) + (fy + dy) * Math.cos(a);
|
|
876
|
+
scaleXFactor = Math.abs(fu) > 1e-6 ? tu / fu : 1;
|
|
877
|
+
scaleYFactor = Math.abs(fv) > 1e-6 ? tv / fv : 1;
|
|
878
|
+
if (proportional) {
|
|
879
|
+
const mean = (scaleXFactor + scaleYFactor) / 2;
|
|
880
|
+
scaleXFactor = mean;
|
|
881
|
+
scaleYFactor = mean;
|
|
882
|
+
}
|
|
883
|
+
} else {
|
|
884
|
+
const factor = 1 + (dx + dy) / (Math.SQRT2 * span);
|
|
885
|
+
scaleXFactor = factor;
|
|
886
|
+
scaleYFactor = factor;
|
|
887
|
+
}
|
|
888
|
+
return {
|
|
889
|
+
...(axis !== 'y' ? { scaleXFactor } : {}),
|
|
890
|
+
...(axis !== 'x' ? { scaleYFactor } : {}),
|
|
891
|
+
...(snapEnabled && !altKey ? { scaleStep } : {}),
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function nativeMovePatch(
|
|
896
|
+
gesture: GestureState,
|
|
897
|
+
dx: number,
|
|
898
|
+
dy: number,
|
|
899
|
+
snapEnabled: boolean,
|
|
900
|
+
altKey: boolean,
|
|
901
|
+
translateStep: number,
|
|
902
|
+
offset: { readonly x: number; readonly y: number } = { x: 0, y: 0 },
|
|
903
|
+
): Record<string, number> {
|
|
904
|
+
const origin = gesture.nativeOrigin;
|
|
905
|
+
if (!origin) return {};
|
|
906
|
+
const snap = snapEnabled && !altKey;
|
|
907
|
+
return {
|
|
908
|
+
...(gesture.moveAxis !== 'y'
|
|
909
|
+
? { originX: snapNativeTransform(origin.x + dx, snap, translateStep, offset.x) }
|
|
910
|
+
: {}),
|
|
911
|
+
...(gesture.moveAxis !== 'x'
|
|
912
|
+
? { originY: snapNativeTransform(origin.y + dy, snap, translateStep, offset.y) }
|
|
913
|
+
: {}),
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
function nativeScaleHandlePoint(
|
|
918
|
+
axis: 'x' | 'y' | 'both',
|
|
919
|
+
origin: { x: number; y: number },
|
|
920
|
+
span: number,
|
|
921
|
+
): { x: number; y: number } {
|
|
922
|
+
if (axis === 'x') return { x: origin.x + span, y: origin.y };
|
|
923
|
+
if (axis === 'y') return { x: origin.x, y: origin.y + span };
|
|
924
|
+
return { x: origin.x + span / Math.SQRT2, y: origin.y + span / Math.SQRT2 };
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function constrainedMoveDelta(
|
|
928
|
+
axis: GestureState['moveAxis'],
|
|
929
|
+
dx: number,
|
|
930
|
+
dy: number,
|
|
931
|
+
): { dx: number; dy: number } {
|
|
932
|
+
if (axis === 'x') return { dx, dy: 0 };
|
|
933
|
+
if (axis === 'y') return { dx: 0, dy };
|
|
934
|
+
return { dx, dy };
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/** D2 — promote a deferred move CANDIDATE (`DragState.moveCandidate`) into a
|
|
938
|
+
* live `move` gesture once the drag threshold is crossed: begins the
|
|
939
|
+
* gesture on the owner (one side effect) and constructs the `GestureState`,
|
|
940
|
+
* seeded from the pointer-DOWN point (`startLocal`) so the move delta stays
|
|
941
|
+
* continuous. Factored out of `onPointerMove` (module-level, not a nested
|
|
942
|
+
* closure) purely to keep that handler's own cognitive complexity down. */
|
|
943
|
+
function promoteMoveCandidate(
|
|
944
|
+
candidate: NonNullable<DragState['moveCandidate']>,
|
|
945
|
+
startLocal: { x: number; y: number },
|
|
946
|
+
): GestureState {
|
|
947
|
+
candidate.ownerBoxEdit.begin(candidate.id);
|
|
948
|
+
for (const peer of candidate.peers) peer.ownerBoxEdit.begin(peer.id);
|
|
949
|
+
return {
|
|
950
|
+
kind: 'move',
|
|
951
|
+
id: candidate.id,
|
|
952
|
+
ownerBoxEdit: candidate.ownerBoxEdit,
|
|
953
|
+
startLocal,
|
|
954
|
+
origRect: candidate.origRect,
|
|
955
|
+
context: candidate.context,
|
|
956
|
+
...(candidate.nativeOrigin ? { nativeOrigin: candidate.nativeOrigin } : {}),
|
|
957
|
+
movePeers: candidate.peers,
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
interface DragState {
|
|
962
|
+
readonly downClientX: number;
|
|
963
|
+
readonly downClientY: number;
|
|
964
|
+
readonly downLocal: { x: number; y: number };
|
|
965
|
+
/** The pick result AT pointerdown — decides click-select vs. marquee. */
|
|
966
|
+
readonly hitAtDown: string | null;
|
|
967
|
+
moved: boolean;
|
|
968
|
+
/**
|
|
969
|
+
* D2 — a DEFERRED move-gesture candidate, recorded at pointerdown when the
|
|
970
|
+
* press landed on an already-selected, absolutely/fixed-positioned,
|
|
971
|
+
* boxEdit-capable node, but NOT promoted to a live gesture until the pointer
|
|
972
|
+
* crosses the drag threshold (`onPointerMove`). A pointerup BEFORE the
|
|
973
|
+
* threshold falls through to the ordinary click-select path — so a plain
|
|
974
|
+
* click still narrows a multi-selection to the clicked node and a shift-click
|
|
975
|
+
* still toggles it (the regression Fable found: the old code began the move
|
|
976
|
+
* gesture at pointerdown unconditionally, and `endGesture()` on pointerup
|
|
977
|
+
* skipped `resolveClickSelection`, swallowing every click on a positioned
|
|
978
|
+
* node). `null` when the press was not a move candidate. */
|
|
979
|
+
readonly moveCandidate: {
|
|
980
|
+
id: string;
|
|
981
|
+
ownerBoxEdit: BoxEditProvider;
|
|
982
|
+
origRect: DOMRectLike;
|
|
983
|
+
context?: ReturnType<typeof contextRectsForId>;
|
|
984
|
+
nativeOrigin?: { x: number; y: number };
|
|
985
|
+
peers: readonly MovePeer[];
|
|
986
|
+
} | null;
|
|
987
|
+
/**
|
|
988
|
+
* D2.b (spec:397-401) — a DEFERRED reorder-preview candidate, recorded at
|
|
989
|
+
* pointerdown under the SAME "already selected" gate {@link moveCandidate}
|
|
990
|
+
* uses, but for the opposite geometry: a FLOW/LIST child (no `boxEdit`
|
|
991
|
+
* move applies — {@link resolveMoveCandidate} already returned `null` for
|
|
992
|
+
* it, since it's neither absolute nor fixed) whose owner exposes
|
|
993
|
+
* `structure.reorder` and has ≥3 total siblings (itself + ≥2 others —
|
|
994
|
+
* spec:398 "drag a child among ≥3 siblings"). Only ONE of
|
|
995
|
+
* `moveCandidate`/`reorderCandidate` is ever non-null for a given press —
|
|
996
|
+
* see `onPointerDown`'s own ordering (move is checked first; a positioned
|
|
997
|
+
* node's owner may ALSO have `structure`, but this spec's move/reorder
|
|
998
|
+
* split is by POSITIONING, not by capability overlap). `null` when the
|
|
999
|
+
* press was not a reorder candidate. */
|
|
1000
|
+
readonly reorderCandidate: {
|
|
1001
|
+
id: string;
|
|
1002
|
+
adapter: AuthoringAdapter;
|
|
1003
|
+
origRect: DOMRectLike;
|
|
1004
|
+
siblingRects: SiblingRectEntry[];
|
|
1005
|
+
} | null;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/**
|
|
1009
|
+
* D2 (spec:322) — resolve a pointerdown hit into a deferred move-gesture
|
|
1010
|
+
* CANDIDATE (see `DragState.moveCandidate`'s doc comment for the full
|
|
1011
|
+
* rationale): only when `hitId` is already selected, its owner exposes
|
|
1012
|
+
* `boxEdit`, and it is absolutely/fixed-positioned or a native 2D transform
|
|
1013
|
+
* subject. Factored out of
|
|
1014
|
+
* `onPointerDown` (a module-level function, not a nested closure) purely to
|
|
1015
|
+
* keep that handler's own cognitive complexity down — pulls no state, reads
|
|
1016
|
+
* only its arguments.
|
|
1017
|
+
*/
|
|
1018
|
+
/**
|
|
1019
|
+
* Figma semantics for a press INSIDE a selection: the deepest pick still wins
|
|
1020
|
+
* for click-select (a release before the drag threshold descends as today),
|
|
1021
|
+
* but a DRAG grabs the SELECTED node whose subtree the press landed in. The
|
|
1022
|
+
* DOM board made this the common case — `pickable.pick` is
|
|
1023
|
+
* smallest-area-wins, so a second press on an already-selected panel resolves
|
|
1024
|
+
* to one of its static children and `selectedEntityIds.has(hitAtDown)` never
|
|
1025
|
+
* held: no move (or reorder) candidate could form on any node with children
|
|
1026
|
+
* (measured on the parity re-walk, 2026-08-30 — six drag variants on an
|
|
1027
|
+
* absolutely-positioned HUD panel all fell through to click-select).
|
|
1028
|
+
*/
|
|
1029
|
+
function nearestSelectedAncestor(
|
|
1030
|
+
adapter: AuthoringAdapter,
|
|
1031
|
+
hitId: string,
|
|
1032
|
+
selectedIds: ReadonlySet<string>,
|
|
1033
|
+
): string | null {
|
|
1034
|
+
let cur: string | null = hitId;
|
|
1035
|
+
for (let hops = 0; cur !== null && hops < 256; hops++) {
|
|
1036
|
+
if (selectedIds.has(cur)) return cur;
|
|
1037
|
+
cur = adapter.hierarchy.node(cur)?.parentId ?? null;
|
|
1038
|
+
}
|
|
1039
|
+
return null;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
function resolveMoveCandidate(
|
|
1043
|
+
active: AuthoringAdapter,
|
|
1044
|
+
hitId: string,
|
|
1045
|
+
selectedIds: ReadonlySet<string>,
|
|
1046
|
+
): DragState['moveCandidate'] {
|
|
1047
|
+
const owner = boxEditForId(active, hitId);
|
|
1048
|
+
if (!owner) return null;
|
|
1049
|
+
const posValue = active.inspector?.get(hitId, 'style.position');
|
|
1050
|
+
const native2D = transformDimensionsFor(active, hitId) === '2d';
|
|
1051
|
+
if (posValue !== 'absolute' && posValue !== 'fixed' && !native2D) return null;
|
|
1052
|
+
const rect = rectForId(active, hitId);
|
|
1053
|
+
if (!rect) return null;
|
|
1054
|
+
const nativeOrigin = native2D ? (owner.gizmoOrigin?.(hitId) ?? undefined) : undefined;
|
|
1055
|
+
const peers: MovePeer[] = [];
|
|
1056
|
+
for (const id of selectedIds) {
|
|
1057
|
+
if (id === hitId) continue;
|
|
1058
|
+
const peerOwner = boxEditForId(active, id);
|
|
1059
|
+
const peerRect = rectForId(active, id);
|
|
1060
|
+
if (!peerOwner || !peerRect) continue;
|
|
1061
|
+
const peerPosition = active.inspector?.get(id, 'style.position');
|
|
1062
|
+
const peerNative = transformDimensionsFor(active, id) === '2d';
|
|
1063
|
+
if (peerPosition !== 'absolute' && peerPosition !== 'fixed' && !peerNative) continue;
|
|
1064
|
+
const peerOrigin = peerNative ? (peerOwner.gizmoOrigin?.(id) ?? undefined) : undefined;
|
|
1065
|
+
peers.push({
|
|
1066
|
+
id,
|
|
1067
|
+
ownerBoxEdit: peerOwner,
|
|
1068
|
+
origRect: peerRect,
|
|
1069
|
+
...(peerOrigin ? { nativeOrigin: peerOrigin } : {}),
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
return {
|
|
1073
|
+
id: hitId,
|
|
1074
|
+
ownerBoxEdit: owner,
|
|
1075
|
+
origRect: rect,
|
|
1076
|
+
context: contextRectsForId(active, hitId),
|
|
1077
|
+
...(nativeOrigin ? { nativeOrigin } : {}),
|
|
1078
|
+
peers,
|
|
1079
|
+
};
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/**
|
|
1083
|
+
* D2.b (spec:397-401) — resolve a pointerdown hit into a deferred
|
|
1084
|
+
* reorder-preview CANDIDATE (see `DragState.reorderCandidate`'s doc comment):
|
|
1085
|
+
* only when `hitId`'s OWNING adapter exposes `structure.reorder` (capability
|
|
1086
|
+
* gate — the ingested react-dom adapter has no `structure` at all, so this
|
|
1087
|
+
* always returns `null` there, per this task's own capability-gating
|
|
1088
|
+
* precedent) AND it has ≥3 total siblings (itself + ≥2 others). Sibling ids
|
|
1089
|
+
* come from {@link siblingIdsForId} (the owner's OWN `hierarchy`, ordered);
|
|
1090
|
+
* each sibling's CURRENT rect is read via {@link rectForId} (same
|
|
1091
|
+
* owner-routing `resolveMoveCandidate` uses) — a sibling with no rect
|
|
1092
|
+
* (unmounted/offscreen) is simply excluded from the gap math, never a throw.
|
|
1093
|
+
* Factored out of `onPointerDown` for the same cognitive-complexity reason
|
|
1094
|
+
* `resolveMoveCandidate` is.
|
|
1095
|
+
*/
|
|
1096
|
+
function resolveReorderCandidate(
|
|
1097
|
+
active: AuthoringAdapter,
|
|
1098
|
+
hitId: string,
|
|
1099
|
+
): DragState['reorderCandidate'] {
|
|
1100
|
+
const structure = structureForId(active, hitId);
|
|
1101
|
+
if (!structure?.reorder) return null;
|
|
1102
|
+
const siblingIds = siblingIdsForId(active, hitId);
|
|
1103
|
+
if (!siblingIds || siblingIds.length < 3) return null;
|
|
1104
|
+
const origRect = rectForId(active, hitId);
|
|
1105
|
+
if (!origRect) return null;
|
|
1106
|
+
const siblingRects: SiblingRectEntry[] = [];
|
|
1107
|
+
for (const sid of siblingIds) {
|
|
1108
|
+
if (sid === hitId) continue;
|
|
1109
|
+
const r = rectForId(active, sid);
|
|
1110
|
+
if (r) siblingRects.push({ id: sid, rect: r });
|
|
1111
|
+
}
|
|
1112
|
+
if (siblingRects.length === 0) return null;
|
|
1113
|
+
return { id: hitId, adapter: active, origRect, siblingRects };
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/** True while a REAL game (Play, an ingested unmodified game, or an
|
|
1117
|
+
* auto-mounted `{ module }` adapter world) owns the canvas and needs its own
|
|
1118
|
+
* pointer events — the overlay must be fully inert then (see the call
|
|
1119
|
+
* site's doc comment for the regression this guards). Factored into its own
|
|
1120
|
+
* function purely to keep the caller's cognitive complexity down. */
|
|
1121
|
+
function isCanvasOwnedByARunningGame(): boolean {
|
|
1122
|
+
return anyLiveSessionMounted();
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
/**
|
|
1126
|
+
* B2/B3 — the single-selection box-edit bundle (handles/rotate/dimension-
|
|
1127
|
+
* label/spacing-bands), or `null` when it doesn't apply: these render ONLY
|
|
1128
|
+
* for EXACTLY one selected node whose OWNER exposes `boxEdit` (spec:317).
|
|
1129
|
+
* Factored out of the component purely to keep ITS cognitive complexity
|
|
1130
|
+
* down — pure, reads only its arguments.
|
|
1131
|
+
*/
|
|
1132
|
+
function computeSingleSelectionBoxEdit(
|
|
1133
|
+
adapter: AuthoringAdapter,
|
|
1134
|
+
selectedIds: ReadonlySet<string>,
|
|
1135
|
+
): {
|
|
1136
|
+
id: string;
|
|
1137
|
+
rect: DOMRectLike;
|
|
1138
|
+
ownerBoxEdit: BoxEditProvider;
|
|
1139
|
+
spacingBands: SpacingBand[];
|
|
1140
|
+
} | null {
|
|
1141
|
+
if (selectedIds.size !== 1) return null;
|
|
1142
|
+
const id = [...selectedIds][0]!;
|
|
1143
|
+
const ownerBoxEdit = boxEditForId(adapter, id);
|
|
1144
|
+
if (!ownerBoxEdit) return null;
|
|
1145
|
+
const rect = rectForId(adapter, id);
|
|
1146
|
+
if (!rect) return null;
|
|
1147
|
+
const spacingBands = computeSpacingBands(rect, readSpacingValues(adapter, id));
|
|
1148
|
+
return { id, rect, ownerBoxEdit, spacingBands };
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/**
|
|
1152
|
+
* D4.b (spec §6 D4 "a multi-select align/distribute toolbar ... shown when
|
|
1153
|
+
* ≥2 nodes are selected") — the `AlignToolbar`'s entries, or `null` when it
|
|
1154
|
+
* shouldn't render: fewer than 2 selected, OR any selected id fails to
|
|
1155
|
+
* resolve BOTH a rect and an owning `boxEdit` (spec:305's own "capability-
|
|
1156
|
+
* gated: only when adapter.boxEdit is present" — an all-or-nothing gate, so
|
|
1157
|
+
* a mixed selection spanning a boxEdit-less world never offers a partially-
|
|
1158
|
+
* broken toolbar). Factored out purely to keep the component's own
|
|
1159
|
+
* cognitive complexity down, same reason `computeSingleSelectionBoxEdit`
|
|
1160
|
+
* above is.
|
|
1161
|
+
*/
|
|
1162
|
+
function computeMultiSelectionAlign(
|
|
1163
|
+
adapter: AuthoringAdapter,
|
|
1164
|
+
selectedIds: ReadonlySet<string>,
|
|
1165
|
+
): AlignEntry[] | null {
|
|
1166
|
+
if (selectedIds.size < 2) return null;
|
|
1167
|
+
const entries: AlignEntry[] = [];
|
|
1168
|
+
for (const id of selectedIds) {
|
|
1169
|
+
const rect = rectForId(adapter, id);
|
|
1170
|
+
if (!rect) return null;
|
|
1171
|
+
if (!boxEditForId(adapter, id)) return null;
|
|
1172
|
+
entries.push({ id, rect });
|
|
1173
|
+
}
|
|
1174
|
+
return entries;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
/**
|
|
1178
|
+
* D1 (spec §6) — measure lines, position/parent-layout badge, and
|
|
1179
|
+
* parent-container outline, all READ-only (rects + `inspector.get`) so —
|
|
1180
|
+
* unlike {@link computeSingleSelectionBoxEdit}'s handles/bands — available
|
|
1181
|
+
* for ANY adapter exposing `rects`, not gated on the owner also exposing
|
|
1182
|
+
* `boxEdit`. Only for EXACTLY one selected node (spec §6's own framing:
|
|
1183
|
+
* "between selection and hover" / "a small badge near the selection" both
|
|
1184
|
+
* presume a single selection). Factored out of the component purely to keep
|
|
1185
|
+
* ITS cognitive complexity down — pure given its arguments.
|
|
1186
|
+
*/
|
|
1187
|
+
function computeD1Affordances(
|
|
1188
|
+
adapter: AuthoringAdapter,
|
|
1189
|
+
selectedIds: ReadonlySet<string>,
|
|
1190
|
+
hoverRect: DOMRectLike | null,
|
|
1191
|
+
): {
|
|
1192
|
+
selectedSingleRect: DOMRectLike | null;
|
|
1193
|
+
measureLines: MeasureLine[];
|
|
1194
|
+
positionBadge: PositionBadgeInfo | null;
|
|
1195
|
+
showPositionBadge: boolean;
|
|
1196
|
+
parentRect: DOMRectLike | null;
|
|
1197
|
+
} {
|
|
1198
|
+
const selectedSingleId = selectedIds.size === 1 ? [...selectedIds][0]! : null;
|
|
1199
|
+
const selectedSingleRect = selectedSingleId ? rectForId(adapter, selectedSingleId) : null;
|
|
1200
|
+
// D1.a — sibling-distance measure lines between the selection and the
|
|
1201
|
+
// CURRENTLY HOVERED sibling (spec §6 "between selection and hover"). `[]`
|
|
1202
|
+
// whenever either rect is missing or the two rects don't leave a gap on
|
|
1203
|
+
// either axis (`computeMeasureLines`'s own honest-empty-result doc
|
|
1204
|
+
// comment) — never a fabricated line.
|
|
1205
|
+
const measureLines =
|
|
1206
|
+
selectedSingleRect && hoverRect ? computeMeasureLines(selectedSingleRect, hoverRect) : [];
|
|
1207
|
+
// D1.c — each segment resolves independently; `null`/`null` renders no
|
|
1208
|
+
// badge at all (never a fabricated default — spec's own honesty rule).
|
|
1209
|
+
const positionBadge: PositionBadgeInfo | null = selectedSingleId
|
|
1210
|
+
? resolvePositionBadge(adapter, selectedSingleId)
|
|
1211
|
+
: null;
|
|
1212
|
+
const showPositionBadge = !!(
|
|
1213
|
+
positionBadge &&
|
|
1214
|
+
(positionBadge.position || positionBadge.parentLayout)
|
|
1215
|
+
);
|
|
1216
|
+
// D1.c — the parent container's own rect (`rects.contextRects(id).parent`,
|
|
1217
|
+
// routed through the SAME owner-child helper the B2 move/resize gestures
|
|
1218
|
+
// already use for this exact field) — `null` when the selection has no
|
|
1219
|
+
// resolvable parent rect (a top-level node, or the owner has no
|
|
1220
|
+
// `contextRects`), in which case no outline renders at all.
|
|
1221
|
+
const parentRect = selectedSingleId
|
|
1222
|
+
? (contextRectsForId(adapter, selectedSingleId)?.parent ?? null)
|
|
1223
|
+
: null;
|
|
1224
|
+
return { selectedSingleRect, measureLines, positionBadge, showPositionBadge, parentRect };
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
export function ViewportPickMenu({
|
|
1228
|
+
state,
|
|
1229
|
+
adapter,
|
|
1230
|
+
onClose,
|
|
1231
|
+
}: {
|
|
1232
|
+
state: { x: number; y: number; ids: readonly string[] };
|
|
1233
|
+
adapter: AuthoringAdapter;
|
|
1234
|
+
onClose: () => void;
|
|
1235
|
+
}): React.ReactNode {
|
|
1236
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
1237
|
+
useEffect(() => {
|
|
1238
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
1239
|
+
if (!ref.current?.contains(event.target as Node)) onClose();
|
|
1240
|
+
};
|
|
1241
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
1242
|
+
if (event.key === 'Escape') onClose();
|
|
1243
|
+
};
|
|
1244
|
+
document.addEventListener('pointerdown', onPointerDown);
|
|
1245
|
+
document.addEventListener('keydown', onKeyDown);
|
|
1246
|
+
return () => {
|
|
1247
|
+
document.removeEventListener('pointerdown', onPointerDown);
|
|
1248
|
+
document.removeEventListener('keydown', onKeyDown);
|
|
1249
|
+
};
|
|
1250
|
+
}, [onClose]);
|
|
1251
|
+
|
|
1252
|
+
return (
|
|
1253
|
+
<ThemeRootPortal>
|
|
1254
|
+
<Menu
|
|
1255
|
+
ref={ref}
|
|
1256
|
+
aria-label="Pick object under pointer"
|
|
1257
|
+
data-testid="viewport-pick-menu"
|
|
1258
|
+
style={{
|
|
1259
|
+
position: 'fixed',
|
|
1260
|
+
left: state.x,
|
|
1261
|
+
top: state.y,
|
|
1262
|
+
minWidth: 190,
|
|
1263
|
+
maxWidth: 320,
|
|
1264
|
+
zIndex: zIndex.dropdown,
|
|
1265
|
+
}}
|
|
1266
|
+
>
|
|
1267
|
+
{state.ids.map((id) => {
|
|
1268
|
+
const node = adapter.hierarchy.node(id);
|
|
1269
|
+
return (
|
|
1270
|
+
<MenuItem
|
|
1271
|
+
key={id}
|
|
1272
|
+
data-testid="viewport-pick-menu-item"
|
|
1273
|
+
onSelect={() => {
|
|
1274
|
+
setAuthoringSelection(adapter, [id]);
|
|
1275
|
+
onClose();
|
|
1276
|
+
}}
|
|
1277
|
+
style={{ justifyContent: 'space-between', gap: 20 }}
|
|
1278
|
+
>
|
|
1279
|
+
<span>{node?.label ?? id}</span>
|
|
1280
|
+
<span style={{ color: themeVars.content.muted, fontSize: 11 }}>
|
|
1281
|
+
{node?.kind ?? ''}
|
|
1282
|
+
</span>
|
|
1283
|
+
</MenuItem>
|
|
1284
|
+
);
|
|
1285
|
+
})}
|
|
1286
|
+
</Menu>
|
|
1287
|
+
</ThemeRootPortal>
|
|
1288
|
+
);
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: one cohesive overlay owns the shared pointer-state machine and its adapter-routed affordances
|
|
1292
|
+
export function RootSelectionOverlay({
|
|
1293
|
+
adapter: scopedAdapter,
|
|
1294
|
+
view: suppliedView,
|
|
1295
|
+
transformModeAware = false,
|
|
1296
|
+
presentationId,
|
|
1297
|
+
}: {
|
|
1298
|
+
adapter?: AuthoringAdapter;
|
|
1299
|
+
/** Presentation camera for this document. Native Canvas scenes pass their
|
|
1300
|
+
* own controller; DOM component boards use the shared board camera. */
|
|
1301
|
+
view?: RootViewController;
|
|
1302
|
+
/** Native scene toolbars use explicit Move/Rotate/Scale modes. Component
|
|
1303
|
+
* boards retain their Figma-style all-handles-at-once interaction. */
|
|
1304
|
+
transformModeAware?: boolean;
|
|
1305
|
+
/** The view whose presentation switches this overlay obeys (a 2D view's Position and
|
|
1306
|
+
* Transformation gizmos, `viewDrafting`); absent, every gizmo draws. */
|
|
1307
|
+
presentationId?: string;
|
|
1308
|
+
} = {}): React.ReactNode {
|
|
1309
|
+
const store = useEditorStore();
|
|
1310
|
+
const storeVersion = useSyncExternalStore(store.subscribe, store.getSnapshot);
|
|
1311
|
+
const adapter = scopedAdapter ?? getActiveAuthoring(store);
|
|
1312
|
+
const pickAt = useCallback(
|
|
1313
|
+
(clientX: number, clientY: number): string | null =>
|
|
1314
|
+
scopedAdapter
|
|
1315
|
+
? (scopedAdapter.pickable?.pick(clientX, clientY) ?? null)
|
|
1316
|
+
: pickTopmost(store, clientX, clientY),
|
|
1317
|
+
[scopedAdapter, store],
|
|
1318
|
+
);
|
|
1319
|
+
|
|
1320
|
+
const interactionRef = useRef<HTMLDivElement>(null);
|
|
1321
|
+
const dragRef = useRef<DragState | null>(null);
|
|
1322
|
+
const marqueeRef = useRef<Rect | null>(null);
|
|
1323
|
+
const [marquee, setMarqueeState] = useState<Rect | null>(null);
|
|
1324
|
+
const [hoverId, setHoverId] = useState<string | null>(null);
|
|
1325
|
+
const [editorControlCursor, setEditorControlCursor] = useState<EditorControlCursor>(null);
|
|
1326
|
+
const [pickMenu, setPickMenu] = useState<{
|
|
1327
|
+
x: number;
|
|
1328
|
+
y: number;
|
|
1329
|
+
ids: readonly string[];
|
|
1330
|
+
} | null>(null);
|
|
1331
|
+
// Right-button navigation belongs to the spatial viewport underneath this
|
|
1332
|
+
// overlay (Three OrbitControls or the native Canvas camera). Track only the
|
|
1333
|
+
// click-vs-drag distinction here so the shared overlap menu never opens at
|
|
1334
|
+
// the end of an orbit/pan. PointerEvent button ids and movement are the same
|
|
1335
|
+
// on macOS and Windows; no platform modifier belongs in this decision.
|
|
1336
|
+
const secondaryPressRef = useRef<{
|
|
1337
|
+
pointerId: number;
|
|
1338
|
+
startClientX: number;
|
|
1339
|
+
startClientY: number;
|
|
1340
|
+
moved: boolean;
|
|
1341
|
+
} | null>(null);
|
|
1342
|
+
const [addMenu, setAddMenu] = useState<AddNodeHereState | null>(null);
|
|
1343
|
+
/** Opens Godot's Add Node menu at a client point; assigned once the host frame is known below. */
|
|
1344
|
+
const openAddMenuRef = useRef<(clientX: number, clientY: number) => void>(() => undefined);
|
|
1345
|
+
const openPickMenuAt = useCallback(
|
|
1346
|
+
(clientX: number, clientY: number, includeLocked = false) => {
|
|
1347
|
+
const ids = pickCandidates(store, clientX, clientY, {
|
|
1348
|
+
...(scopedAdapter ? { adapter: scopedAdapter } : {}),
|
|
1349
|
+
...(includeLocked ? { includeLocked } : {}),
|
|
1350
|
+
});
|
|
1351
|
+
if (ids.length === 0) {
|
|
1352
|
+
setPickMenu(null);
|
|
1353
|
+
return;
|
|
1354
|
+
}
|
|
1355
|
+
setPickMenu({ x: clientX, y: clientY, ids });
|
|
1356
|
+
},
|
|
1357
|
+
[scopedAdapter, store],
|
|
1358
|
+
);
|
|
1359
|
+
|
|
1360
|
+
useEffect(() => {
|
|
1361
|
+
const onPointerMove = (event: PointerEvent): void => {
|
|
1362
|
+
const press = secondaryPressRef.current;
|
|
1363
|
+
if (!press || press.pointerId !== event.pointerId || press.moved) return;
|
|
1364
|
+
if (
|
|
1365
|
+
Math.hypot(event.clientX - press.startClientX, event.clientY - press.startClientY) >
|
|
1366
|
+
DRAG_THRESHOLD_PX
|
|
1367
|
+
) {
|
|
1368
|
+
press.moved = true;
|
|
1369
|
+
}
|
|
1370
|
+
};
|
|
1371
|
+
const finish = (event: PointerEvent): void => {
|
|
1372
|
+
const press = secondaryPressRef.current;
|
|
1373
|
+
if (!press || press.pointerId !== event.pointerId) return;
|
|
1374
|
+
secondaryPressRef.current = null;
|
|
1375
|
+
// Chromium does not order `contextmenu` consistently across desktop
|
|
1376
|
+
// platforms: macOS can emit it as soon as the secondary button goes
|
|
1377
|
+
// down, before this press has had a chance to become a drag. Opening the
|
|
1378
|
+
// editor menu from that event therefore makes right-drag panning
|
|
1379
|
+
// impossible on macOS. The native event is suppressed below; release is
|
|
1380
|
+
// the one cross-platform decision point. A cancelled press opens
|
|
1381
|
+
// nothing, a moved press was navigation, and only a stationary release
|
|
1382
|
+
// is the overlap menu gesture.
|
|
1383
|
+
if (event.type === 'pointerup' && !press.moved) {
|
|
1384
|
+
// Godot's 2D scene: RMB adds a node where it lands, Alt+RMB lists the nodes there.
|
|
1385
|
+
if (transformModeAware && !event.altKey) openAddMenuRef.current(event.clientX, event.clientY);
|
|
1386
|
+
// Godot's Alt+RMB lists every node there, locked ones included.
|
|
1387
|
+
else openPickMenuAt(event.clientX, event.clientY, transformModeAware);
|
|
1388
|
+
}
|
|
1389
|
+
};
|
|
1390
|
+
window.addEventListener('pointermove', onPointerMove);
|
|
1391
|
+
window.addEventListener('pointerup', finish);
|
|
1392
|
+
window.addEventListener('pointercancel', finish);
|
|
1393
|
+
return () => {
|
|
1394
|
+
window.removeEventListener('pointermove', onPointerMove);
|
|
1395
|
+
window.removeEventListener('pointerup', finish);
|
|
1396
|
+
window.removeEventListener('pointercancel', finish);
|
|
1397
|
+
};
|
|
1398
|
+
}, [openPickMenuAt, transformModeAware]);
|
|
1399
|
+
|
|
1400
|
+
const onContextMenu = useCallback((event: ReactMouseEvent<HTMLDivElement>) => {
|
|
1401
|
+
// This event is prevention-only. Stationary-click versus pan is decided
|
|
1402
|
+
// on pointer release above, after the movement threshold has an honest
|
|
1403
|
+
// answer on both macOS and Windows.
|
|
1404
|
+
event.preventDefault();
|
|
1405
|
+
event.stopPropagation();
|
|
1406
|
+
}, []);
|
|
1407
|
+
|
|
1408
|
+
// D4 (spec27 §8 "space-pan" row) — the shared pan + zoom, applied to the
|
|
1409
|
+
// visual-chrome wrapper below (`world-pan-state.ts` applies the identical
|
|
1410
|
+
// value to every design-time world-layer host — see that module's doc
|
|
1411
|
+
// comment for the lockstep-transform soundness argument). The interaction
|
|
1412
|
+
// surface deliberately stays OUTSIDE that transformed wrapper: input must
|
|
1413
|
+
// cover the complete viewport at every zoom level, like a design canvas.
|
|
1414
|
+
// `useSyncExternalStore` so a pan change (from THIS component's own
|
|
1415
|
+
// space-drag below, or a reset) re-renders immediately.
|
|
1416
|
+
const view = suppliedView ?? sharedRootViewController;
|
|
1417
|
+
useSyncExternalStore(subscribeTemporaryPivot, temporaryPivotVersion);
|
|
1418
|
+
const tempPivot = transformModeAware ? temporaryPivot(view) : null;
|
|
1419
|
+
// A new selection lets go of the temporary pivot.
|
|
1420
|
+
const selectionKey = [...store.selectedEntityIds].sort().join('\0');
|
|
1421
|
+
const lastSelectionKey = useRef(selectionKey);
|
|
1422
|
+
useEffect(() => {
|
|
1423
|
+
if (lastSelectionKey.current === selectionKey) return;
|
|
1424
|
+
lastSelectionKey.current = selectionKey;
|
|
1425
|
+
if (temporaryPivot(view)) setTemporaryPivot(view, null);
|
|
1426
|
+
}, [selectionKey, view]);
|
|
1427
|
+
const pan = useSyncExternalStore(view.subscribe, view.get, view.get);
|
|
1428
|
+
useSyncExternalStore(
|
|
1429
|
+
useCallback((listener) => subscribeCanvasSceneGuides(view, listener), [view]),
|
|
1430
|
+
useCallback(() => canvasSceneGuideRevision(view), [view]),
|
|
1431
|
+
);
|
|
1432
|
+
const nativeGuides = transformModeAware ? canvasSceneGuides(view) : [];
|
|
1433
|
+
// Native scene chrome is measured in SCREEN pixels even though its anchors
|
|
1434
|
+
// live in world space: zooming should enlarge the subject, not its labels or
|
|
1435
|
+
// grab targets. DOM boards keep their historical canvas-scaled chrome.
|
|
1436
|
+
const chromeScale = transformModeAware ? 1 / pan.zoom : 1;
|
|
1437
|
+
// A dedicated Move/Rotate/Scale tool draws its axis gizmo at the node's
|
|
1438
|
+
// origin; the combined tool draws handles on the selection's bounds, the way
|
|
1439
|
+
// Godot's Select tool and Figma's selection do.
|
|
1440
|
+
const axisGizmos =
|
|
1441
|
+
transformModeAware && store.transformMode !== 'combined' && store.transformMode !== 'select';
|
|
1442
|
+
// Godot's View › Gizmos: Position (the origin handle) and Transformation (the tool's axis gizmo).
|
|
1443
|
+
useSyncExternalStore(subscribeViewportPresentation, viewportPresentationVersion);
|
|
1444
|
+
const gizmoSwitches = presentationId ? viewDrafting(presentationId) : null;
|
|
1445
|
+
const showPositionGizmo = gizmoSwitches?.position ?? true;
|
|
1446
|
+
const showAxisGizmo = !axisGizmos || (gizmoSwitches?.transformation ?? true);
|
|
1447
|
+
// True while the Space key is physically held (and not typing — see the
|
|
1448
|
+
// keydown/keyup effect below), the hold-to-pan gesture's arm switch.
|
|
1449
|
+
// `spaceHeldRef` mirrors the state for a synchronous read in the pointer
|
|
1450
|
+
// handlers (same "ref mirrors state" convention `marqueeRef`/`textEditRef`
|
|
1451
|
+
// already use in this file).
|
|
1452
|
+
const spaceHeldRef = useRef(false);
|
|
1453
|
+
/** Where the pointer last hovered this surface (client px), for V; null once it leaves. */
|
|
1454
|
+
const hoverClientRef = useRef<{ x: number; y: number } | null>(null);
|
|
1455
|
+
const [spaceHeld, setSpaceHeld] = useState(false);
|
|
1456
|
+
// The active space-drag pan gesture, or `null` between drags — kept
|
|
1457
|
+
// SEPARATE from `dragRef`/`gestureRef` (mutually exclusive per pointer
|
|
1458
|
+
// session, same reasoning `GestureState`'s own doc comment gives for being
|
|
1459
|
+
// separate from `DragState`): a pan-drag never picks/selects/resizes, it
|
|
1460
|
+
// only ever writes `world-pan-state.ts`.
|
|
1461
|
+
const panDragRef = useRef<{
|
|
1462
|
+
startClientX: number;
|
|
1463
|
+
startClientY: number;
|
|
1464
|
+
startPan: { x: number; y: number };
|
|
1465
|
+
} | null>(null);
|
|
1466
|
+
const [isPanning, setIsPanning] = useState(false);
|
|
1467
|
+
|
|
1468
|
+
// B2/B3 — the currently-active resize/move/rotate/spacing gesture, or
|
|
1469
|
+
// `null` between gestures (see `GestureState`'s doc comment for why this is
|
|
1470
|
+
// a SEPARATE ref from `dragRef`/A3's click-marquee tracking).
|
|
1471
|
+
const gestureRef = useRef<GestureState | null>(null);
|
|
1472
|
+
// Adapter replacement/unmount must close the old provider's edit bracket,
|
|
1473
|
+
// even when no pointer-up arrives. Transient interaction state then dies
|
|
1474
|
+
// with this overlay rather than leaking into the replacement document.
|
|
1475
|
+
useEffect(
|
|
1476
|
+
() => () => {
|
|
1477
|
+
const gesture = gestureRef.current;
|
|
1478
|
+
gestureRef.current = null;
|
|
1479
|
+
if (!gesture) return;
|
|
1480
|
+
gesture.ownerBoxEdit.end(gesture.id);
|
|
1481
|
+
for (const peer of gesture.movePeers ?? []) peer.ownerBoxEdit.end(peer.id);
|
|
1482
|
+
},
|
|
1483
|
+
[],
|
|
1484
|
+
);
|
|
1485
|
+
|
|
1486
|
+
// Bumped on every `apply` so the box/handles/bands re-render against the
|
|
1487
|
+
// LIVE rect `boxEdit.apply`'s preview mutation just produced (step 5).
|
|
1488
|
+
const [, setGestureTick] = useState(0);
|
|
1489
|
+
|
|
1490
|
+
// D2.a — the drag-ghost rect (a semi-transparent follow-cursor copy of the
|
|
1491
|
+
// dragged element, spec:399), live during EITHER a B2 move gesture or a
|
|
1492
|
+
// D2.b reorder-preview drag — "dragging a selected element on the canvas"
|
|
1493
|
+
// per the spec's own D2 framing. Purely visual: it never feeds a commit
|
|
1494
|
+
// path itself (`computeDragGhost` is pure geometry — see its doc comment).
|
|
1495
|
+
const [dragGhost, setDragGhost] = useState<Rect | null>(null);
|
|
1496
|
+
|
|
1497
|
+
// D1.b — the currently-engaged alignment/snap guide(s), live during EITHER
|
|
1498
|
+
// a B2 move or resize gesture (spec §6 "guides appear only within the
|
|
1499
|
+
// snap threshold; disappear when not snapping"). `[]` between gestures and
|
|
1500
|
+
// whenever the live gesture's own edge-snap isn't currently engaged —
|
|
1501
|
+
// never a stale guide left over from a moment ago.
|
|
1502
|
+
const [snapGuides, setSnapGuides] = useState<SnapGuide[]>([]);
|
|
1503
|
+
|
|
1504
|
+
// D2.b — the live reorder-preview: which gap the pointer currently resolves
|
|
1505
|
+
// to among the dragged node's siblings, or `null` between reorder drags.
|
|
1506
|
+
// `reorderPreviewRef` mirrors the state for a synchronous read in
|
|
1507
|
+
// `onPointerUp` (same "ref mirrors state" convention `textEditRef`/
|
|
1508
|
+
// `marqueeRef` already use) — NOT tracked via `gestureRef`/`GestureState`:
|
|
1509
|
+
// a reorder-preview has no `BoxEditProvider` begin/apply/end bracket, it is
|
|
1510
|
+
// a single `structure.reorder` commit at drop, so it needs its own
|
|
1511
|
+
// independent lifecycle (mirrors `GestureState`'s own doc comment on why
|
|
1512
|
+
// IT is separate from `DragState`).
|
|
1513
|
+
const reorderPreviewRef = useRef<{
|
|
1514
|
+
id: string;
|
|
1515
|
+
adapter: AuthoringAdapter;
|
|
1516
|
+
gap: ReorderGap;
|
|
1517
|
+
} | null>(null);
|
|
1518
|
+
const [reorderPreview, setReorderPreviewState] = useState<{
|
|
1519
|
+
id: string;
|
|
1520
|
+
adapter: AuthoringAdapter;
|
|
1521
|
+
gap: ReorderGap;
|
|
1522
|
+
} | null>(null);
|
|
1523
|
+
const setReorderPreview = useCallback(
|
|
1524
|
+
(v: { id: string; adapter: AuthoringAdapter; gap: ReorderGap } | null) => {
|
|
1525
|
+
reorderPreviewRef.current = v;
|
|
1526
|
+
setReorderPreviewState(v);
|
|
1527
|
+
},
|
|
1528
|
+
[],
|
|
1529
|
+
);
|
|
1530
|
+
|
|
1531
|
+
// D3.b — the currently-open inline text editor (double-clicked node), or
|
|
1532
|
+
// `null`. `textEditRef` mirrors the state for synchronous reads from
|
|
1533
|
+
// callbacks (same "ref mirrors state" convention `marqueeRef`/`setMarquee`
|
|
1534
|
+
// already use above).
|
|
1535
|
+
const textEditRef = useRef<{ id: string; rect: DOMRectLike; initialText: string } | null>(null);
|
|
1536
|
+
const [textEdit, setTextEditState] = useState<{
|
|
1537
|
+
id: string;
|
|
1538
|
+
rect: DOMRectLike;
|
|
1539
|
+
initialText: string;
|
|
1540
|
+
} | null>(null);
|
|
1541
|
+
// D3.b — a double-click that was REFUSED (dynamic body/has children): no
|
|
1542
|
+
// textarea, just a loud, honest "not editable" indicator at the rect,
|
|
1543
|
+
// mirroring the store's existing dynamic-refusal pattern (console.warn +
|
|
1544
|
+
// a visible degraded state, never a silent no-op).
|
|
1545
|
+
const [textEditRefused, setTextEditRefused] = useState<{ id: string; rect: DOMRectLike } | null>(
|
|
1546
|
+
null,
|
|
1547
|
+
);
|
|
1548
|
+
const setTextEdit = useCallback(
|
|
1549
|
+
(v: { id: string; rect: DOMRectLike; initialText: string } | null) => {
|
|
1550
|
+
textEditRef.current = v;
|
|
1551
|
+
setTextEditState(v);
|
|
1552
|
+
},
|
|
1553
|
+
[],
|
|
1554
|
+
);
|
|
1555
|
+
// D3.R1 (reopen fix) — `TextProvider.set` is fire-and-forget (the write is an
|
|
1556
|
+
// async round trip to the dev server); the textarea closes immediately on
|
|
1557
|
+
// commit, so whether it landed or was refused can only be learned from a
|
|
1558
|
+
// LATER re-render. Stash the committed id+rect here the instant `commitTextEdit`
|
|
1559
|
+
// fires; the effect below consumes it on the next store notify (`editText`
|
|
1560
|
+
// calls `store.notifyIngestEdit()` unconditionally on both the themeVars.semantic.success and the
|
|
1561
|
+
// refusal path) and checks `text.get` — which reports `null` for a refused id
|
|
1562
|
+
// (via the adapter's own `dynamicPaths` mark) — to decide whether to surface
|
|
1563
|
+
// the refused indicator instead of letting the edit silently vanish.
|
|
1564
|
+
const pendingTextCommitRef = useRef<{ id: string; rect: DOMRectLike } | null>(null);
|
|
1565
|
+
|
|
1566
|
+
// Perf debt note (reviewer, D3 reopen review) — `emptyContainerHintsFor`
|
|
1567
|
+
// walks every node in the active adapter's tree calling
|
|
1568
|
+
// `getBoundingClientRect()` on each; it was previously recomputed on EVERY
|
|
1569
|
+
// render, including the plain hover `pointermove` path (`setHoverId` is
|
|
1570
|
+
// component-LOCAL state, so it re-renders this component without touching
|
|
1571
|
+
// `store` at all). Cached here keyed on `[adapter, storeVersion]` — `store`
|
|
1572
|
+
// only bumps `storeVersion` on an actual structural/selection/edit notify
|
|
1573
|
+
// (`notifyIngestEdit`/`setSelection` etc.), never on a bare hover move — so
|
|
1574
|
+
// a mousemove-only re-render now reuses the prior render's hints instead of
|
|
1575
|
+
// re-walking the whole tree.
|
|
1576
|
+
const emptyHintsCacheRef = useRef<{
|
|
1577
|
+
adapter: AuthoringAdapter;
|
|
1578
|
+
version: number;
|
|
1579
|
+
hints: ReturnType<typeof emptyContainerHintsFor>;
|
|
1580
|
+
} | null>(null);
|
|
1581
|
+
|
|
1582
|
+
// D3.R1 — consume a pending commit on the next store tick (see
|
|
1583
|
+
// `pendingTextCommitRef` doc comment). Runs at most once per commit: it always
|
|
1584
|
+
// clears the pending ref, whether or not the write was refused.
|
|
1585
|
+
useEffect(() => {
|
|
1586
|
+
const pending = pendingTextCommitRef.current;
|
|
1587
|
+
if (!pending) return;
|
|
1588
|
+
pendingTextCommitRef.current = null;
|
|
1589
|
+
const provider = textForId(adapter, pending.id);
|
|
1590
|
+
if (provider && provider.get(pending.id) === null) {
|
|
1591
|
+
setTextEditRefused({ id: pending.id, rect: pending.rect });
|
|
1592
|
+
}
|
|
1593
|
+
// `storeVersion` is the only intentional trigger — `store` is stable and
|
|
1594
|
+
// `pending` is read off the ref fresh each tick, per `useExhaustiveDependencies: off` (biome.json).
|
|
1595
|
+
}, [storeVersion]);
|
|
1596
|
+
|
|
1597
|
+
// D3.R1 — the refused indicator is a TRANSIENT affordance, not a permanent
|
|
1598
|
+
// mark: it must clear on a selection change (the user moved on to something
|
|
1599
|
+
// else) or after a reasonable timeout (they didn't), so it never sticks
|
|
1600
|
+
// forever the way it did pre-fix (only another double-click cleared it).
|
|
1601
|
+
const textEditRefusedSelectionRef = useRef<string>('');
|
|
1602
|
+
useEffect(() => {
|
|
1603
|
+
const signature = [...store.selectedEntityIds].sort().join(',');
|
|
1604
|
+
if (signature !== textEditRefusedSelectionRef.current) {
|
|
1605
|
+
textEditRefusedSelectionRef.current = signature;
|
|
1606
|
+
if (textEditRefused) setTextEditRefused(null);
|
|
1607
|
+
}
|
|
1608
|
+
});
|
|
1609
|
+
useEffect(() => {
|
|
1610
|
+
if (!textEditRefused) return;
|
|
1611
|
+
const timer = window.setTimeout(() => setTextEditRefused(null), 4000);
|
|
1612
|
+
return () => window.clearTimeout(timer);
|
|
1613
|
+
}, [textEditRefused]);
|
|
1614
|
+
|
|
1615
|
+
// D3.d — the fallback eyedropper session (native EyeDropper needs none of
|
|
1616
|
+
// this — it samples real rendered pixels itself). Re-renders whenever a
|
|
1617
|
+
// session begins/ends so the cursor swatch appears/disappears immediately.
|
|
1618
|
+
const eyedropperActive = useSyncExternalStore(
|
|
1619
|
+
subscribeEyedropperSession,
|
|
1620
|
+
isEyedropperSessionActive,
|
|
1621
|
+
);
|
|
1622
|
+
const [eyedropperPreview, setEyedropperPreview] = useState<{
|
|
1623
|
+
x: number;
|
|
1624
|
+
y: number;
|
|
1625
|
+
color: string | null;
|
|
1626
|
+
} | null>(null);
|
|
1627
|
+
|
|
1628
|
+
const setMarquee = useCallback((r: Rect | null) => {
|
|
1629
|
+
marqueeRef.current = r;
|
|
1630
|
+
setMarqueeState(r);
|
|
1631
|
+
}, []);
|
|
1632
|
+
|
|
1633
|
+
const toHostLocal = useCallback(
|
|
1634
|
+
(clientX: number, clientY: number): { x: number; y: number } => {
|
|
1635
|
+
const host = interactionRef.current?.getBoundingClientRect();
|
|
1636
|
+
return {
|
|
1637
|
+
x: (clientX - (host?.left ?? 0) - pan.x) / pan.zoom,
|
|
1638
|
+
y: (clientY - (host?.top ?? 0) - pan.y) / pan.zoom,
|
|
1639
|
+
};
|
|
1640
|
+
},
|
|
1641
|
+
[pan.x, pan.y, pan.zoom],
|
|
1642
|
+
);
|
|
1643
|
+
openAddMenuRef.current = (clientX: number, clientY: number): void => {
|
|
1644
|
+
// Godot's parent for a node added here: the selected node, else the scene's root.
|
|
1645
|
+
const selected = store.selectedEntityIds;
|
|
1646
|
+
const roots = adapter.hierarchy.roots();
|
|
1647
|
+
const parentId =
|
|
1648
|
+
selected.size === 1 ? [...selected][0]! : roots.length === 1 ? roots[0]!.id : null;
|
|
1649
|
+
const structure = parentId ? structureForId(adapter, parentId) : (adapter.structure ?? null);
|
|
1650
|
+
const kinds = structure?.creatableKinds?.(parentId) ?? [];
|
|
1651
|
+
// With nothing that can go under the node, the menu still opens, its items saying so.
|
|
1652
|
+
if (!structure) {
|
|
1653
|
+
setAddMenu(null);
|
|
1654
|
+
return;
|
|
1655
|
+
}
|
|
1656
|
+
// The point snaps as a moved node's would: the grid when the magnet is on, then whole pixels.
|
|
1657
|
+
const grid = store.snap2D;
|
|
1658
|
+
const onGrid = (value: number, offset: number): number =>
|
|
1659
|
+
store.snapEnabled && grid.step > 0 ? Math.round((value - offset) / grid.step) * grid.step + offset : value;
|
|
1660
|
+
const point = toHostLocal(clientX, clientY);
|
|
1661
|
+
const snapped = { x: onGrid(point.x, grid.offsetX), y: onGrid(point.y, grid.offsetY) };
|
|
1662
|
+
const at = grid.pixel ? { x: Math.round(snapped.x), y: Math.round(snapped.y) } : snapped;
|
|
1663
|
+
setAddMenu({ x: clientX, y: clientY, at, parentId, structure, kinds, adapter });
|
|
1664
|
+
};
|
|
1665
|
+
|
|
1666
|
+
const onAssetDragOver = useCallback(
|
|
1667
|
+
(event: ReactDragEvent<HTMLDivElement>) => {
|
|
1668
|
+
if (
|
|
1669
|
+
!suppliedView ||
|
|
1670
|
+
!transformModeAware ||
|
|
1671
|
+
!adapter.assetDrop ||
|
|
1672
|
+
!event.dataTransfer.types.includes('application/x-editor-asset')
|
|
1673
|
+
) {
|
|
1674
|
+
return;
|
|
1675
|
+
}
|
|
1676
|
+
event.preventDefault();
|
|
1677
|
+
event.dataTransfer.dropEffect = 'copy';
|
|
1678
|
+
},
|
|
1679
|
+
[adapter.assetDrop, suppliedView, transformModeAware],
|
|
1680
|
+
);
|
|
1681
|
+
|
|
1682
|
+
const onAssetDrop = useCallback(
|
|
1683
|
+
(event: ReactDragEvent<HTMLDivElement>) => {
|
|
1684
|
+
if (!suppliedView || !transformModeAware || !adapter.assetDrop) return;
|
|
1685
|
+
const raw = event.dataTransfer.getData('application/x-editor-asset');
|
|
1686
|
+
if (!raw) return;
|
|
1687
|
+
let data: {
|
|
1688
|
+
path: string;
|
|
1689
|
+
name?: string;
|
|
1690
|
+
component?: AssetDropContext['item'];
|
|
1691
|
+
};
|
|
1692
|
+
try {
|
|
1693
|
+
data = JSON.parse(raw);
|
|
1694
|
+
} catch {
|
|
1695
|
+
return;
|
|
1696
|
+
}
|
|
1697
|
+
const point = toHostLocal(event.clientX, event.clientY);
|
|
1698
|
+
const context: AssetDropContext = {
|
|
1699
|
+
position: [point.x, point.y, 0],
|
|
1700
|
+
item: data.component ?? {
|
|
1701
|
+
kind: 'file',
|
|
1702
|
+
name: data.name ?? data.path.split('/').pop() ?? data.path,
|
|
1703
|
+
},
|
|
1704
|
+
};
|
|
1705
|
+
if (!adapter.assetDrop.accepts('', data.path, context)) return;
|
|
1706
|
+
event.preventDefault();
|
|
1707
|
+
event.stopPropagation();
|
|
1708
|
+
void dropAuthoringAsset(adapter, '', data.path, context);
|
|
1709
|
+
},
|
|
1710
|
+
[adapter.assetDrop, suppliedView, toHostLocal, transformModeAware],
|
|
1711
|
+
);
|
|
1712
|
+
|
|
1713
|
+
const bumpGesture = useCallback(() => setGestureTick((t) => t + 1), []);
|
|
1714
|
+
|
|
1715
|
+
/** Common gesture-end path for `onPointerUp`/`onPointerCancel` (step 6):
|
|
1716
|
+
* `ownerBoxEdit.end(id)`, clear the ref, force one more re-render so the
|
|
1717
|
+
* handles snap to the FINAL committed rect. */
|
|
1718
|
+
const endGesture = useCallback((): boolean => {
|
|
1719
|
+
const gesture = gestureRef.current;
|
|
1720
|
+
if (!gesture) return false;
|
|
1721
|
+
gestureRef.current = null;
|
|
1722
|
+
gesture.ownerBoxEdit.end(gesture.id);
|
|
1723
|
+
for (const peer of gesture.movePeers ?? []) peer.ownerBoxEdit.end(peer.id);
|
|
1724
|
+
// D2.a — the drag-ghost (if any) belonged to this gesture; clear it now
|
|
1725
|
+
// that the FINAL committed rect is what the box/handles snap back to.
|
|
1726
|
+
setDragGhost(null);
|
|
1727
|
+
// D1.b — likewise, any live alignment guide belonged to this gesture.
|
|
1728
|
+
setSnapGuides([]);
|
|
1729
|
+
bumpGesture();
|
|
1730
|
+
return true;
|
|
1731
|
+
}, [bumpGesture]);
|
|
1732
|
+
|
|
1733
|
+
const startResizeGesture = useCallback(
|
|
1734
|
+
(pos: HandlePos, e: ReactPointerEvent) => {
|
|
1735
|
+
e.stopPropagation();
|
|
1736
|
+
if (e.button !== 0) return;
|
|
1737
|
+
const selected = store.selectedEntityIds;
|
|
1738
|
+
if (selected.size !== 1) return;
|
|
1739
|
+
const id = [...selected][0]!;
|
|
1740
|
+
const owner = boxEditForId(adapter, id);
|
|
1741
|
+
const rect = rectForId(adapter, id);
|
|
1742
|
+
if (!owner || !rect) return;
|
|
1743
|
+
const posValue = adapter.inspector?.get(id, 'style.position');
|
|
1744
|
+
const native2D = transformDimensionsFor(adapter, id) === '2d';
|
|
1745
|
+
const frame = native2D ? frameForId(adapter, id) : null;
|
|
1746
|
+
const frameOrigin = frame && frameIsTurned(frame) ? (owner.gizmoOrigin?.(id) ?? null) : null;
|
|
1747
|
+
capturePointer(e);
|
|
1748
|
+
owner.begin(id);
|
|
1749
|
+
gestureRef.current = {
|
|
1750
|
+
kind: 'resize',
|
|
1751
|
+
id,
|
|
1752
|
+
ownerBoxEdit: owner,
|
|
1753
|
+
startLocal: toHostLocal(e.clientX, e.clientY),
|
|
1754
|
+
origRect: rect,
|
|
1755
|
+
pos,
|
|
1756
|
+
...(frame && frameOrigin ? { frame, frameOrigin } : {}),
|
|
1757
|
+
isPositioned: posValue === 'absolute' || posValue === 'fixed' || native2D,
|
|
1758
|
+
// D1.b — same `contextRectsForId` call the move gesture already
|
|
1759
|
+
// seeds `context` from (`resolveMoveCandidate`), so a resize handle
|
|
1760
|
+
// drag gets edge-snapping + guides too (spec §6's own instruction:
|
|
1761
|
+
// "wire the resize handle drag to computeSnapTargets too").
|
|
1762
|
+
context: contextRectsForId(adapter, id),
|
|
1763
|
+
};
|
|
1764
|
+
},
|
|
1765
|
+
[adapter, store, toHostLocal],
|
|
1766
|
+
);
|
|
1767
|
+
|
|
1768
|
+
/** A native 2D multi-selection as one gesture (Godot's `canvas_item_editor_plugin.cpp`): its
|
|
1769
|
+
* first node leads, the rest follow as peers. The turn and the scale are measured about the
|
|
1770
|
+
* temporary pivot, else the first node's origin; only about a temporary pivot do the nodes'
|
|
1771
|
+
* positions move with them, and otherwise each turns or scales in place. */
|
|
1772
|
+
const nativeGroup = useCallback((): {
|
|
1773
|
+
primary: MovePeer & { nativeOrigin: { x: number; y: number } };
|
|
1774
|
+
peers: MovePeer[];
|
|
1775
|
+
center: { x: number; y: number };
|
|
1776
|
+
pivot: { x: number; y: number } | null;
|
|
1777
|
+
} | null => {
|
|
1778
|
+
if (!transformModeAware || store.selectedEntityIds.size < 2) return null;
|
|
1779
|
+
const members = [...store.selectedEntityIds].flatMap((id) => {
|
|
1780
|
+
const owner = boxEditForId(adapter, id);
|
|
1781
|
+
const rect = rectForId(adapter, id);
|
|
1782
|
+
const origin = owner?.gizmoOrigin?.(id);
|
|
1783
|
+
return owner && rect && origin ? [{ id, ownerBoxEdit: owner, origRect: rect, nativeOrigin: origin }] : [];
|
|
1784
|
+
});
|
|
1785
|
+
if (members.length < 2) return null;
|
|
1786
|
+
const pivot = temporaryPivot(view);
|
|
1787
|
+
return { primary: members[0]!, peers: members.slice(1), center: pivot ?? members[0]!.nativeOrigin, pivot };
|
|
1788
|
+
}, [adapter, store, transformModeAware, view]);
|
|
1789
|
+
|
|
1790
|
+
const startGroupGesture = useCallback(
|
|
1791
|
+
(kind: 'rotate' | 'native-scale', e: ReactPointerEvent, axis: 'x' | 'y' | 'both' = 'both'): boolean => {
|
|
1792
|
+
const group = nativeGroup();
|
|
1793
|
+
if (!group) return false;
|
|
1794
|
+
const frame = frameForId(adapter, group.primary.id);
|
|
1795
|
+
capturePointer(e);
|
|
1796
|
+
group.primary.ownerBoxEdit.begin(group.primary.id);
|
|
1797
|
+
for (const peer of group.peers) peer.ownerBoxEdit.begin(peer.id);
|
|
1798
|
+
gestureRef.current = {
|
|
1799
|
+
kind,
|
|
1800
|
+
id: group.primary.id,
|
|
1801
|
+
ownerBoxEdit: group.primary.ownerBoxEdit,
|
|
1802
|
+
startLocal: toHostLocal(e.clientX, e.clientY),
|
|
1803
|
+
origRect: group.primary.origRect,
|
|
1804
|
+
center: group.center,
|
|
1805
|
+
nativeOrigin: group.primary.nativeOrigin,
|
|
1806
|
+
movePeers: group.peers,
|
|
1807
|
+
...(group.pivot ? { groupCenter: group.pivot } : {}),
|
|
1808
|
+
...(frame ? { startAngleDeg: (frameAngle(frame) * 180) / Math.PI } : {}),
|
|
1809
|
+
...(kind === 'native-scale'
|
|
1810
|
+
? { nativeScaleAxis: axis, nativeHandleSpan: NATIVE_GIZMO_LENGTH_PX / pan.zoom }
|
|
1811
|
+
: {}),
|
|
1812
|
+
};
|
|
1813
|
+
return true;
|
|
1814
|
+
},
|
|
1815
|
+
[adapter, nativeGroup, toHostLocal, pan.zoom],
|
|
1816
|
+
);
|
|
1817
|
+
|
|
1818
|
+
const startRotateGesture = useCallback(
|
|
1819
|
+
(e: ReactPointerEvent) => {
|
|
1820
|
+
e.stopPropagation();
|
|
1821
|
+
if (e.button !== 0) return;
|
|
1822
|
+
const selected = store.selectedEntityIds;
|
|
1823
|
+
if (selected.size > 1) {
|
|
1824
|
+
startGroupGesture('rotate', e);
|
|
1825
|
+
return;
|
|
1826
|
+
}
|
|
1827
|
+
if (selected.size !== 1) return;
|
|
1828
|
+
const id = [...selected][0]!;
|
|
1829
|
+
const owner = boxEditForId(adapter, id);
|
|
1830
|
+
const rect = rectForId(adapter, id);
|
|
1831
|
+
if (!owner || !rect) return;
|
|
1832
|
+
const nativeOrigin = transformModeAware ? owner.gizmoOrigin?.(id) : null;
|
|
1833
|
+
const frame = nativeOrigin ? frameForId(adapter, id) : null;
|
|
1834
|
+
capturePointer(e);
|
|
1835
|
+
owner.begin(id);
|
|
1836
|
+
gestureRef.current = {
|
|
1837
|
+
...(frame ? { startAngleDeg: (frameAngle(frame) * 180) / Math.PI } : {}),
|
|
1838
|
+
kind: 'rotate',
|
|
1839
|
+
id,
|
|
1840
|
+
ownerBoxEdit: owner,
|
|
1841
|
+
startLocal: toHostLocal(e.clientX, e.clientY),
|
|
1842
|
+
origRect: rect,
|
|
1843
|
+
// A temporary pivot (Godot's) is what the turn is measured around, when one is set.
|
|
1844
|
+
center: (nativeOrigin ? temporaryPivot(view) : null) ??
|
|
1845
|
+
nativeOrigin ?? { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 },
|
|
1846
|
+
...(nativeOrigin ? { nativeOrigin } : {}),
|
|
1847
|
+
};
|
|
1848
|
+
},
|
|
1849
|
+
[adapter, store, toHostLocal, transformModeAware, startGroupGesture, view],
|
|
1850
|
+
);
|
|
1851
|
+
|
|
1852
|
+
const startMoveGizmoGesture = useCallback(
|
|
1853
|
+
(axis: 'x' | 'y' | 'both', e: ReactPointerEvent) => {
|
|
1854
|
+
e.stopPropagation();
|
|
1855
|
+
if (e.button !== 0) return;
|
|
1856
|
+
const selected = store.selectedEntityIds;
|
|
1857
|
+
if (selected.size !== 1) return;
|
|
1858
|
+
const id = [...selected][0]!;
|
|
1859
|
+
const owner = boxEditForId(adapter, id);
|
|
1860
|
+
const rect = rectForId(adapter, id);
|
|
1861
|
+
if (!owner || !rect) return;
|
|
1862
|
+
const nativeOrigin = owner.gizmoOrigin?.(id) ?? {
|
|
1863
|
+
x: rect.x + rect.width / 2,
|
|
1864
|
+
y: rect.y + rect.height / 2,
|
|
1865
|
+
};
|
|
1866
|
+
capturePointer(e);
|
|
1867
|
+
owner.begin(id);
|
|
1868
|
+
gestureRef.current = {
|
|
1869
|
+
kind: 'move',
|
|
1870
|
+
id,
|
|
1871
|
+
ownerBoxEdit: owner,
|
|
1872
|
+
startLocal: toHostLocal(e.clientX, e.clientY),
|
|
1873
|
+
origRect: rect,
|
|
1874
|
+
context: contextRectsForId(adapter, id),
|
|
1875
|
+
moveAxis: axis,
|
|
1876
|
+
nativeOrigin,
|
|
1877
|
+
};
|
|
1878
|
+
},
|
|
1879
|
+
[adapter, store, toHostLocal],
|
|
1880
|
+
);
|
|
1881
|
+
|
|
1882
|
+
const startNativeScaleGesture = useCallback(
|
|
1883
|
+
(axis: 'x' | 'y' | 'both', e: ReactPointerEvent) => {
|
|
1884
|
+
e.stopPropagation();
|
|
1885
|
+
if (e.button !== 0) return;
|
|
1886
|
+
const selected = store.selectedEntityIds;
|
|
1887
|
+
if (selected.size > 1) {
|
|
1888
|
+
startGroupGesture('native-scale', e, axis);
|
|
1889
|
+
return;
|
|
1890
|
+
}
|
|
1891
|
+
if (selected.size !== 1) return;
|
|
1892
|
+
const id = [...selected][0]!;
|
|
1893
|
+
const owner = boxEditForId(adapter, id);
|
|
1894
|
+
const rect = rectForId(adapter, id);
|
|
1895
|
+
if (!owner || !rect) return;
|
|
1896
|
+
const nativeOrigin = owner.gizmoOrigin?.(id) ?? {
|
|
1897
|
+
x: rect.x + rect.width / 2,
|
|
1898
|
+
y: rect.y + rect.height / 2,
|
|
1899
|
+
};
|
|
1900
|
+
const frame = frameForId(adapter, id);
|
|
1901
|
+
capturePointer(e);
|
|
1902
|
+
owner.begin(id);
|
|
1903
|
+
gestureRef.current = {
|
|
1904
|
+
...(frame ? { startAngleDeg: (frameAngle(frame) * 180) / Math.PI } : {}),
|
|
1905
|
+
kind: 'native-scale',
|
|
1906
|
+
center: temporaryPivot(view) ?? nativeOrigin,
|
|
1907
|
+
id,
|
|
1908
|
+
ownerBoxEdit: owner,
|
|
1909
|
+
startLocal: toHostLocal(e.clientX, e.clientY),
|
|
1910
|
+
origRect: rect,
|
|
1911
|
+
nativeOrigin,
|
|
1912
|
+
nativeScaleAxis: axis,
|
|
1913
|
+
nativeHandleSpan: NATIVE_GIZMO_LENGTH_PX / pan.zoom,
|
|
1914
|
+
};
|
|
1915
|
+
},
|
|
1916
|
+
[adapter, pan.zoom, store, toHostLocal, startGroupGesture, view],
|
|
1917
|
+
);
|
|
1918
|
+
|
|
1919
|
+
const startReferenceGesture = useCallback(
|
|
1920
|
+
(point: BoxEditReferencePoint, e: ReactPointerEvent) => {
|
|
1921
|
+
e.stopPropagation();
|
|
1922
|
+
if (e.button !== 0) return;
|
|
1923
|
+
const selected = store.selectedEntityIds;
|
|
1924
|
+
if (selected.size !== 1) return;
|
|
1925
|
+
const id = [...selected][0]!;
|
|
1926
|
+
const owner = boxEditForId(adapter, id);
|
|
1927
|
+
const rect = rectForId(adapter, id);
|
|
1928
|
+
if (!owner || !rect || owner.referencePoint?.(id)?.id !== point.id) return;
|
|
1929
|
+
capturePointer(e);
|
|
1930
|
+
owner.begin(id);
|
|
1931
|
+
gestureRef.current = {
|
|
1932
|
+
kind: 'reference',
|
|
1933
|
+
id,
|
|
1934
|
+
ownerBoxEdit: owner,
|
|
1935
|
+
startLocal: toHostLocal(e.clientX, e.clientY),
|
|
1936
|
+
origRect: rect,
|
|
1937
|
+
context: contextRectsForId(adapter, id),
|
|
1938
|
+
pointerId: e.pointerId,
|
|
1939
|
+
referencePoint: point,
|
|
1940
|
+
};
|
|
1941
|
+
},
|
|
1942
|
+
[adapter, store, toHostLocal],
|
|
1943
|
+
);
|
|
1944
|
+
|
|
1945
|
+
/**
|
|
1946
|
+
* An adapter-owned spatial handle's own drag, deliberately OUTSIDE the
|
|
1947
|
+
* `GestureState` machine: it has no `BoxEditProvider` begin/apply/end bracket
|
|
1948
|
+
* (the provider owns its own preview/commit pair), so it needs an independent
|
|
1949
|
+
* lifecycle — the same reasoning `reorderPreviewRef` records for being
|
|
1950
|
+
* separate. The pointer is captured on the handle itself, so the move/up pair
|
|
1951
|
+
* arrives there and never touches the marquee/selection machine.
|
|
1952
|
+
*/
|
|
1953
|
+
const spatialDragRef = useRef<{
|
|
1954
|
+
id: string;
|
|
1955
|
+
handleId: string;
|
|
1956
|
+
provider: SpatialHandlesProvider;
|
|
1957
|
+
/** The handle is the node's pivot (its owner's `origin` layer), which snaps to its own box. */
|
|
1958
|
+
pivot: boolean;
|
|
1959
|
+
} | null>(null);
|
|
1960
|
+
|
|
1961
|
+
/** Where a pivot lands: `snapPivotPoint`, Godot's pivot drag. */
|
|
1962
|
+
const snapPivot = useCallback(
|
|
1963
|
+
(id: string, local: { x: number; y: number }, invert: boolean): { x: number; y: number } => {
|
|
1964
|
+
const rect = rectForId(adapter, id);
|
|
1965
|
+
return snapPivotPoint(local, frameForId(adapter, id) ?? (rect ? rectFrame(rect) : null), {
|
|
1966
|
+
invert,
|
|
1967
|
+
smart: store.smartSnap,
|
|
1968
|
+
gridOn: store.snapEnabled,
|
|
1969
|
+
grid: store.snap2D,
|
|
1970
|
+
threshold: EDGE_SNAP_THRESHOLD_PX / Math.max(view.get().zoom, 0.01),
|
|
1971
|
+
});
|
|
1972
|
+
},
|
|
1973
|
+
[adapter, store, view],
|
|
1974
|
+
);
|
|
1975
|
+
|
|
1976
|
+
const onSpatialHandleDown = useCallback(
|
|
1977
|
+
(
|
|
1978
|
+
id: string,
|
|
1979
|
+
provider: SpatialHandlesProvider,
|
|
1980
|
+
handle: SpatialDragHandle,
|
|
1981
|
+
e: ReactPointerEvent<HTMLElement>,
|
|
1982
|
+
) => {
|
|
1983
|
+
e.stopPropagation();
|
|
1984
|
+
if (e.button !== 0 || !handle.writable) return;
|
|
1985
|
+
capturePointer(e);
|
|
1986
|
+
const pivot = provider
|
|
1987
|
+
.layers(id)
|
|
1988
|
+
.some((layer) => layer.category === 'origin' && layer.handles.some((h) => h.id === handle.id));
|
|
1989
|
+
spatialDragRef.current = { id, handleId: handle.id, provider, pivot };
|
|
1990
|
+
},
|
|
1991
|
+
[],
|
|
1992
|
+
);
|
|
1993
|
+
|
|
1994
|
+
const onSpatialHandleMove = useCallback(
|
|
1995
|
+
(e: ReactPointerEvent<HTMLElement>) => {
|
|
1996
|
+
const drag = spatialDragRef.current;
|
|
1997
|
+
if (!drag) return;
|
|
1998
|
+
const pointer = toHostLocal(e.clientX, e.clientY);
|
|
1999
|
+
const local = drag.pivot ? snapPivot(drag.id, pointer, e.metaKey || e.ctrlKey) : pointer;
|
|
2000
|
+
drag.provider.preview(drag.id, drag.handleId, [local.x, local.y, 0]);
|
|
2001
|
+
bumpGesture();
|
|
2002
|
+
},
|
|
2003
|
+
[bumpGesture, toHostLocal, snapPivot],
|
|
2004
|
+
);
|
|
2005
|
+
|
|
2006
|
+
const onSpatialHandleUp = useCallback(
|
|
2007
|
+
(e: ReactPointerEvent<HTMLElement>) => {
|
|
2008
|
+
const drag = spatialDragRef.current;
|
|
2009
|
+
spatialDragRef.current = null;
|
|
2010
|
+
if (!drag) return;
|
|
2011
|
+
releaseCapturedPointer(e);
|
|
2012
|
+
const pointer = toHostLocal(e.clientX, e.clientY);
|
|
2013
|
+
const local = drag.pivot ? snapPivot(drag.id, pointer, e.metaKey || e.ctrlKey) : pointer;
|
|
2014
|
+
void drag.provider.commit(drag.id, drag.handleId, [local.x, local.y, 0]);
|
|
2015
|
+
bumpGesture();
|
|
2016
|
+
},
|
|
2017
|
+
[bumpGesture, toHostLocal, snapPivot],
|
|
2018
|
+
);
|
|
2019
|
+
|
|
2020
|
+
const startSpacingGesture = useCallback(
|
|
2021
|
+
(band: SpacingBand, e: ReactPointerEvent) => {
|
|
2022
|
+
e.stopPropagation();
|
|
2023
|
+
if (e.button !== 0) return;
|
|
2024
|
+
const selected = store.selectedEntityIds;
|
|
2025
|
+
if (selected.size !== 1) return;
|
|
2026
|
+
const id = [...selected][0]!;
|
|
2027
|
+
const owner = boxEditForId(adapter, id);
|
|
2028
|
+
const rect = rectForId(adapter, id);
|
|
2029
|
+
if (!owner || !rect) return;
|
|
2030
|
+
capturePointer(e);
|
|
2031
|
+
owner.begin(id);
|
|
2032
|
+
gestureRef.current = {
|
|
2033
|
+
kind: 'spacing',
|
|
2034
|
+
id,
|
|
2035
|
+
ownerBoxEdit: owner,
|
|
2036
|
+
startLocal: toHostLocal(e.clientX, e.clientY),
|
|
2037
|
+
origRect: rect,
|
|
2038
|
+
side: band.side,
|
|
2039
|
+
origValue: band.value,
|
|
2040
|
+
};
|
|
2041
|
+
},
|
|
2042
|
+
[adapter, store, toHostLocal],
|
|
2043
|
+
);
|
|
2044
|
+
|
|
2045
|
+
/** Compute this gesture's patch from the current pointer position and apply
|
|
2046
|
+
* it live, bumping the re-render counter so box/handles/bands track the
|
|
2047
|
+
* preview. Shared by the active-gesture branch of `onPointerMove` and the
|
|
2048
|
+
* D2 threshold-promotion of a deferred move candidate. */
|
|
2049
|
+
const applyGesturePatch = useCallback(
|
|
2050
|
+
(gesture: GestureState, e: ReactPointerEvent<HTMLElement>) => {
|
|
2051
|
+
if (gesture.pointerId !== undefined && e.pointerId !== gesture.pointerId) return;
|
|
2052
|
+
const local = toHostLocal(e.clientX, e.clientY);
|
|
2053
|
+
const dx = local.x - gesture.startLocal.x;
|
|
2054
|
+
const dy = local.y - gesture.startLocal.y;
|
|
2055
|
+
let patch: Record<string, number>;
|
|
2056
|
+
if (gesture.kind === 'resize' && gesture.frame && gesture.frameOrigin) {
|
|
2057
|
+
// A turned node resizes along its own axes, its opposite corner held.
|
|
2058
|
+
patch = frameResizePatch(gesture.frame, gesture.pos!, dx, dy, gesture.frameOrigin, e.shiftKey);
|
|
2059
|
+
setSnapGuides([]);
|
|
2060
|
+
} else if (gesture.kind === 'resize') {
|
|
2061
|
+
patch = computeResizePatch(
|
|
2062
|
+
gesture.pos!,
|
|
2063
|
+
dx,
|
|
2064
|
+
dy,
|
|
2065
|
+
gesture.origRect,
|
|
2066
|
+
!!gesture.isPositioned,
|
|
2067
|
+
e.altKey,
|
|
2068
|
+
gesture.context,
|
|
2069
|
+
);
|
|
2070
|
+
// Shift keeps a corner resize's proportions (Godot's Scale mode, Figma's frame), where the
|
|
2071
|
+
// box writes its position; a laid-out element's size alone is not anchored this way.
|
|
2072
|
+
if (e.shiftKey && gesture.isPositioned) patch = proportionalResize(patch, gesture.origRect, gesture.pos!);
|
|
2073
|
+
// D1.b — the same edge-snap decision `computeResizePatch` just
|
|
2074
|
+
// applied to the patch, rendered as a guide (spec §6).
|
|
2075
|
+
setSnapGuides(
|
|
2076
|
+
computeResizeSnapGuides(
|
|
2077
|
+
gesture.pos!,
|
|
2078
|
+
dx,
|
|
2079
|
+
dy,
|
|
2080
|
+
gesture.origRect,
|
|
2081
|
+
e.altKey,
|
|
2082
|
+
gesture.context,
|
|
2083
|
+
),
|
|
2084
|
+
);
|
|
2085
|
+
} else if (gesture.kind === 'native-scale') {
|
|
2086
|
+
patch = nativeScalePatch(
|
|
2087
|
+
gesture,
|
|
2088
|
+
dx,
|
|
2089
|
+
dy,
|
|
2090
|
+
// A 2D scale steps under its own switch (Godot's Use Scale Snap), not the grid magnet.
|
|
2091
|
+
store.scaleSnap,
|
|
2092
|
+
e.altKey,
|
|
2093
|
+
store.snap2D.scaleStep,
|
|
2094
|
+
e.shiftKey,
|
|
2095
|
+
);
|
|
2096
|
+
setSnapGuides([]);
|
|
2097
|
+
} else if (gesture.kind === 'move') {
|
|
2098
|
+
const { dx: moveDx, dy: moveDy } = constrainedMoveDelta(gesture.moveAxis, dx, dy);
|
|
2099
|
+
if (gesture.nativeOrigin) {
|
|
2100
|
+
patch = nativeMovePatch(
|
|
2101
|
+
gesture,
|
|
2102
|
+
moveDx,
|
|
2103
|
+
moveDy,
|
|
2104
|
+
store.snapEnabled,
|
|
2105
|
+
// On the 2D surface Alt is Godot's move modifier, so it never frees the move.
|
|
2106
|
+
false,
|
|
2107
|
+
// A native (2D) origin steps on the scene's grid, not the 3D translate step; Snap
|
|
2108
|
+
// Relative steps from where the node started instead.
|
|
2109
|
+
store.snap2D.step,
|
|
2110
|
+
store.snap2D.relative
|
|
2111
|
+
? gesture.nativeOrigin
|
|
2112
|
+
: { x: store.snap2D.offsetX, y: store.snap2D.offsetY },
|
|
2113
|
+
);
|
|
2114
|
+
const snapped = computeNativeMoveSnap(
|
|
2115
|
+
{
|
|
2116
|
+
x: patch['originX'] ?? gesture.nativeOrigin.x,
|
|
2117
|
+
y: patch['originY'] ?? gesture.nativeOrigin.y,
|
|
2118
|
+
},
|
|
2119
|
+
gesture.nativeOrigin,
|
|
2120
|
+
gesture.origRect,
|
|
2121
|
+
gesture.context,
|
|
2122
|
+
gesture.moveAxis,
|
|
2123
|
+
// Alignment is smart snapping's, beside the grid's step (Godot's two toggles); Cmd
|
|
2124
|
+
// inverts it, as Godot's `snap_point` reads `smart_snap_active ^ Cmd`.
|
|
2125
|
+
store.smartSnap.enabled === (e.metaKey || e.ctrlKey),
|
|
2126
|
+
EDGE_SNAP_THRESHOLD_PX / Math.max(pan.zoom, 0.01),
|
|
2127
|
+
{
|
|
2128
|
+
x: nativeGuides.filter((guide) => guide.axis === 'x').map((guide) => guide.value),
|
|
2129
|
+
y: nativeGuides.filter((guide) => guide.axis === 'y').map((guide) => guide.value),
|
|
2130
|
+
},
|
|
2131
|
+
store.smartSnap,
|
|
2132
|
+
);
|
|
2133
|
+
// Use Pixel Snap rounds what the move writes to whole pixels, snapped or free: Godot's
|
|
2134
|
+
// move names no node to `snap_point`, so it snaps however the node is turned.
|
|
2135
|
+
const pixel = (v: number): number => (store.snap2D.pixel ? Math.round(v) : v);
|
|
2136
|
+
if (patch['originX'] !== undefined) patch['originX'] = pixel(snapped.position.x);
|
|
2137
|
+
if (patch['originY'] !== undefined) patch['originY'] = pixel(snapped.position.y);
|
|
2138
|
+
setSnapGuides(snapped.guides);
|
|
2139
|
+
} else {
|
|
2140
|
+
patch = computeMovePatch(moveDx, moveDy, gesture.origRect, e.altKey, gesture.context);
|
|
2141
|
+
}
|
|
2142
|
+
// D2.a — the move gesture is "dragging a selected element on the
|
|
2143
|
+
// canvas" (spec:399); track the drag-ghost alongside the live
|
|
2144
|
+
// boxEdit preview. Resize/rotate/spacing gestures don't get a ghost
|
|
2145
|
+
// — the spec's D2 framing is specifically about MOVING an element.
|
|
2146
|
+
setDragGhost(computeDragGhost(gesture.origRect, moveDx, moveDy));
|
|
2147
|
+
// D1.b — same relationship as the resize branch above.
|
|
2148
|
+
if (!gesture.nativeOrigin) {
|
|
2149
|
+
setSnapGuides(
|
|
2150
|
+
computeMoveSnapGuides(moveDx, moveDy, gesture.origRect, e.altKey, gesture.context),
|
|
2151
|
+
);
|
|
2152
|
+
}
|
|
2153
|
+
} else if (gesture.kind === 'reference') {
|
|
2154
|
+
const snapped = computePointSnap(local, gesture.origRect, e.altKey, gesture.context, {
|
|
2155
|
+
bounded: gesture.referencePoint?.bounded,
|
|
2156
|
+
snapPoints: gesture.referencePoint?.snapPoints,
|
|
2157
|
+
});
|
|
2158
|
+
patch = { referenceX: snapped.position.x, referenceY: snapped.position.y };
|
|
2159
|
+
setSnapGuides(snapped.guides);
|
|
2160
|
+
} else if (gesture.kind === 'rotate') {
|
|
2161
|
+
const absolute =
|
|
2162
|
+
gesture.nativeOrigin && gesture.startAngleDeg !== undefined && !store.snap2D.relative;
|
|
2163
|
+
// Godot's `snap_angle`: a 2D turn snaps when (Smart Snap or Use Rotation Snap) XOR Cmd, so a
|
|
2164
|
+
// Select-mode Cmd-drag snaps with both off and Cmd frees a snapped one.
|
|
2165
|
+
const snapTurn =
|
|
2166
|
+
(store.smartSnap.enabled || store.rotationSnap) !== (e.metaKey || e.ctrlKey);
|
|
2167
|
+
patch = computeRotatePatch(
|
|
2168
|
+
gesture.center!,
|
|
2169
|
+
gesture.startLocal,
|
|
2170
|
+
local,
|
|
2171
|
+
gesture.nativeOrigin ? absolute || !snapTurn : e.altKey,
|
|
2172
|
+
gesture.nativeOrigin ? store.snap2D.rotationStep : 15,
|
|
2173
|
+
);
|
|
2174
|
+
if (absolute && snapTurn && patch['rotate'] !== undefined) {
|
|
2175
|
+
// Godot's Configure Snap: the angle lands on the step from the rotation offset.
|
|
2176
|
+
const step = store.snap2D.rotationStep;
|
|
2177
|
+
const offset = store.snap2D.rotationOffset;
|
|
2178
|
+
const start = gesture.startAngleDeg!;
|
|
2179
|
+
const angle = start + patch['rotate'];
|
|
2180
|
+
// The owner lands the node's LOCAL rotation on the step (Godot's `snap_angle` on
|
|
2181
|
+
// `_edit_get_rotation()`), which is the angle on screen only under an unturned parent.
|
|
2182
|
+
patch = {
|
|
2183
|
+
rotate: Math.round((angle - offset) / step) * step + offset - start,
|
|
2184
|
+
rotationStep: step,
|
|
2185
|
+
rotationOffset: offset,
|
|
2186
|
+
};
|
|
2187
|
+
}
|
|
2188
|
+
// About a temporary pivot the node turns around that point: its origin moves by the turn.
|
|
2189
|
+
const pivot = gesture.groupCenter ?? (gesture.nativeOrigin ? temporaryPivot(view) : null);
|
|
2190
|
+
if (pivot && gesture.nativeOrigin && patch['rotate'] !== undefined) {
|
|
2191
|
+
const turn = (patch['rotate'] * Math.PI) / 180;
|
|
2192
|
+
const ox = gesture.nativeOrigin.x - pivot.x;
|
|
2193
|
+
const oy = gesture.nativeOrigin.y - pivot.y;
|
|
2194
|
+
patch = {
|
|
2195
|
+
...patch,
|
|
2196
|
+
originX: pivot.x + ox * Math.cos(turn) - oy * Math.sin(turn),
|
|
2197
|
+
originY: pivot.y + ox * Math.sin(turn) + oy * Math.cos(turn),
|
|
2198
|
+
};
|
|
2199
|
+
}
|
|
2200
|
+
} else {
|
|
2201
|
+
patch = computeSpacingPatch(gesture.side!, dx, dy, gesture.origValue ?? 0);
|
|
2202
|
+
}
|
|
2203
|
+
// A 2D group scale about a temporary pivot (Godot's): every node scales by the same factors,
|
|
2204
|
+
// and its origin moves away from the pivot by them.
|
|
2205
|
+
if (gesture.kind === 'native-scale' && gesture.groupCenter && gesture.nativeOrigin) {
|
|
2206
|
+
const c = gesture.groupCenter;
|
|
2207
|
+
const fx = patch['scaleXFactor'] ?? 1;
|
|
2208
|
+
const fy = patch['scaleYFactor'] ?? 1;
|
|
2209
|
+
patch = {
|
|
2210
|
+
...patch,
|
|
2211
|
+
originX: c.x + (gesture.nativeOrigin.x - c.x) * fx,
|
|
2212
|
+
originY: c.y + (gesture.nativeOrigin.y - c.y) * fy,
|
|
2213
|
+
};
|
|
2214
|
+
}
|
|
2215
|
+
// One node scales about Godot's temporary pivot when one is set: its origin's offset from the
|
|
2216
|
+
// pivot scales per component by the factors its scale takes, as Godot moves it.
|
|
2217
|
+
const scalePivot =
|
|
2218
|
+
gesture.kind === 'native-scale' && !gesture.groupCenter && gesture.nativeOrigin ? temporaryPivot(view) : null;
|
|
2219
|
+
if (scalePivot && gesture.nativeOrigin) {
|
|
2220
|
+
patch = {
|
|
2221
|
+
...patch,
|
|
2222
|
+
originX: scalePivot.x + (gesture.nativeOrigin.x - scalePivot.x) * (patch['scaleXFactor'] ?? 1),
|
|
2223
|
+
originY: scalePivot.y + (gesture.nativeOrigin.y - scalePivot.y) * (patch['scaleYFactor'] ?? 1),
|
|
2224
|
+
};
|
|
2225
|
+
}
|
|
2226
|
+
gesture.ownerBoxEdit.apply(gesture.id, patch);
|
|
2227
|
+
if (gesture.movePeers && (gesture.kind === 'rotate' || gesture.kind === 'native-scale')) {
|
|
2228
|
+
// About a temporary pivot each peer's origin orbits or scales with it; without one it stays.
|
|
2229
|
+
const c = gesture.groupCenter;
|
|
2230
|
+
const turn = ((patch['rotate'] ?? 0) * Math.PI) / 180;
|
|
2231
|
+
const fx = patch['scaleXFactor'] ?? 1;
|
|
2232
|
+
const fy = patch['scaleYFactor'] ?? 1;
|
|
2233
|
+
for (const peer of gesture.movePeers) {
|
|
2234
|
+
if (!peer.nativeOrigin) continue;
|
|
2235
|
+
const ox = c ? peer.nativeOrigin.x - c.x : 0;
|
|
2236
|
+
const oy = c ? peer.nativeOrigin.y - c.y : 0;
|
|
2237
|
+
peer.ownerBoxEdit.apply(
|
|
2238
|
+
peer.id,
|
|
2239
|
+
gesture.kind === 'rotate'
|
|
2240
|
+
? {
|
|
2241
|
+
rotate: patch['rotate'] ?? 0,
|
|
2242
|
+
...(patch['rotationStep'] !== undefined
|
|
2243
|
+
? { rotationStep: patch['rotationStep'], rotationOffset: patch['rotationOffset'] ?? 0 }
|
|
2244
|
+
: {}),
|
|
2245
|
+
...(c
|
|
2246
|
+
? {
|
|
2247
|
+
originX: c.x + ox * Math.cos(turn) - oy * Math.sin(turn),
|
|
2248
|
+
originY: c.y + ox * Math.sin(turn) + oy * Math.cos(turn),
|
|
2249
|
+
}
|
|
2250
|
+
: {}),
|
|
2251
|
+
}
|
|
2252
|
+
: {
|
|
2253
|
+
...(patch['scaleXFactor'] !== undefined ? { scaleXFactor: fx } : {}),
|
|
2254
|
+
...(patch['scaleYFactor'] !== undefined ? { scaleYFactor: fy } : {}),
|
|
2255
|
+
...(patch['scaleStep'] !== undefined ? { scaleStep: patch['scaleStep'] } : {}),
|
|
2256
|
+
...(c ? { originX: c.x + ox * fx, originY: c.y + oy * fy } : {}),
|
|
2257
|
+
},
|
|
2258
|
+
);
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
if (gesture.kind === 'move' && gesture.movePeers) {
|
|
2262
|
+
const primaryDx = gesture.nativeOrigin
|
|
2263
|
+
? (patch['originX'] ?? gesture.nativeOrigin.x) - gesture.nativeOrigin.x
|
|
2264
|
+
: (patch['x'] ?? gesture.origRect.x) - gesture.origRect.x;
|
|
2265
|
+
const primaryDy = gesture.nativeOrigin
|
|
2266
|
+
? (patch['originY'] ?? gesture.nativeOrigin.y) - gesture.nativeOrigin.y
|
|
2267
|
+
: (patch['y'] ?? gesture.origRect.y) - gesture.origRect.y;
|
|
2268
|
+
for (const peer of gesture.movePeers) {
|
|
2269
|
+
peer.ownerBoxEdit.apply(
|
|
2270
|
+
peer.id,
|
|
2271
|
+
peer.nativeOrigin
|
|
2272
|
+
? {
|
|
2273
|
+
originX: peer.nativeOrigin.x + primaryDx,
|
|
2274
|
+
originY: peer.nativeOrigin.y + primaryDy,
|
|
2275
|
+
}
|
|
2276
|
+
: { x: peer.origRect.x + primaryDx, y: peer.origRect.y + primaryDy },
|
|
2277
|
+
);
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
bumpGesture();
|
|
2281
|
+
},
|
|
2282
|
+
[toHostLocal, bumpGesture, store, nativeGuides, pan.zoom],
|
|
2283
|
+
);
|
|
2284
|
+
|
|
2285
|
+
// D3.d — sample the effective background color at a client point: pick the
|
|
2286
|
+
// topmost node (the SAME `pickTopmost` A3 already uses), route to ITS
|
|
2287
|
+
// owner's `colorSample` (owner-routed, not merged on the composite — see
|
|
2288
|
+
// `colorSampleForId`'s doc comment), then reduce the raw ancestor chain
|
|
2289
|
+
// through the pure `effectiveColorFromChain`. `null` when nothing is hit or
|
|
2290
|
+
// the owner has no `colorSample` — the caller renders the honest "unknown"
|
|
2291
|
+
// swatch state for that, never a fabricated color.
|
|
2292
|
+
const sampleColorAt = useCallback(
|
|
2293
|
+
(clientX: number, clientY: number): string | null => {
|
|
2294
|
+
const hitId = pickAt(clientX, clientY);
|
|
2295
|
+
if (!hitId) return null;
|
|
2296
|
+
const chain = colorSampleForId(adapter, hitId)?.backgroundChainAt(clientX, clientY) ?? null;
|
|
2297
|
+
return chain ? effectiveColorFromChain(chain) : null;
|
|
2298
|
+
},
|
|
2299
|
+
[adapter, pickAt],
|
|
2300
|
+
);
|
|
2301
|
+
|
|
2302
|
+
// D3.d — Escape cancels an in-flight fallback eyedropper session (never a
|
|
2303
|
+
// fabricated color — `resolveEyedropperSession(null)` is the honest
|
|
2304
|
+
// "cancelled" outcome the widget's `apply` callback already understands).
|
|
2305
|
+
useEffect(() => {
|
|
2306
|
+
if (!eyedropperActive) return;
|
|
2307
|
+
const onKeyDown = (e: KeyboardEvent): void => {
|
|
2308
|
+
if (e.key === 'Escape') resolveEyedropperSession(null);
|
|
2309
|
+
};
|
|
2310
|
+
window.addEventListener('keydown', onKeyDown);
|
|
2311
|
+
return () => window.removeEventListener('keydown', onKeyDown);
|
|
2312
|
+
}, [eyedropperActive]);
|
|
2313
|
+
|
|
2314
|
+
// Godot's V on a native 2D surface: put the selected node's pivot where the pointer is, snapped as
|
|
2315
|
+
// a dragged pivot snaps. The pivot is the owner's `origin` spatial handle, committed the way its
|
|
2316
|
+
// own drag commits (one edit).
|
|
2317
|
+
useEffect(() => {
|
|
2318
|
+
if (!transformModeAware) return;
|
|
2319
|
+
const onKeyDown = (e: KeyboardEvent): void => {
|
|
2320
|
+
if (e.key.toLowerCase() !== 'v' || e.repeat || e.metaKey || e.ctrlKey || e.altKey) return;
|
|
2321
|
+
const t = e.target;
|
|
2322
|
+
if (
|
|
2323
|
+
t instanceof HTMLInputElement ||
|
|
2324
|
+
t instanceof HTMLTextAreaElement ||
|
|
2325
|
+
t instanceof HTMLSelectElement ||
|
|
2326
|
+
(t instanceof HTMLElement && t.isContentEditable)
|
|
2327
|
+
) {
|
|
2328
|
+
return;
|
|
2329
|
+
}
|
|
2330
|
+
const at = hoverClientRef.current;
|
|
2331
|
+
const selected = store.selectedEntityIds;
|
|
2332
|
+
if (!at || selected.size !== 1 || gestureRef.current || spatialDragRef.current) return;
|
|
2333
|
+
const id = [...selected][0]!;
|
|
2334
|
+
const provider = spatialHandlesForId(adapter, id);
|
|
2335
|
+
const handle = provider
|
|
2336
|
+
?.layers(id)
|
|
2337
|
+
.find((layer) => layer.category === 'origin')
|
|
2338
|
+
?.handles.find((h) => h.writable);
|
|
2339
|
+
if (!provider || !handle) return;
|
|
2340
|
+
e.preventDefault();
|
|
2341
|
+
const local = snapPivot(id, toHostLocal(at.x, at.y), false);
|
|
2342
|
+
void provider.commit(id, handle.id, [local.x, local.y, 0]);
|
|
2343
|
+
bumpGesture();
|
|
2344
|
+
};
|
|
2345
|
+
window.addEventListener('keydown', onKeyDown);
|
|
2346
|
+
return () => window.removeEventListener('keydown', onKeyDown);
|
|
2347
|
+
}, [adapter, store, transformModeAware, toHostLocal, bumpGesture, snapPivot]);
|
|
2348
|
+
|
|
2349
|
+
// D4 (spec27 §8 "space-pan" row) — the Space-held ARM SWITCH for the pan
|
|
2350
|
+
// gesture below (`onPointerDown`'s own early branch). A raw `window`
|
|
2351
|
+
// keydown/keyup pair, mounted once — NOT routed through `registerHotkeys`/
|
|
2352
|
+
// `editor-hotkeys.ts` (that registry fires once per keydown and has no
|
|
2353
|
+
// "held" concept; this mirrors the existing vertex-snap V-key pattern in
|
|
2354
|
+
// `viewport-hotkeys.ts` instead, which is the established precedent for a
|
|
2355
|
+
// literal key-HOLD state in this codebase). Matches `hotkeys.ts`'s own
|
|
2356
|
+
// not-typing guard exactly (input/textarea/select/contentEditable) so Space
|
|
2357
|
+
// still types a literal space character in any of those, never arming pan.
|
|
2358
|
+
// A focused `<Button>` is ALSO excluded so Space keeps its native
|
|
2359
|
+
// accessible "activate the focused control" behavior instead of arming pan
|
|
2360
|
+
// out from under it. `e.repeat` is ignored so an OS key-repeat storm is a
|
|
2361
|
+
// single no-op transition, not thousands of redundant `preventDefault`s.
|
|
2362
|
+
useEffect(() => {
|
|
2363
|
+
const isTypingTarget = (t: EventTarget | null): boolean =>
|
|
2364
|
+
t instanceof HTMLInputElement ||
|
|
2365
|
+
t instanceof HTMLTextAreaElement ||
|
|
2366
|
+
t instanceof HTMLSelectElement ||
|
|
2367
|
+
(t instanceof HTMLElement && t.isContentEditable);
|
|
2368
|
+
const onKeyDown = (e: KeyboardEvent): void => {
|
|
2369
|
+
if (e.code !== 'Space' || e.repeat) return;
|
|
2370
|
+
if (isTypingTarget(e.target) || e.target instanceof HTMLButtonElement) return;
|
|
2371
|
+
// Space's native action is page-scroll (or activating a focused
|
|
2372
|
+
// button, excluded above) — the hold-to-pan gesture replaces it, so
|
|
2373
|
+
// suppress the default here rather than fighting a scrolled page mid-drag.
|
|
2374
|
+
e.preventDefault();
|
|
2375
|
+
spaceHeldRef.current = true;
|
|
2376
|
+
setSpaceHeld(true);
|
|
2377
|
+
};
|
|
2378
|
+
const onKeyUp = (e: KeyboardEvent): void => {
|
|
2379
|
+
if (e.code !== 'Space') return;
|
|
2380
|
+
spaceHeldRef.current = false;
|
|
2381
|
+
setSpaceHeld(false);
|
|
2382
|
+
// Releasing Space mid-drag ends the pan exactly like a pointerup would
|
|
2383
|
+
// (see `onPointerUp`'s own early branch) — the pointer may still be
|
|
2384
|
+
// down, but with the arm switch off a further move must fall through
|
|
2385
|
+
// to ordinary hover/drag handling, never keep panning.
|
|
2386
|
+
if (panDragRef.current) {
|
|
2387
|
+
panDragRef.current = null;
|
|
2388
|
+
setIsPanning(false);
|
|
2389
|
+
}
|
|
2390
|
+
};
|
|
2391
|
+
window.addEventListener('keydown', onKeyDown);
|
|
2392
|
+
window.addEventListener('keyup', onKeyUp);
|
|
2393
|
+
return () => {
|
|
2394
|
+
window.removeEventListener('keydown', onKeyDown);
|
|
2395
|
+
window.removeEventListener('keyup', onKeyUp);
|
|
2396
|
+
};
|
|
2397
|
+
}, []);
|
|
2398
|
+
|
|
2399
|
+
const onPrimaryPointerDown = useCallback(
|
|
2400
|
+
(e: ReactPointerEvent<HTMLElement>) => {
|
|
2401
|
+
// Camera navigation is bound to the viewport container so right/middle
|
|
2402
|
+
// drags can pass through this DOM overlay. Keep left-button authoring
|
|
2403
|
+
// gestures owned here: OrbitControls captures every pointer it sees,
|
|
2404
|
+
// including left clicks, which would otherwise retarget pointerup to
|
|
2405
|
+
// the container before this overlay can finish selection/marquee/edit.
|
|
2406
|
+
e.stopPropagation();
|
|
2407
|
+
// Keep the complete interaction session on this full-viewport surface,
|
|
2408
|
+
// even when the pointer leaves the viewport or crosses transformed
|
|
2409
|
+
// story/frame content. Without capture, a fast Figma-style marquee or
|
|
2410
|
+
// Space-drag can strand the overlay in a half-finished gesture.
|
|
2411
|
+
capturePointer(e);
|
|
2412
|
+
// D4 (spec27 §8 "space-pan" row) — a press while Space is held ARMS a
|
|
2413
|
+
// pan-drag instead of the ordinary pick/select/marquee/eyedropper flow
|
|
2414
|
+
// below, which it skips entirely (never fights another gesture for the
|
|
2415
|
+
// same pointer session — same precedent `endGesture`/`reorderPreview`'s
|
|
2416
|
+
// early returns in `onPointerUp` already set). Seeds from the CURRENT
|
|
2417
|
+
// document view so the drag delta composes onto whatever pan was
|
|
2418
|
+
// already in effect.
|
|
2419
|
+
if (spaceHeldRef.current) {
|
|
2420
|
+
panDragRef.current = {
|
|
2421
|
+
startClientX: e.clientX,
|
|
2422
|
+
startClientY: e.clientY,
|
|
2423
|
+
startPan: view.get(),
|
|
2424
|
+
};
|
|
2425
|
+
setIsPanning(true);
|
|
2426
|
+
return;
|
|
2427
|
+
}
|
|
2428
|
+
// D3.d — a click while an eyedropper session is open SAMPLES AND ENDS
|
|
2429
|
+
// the session instead of the ordinary click-select flow below (which it
|
|
2430
|
+
// deliberately skips entirely — picking a color is not a selection
|
|
2431
|
+
// gesture).
|
|
2432
|
+
// Godot's Select-mode modifier drags on a native 2D surface: Cmd (Ctrl) rotates the selected
|
|
2433
|
+
// node about its pivot, Cmd+Alt (Ctrl+Alt) scales it — wherever the press lands.
|
|
2434
|
+
// With several nodes selected, Rotate and Scale mode act on the whole group from any press, as
|
|
2435
|
+
// Godot's modes do on a multi-selection.
|
|
2436
|
+
if (
|
|
2437
|
+
transformModeAware &&
|
|
2438
|
+
store.selectedEntityIds.size > 1 &&
|
|
2439
|
+
!(e.metaKey || e.ctrlKey) &&
|
|
2440
|
+
(store.transformMode === 'rotate' || store.transformMode === 'scale')
|
|
2441
|
+
) {
|
|
2442
|
+
if (store.transformMode === 'rotate') startRotateGesture(e);
|
|
2443
|
+
else startNativeScaleGesture('both', e);
|
|
2444
|
+
return;
|
|
2445
|
+
}
|
|
2446
|
+
if (transformModeAware && store.selectedEntityIds.size >= 1 && (e.metaKey || e.ctrlKey)) {
|
|
2447
|
+
if (e.altKey) startNativeScaleGesture('both', e);
|
|
2448
|
+
else startRotateGesture(e);
|
|
2449
|
+
return;
|
|
2450
|
+
}
|
|
2451
|
+
if (eyedropperActive) {
|
|
2452
|
+
resolveEyedropperSession(sampleColorAt(e.clientX, e.clientY));
|
|
2453
|
+
setEyedropperPreview(null);
|
|
2454
|
+
return;
|
|
2455
|
+
}
|
|
2456
|
+
// The project viewport's Three canvas sits below this full-cover
|
|
2457
|
+
// interaction layer. Give editor-owned effectors first refusal through
|
|
2458
|
+
// the viewport's own raycaster; activation selects their REAL authored
|
|
2459
|
+
// target/pole with exact intent and leaves ordinary world picking alone.
|
|
2460
|
+
if (activateViewportEditorControl(scopedAdapter, e.clientX, e.clientY)) {
|
|
2461
|
+
dragRef.current = null;
|
|
2462
|
+
setHoverId(null);
|
|
2463
|
+
setMarquee(null);
|
|
2464
|
+
return;
|
|
2465
|
+
}
|
|
2466
|
+
const hitAtDown = pickAt(e.clientX, e.clientY);
|
|
2467
|
+
// A board shows every story, while the authoring adapter intentionally
|
|
2468
|
+
// exposes only the active story's DOM. A press inside an inactive frame
|
|
2469
|
+
// therefore has no adapter hit. Treat the frame itself as the first
|
|
2470
|
+
// selection target: activate/focus that story, consume this pointer
|
|
2471
|
+
// session, and let a subsequent press edit its DOM. This makes the whole
|
|
2472
|
+
// frame behave like a Figma frame instead of requiring the tiny label.
|
|
2473
|
+
if (selectInactiveStoryFrame(e, hitAtDown)) {
|
|
2474
|
+
dragRef.current = null;
|
|
2475
|
+
setHoverId(null);
|
|
2476
|
+
setMarquee(null);
|
|
2477
|
+
return;
|
|
2478
|
+
}
|
|
2479
|
+
|
|
2480
|
+
// D2 (spec:322): a press on an ALREADY-SELECTED, boxEdit-capable,
|
|
2481
|
+
// absolutely/fixed-positioned node RECORDS a move CANDIDATE — it does NOT
|
|
2482
|
+
// begin the gesture here. Promotion waits for the drag threshold
|
|
2483
|
+
// (`onPointerMove`), so a plain click (or shift-click) still falls
|
|
2484
|
+
// through to `resolveClickSelection` on pointerup (multi-select narrow /
|
|
2485
|
+
// shift-toggle preserved). A non-positioned node, or a hit that isn't yet
|
|
2486
|
+
// selected, records no candidate and keeps today's click-select behavior.
|
|
2487
|
+
// The drag SUBJECT is the nearest selected ancestor of the deep pick
|
|
2488
|
+
// (usually the pick itself) — see `nearestSelectedAncestor`'s doc
|
|
2489
|
+
// comment. `hitAtDown` keeps the deep pick for click-select on release.
|
|
2490
|
+
// Godot's Alt+Drag moves the selected node wherever the press lands.
|
|
2491
|
+
const altMove = transformModeAware && e.altKey && store.selectedEntityIds.size === 1;
|
|
2492
|
+
const moveSubject = altMove
|
|
2493
|
+
? [...store.selectedEntityIds][0]!
|
|
2494
|
+
: hitAtDown !== null
|
|
2495
|
+
? nearestSelectedAncestor(adapter, hitAtDown, store.selectedEntityIds)
|
|
2496
|
+
: null;
|
|
2497
|
+
const moveCandidate: DragState['moveCandidate'] =
|
|
2498
|
+
transformArms(transformModeAware, store.transformMode, 'translate') && moveSubject !== null
|
|
2499
|
+
? resolveMoveCandidate(adapter, moveSubject, store.selectedEntityIds)
|
|
2500
|
+
: null;
|
|
2501
|
+
|
|
2502
|
+
// D2.b: the SAME "already selected" gate, checked ONLY when there's no
|
|
2503
|
+
// move candidate — a positioned node moves (writes x/y); a flow/list
|
|
2504
|
+
// child (never absolute/fixed, so `moveCandidate` above is always
|
|
2505
|
+
// `null` for it) reorders instead (writes source order via
|
|
2506
|
+
// `structure.reorder`). The two are mutually exclusive per press.
|
|
2507
|
+
const reorderCandidate: DragState['reorderCandidate'] =
|
|
2508
|
+
!moveCandidate && moveSubject !== null
|
|
2509
|
+
? resolveReorderCandidate(adapter, moveSubject)
|
|
2510
|
+
: null;
|
|
2511
|
+
|
|
2512
|
+
dragRef.current = {
|
|
2513
|
+
downClientX: e.clientX,
|
|
2514
|
+
downClientY: e.clientY,
|
|
2515
|
+
downLocal: toHostLocal(e.clientX, e.clientY),
|
|
2516
|
+
hitAtDown,
|
|
2517
|
+
moved: false,
|
|
2518
|
+
moveCandidate,
|
|
2519
|
+
reorderCandidate,
|
|
2520
|
+
};
|
|
2521
|
+
setHoverId(null);
|
|
2522
|
+
setMarquee(null);
|
|
2523
|
+
},
|
|
2524
|
+
[
|
|
2525
|
+
adapter,
|
|
2526
|
+
store,
|
|
2527
|
+
view,
|
|
2528
|
+
transformModeAware,
|
|
2529
|
+
toHostLocal,
|
|
2530
|
+
setMarquee,
|
|
2531
|
+
eyedropperActive,
|
|
2532
|
+
sampleColorAt,
|
|
2533
|
+
pickAt,
|
|
2534
|
+
scopedAdapter,
|
|
2535
|
+
startNativeScaleGesture,
|
|
2536
|
+
startRotateGesture,
|
|
2537
|
+
],
|
|
2538
|
+
);
|
|
2539
|
+
|
|
2540
|
+
const onPointerDown = useCallback(
|
|
2541
|
+
(e: ReactPointerEvent<HTMLElement>) => {
|
|
2542
|
+
if (e.button === 2) {
|
|
2543
|
+
secondaryPressRef.current = {
|
|
2544
|
+
pointerId: e.pointerId,
|
|
2545
|
+
startClientX: e.clientX,
|
|
2546
|
+
startClientY: e.clientY,
|
|
2547
|
+
moved: false,
|
|
2548
|
+
};
|
|
2549
|
+
// Do not capture, prevent, or stop this event: the spatial viewport
|
|
2550
|
+
// underneath owns right-drag orbit/pan. This overlay owns only the
|
|
2551
|
+
// stationary-click menu handled above.
|
|
2552
|
+
return;
|
|
2553
|
+
}
|
|
2554
|
+
if (e.button === 0) onPrimaryPointerDown(e);
|
|
2555
|
+
},
|
|
2556
|
+
[onPrimaryPointerDown],
|
|
2557
|
+
);
|
|
2558
|
+
|
|
2559
|
+
/** The active-drag half of `onPointerMove` (drag-threshold promotion +
|
|
2560
|
+
* move-gesture promotion + marquee update) — factored into its OWN
|
|
2561
|
+
* `useCallback` purely to keep `onPointerMove` itself under the
|
|
2562
|
+
* cognitive-complexity ceiling; behavior is unchanged. */
|
|
2563
|
+
const handleActiveDrag = useCallback(
|
|
2564
|
+
(drag: DragState, e: ReactPointerEvent<HTMLElement>) => {
|
|
2565
|
+
const dx = e.clientX - drag.downClientX;
|
|
2566
|
+
const dy = e.clientY - drag.downClientY;
|
|
2567
|
+
if (!drag.moved && Math.hypot(dx, dy) > DRAG_THRESHOLD_PX) drag.moved = true;
|
|
2568
|
+
if (!drag.moved) return;
|
|
2569
|
+
|
|
2570
|
+
// D2: threshold crossed with a deferred move candidate — promote it to a
|
|
2571
|
+
// live move gesture NOW (not at pointerdown).
|
|
2572
|
+
if (drag.moveCandidate) {
|
|
2573
|
+
const g = promoteMoveCandidate(drag.moveCandidate, drag.downLocal);
|
|
2574
|
+
gestureRef.current = g;
|
|
2575
|
+
applyGesturePatch(g, e);
|
|
2576
|
+
return;
|
|
2577
|
+
}
|
|
2578
|
+
// D2.b: threshold crossed with a deferred reorder candidate — compute
|
|
2579
|
+
// the live insertion gap for the CURRENT pointer position and update
|
|
2580
|
+
// the preview (+ its drag-ghost). This is NOT a `GestureState` (no
|
|
2581
|
+
// `boxEdit` bracket — see `reorderPreviewRef`'s doc comment) so it has
|
|
2582
|
+
// no `promote*` step; every move while it's live just recomputes the
|
|
2583
|
+
// gap. `gap` can be `null` only when `siblingRects` is somehow empty
|
|
2584
|
+
// (the capability gate above should make this unreachable) — in that
|
|
2585
|
+
// case this deliberately falls through to NEITHER a preview NOR the
|
|
2586
|
+
// marquee branch below (guarded by the `return`), matching "does not
|
|
2587
|
+
// trigger marquee" for a reorder-candidate drag regardless.
|
|
2588
|
+
if (drag.reorderCandidate) {
|
|
2589
|
+
const local = toHostLocal(e.clientX, e.clientY);
|
|
2590
|
+
const gap = computeReorderGap(drag.reorderCandidate.siblingRects, local);
|
|
2591
|
+
if (gap) {
|
|
2592
|
+
setReorderPreview({
|
|
2593
|
+
id: drag.reorderCandidate.id,
|
|
2594
|
+
adapter: drag.reorderCandidate.adapter,
|
|
2595
|
+
gap,
|
|
2596
|
+
});
|
|
2597
|
+
setDragGhost(
|
|
2598
|
+
computeDragGhost(
|
|
2599
|
+
drag.reorderCandidate.origRect,
|
|
2600
|
+
local.x - drag.downLocal.x,
|
|
2601
|
+
local.y - drag.downLocal.y,
|
|
2602
|
+
),
|
|
2603
|
+
);
|
|
2604
|
+
}
|
|
2605
|
+
return;
|
|
2606
|
+
}
|
|
2607
|
+
// Marquee only starts when the drag began over EMPTY space (no pick at
|
|
2608
|
+
// pointerdown) — a drag on a NON-candidate hit (unselected, or a
|
|
2609
|
+
// non-positioned node) is today's plain click-select, no marquee.
|
|
2610
|
+
if (drag.hitAtDown === null) {
|
|
2611
|
+
const local = toHostLocal(e.clientX, e.clientY);
|
|
2612
|
+
setMarquee({
|
|
2613
|
+
x: Math.min(drag.downLocal.x, local.x),
|
|
2614
|
+
y: Math.min(drag.downLocal.y, local.y),
|
|
2615
|
+
width: Math.abs(local.x - drag.downLocal.x),
|
|
2616
|
+
height: Math.abs(local.y - drag.downLocal.y),
|
|
2617
|
+
});
|
|
2618
|
+
}
|
|
2619
|
+
},
|
|
2620
|
+
[toHostLocal, setMarquee, applyGesturePatch, setReorderPreview],
|
|
2621
|
+
);
|
|
2622
|
+
|
|
2623
|
+
const onPointerMove = useCallback(
|
|
2624
|
+
(e: ReactPointerEvent<HTMLElement>) => {
|
|
2625
|
+
// D4 — an armed pan-drag just writes the shared pan translate from the
|
|
2626
|
+
// client-space delta since pointerdown (client px, NOT `toHostLocal` —
|
|
2627
|
+
// the pan target IS the frame `toHostLocal` reads through, so this must
|
|
2628
|
+
// stay in the frame-independent client space); nothing else runs
|
|
2629
|
+
// underneath it.
|
|
2630
|
+
const panDrag = panDragRef.current;
|
|
2631
|
+
if (panDrag) {
|
|
2632
|
+
view.setPan(
|
|
2633
|
+
panDrag.startPan.x + (e.clientX - panDrag.startClientX),
|
|
2634
|
+
panDrag.startPan.y + (e.clientY - panDrag.startClientY),
|
|
2635
|
+
);
|
|
2636
|
+
return;
|
|
2637
|
+
}
|
|
2638
|
+
// D3.d — while an eyedropper session is open, every move just updates
|
|
2639
|
+
// the cursor-following preview swatch; no hover/drag/gesture logic runs
|
|
2640
|
+
// underneath it (a real color-at-point pick, not a selection gesture).
|
|
2641
|
+
if (eyedropperActive) {
|
|
2642
|
+
const local = toHostLocal(e.clientX, e.clientY);
|
|
2643
|
+
setEyedropperPreview({ ...local, color: sampleColorAt(e.clientX, e.clientY) });
|
|
2644
|
+
return;
|
|
2645
|
+
}
|
|
2646
|
+
hoverClientRef.current = { x: e.clientX, y: e.clientY };
|
|
2647
|
+
const gesture = gestureRef.current;
|
|
2648
|
+
if (gesture) {
|
|
2649
|
+
applyGesturePatch(gesture, e);
|
|
2650
|
+
return;
|
|
2651
|
+
}
|
|
2652
|
+
// No button held — plain hover: pick under the cursor, draw a highlight.
|
|
2653
|
+
if ((e.buttons & 1) === 0) {
|
|
2654
|
+
updateViewportEditorControlHover(
|
|
2655
|
+
scopedAdapter,
|
|
2656
|
+
e.clientX,
|
|
2657
|
+
e.clientY,
|
|
2658
|
+
pickAt,
|
|
2659
|
+
setEditorControlCursor,
|
|
2660
|
+
setHoverId,
|
|
2661
|
+
);
|
|
2662
|
+
return;
|
|
2663
|
+
}
|
|
2664
|
+
const drag = dragRef.current;
|
|
2665
|
+
if (!drag) return;
|
|
2666
|
+
handleActiveDrag(drag, e);
|
|
2667
|
+
},
|
|
2668
|
+
[
|
|
2669
|
+
store,
|
|
2670
|
+
view,
|
|
2671
|
+
toHostLocal,
|
|
2672
|
+
applyGesturePatch,
|
|
2673
|
+
eyedropperActive,
|
|
2674
|
+
sampleColorAt,
|
|
2675
|
+
handleActiveDrag,
|
|
2676
|
+
pickAt,
|
|
2677
|
+
scopedAdapter,
|
|
2678
|
+
],
|
|
2679
|
+
);
|
|
2680
|
+
|
|
2681
|
+
const onPointerUp = useCallback(
|
|
2682
|
+
(e: ReactPointerEvent<HTMLElement>) => {
|
|
2683
|
+
releaseCapturedPointer(e);
|
|
2684
|
+
// D4 — releasing the pointer ends an armed pan-drag here, same early-
|
|
2685
|
+
// return precedent as `endGesture()`/the reorder-preview branch below:
|
|
2686
|
+
// a pan-drag never falls through to click-select.
|
|
2687
|
+
if (panDragRef.current) {
|
|
2688
|
+
panDragRef.current = null;
|
|
2689
|
+
setIsPanning(false);
|
|
2690
|
+
return;
|
|
2691
|
+
}
|
|
2692
|
+
// A gesture in progress commits+ends here and skips the ordinary
|
|
2693
|
+
// click-select logic below entirely (step 6).
|
|
2694
|
+
const gesture = gestureRef.current;
|
|
2695
|
+
if (gesture?.pointerId !== undefined && e.pointerId !== gesture.pointerId) return;
|
|
2696
|
+
if (endGesture()) return;
|
|
2697
|
+
// D2.b — a live reorder-preview commits here: ONE `structure.reorder`
|
|
2698
|
+
// call (on-contract since T0/D-1, so this lands on the existing
|
|
2699
|
+
// checksum-guarded whole-file undo/redo timeline — the same "one undo
|
|
2700
|
+
// step" property every other structural op in this file already has),
|
|
2701
|
+
// then clears the preview + ghost + drag state, skipping click-select
|
|
2702
|
+
// entirely (mirrors `endGesture`'s own early return above).
|
|
2703
|
+
const reorderPreviewNow = reorderPreviewRef.current;
|
|
2704
|
+
if (reorderPreviewNow) {
|
|
2705
|
+
setReorderPreview(null);
|
|
2706
|
+
setDragGhost(null);
|
|
2707
|
+
dragRef.current = null;
|
|
2708
|
+
reorderAuthoringNode(
|
|
2709
|
+
reorderPreviewNow.adapter,
|
|
2710
|
+
reorderPreviewNow.id,
|
|
2711
|
+
reorderPreviewNow.gap.beforeSiblingId,
|
|
2712
|
+
);
|
|
2713
|
+
return;
|
|
2714
|
+
}
|
|
2715
|
+
const drag = dragRef.current;
|
|
2716
|
+
dragRef.current = null;
|
|
2717
|
+
if (!drag) return;
|
|
2718
|
+
const nextSelection =
|
|
2719
|
+
drag.hitAtDown !== null
|
|
2720
|
+
? resolveClickSelection(store.selectedEntityIds, drag.hitAtDown, e.shiftKey)
|
|
2721
|
+
: resolveEmptySpaceSelection(adapter, drag.moved, marqueeRef.current, e.shiftKey);
|
|
2722
|
+
if (nextSelection !== null) setAuthoringSelection(adapter, nextSelection);
|
|
2723
|
+
setMarquee(null);
|
|
2724
|
+
},
|
|
2725
|
+
[adapter, store, setMarquee, endGesture, setReorderPreview],
|
|
2726
|
+
);
|
|
2727
|
+
|
|
2728
|
+
const onPointerCancel = useCallback(
|
|
2729
|
+
(e: ReactPointerEvent<HTMLElement>) => {
|
|
2730
|
+
releaseCapturedPointer(e);
|
|
2731
|
+
// D4 — a cancelled pan-drag (e.g. pointer capture lost) just disarms;
|
|
2732
|
+
// there is nothing to commit (the pan writes were already live, same as
|
|
2733
|
+
// every other gesture's `apply` — cancelling a pan simply stops moving it
|
|
2734
|
+
// further, it does not revert, matching resize/move's own no-revert
|
|
2735
|
+
// cancel semantics below).
|
|
2736
|
+
if (panDragRef.current) {
|
|
2737
|
+
panDragRef.current = null;
|
|
2738
|
+
setIsPanning(false);
|
|
2739
|
+
return;
|
|
2740
|
+
}
|
|
2741
|
+
const gesture = gestureRef.current;
|
|
2742
|
+
if (gesture?.pointerId !== undefined && e.pointerId !== gesture.pointerId) return;
|
|
2743
|
+
if (endGesture()) return;
|
|
2744
|
+
// D2.b — a CANCELLED reorder drag (e.g. pointer capture lost) clears the
|
|
2745
|
+
// preview/ghost WITHOUT committing — unlike `onPointerUp` above, this is
|
|
2746
|
+
// never a "drop", so no `structure.reorder` call here.
|
|
2747
|
+
if (reorderPreviewRef.current) {
|
|
2748
|
+
setReorderPreview(null);
|
|
2749
|
+
setDragGhost(null);
|
|
2750
|
+
}
|
|
2751
|
+
dragRef.current = null;
|
|
2752
|
+
setMarquee(null);
|
|
2753
|
+
},
|
|
2754
|
+
[endGesture, setMarquee, setReorderPreview],
|
|
2755
|
+
);
|
|
2756
|
+
|
|
2757
|
+
const onPointerLeave = useCallback(() => {
|
|
2758
|
+
hoverClientRef.current = null;
|
|
2759
|
+
if (!scopedAdapter) viewportEditorControls()?.clearHover();
|
|
2760
|
+
setEditorControlCursor(null);
|
|
2761
|
+
setHoverId(null);
|
|
2762
|
+
}, [scopedAdapter]);
|
|
2763
|
+
|
|
2764
|
+
// D3.b — double-click picks the node and opens the inline text editor
|
|
2765
|
+
// through `adapter.text` (owner-routed — `textForId`), rule zero: no DOM
|
|
2766
|
+
// read of the node's own text happens here, only the contract's own
|
|
2767
|
+
// `TextProvider.get`. Absent `text` (the ingested react-dom adapter has
|
|
2768
|
+
// none) ⇒ silently nothing, matching D3.a's identical capability-gating
|
|
2769
|
+
// stance for structural items. A `null` get (dynamic body / has child
|
|
2770
|
+
// elements) is a LOUD, honest refusal — no textarea, a visible indicator +
|
|
2771
|
+
// a console.warn, mirroring the store's existing dynamic-write-refusal
|
|
2772
|
+
// pattern (`ReactRootAuthoringAdapter`'s own `console.warn` + re-render).
|
|
2773
|
+
const onDoubleClick = useCallback(
|
|
2774
|
+
(e: ReactMouseEvent<HTMLDivElement>) => {
|
|
2775
|
+
// The React design board owns frame-level double-click before DOM text
|
|
2776
|
+
// editing: single click selects in place; double-click is the explicit
|
|
2777
|
+
// Figma-style zoom/recenter gesture, including for the active frame.
|
|
2778
|
+
const overlayContainer = interactionRef.current?.parentElement ?? null;
|
|
2779
|
+
if (zoomReactStoryFrameAtPoint(overlayContainer, e.clientX, e.clientY)) return;
|
|
2780
|
+
const hitId = pickAt(e.clientX, e.clientY);
|
|
2781
|
+
if (!hitId) return;
|
|
2782
|
+
const provider = textForId(adapter, hitId);
|
|
2783
|
+
if (!provider) return;
|
|
2784
|
+
const rect = rectForId(adapter, hitId);
|
|
2785
|
+
if (!rect) return;
|
|
2786
|
+
const text = provider.get(hitId);
|
|
2787
|
+
if (text === null) {
|
|
2788
|
+
// biome-ignore lint/suspicious/noConsole: deliberate, greppable refusal report — mirrors ReactRootAuthoringAdapter's own console.warn refusal pattern
|
|
2789
|
+
console.warn(
|
|
2790
|
+
`[RootSelectionOverlay] text edit refused for "${hitId}": dynamic body or has ` +
|
|
2791
|
+
'child elements — not inline-editable.',
|
|
2792
|
+
);
|
|
2793
|
+
setTextEdit(null);
|
|
2794
|
+
setTextEditRefused({ id: hitId, rect });
|
|
2795
|
+
return;
|
|
2796
|
+
}
|
|
2797
|
+
setTextEditRefused(null);
|
|
2798
|
+
setTextEdit({ id: hitId, rect, initialText: text });
|
|
2799
|
+
},
|
|
2800
|
+
[adapter, pickAt, setTextEdit],
|
|
2801
|
+
);
|
|
2802
|
+
|
|
2803
|
+
const commitTextEdit = useCallback(
|
|
2804
|
+
(text: string) => {
|
|
2805
|
+
const edit = textEditRef.current;
|
|
2806
|
+
if (!edit) return;
|
|
2807
|
+
const provider = textForId(adapter, edit.id);
|
|
2808
|
+
if (provider) {
|
|
2809
|
+
// D3.R1 (reopen fix) — `set` is fire-and-forget; stash id+rect so the
|
|
2810
|
+
// pending-commit effect above can surface the refused indicator once
|
|
2811
|
+
// the async write's own refusal-notify lands, instead of the edit
|
|
2812
|
+
// silently vanishing (the pre-fix bug: this closed the textarea
|
|
2813
|
+
// unconditionally and never checked the result at all).
|
|
2814
|
+
pendingTextCommitRef.current = { id: edit.id, rect: edit.rect };
|
|
2815
|
+
provider.set(edit.id, text);
|
|
2816
|
+
}
|
|
2817
|
+
setTextEdit(null);
|
|
2818
|
+
},
|
|
2819
|
+
[adapter, setTextEdit],
|
|
2820
|
+
);
|
|
2821
|
+
|
|
2822
|
+
const cancelTextEdit = useCallback(() => setTextEdit(null), [setTextEdit]);
|
|
2823
|
+
|
|
2824
|
+
if (!hasRectCapableChild(adapter)) return null;
|
|
2825
|
+
// The overlay is a DESIGN-TIME affordance and must be INERT whenever a real
|
|
2826
|
+
// game owns its own DOM/canvas and needs pointer events — Play, an ingested
|
|
2827
|
+
// unmodified game, or an auto-mounted `{ module }` adapter world — so this
|
|
2828
|
+
// component's full-cover `pointerEvents:'auto'` interaction layer never
|
|
2829
|
+
// swallows that game's own clicks (that regressed 20-ingest-react-rpg: the
|
|
2830
|
+
// menu→dungeon flow never advanced, the sprite never moved).
|
|
2831
|
+
//
|
|
2832
|
+
// Gate on the SAME "is a session like that actually active" predicates
|
|
2833
|
+
// `isPlayModeActive`/`isIngestActive`/`isModuleModeActive` already expose
|
|
2834
|
+
// (play-mode.ts / ingest/mount-ingest-root.ts / module-mode.ts) — NOT the coarser
|
|
2835
|
+
// `store.playState !== 'stopped'` flag a prior fix used here. That flag is
|
|
2836
|
+
// shared, multi-purpose editor state (it also drives tab-switching,
|
|
2837
|
+
// autosave suppression, etc.) and is `'playing'` for the ENTIRE lifetime of
|
|
2838
|
+
// those sessions, exactly like these predicates — so swapping in the
|
|
2839
|
+
// narrower, purpose-built signal changes nothing for the cases the prior
|
|
2840
|
+
// fix targeted. What it fixes: `mountDesignTimeLayers` (B1) mounts a LIVE,
|
|
2841
|
+
// editable `ReactRootAuthoringAdapter` for every non-focused react/pixi
|
|
2842
|
+
// world while the editor sits in ordinary stopped design-time editing
|
|
2843
|
+
// (27a's own scenario: select a react element, edit it, watch HMR settle)
|
|
2844
|
+
// — a session `playState` alone cannot distinguish from "a game is
|
|
2845
|
+
// running" the moment anything else in this growing multi-purpose flag's
|
|
2846
|
+
// surface touches it. Routing through the actual session predicates keeps
|
|
2847
|
+
// this component's own inertness tied to the ONE thing it actually cares
|
|
2848
|
+
// about (a foreign/first-party game owning the canvas), decoupled from
|
|
2849
|
+
// every other reason `playState` might change.
|
|
2850
|
+
if (isCanvasOwnedByARunningGame()) return null;
|
|
2851
|
+
|
|
2852
|
+
// Play→Stop replaces the live Pixi tree and its adapter in separate React
|
|
2853
|
+
// commits. Selection intentionally survives that transition, but an id from
|
|
2854
|
+
// the previous tree must not be sent through owner-routed providers while
|
|
2855
|
+
// the replacement hierarchy is still mounting. `hierarchy.node` is the
|
|
2856
|
+
// merged, non-owner-routed membership check, so filtering here keeps the
|
|
2857
|
+
// overlay quiet for that one frame and lets stable ids resume naturally.
|
|
2858
|
+
const selectedIds = new Set(
|
|
2859
|
+
[...store.selectedEntityIds].filter((id) => adapter.hierarchy.node(id) !== undefined),
|
|
2860
|
+
);
|
|
2861
|
+
const hoverRect =
|
|
2862
|
+
hoverId !== null && !selectedIds.has(hoverId) ? rectForId(adapter, hoverId) : null;
|
|
2863
|
+
|
|
2864
|
+
// B2/B3 — handles/rotate/dimension-label/spacing-bands render ONLY for
|
|
2865
|
+
// EXACTLY one selected node whose OWNER exposes `boxEdit` (spec:317 "render
|
|
2866
|
+
// ONLY when EXACTLY one node is selected AND its owner has boxEdit" —
|
|
2867
|
+
// multi-select keeps just the plain A3 boxes above). Computed by a
|
|
2868
|
+
// module-level helper purely to keep THIS function's own cognitive
|
|
2869
|
+
// complexity down.
|
|
2870
|
+
const single = computeSingleSelectionBoxEdit(adapter, selectedIds);
|
|
2871
|
+
const singleRect = single?.rect ?? null;
|
|
2872
|
+
const singleOwnerBoxEdit = single?.ownerBoxEdit ?? null;
|
|
2873
|
+
// A turned 2D node frames, handles and labels on its own box (Godot's 2D frame).
|
|
2874
|
+
const singleFrame = transformModeAware && single ? frameForId(adapter, single.id) : null;
|
|
2875
|
+
const singleTurned = singleFrame && frameIsTurned(singleFrame) ? singleFrame : null;
|
|
2876
|
+
const nativeGizmoOrigin =
|
|
2877
|
+
transformModeAware && single
|
|
2878
|
+
? (single.ownerBoxEdit.gizmoOrigin?.(single.id) ?? {
|
|
2879
|
+
x: single.rect.x + single.rect.width / 2,
|
|
2880
|
+
y: single.rect.y + single.rect.height / 2,
|
|
2881
|
+
})
|
|
2882
|
+
: null;
|
|
2883
|
+
const nativeGizmoSpan = NATIVE_GIZMO_LENGTH_PX / pan.zoom;
|
|
2884
|
+
// Godot draws a multi-selection's x and y scale handles at its first node; each scales the whole
|
|
2885
|
+
// selection along its axis.
|
|
2886
|
+
const groupFirstId = transformModeAware && selectedIds.size > 1 ? [...selectedIds][0]! : null;
|
|
2887
|
+
const groupGizmoOrigin = groupFirstId ? (boxEditForId(adapter, groupFirstId)?.gizmoOrigin?.(groupFirstId) ?? null) : null;
|
|
2888
|
+
const referencePoint = single ? single.ownerBoxEdit.referencePoint?.(single.id) : null;
|
|
2889
|
+
const spacingBands = single?.spacingBands ?? [];
|
|
2890
|
+
|
|
2891
|
+
// Adapter-owned component handles for the selection, in the SAME authored
|
|
2892
|
+
// frame as `rects` (an owner that answers in another frame is declined by
|
|
2893
|
+
// `spatialHandlesForId`). Exactly one selected node, mirroring every other
|
|
2894
|
+
// single-selection affordance above; `[]` for an owner with no handles, which
|
|
2895
|
+
// draws nothing at all.
|
|
2896
|
+
const spatialSelection = selectedIds.size === 1 ? [...selectedIds][0]! : null;
|
|
2897
|
+
const spatialHandles = spatialSelection ? spatialHandlesForId(adapter, spatialSelection) : null;
|
|
2898
|
+
const spatialLayers =
|
|
2899
|
+
spatialSelection && spatialHandles
|
|
2900
|
+
? spatialHandles.layers(spatialSelection).filter((layer) => {
|
|
2901
|
+
// A 2D view's pivot answers to its own Position gizmo (Godot's), never to the shell-wide
|
|
2902
|
+
// Helpers switch a 3D view flips.
|
|
2903
|
+
if (transformModeAware && layer.category === 'origin') return showPositionGizmo;
|
|
2904
|
+
if (!store.showHelpers) return false;
|
|
2905
|
+
const visibility = store.helperVisibility as Record<string, boolean | undefined>;
|
|
2906
|
+
// A category the shell does not name obeys the master Helpers toggle,
|
|
2907
|
+
// per the `SpatialHandleLayer` contract — never rejected for being
|
|
2908
|
+
// unknown.
|
|
2909
|
+
return visibility[layer.category] ?? true;
|
|
2910
|
+
})
|
|
2911
|
+
: [];
|
|
2912
|
+
|
|
2913
|
+
// D1 — measure lines, position/parent-layout badge, parent-container
|
|
2914
|
+
// outline (all READ-only — spec §6). Computed by a module-level helper,
|
|
2915
|
+
// same "keep THIS function's own cognitive complexity down" reason
|
|
2916
|
+
// `computeSingleSelectionBoxEdit` above is factored out.
|
|
2917
|
+
const d1 = transformModeAware
|
|
2918
|
+
? {
|
|
2919
|
+
selectedSingleRect: null,
|
|
2920
|
+
measureLines: [],
|
|
2921
|
+
positionBadge: null,
|
|
2922
|
+
showPositionBadge: false,
|
|
2923
|
+
parentRect: null,
|
|
2924
|
+
}
|
|
2925
|
+
: computeD1Affordances(adapter, selectedIds, hoverRect);
|
|
2926
|
+
|
|
2927
|
+
// D4.b — the multi-select align/distribute toolbar's entries, or `null`
|
|
2928
|
+
// when it shouldn't render (see `computeMultiSelectionAlign`'s doc
|
|
2929
|
+
// comment for the capability gate).
|
|
2930
|
+
const alignEntries = transformModeAware ? null : computeMultiSelectionAlign(adapter, selectedIds);
|
|
2931
|
+
|
|
2932
|
+
// D3.c — every currently-empty container hint reachable from the active
|
|
2933
|
+
// adapter (owner-routed per child, session-hidden roots excluded — see
|
|
2934
|
+
// `emptyContainerHintsFor`'s doc comment). Cached (see `emptyHintsCacheRef`
|
|
2935
|
+
// above) so a hover-only re-render reuses the last computed hints instead
|
|
2936
|
+
// of re-walking + re-measuring the whole tree.
|
|
2937
|
+
const emptyHintsCache = emptyHintsCacheRef.current;
|
|
2938
|
+
const emptyHints = transformModeAware
|
|
2939
|
+
? []
|
|
2940
|
+
: emptyHintsCache &&
|
|
2941
|
+
emptyHintsCache.adapter === adapter &&
|
|
2942
|
+
emptyHintsCache.version === storeVersion
|
|
2943
|
+
? emptyHintsCache.hints
|
|
2944
|
+
: (() => {
|
|
2945
|
+
const hints = emptyContainerHintsFor(adapter);
|
|
2946
|
+
emptyHintsCacheRef.current = { adapter, version: storeVersion, hints };
|
|
2947
|
+
return hints;
|
|
2948
|
+
})();
|
|
2949
|
+
|
|
2950
|
+
return (
|
|
2951
|
+
<div
|
|
2952
|
+
style={{
|
|
2953
|
+
position: 'absolute',
|
|
2954
|
+
inset: 0,
|
|
2955
|
+
zIndex: Z_INDEX,
|
|
2956
|
+
pointerEvents: 'none',
|
|
2957
|
+
}}
|
|
2958
|
+
>
|
|
2959
|
+
<div
|
|
2960
|
+
ref={interactionRef}
|
|
2961
|
+
data-testid="world-selection-overlay-interaction"
|
|
2962
|
+
style={{
|
|
2963
|
+
position: 'absolute',
|
|
2964
|
+
inset: 0,
|
|
2965
|
+
pointerEvents: 'auto',
|
|
2966
|
+
touchAction: 'none',
|
|
2967
|
+
userSelect: 'none',
|
|
2968
|
+
cursor:
|
|
2969
|
+
editorControlCursor ??
|
|
2970
|
+
interactionCursor(isPanning, spaceHeld, eyedropperActive, !!marquee),
|
|
2971
|
+
}}
|
|
2972
|
+
onPointerDownCapture={onPointerDown}
|
|
2973
|
+
onPointerMove={onPointerMove}
|
|
2974
|
+
onPointerUp={onPointerUp}
|
|
2975
|
+
onPointerCancel={onPointerCancel}
|
|
2976
|
+
onLostPointerCapture={onPointerCancel}
|
|
2977
|
+
onPointerLeave={onPointerLeave}
|
|
2978
|
+
onDoubleClick={onDoubleClick}
|
|
2979
|
+
onContextMenu={onContextMenu}
|
|
2980
|
+
onDragOver={onAssetDragOver}
|
|
2981
|
+
onDrop={onAssetDrop}
|
|
2982
|
+
/>
|
|
2983
|
+
{addMenu ? <AddNodeHere state={addMenu} onClose={() => setAddMenu(null)} /> : null}
|
|
2984
|
+
{pickMenu ? (
|
|
2985
|
+
<ViewportPickMenu state={pickMenu} adapter={adapter} onClose={() => setPickMenu(null)} />
|
|
2986
|
+
) : null}
|
|
2987
|
+
<div
|
|
2988
|
+
data-testid="world-selection-overlay-chrome"
|
|
2989
|
+
style={{
|
|
2990
|
+
position: 'absolute',
|
|
2991
|
+
inset: 0,
|
|
2992
|
+
pointerEvents: 'none',
|
|
2993
|
+
// Only visual chrome follows the authored world. The input sibling
|
|
2994
|
+
// above remains an untransformed, full-viewport hit surface.
|
|
2995
|
+
transform: panTransformValue(pan),
|
|
2996
|
+
transformOrigin: '0 0',
|
|
2997
|
+
}}
|
|
2998
|
+
>
|
|
2999
|
+
{/* D3.c — dashed empty-container placeholders, visual hints only. */}
|
|
3000
|
+
{!transformModeAware &&
|
|
3001
|
+
emptyHints.map((hint) => (
|
|
3002
|
+
<div
|
|
3003
|
+
key={hint.id}
|
|
3004
|
+
data-testid="world-empty-container-hint"
|
|
3005
|
+
data-entity-id={hint.id}
|
|
3006
|
+
title={hint.displayName}
|
|
3007
|
+
style={{
|
|
3008
|
+
position: 'absolute',
|
|
3009
|
+
left: hint.rect.x,
|
|
3010
|
+
top: hint.rect.y,
|
|
3011
|
+
width: hint.rect.width,
|
|
3012
|
+
height: hint.rect.height,
|
|
3013
|
+
// text-2 at 0.7 alpha — a quiet non-interactive hint outline.
|
|
3014
|
+
borderStyle: 'dashed',
|
|
3015
|
+
borderColor: 'rgba(154, 160, 166, 0.7)',
|
|
3016
|
+
borderWidth: transformModeAware ? 1 / pan.zoom : 1,
|
|
3017
|
+
boxSizing: 'border-box',
|
|
3018
|
+
pointerEvents: 'none',
|
|
3019
|
+
}}
|
|
3020
|
+
/>
|
|
3021
|
+
))}
|
|
3022
|
+
{hoverRect && (
|
|
3023
|
+
<div
|
|
3024
|
+
data-testid="world-selection-hover"
|
|
3025
|
+
style={{
|
|
3026
|
+
...boxStyle(hoverRect, false),
|
|
3027
|
+
...(transformModeAware ? { borderWidth: 1 / pan.zoom } : {}),
|
|
3028
|
+
}}
|
|
3029
|
+
/>
|
|
3030
|
+
)}
|
|
3031
|
+
{[...selectedIds].map((id) => {
|
|
3032
|
+
const r = rectForId(adapter, id);
|
|
3033
|
+
if (!r) return null;
|
|
3034
|
+
const frame = transformModeAware ? frameForId(adapter, id) : null;
|
|
3035
|
+
const turned = frame && frameIsTurned(frame) ? frame : null;
|
|
3036
|
+
return (
|
|
3037
|
+
<div key={id}>
|
|
3038
|
+
<div
|
|
3039
|
+
data-testid="world-selection-box"
|
|
3040
|
+
data-entity-id={id}
|
|
3041
|
+
data-turned={turned ? 'true' : undefined}
|
|
3042
|
+
style={{
|
|
3043
|
+
...(turned ? turnedBoxStyle(turned, 1 / pan.zoom) : boxStyle(r, true)),
|
|
3044
|
+
...(transformModeAware && !turned
|
|
3045
|
+
? {
|
|
3046
|
+
// Pixi's alpha outline now owns selection emphasis.
|
|
3047
|
+
// This remains the thin, distinct transform frame.
|
|
3048
|
+
borderWidth: 1 / pan.zoom,
|
|
3049
|
+
background: 'transparent',
|
|
3050
|
+
}
|
|
3051
|
+
: {}),
|
|
3052
|
+
}}
|
|
3053
|
+
/>
|
|
3054
|
+
{!transformModeAware && (
|
|
3055
|
+
<div data-testid="world-selection-label" style={labelStyle(r)}>
|
|
3056
|
+
{labelForId(adapter, id)}
|
|
3057
|
+
</div>
|
|
3058
|
+
)}
|
|
3059
|
+
</div>
|
|
3060
|
+
);
|
|
3061
|
+
})}
|
|
3062
|
+
{marquee && (
|
|
3063
|
+
<div
|
|
3064
|
+
data-testid="world-selection-marquee"
|
|
3065
|
+
style={{
|
|
3066
|
+
position: 'absolute',
|
|
3067
|
+
left: marquee.x,
|
|
3068
|
+
top: marquee.y,
|
|
3069
|
+
width: marquee.width,
|
|
3070
|
+
height: marquee.height,
|
|
3071
|
+
border: `1px solid ${ACCENT}`,
|
|
3072
|
+
background: `color-mix(in srgb, ${ACCENT} 10%, transparent)`,
|
|
3073
|
+
pointerEvents: 'none',
|
|
3074
|
+
}}
|
|
3075
|
+
/>
|
|
3076
|
+
)}
|
|
3077
|
+
{!transformModeAware && (
|
|
3078
|
+
<D1Affordances
|
|
3079
|
+
measureLines={d1.measureLines}
|
|
3080
|
+
snapGuides={snapGuides}
|
|
3081
|
+
positionBadge={d1.positionBadge}
|
|
3082
|
+
showPositionBadge={d1.showPositionBadge}
|
|
3083
|
+
badgeAnchorRect={d1.selectedSingleRect}
|
|
3084
|
+
parentRect={d1.parentRect}
|
|
3085
|
+
screenScale={chromeScale}
|
|
3086
|
+
screenZoom={1}
|
|
3087
|
+
/>
|
|
3088
|
+
)}
|
|
3089
|
+
{!transformModeAware && (
|
|
3090
|
+
<D2Affordances dragGhost={dragGhost} reorderPreview={reorderPreview} />
|
|
3091
|
+
)}
|
|
3092
|
+
{!transformModeAware && alignEntries && (
|
|
3093
|
+
<AlignToolbar adapter={adapter} entries={alignEntries} />
|
|
3094
|
+
)}
|
|
3095
|
+
{tempPivot ? (
|
|
3096
|
+
// Godot's temporary pivot: a cross where rotation turns the selection.
|
|
3097
|
+
<div
|
|
3098
|
+
data-testid="world-2d-temporary-pivot"
|
|
3099
|
+
aria-label="Temporary pivot"
|
|
3100
|
+
style={{
|
|
3101
|
+
position: 'absolute',
|
|
3102
|
+
left: tempPivot.x - 8,
|
|
3103
|
+
top: tempPivot.y - 8,
|
|
3104
|
+
width: 16,
|
|
3105
|
+
height: 16,
|
|
3106
|
+
transform: `scale(${chromeScale})`,
|
|
3107
|
+
transformOrigin: 'center',
|
|
3108
|
+
pointerEvents: 'none',
|
|
3109
|
+
background:
|
|
3110
|
+
'linear-gradient(#ffb020, #ffb020) center / 16px 2px no-repeat, linear-gradient(#ffb020, #ffb020) center / 2px 16px no-repeat',
|
|
3111
|
+
}}
|
|
3112
|
+
/>
|
|
3113
|
+
) : null}
|
|
3114
|
+
{groupGizmoOrigin &&
|
|
3115
|
+
transformArms(transformModeAware, store.transformMode, 'scale') &&
|
|
3116
|
+
showAxisGizmo &&
|
|
3117
|
+
axisGizmos &&
|
|
3118
|
+
(['x', 'y'] as const).map((axis) => (
|
|
3119
|
+
<div
|
|
3120
|
+
key={axis}
|
|
3121
|
+
data-testid={`world-2d-group-scale-${axis}`}
|
|
3122
|
+
title={axis === 'x' ? 'Scale the selection on X' : 'Scale the selection on Y'}
|
|
3123
|
+
style={{
|
|
3124
|
+
...handleStyle(
|
|
3125
|
+
nativeScaleHandlePoint(axis, groupGizmoOrigin, nativeGizmoSpan),
|
|
3126
|
+
axis === 'x' ? 'ew-resize' : 'ns-resize',
|
|
3127
|
+
),
|
|
3128
|
+
transform: `scale(${chromeScale})`,
|
|
3129
|
+
transformOrigin: 'center',
|
|
3130
|
+
background: axis === 'x' ? '#ef5350' : '#66bb6a',
|
|
3131
|
+
}}
|
|
3132
|
+
onPointerDown={(event) => startNativeScaleGesture(axis, event)}
|
|
3133
|
+
onPointerMove={onPointerMove}
|
|
3134
|
+
onPointerUp={onPointerUp}
|
|
3135
|
+
onPointerCancel={onPointerCancel}
|
|
3136
|
+
/>
|
|
3137
|
+
))}
|
|
3138
|
+
{singleRect && singleOwnerBoxEdit && (
|
|
3139
|
+
<>
|
|
3140
|
+
{/* B-fix (D1-visual + D4 review): the B3 spacing bands render
|
|
3141
|
+
* BEFORE the resize handles below — deliberately, so the handles
|
|
3142
|
+
* paint ON TOP wherever their small hit box overlaps a band. A
|
|
3143
|
+
* zero-value side still renders a `ZERO_STRIP_PX` grab-strip
|
|
3144
|
+
* (spec B3) flush against the rect edge, and a mid-edge handle's
|
|
3145
|
+
* clickable CENTER point sits exactly on that same edge — pre-fix,
|
|
3146
|
+
* the bands rendered AFTER the handles (reverse of this order) and
|
|
3147
|
+
* so sat on top in DOM/paint order, silently eating the
|
|
3148
|
+
* pointerdown meant for the handle (n/s/e/w all affected; the
|
|
3149
|
+
* corner handles were not — a corner's point always lands on the
|
|
3150
|
+
* EXCLUSIVE far edge of its two adjacent bands). This is a pure
|
|
3151
|
+
* DOM-order swap: the band geometry/hit area is UNCHANGED, so
|
|
3152
|
+
* every band remains fully draggable everywhere it isn't directly
|
|
3153
|
+
* shadowed by a handle's own small box. */}
|
|
3154
|
+
{!transformModeAware &&
|
|
3155
|
+
spacingBands.map((band) => (
|
|
3156
|
+
<div key={band.side}>
|
|
3157
|
+
<div
|
|
3158
|
+
data-testid={`world-spacing-${band.kind}-${spacingDirection(band.side)}`}
|
|
3159
|
+
style={spacingBandStyle(band)}
|
|
3160
|
+
onPointerDown={(e) => startSpacingGesture(band, e)}
|
|
3161
|
+
onPointerMove={onPointerMove}
|
|
3162
|
+
onPointerUp={onPointerUp}
|
|
3163
|
+
onPointerCancel={onPointerCancel}
|
|
3164
|
+
/>
|
|
3165
|
+
{!band.isZero && (
|
|
3166
|
+
<div
|
|
3167
|
+
style={{
|
|
3168
|
+
...spacingLabelStyle(band),
|
|
3169
|
+
...(transformModeAware
|
|
3170
|
+
? {
|
|
3171
|
+
transform: `translate(-50%, -50%) scale(${chromeScale})`,
|
|
3172
|
+
transformOrigin: 'center',
|
|
3173
|
+
}
|
|
3174
|
+
: {}),
|
|
3175
|
+
}}
|
|
3176
|
+
>
|
|
3177
|
+
{Math.round(band.value)}
|
|
3178
|
+
</div>
|
|
3179
|
+
)}
|
|
3180
|
+
</div>
|
|
3181
|
+
))}
|
|
3182
|
+
{referencePoint && (
|
|
3183
|
+
<div
|
|
3184
|
+
aria-label={`Edit ${referencePoint.label}`}
|
|
3185
|
+
data-reference-kind={referencePoint.kind}
|
|
3186
|
+
data-testid="world-reference-point-handle"
|
|
3187
|
+
title={`${referencePoint.label} (${referencePoint.bounded ? 'native corner/edge/center snap' : '8px/alignment snap'}, Alt for free movement)`}
|
|
3188
|
+
style={{
|
|
3189
|
+
...referencePointStyle(referencePoint),
|
|
3190
|
+
...(transformModeAware
|
|
3191
|
+
? {
|
|
3192
|
+
transform: `scale(${chromeScale})`,
|
|
3193
|
+
transformOrigin: 'center',
|
|
3194
|
+
}
|
|
3195
|
+
: {}),
|
|
3196
|
+
}}
|
|
3197
|
+
onPointerDown={(event) => startReferenceGesture(referencePoint, event)}
|
|
3198
|
+
onPointerMove={onPointerMove}
|
|
3199
|
+
onPointerUp={onPointerUp}
|
|
3200
|
+
onPointerCancel={onPointerCancel}
|
|
3201
|
+
onLostPointerCapture={onPointerCancel}
|
|
3202
|
+
role="button"
|
|
3203
|
+
tabIndex={0}
|
|
3204
|
+
>
|
|
3205
|
+
{referencePoint.kind === 'anchor' ? 'A' : 'P'}
|
|
3206
|
+
</div>
|
|
3207
|
+
)}
|
|
3208
|
+
{/* Adapter-owned component handles — the ONE draggable point per
|
|
3209
|
+
entry, drawn where the provider says it is. The 3D viewport
|
|
3210
|
+
draws the same contract's layers by raycast; this is the same
|
|
3211
|
+
data in the surface where a 2D world is actually painted. */}
|
|
3212
|
+
{spatialSelection &&
|
|
3213
|
+
spatialHandles &&
|
|
3214
|
+
spatialLayers.flatMap((layer) =>
|
|
3215
|
+
layer.handles.map((handle) => (
|
|
3216
|
+
<div
|
|
3217
|
+
aria-label={handle.label}
|
|
3218
|
+
data-testid="world-spatial-handle"
|
|
3219
|
+
data-handle-id={handle.id}
|
|
3220
|
+
data-writable={handle.writable}
|
|
3221
|
+
key={`${layer.id}:${handle.id}`}
|
|
3222
|
+
role="button"
|
|
3223
|
+
tabIndex={0}
|
|
3224
|
+
title={handle.writable ? handle.label : `${handle.label} — ${handle.reason}`}
|
|
3225
|
+
style={{
|
|
3226
|
+
...spatialHandleStyle(handle),
|
|
3227
|
+
transform: `translate(-50%, -50%) scale(${chromeScale})`,
|
|
3228
|
+
}}
|
|
3229
|
+
onPointerDown={(event) =>
|
|
3230
|
+
onSpatialHandleDown(spatialSelection, spatialHandles, handle, event)
|
|
3231
|
+
}
|
|
3232
|
+
onPointerMove={onSpatialHandleMove}
|
|
3233
|
+
onPointerUp={onSpatialHandleUp}
|
|
3234
|
+
onPointerCancel={onSpatialHandleUp}
|
|
3235
|
+
onLostPointerCapture={onSpatialHandleUp}
|
|
3236
|
+
/>
|
|
3237
|
+
)),
|
|
3238
|
+
)}
|
|
3239
|
+
{transformModeAware && store.transformMode === 'translate' && showAxisGizmo && (
|
|
3240
|
+
<>
|
|
3241
|
+
<div
|
|
3242
|
+
data-testid="world-2d-move-axis-x"
|
|
3243
|
+
title="Move on X axis"
|
|
3244
|
+
style={{
|
|
3245
|
+
position: 'absolute',
|
|
3246
|
+
left: nativeGizmoOrigin!.x,
|
|
3247
|
+
top: nativeGizmoOrigin!.y - 5 / pan.zoom,
|
|
3248
|
+
width: nativeGizmoSpan,
|
|
3249
|
+
height: 10 / pan.zoom,
|
|
3250
|
+
borderTop: `${2 / pan.zoom}px solid #ef5350`,
|
|
3251
|
+
boxSizing: 'border-box',
|
|
3252
|
+
cursor: 'ew-resize',
|
|
3253
|
+
pointerEvents: 'auto',
|
|
3254
|
+
}}
|
|
3255
|
+
onPointerDown={(event) => startMoveGizmoGesture('x', event)}
|
|
3256
|
+
onPointerMove={onPointerMove}
|
|
3257
|
+
onPointerUp={onPointerUp}
|
|
3258
|
+
onPointerCancel={onPointerCancel}
|
|
3259
|
+
/>
|
|
3260
|
+
<div
|
|
3261
|
+
data-testid="world-2d-move-axis-y"
|
|
3262
|
+
title="Move on Y axis"
|
|
3263
|
+
style={{
|
|
3264
|
+
position: 'absolute',
|
|
3265
|
+
left: nativeGizmoOrigin!.x - 5 / pan.zoom,
|
|
3266
|
+
top: nativeGizmoOrigin!.y,
|
|
3267
|
+
width: 10 / pan.zoom,
|
|
3268
|
+
height: nativeGizmoSpan,
|
|
3269
|
+
borderLeft: `${2 / pan.zoom}px solid #66bb6a`,
|
|
3270
|
+
boxSizing: 'border-box',
|
|
3271
|
+
cursor: 'ns-resize',
|
|
3272
|
+
pointerEvents: 'auto',
|
|
3273
|
+
}}
|
|
3274
|
+
onPointerDown={(event) => startMoveGizmoGesture('y', event)}
|
|
3275
|
+
onPointerMove={onPointerMove}
|
|
3276
|
+
onPointerUp={onPointerUp}
|
|
3277
|
+
onPointerCancel={onPointerCancel}
|
|
3278
|
+
/>
|
|
3279
|
+
<div
|
|
3280
|
+
data-testid="world-2d-move-center"
|
|
3281
|
+
title="Move freely"
|
|
3282
|
+
style={{
|
|
3283
|
+
position: 'absolute',
|
|
3284
|
+
left: nativeGizmoOrigin!.x - 5 / pan.zoom,
|
|
3285
|
+
top: nativeGizmoOrigin!.y - 5 / pan.zoom,
|
|
3286
|
+
width: 10 / pan.zoom,
|
|
3287
|
+
height: 10 / pan.zoom,
|
|
3288
|
+
border: '1px solid rgba(255,255,255,.9)',
|
|
3289
|
+
background: '#4aa3ff',
|
|
3290
|
+
boxSizing: 'border-box',
|
|
3291
|
+
cursor: 'move',
|
|
3292
|
+
pointerEvents: 'auto',
|
|
3293
|
+
}}
|
|
3294
|
+
onPointerDown={(event) => startMoveGizmoGesture('both', event)}
|
|
3295
|
+
onPointerMove={onPointerMove}
|
|
3296
|
+
onPointerUp={onPointerUp}
|
|
3297
|
+
onPointerCancel={onPointerCancel}
|
|
3298
|
+
/>
|
|
3299
|
+
</>
|
|
3300
|
+
)}
|
|
3301
|
+
{transformArms(transformModeAware, store.transformMode, 'scale') &&
|
|
3302
|
+
showAxisGizmo &&
|
|
3303
|
+
(axisGizmos ? (
|
|
3304
|
+
<>
|
|
3305
|
+
<div
|
|
3306
|
+
data-testid="world-2d-scale-axis-x"
|
|
3307
|
+
style={{
|
|
3308
|
+
position: 'absolute',
|
|
3309
|
+
left: nativeGizmoOrigin!.x,
|
|
3310
|
+
top: nativeGizmoOrigin!.y,
|
|
3311
|
+
width: nativeGizmoSpan,
|
|
3312
|
+
borderTop: `${2 / pan.zoom}px solid #ef5350`,
|
|
3313
|
+
pointerEvents: 'none',
|
|
3314
|
+
}}
|
|
3315
|
+
/>
|
|
3316
|
+
<div
|
|
3317
|
+
data-testid="world-2d-scale-axis-y"
|
|
3318
|
+
style={{
|
|
3319
|
+
position: 'absolute',
|
|
3320
|
+
left: nativeGizmoOrigin!.x,
|
|
3321
|
+
top: nativeGizmoOrigin!.y,
|
|
3322
|
+
height: nativeGizmoSpan,
|
|
3323
|
+
borderLeft: `${2 / pan.zoom}px solid #66bb6a`,
|
|
3324
|
+
pointerEvents: 'none',
|
|
3325
|
+
}}
|
|
3326
|
+
/>
|
|
3327
|
+
{(
|
|
3328
|
+
[
|
|
3329
|
+
['x', 'ew-resize'],
|
|
3330
|
+
['y', 'ns-resize'],
|
|
3331
|
+
['both', 'nwse-resize'],
|
|
3332
|
+
] as const
|
|
3333
|
+
).map(([axis, cursor]) => {
|
|
3334
|
+
const point = nativeScaleHandlePoint(axis, nativeGizmoOrigin!, nativeGizmoSpan);
|
|
3335
|
+
const testId = axis === 'x' ? 'e' : axis === 'y' ? 's' : 'se';
|
|
3336
|
+
return (
|
|
3337
|
+
<div
|
|
3338
|
+
key={axis}
|
|
3339
|
+
data-testid={`world-resize-handle-${testId}`}
|
|
3340
|
+
style={{
|
|
3341
|
+
...handleStyle(point, cursor),
|
|
3342
|
+
transform: `scale(${chromeScale})`,
|
|
3343
|
+
transformOrigin: 'center',
|
|
3344
|
+
background: axis === 'x' ? '#ef5350' : axis === 'y' ? '#66bb6a' : '#fff',
|
|
3345
|
+
}}
|
|
3346
|
+
onPointerDown={(event) => startNativeScaleGesture(axis, event)}
|
|
3347
|
+
onPointerMove={onPointerMove}
|
|
3348
|
+
onPointerUp={onPointerUp}
|
|
3349
|
+
onPointerCancel={onPointerCancel}
|
|
3350
|
+
/>
|
|
3351
|
+
);
|
|
3352
|
+
})}
|
|
3353
|
+
</>
|
|
3354
|
+
) : (
|
|
3355
|
+
<>
|
|
3356
|
+
{RESIZE_HANDLES.map(({ pos, cursor }) => (
|
|
3357
|
+
<div
|
|
3358
|
+
key={pos}
|
|
3359
|
+
data-testid={`world-resize-handle-${pos}`}
|
|
3360
|
+
style={{
|
|
3361
|
+
...handleStyle(
|
|
3362
|
+
singleTurned ? frameHandlePosition(singleTurned, pos) : handlePosition(singleRect, pos),
|
|
3363
|
+
cursor,
|
|
3364
|
+
),
|
|
3365
|
+
...(transformModeAware
|
|
3366
|
+
? {
|
|
3367
|
+
transform: singleTurned
|
|
3368
|
+
? `rotate(${frameAngle(singleTurned)}rad) scale(${chromeScale})`
|
|
3369
|
+
: `scale(${chromeScale})`,
|
|
3370
|
+
transformOrigin: 'center',
|
|
3371
|
+
}
|
|
3372
|
+
: {}),
|
|
3373
|
+
}}
|
|
3374
|
+
onPointerDown={(event) => startResizeGesture(pos, event)}
|
|
3375
|
+
onPointerMove={onPointerMove}
|
|
3376
|
+
onPointerUp={onPointerUp}
|
|
3377
|
+
onPointerCancel={onPointerCancel}
|
|
3378
|
+
/>
|
|
3379
|
+
))}
|
|
3380
|
+
<div
|
|
3381
|
+
data-testid="world-resize-dimension-label"
|
|
3382
|
+
style={{
|
|
3383
|
+
...dimensionLabelStyle(singleRect),
|
|
3384
|
+
...(transformModeAware
|
|
3385
|
+
? {
|
|
3386
|
+
top: singleRect.y + singleRect.height + 4 * chromeScale,
|
|
3387
|
+
transform: `scale(${chromeScale})`,
|
|
3388
|
+
transformOrigin: 'top left',
|
|
3389
|
+
}
|
|
3390
|
+
: {}),
|
|
3391
|
+
}}
|
|
3392
|
+
>
|
|
3393
|
+
{singleTurned
|
|
3394
|
+
? `${Math.round(Math.hypot(singleTurned.tr.x - singleTurned.tl.x, singleTurned.tr.y - singleTurned.tl.y))}×${Math.round(
|
|
3395
|
+
Math.hypot(singleTurned.bl.x - singleTurned.tl.x, singleTurned.bl.y - singleTurned.tl.y),
|
|
3396
|
+
)}`
|
|
3397
|
+
: `${Math.round(singleRect.width)}×${Math.round(singleRect.height)}`}
|
|
3398
|
+
</div>
|
|
3399
|
+
</>
|
|
3400
|
+
))}
|
|
3401
|
+
{transformArms(transformModeAware, store.transformMode, 'rotate') && showAxisGizmo && (
|
|
3402
|
+
<>
|
|
3403
|
+
{axisGizmos && (
|
|
3404
|
+
<div
|
|
3405
|
+
data-testid="world-2d-rotate-ring"
|
|
3406
|
+
style={{
|
|
3407
|
+
position: 'absolute',
|
|
3408
|
+
left: nativeGizmoOrigin!.x - 40 / pan.zoom,
|
|
3409
|
+
top: nativeGizmoOrigin!.y - 40 / pan.zoom,
|
|
3410
|
+
width: 80 / pan.zoom,
|
|
3411
|
+
height: 80 / pan.zoom,
|
|
3412
|
+
border: `${1 / pan.zoom}px solid #4aa3ff`,
|
|
3413
|
+
borderRadius: '50%',
|
|
3414
|
+
boxSizing: 'border-box',
|
|
3415
|
+
pointerEvents: 'none',
|
|
3416
|
+
}}
|
|
3417
|
+
/>
|
|
3418
|
+
)}
|
|
3419
|
+
<div
|
|
3420
|
+
data-testid="world-rotate-handle"
|
|
3421
|
+
aria-label="Rotate selection"
|
|
3422
|
+
title={
|
|
3423
|
+
transformModeAware
|
|
3424
|
+
? 'Rotate selection (snaps under Smart Snap or Use Rotation Snap; Cmd inverts)'
|
|
3425
|
+
: 'Rotate selection (15° snap, Alt for free rotation)'
|
|
3426
|
+
}
|
|
3427
|
+
style={{
|
|
3428
|
+
...rotateHandleStyle(singleRect),
|
|
3429
|
+
...(transformModeAware && !axisGizmos
|
|
3430
|
+
? {
|
|
3431
|
+
...turnedRotateHandlePosition(singleTurned, singleRect, ROTATE_OFFSET * chromeScale),
|
|
3432
|
+
transform: `scale(${chromeScale})`,
|
|
3433
|
+
transformOrigin: 'center',
|
|
3434
|
+
}
|
|
3435
|
+
: {}),
|
|
3436
|
+
...(axisGizmos
|
|
3437
|
+
? {
|
|
3438
|
+
left: nativeGizmoOrigin!.x - ROTATE_SIZE / 2,
|
|
3439
|
+
top: nativeGizmoOrigin!.y - 40 / pan.zoom - ROTATE_SIZE / 2,
|
|
3440
|
+
transform: `scale(${chromeScale})`,
|
|
3441
|
+
transformOrigin: 'center',
|
|
3442
|
+
background: '#4aa3ff',
|
|
3443
|
+
}
|
|
3444
|
+
: {}),
|
|
3445
|
+
}}
|
|
3446
|
+
onPointerDown={startRotateGesture}
|
|
3447
|
+
onPointerMove={onPointerMove}
|
|
3448
|
+
onPointerUp={onPointerUp}
|
|
3449
|
+
onPointerCancel={onPointerCancel}
|
|
3450
|
+
role="button"
|
|
3451
|
+
tabIndex={0}
|
|
3452
|
+
/>
|
|
3453
|
+
</>
|
|
3454
|
+
)}
|
|
3455
|
+
</>
|
|
3456
|
+
)}
|
|
3457
|
+
{!transformModeAware && (
|
|
3458
|
+
<D3Affordances
|
|
3459
|
+
textEdit={textEdit}
|
|
3460
|
+
onCommitTextEdit={commitTextEdit}
|
|
3461
|
+
onCancelTextEdit={cancelTextEdit}
|
|
3462
|
+
textEditRefused={textEditRefused}
|
|
3463
|
+
eyedropperActive={eyedropperActive}
|
|
3464
|
+
eyedropperPreview={eyedropperPreview}
|
|
3465
|
+
/>
|
|
3466
|
+
)}
|
|
3467
|
+
</div>
|
|
3468
|
+
</div>
|
|
3469
|
+
);
|
|
3470
|
+
}
|
|
3471
|
+
|
|
3472
|
+
/**
|
|
3473
|
+
* D2Affordances — the D2.a drag-ghost + D2.b reorder-preview marker,
|
|
3474
|
+
* factored into its OWN component for the SAME reason `D3Affordances` below
|
|
3475
|
+
* is: keeps `RootSelectionOverlay`'s own cognitive complexity down (two
|
|
3476
|
+
* independent conditional renders, bundled here at zero cost to the parent
|
|
3477
|
+
* beyond one element reference). No behavior change from having them inline.
|
|
3478
|
+
*/
|
|
3479
|
+
/**
|
|
3480
|
+
* D1Affordances — the D1 measurement + snap-guide + badge + parent-outline
|
|
3481
|
+
* overlay bits, factored into its OWN component for the SAME reason
|
|
3482
|
+
* `D2Affordances`/`D3Affordances` are (keeps `RootSelectionOverlay`'s own
|
|
3483
|
+
* cognitive complexity down; no behavior change from having them inline).
|
|
3484
|
+
* All four are READ-only visual affordances (spec §6 D1) — none of them
|
|
3485
|
+
* drives a commit path.
|
|
3486
|
+
*/
|
|
3487
|
+
function D1Affordances(props: {
|
|
3488
|
+
measureLines: MeasureLine[];
|
|
3489
|
+
snapGuides: SnapGuide[];
|
|
3490
|
+
positionBadge: PositionBadgeInfo | null;
|
|
3491
|
+
showPositionBadge: boolean;
|
|
3492
|
+
badgeAnchorRect: DOMRectLike | null;
|
|
3493
|
+
parentRect: DOMRectLike | null;
|
|
3494
|
+
screenScale: number;
|
|
3495
|
+
screenZoom: number;
|
|
3496
|
+
}): React.ReactNode {
|
|
3497
|
+
const {
|
|
3498
|
+
measureLines,
|
|
3499
|
+
snapGuides,
|
|
3500
|
+
positionBadge,
|
|
3501
|
+
showPositionBadge,
|
|
3502
|
+
badgeAnchorRect,
|
|
3503
|
+
parentRect,
|
|
3504
|
+
screenScale,
|
|
3505
|
+
screenZoom,
|
|
3506
|
+
} = props;
|
|
3507
|
+
return (
|
|
3508
|
+
<>
|
|
3509
|
+
{/* D1.c — the parent-container outline, drawn BEHIND the selection/
|
|
3510
|
+
hover boxes (DOM order) so it never visually competes with them. */}
|
|
3511
|
+
{parentRect && (
|
|
3512
|
+
<div data-testid="world-parent-outline" style={parentOutlineStyle(parentRect)} />
|
|
3513
|
+
)}
|
|
3514
|
+
{/* D1.a — sibling-distance measure line(s) between the selection and
|
|
3515
|
+
the hovered sibling, each with two end-caps + a px value label. */}
|
|
3516
|
+
{measureLines.map((line) => (
|
|
3517
|
+
<div key={line.orientation} data-testid="world-measure-line">
|
|
3518
|
+
<div style={measureLineStyle(line)} />
|
|
3519
|
+
<div style={measureCapStyle(line.x1, line.y1, line.orientation === 'horizontal')} />
|
|
3520
|
+
<div style={measureCapStyle(line.x2, line.y2, line.orientation === 'horizontal')} />
|
|
3521
|
+
<div
|
|
3522
|
+
data-testid="world-measure-line-label"
|
|
3523
|
+
style={{
|
|
3524
|
+
...measureLabelStyle(line),
|
|
3525
|
+
...(screenScale !== 1
|
|
3526
|
+
? {
|
|
3527
|
+
transform: `translate(-50%, -50%) scale(${screenScale})`,
|
|
3528
|
+
transformOrigin: 'center',
|
|
3529
|
+
}
|
|
3530
|
+
: {}),
|
|
3531
|
+
}}
|
|
3532
|
+
>
|
|
3533
|
+
{line.distance}
|
|
3534
|
+
</div>
|
|
3535
|
+
</div>
|
|
3536
|
+
))}
|
|
3537
|
+
{/* D1.b — the currently-engaged alignment/snap guide(s), live only
|
|
3538
|
+
while a B2 move/resize gesture's own edge-snap is engaged. */}
|
|
3539
|
+
{snapGuides.map((guide) => (
|
|
3540
|
+
// At most ONE guide per orientation (`computeMoveSnapGuides`/
|
|
3541
|
+
// `computeResizeSnapGuides` each push ≤1 vertical + ≤1 horizontal),
|
|
3542
|
+
// so `orientation` alone is a stable, unique key — no array index.
|
|
3543
|
+
<div key={guide.orientation} data-testid="world-snap-guide" style={snapGuideStyle(guide)} />
|
|
3544
|
+
))}
|
|
3545
|
+
{/* D1.c — the position/parent-layout badge; each segment renders only
|
|
3546
|
+
when its own value resolved (never a fabricated default). */}
|
|
3547
|
+
{showPositionBadge && badgeAnchorRect && positionBadge && (
|
|
3548
|
+
<div
|
|
3549
|
+
data-testid="world-position-badge"
|
|
3550
|
+
style={{
|
|
3551
|
+
...positionBadgeStyle(badgeAnchorRect),
|
|
3552
|
+
...(screenScale !== 1
|
|
3553
|
+
? {
|
|
3554
|
+
top: badgeAnchorRect.y - 36 / screenZoom,
|
|
3555
|
+
transform: `scale(${screenScale})`,
|
|
3556
|
+
transformOrigin: 'top left',
|
|
3557
|
+
}
|
|
3558
|
+
: {}),
|
|
3559
|
+
}}
|
|
3560
|
+
>
|
|
3561
|
+
{positionBadge.position && (
|
|
3562
|
+
<span data-testid="world-position-badge-mode" style={BADGE_SEGMENT_STYLE}>
|
|
3563
|
+
{positionBadge.position}
|
|
3564
|
+
</span>
|
|
3565
|
+
)}
|
|
3566
|
+
{positionBadge.parentLayout && (
|
|
3567
|
+
<span data-testid="world-position-badge-layout" style={BADGE_SEGMENT_STYLE}>
|
|
3568
|
+
{positionBadge.parentLayout}
|
|
3569
|
+
</span>
|
|
3570
|
+
)}
|
|
3571
|
+
</div>
|
|
3572
|
+
)}
|
|
3573
|
+
</>
|
|
3574
|
+
);
|
|
3575
|
+
}
|
|
3576
|
+
|
|
3577
|
+
function D2Affordances(props: {
|
|
3578
|
+
dragGhost: Rect | null;
|
|
3579
|
+
reorderPreview: { gap: ReorderGap } | null;
|
|
3580
|
+
}): React.ReactNode {
|
|
3581
|
+
const { dragGhost, reorderPreview } = props;
|
|
3582
|
+
return (
|
|
3583
|
+
<>
|
|
3584
|
+
{/* D2.a — the drag-ghost, live during a B2 move gesture OR a D2.b
|
|
3585
|
+
reorder-preview drag. Pure visual, never a commit path. */}
|
|
3586
|
+
{dragGhost && <div data-testid="world-drag-ghost" style={dragGhostStyle(dragGhost)} />}
|
|
3587
|
+
{/* D2.b — the live reorder-preview marker at the gap the drop would
|
|
3588
|
+
land in. */}
|
|
3589
|
+
{reorderPreview && (
|
|
3590
|
+
<div
|
|
3591
|
+
data-testid="world-reorder-preview"
|
|
3592
|
+
style={reorderMarkerStyle(reorderPreview.gap.marker)}
|
|
3593
|
+
/>
|
|
3594
|
+
)}
|
|
3595
|
+
</>
|
|
3596
|
+
);
|
|
3597
|
+
}
|
|
3598
|
+
|
|
3599
|
+
/**
|
|
3600
|
+
* D3Affordances — the D3.b/d overlay bits (inline text editor + its refusal
|
|
3601
|
+
* indicator, eyedropper swatch), factored into its OWN
|
|
3602
|
+
* component purely to keep `RootSelectionOverlay`'s own cognitive
|
|
3603
|
+
* complexity down (each is an independent, mutually-exclusive-in-practice
|
|
3604
|
+
* conditional render — bundling them here costs the PARENT nothing beyond
|
|
3605
|
+
* one element reference). No behavior change from having them inline.
|
|
3606
|
+
*/
|
|
3607
|
+
function D3Affordances(props: {
|
|
3608
|
+
textEdit: { id: string; rect: DOMRectLike; initialText: string } | null;
|
|
3609
|
+
onCommitTextEdit: (text: string) => void;
|
|
3610
|
+
onCancelTextEdit: () => void;
|
|
3611
|
+
textEditRefused: { id: string; rect: DOMRectLike } | null;
|
|
3612
|
+
eyedropperActive: boolean;
|
|
3613
|
+
eyedropperPreview: { x: number; y: number; color: string | null } | null;
|
|
3614
|
+
}): React.ReactNode {
|
|
3615
|
+
const {
|
|
3616
|
+
textEdit,
|
|
3617
|
+
onCommitTextEdit,
|
|
3618
|
+
onCancelTextEdit,
|
|
3619
|
+
textEditRefused,
|
|
3620
|
+
eyedropperActive,
|
|
3621
|
+
eyedropperPreview,
|
|
3622
|
+
} = props;
|
|
3623
|
+
return (
|
|
3624
|
+
<>
|
|
3625
|
+
{/* D3.b — the inline text editor (double-click), or the loud "not
|
|
3626
|
+
editable" refusal indicator for a dynamic/child-bearing body. */}
|
|
3627
|
+
{textEdit && (
|
|
3628
|
+
<div style={{ pointerEvents: 'auto' }}>
|
|
3629
|
+
<RootTextEditor
|
|
3630
|
+
rect={textEdit.rect}
|
|
3631
|
+
initialText={textEdit.initialText}
|
|
3632
|
+
onCommit={onCommitTextEdit}
|
|
3633
|
+
onCancel={onCancelTextEdit}
|
|
3634
|
+
/>
|
|
3635
|
+
</div>
|
|
3636
|
+
)}
|
|
3637
|
+
{textEditRefused && (
|
|
3638
|
+
<div
|
|
3639
|
+
data-testid="world-text-edit-refused"
|
|
3640
|
+
title="dynamic body — not inline-editable"
|
|
3641
|
+
style={{
|
|
3642
|
+
position: 'absolute',
|
|
3643
|
+
left: textEditRefused.rect.x,
|
|
3644
|
+
top: textEditRefused.rect.y,
|
|
3645
|
+
width: Math.max(textEditRefused.rect.width, 40),
|
|
3646
|
+
height: Math.max(textEditRefused.rect.height, 20),
|
|
3647
|
+
border: `2px dashed ${themeVars.semantic.danger}`,
|
|
3648
|
+
boxSizing: 'border-box',
|
|
3649
|
+
background: themeVars.semantic.dangerMuted,
|
|
3650
|
+
pointerEvents: 'none',
|
|
3651
|
+
}}
|
|
3652
|
+
/>
|
|
3653
|
+
)}
|
|
3654
|
+
{/* D3.d — the fallback eyedropper cursor swatch: a resolved color, or an
|
|
3655
|
+
explicit "unknown" state — NEVER a fabricated color. */}
|
|
3656
|
+
{eyedropperActive && eyedropperPreview && (
|
|
3657
|
+
<div
|
|
3658
|
+
data-testid="world-eyedropper-swatch"
|
|
3659
|
+
data-color-state={eyedropperPreview.color ? 'resolved' : 'unknown'}
|
|
3660
|
+
style={{
|
|
3661
|
+
position: 'absolute',
|
|
3662
|
+
left: eyedropperPreview.x + 12,
|
|
3663
|
+
top: eyedropperPreview.y + 12,
|
|
3664
|
+
width: 24,
|
|
3665
|
+
height: 24,
|
|
3666
|
+
borderRadius: 4,
|
|
3667
|
+
border: '2px solid #fff',
|
|
3668
|
+
boxShadow: '0 1px 4px rgba(0,0,0,0.5)',
|
|
3669
|
+
background:
|
|
3670
|
+
eyedropperPreview.color ??
|
|
3671
|
+
'repeating-linear-gradient(45deg, #999, #999 4px, #ccc 4px, #ccc 8px)',
|
|
3672
|
+
pointerEvents: 'none',
|
|
3673
|
+
}}
|
|
3674
|
+
/>
|
|
3675
|
+
)}
|
|
3676
|
+
</>
|
|
3677
|
+
);
|
|
3678
|
+
}
|
|
3679
|
+
|
|
3680
|
+
/** `paddingTop`/`marginTop` → `'top'`, etc. — the direction suffix in each
|
|
3681
|
+
* spacing band's `data-testid` (spec's own `world-spacing-{padding|margin}-
|
|
3682
|
+
* {top|right|bottom|left}` naming). */
|
|
3683
|
+
function spacingDirection(side: SpacingSide): 'top' | 'right' | 'bottom' | 'left' {
|
|
3684
|
+
if (side.endsWith('Top')) return 'top';
|
|
3685
|
+
if (side.endsWith('Right')) return 'right';
|
|
3686
|
+
if (side.endsWith('Bottom')) return 'bottom';
|
|
3687
|
+
return 'left';
|
|
3688
|
+
}
|