@dailies-studio/shot-editor 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.vite/manifest.json +382 -0
- package/dist/assets/AgentPanel-CQejLbiX.js +2 -0
- package/dist/assets/AgentPanel-DJEphah0.css +1 -0
- package/dist/assets/AgentQuestionDialog-BqkD0kXF.js +1 -0
- package/dist/assets/CodeStudio-Dv4ihHpz.js +23 -0
- package/dist/assets/CommentsPanel-B4yhhT1T.js +1 -0
- package/dist/assets/ComponentLibraryRail-Ch_dsAru.js +1 -0
- package/dist/assets/ComponentStage-BRhRotyH.js +1 -0
- package/dist/assets/CurvePane-BjFVabKX.css +1 -0
- package/dist/assets/CurvePane-R22WzllJ.js +1 -0
- package/dist/assets/ExportDialog-CWLQWTmG.js +2 -0
- package/dist/assets/FontPicker-DWKaYL7F.js +1 -0
- package/dist/assets/FontPicker-v0IOIArM.css +1 -0
- package/dist/assets/GenerateDrawer-Bh3SqDKt.js +2 -0
- package/dist/assets/InsertDrawer-Ce1fod1L.js +1 -0
- package/dist/assets/LibraryBrowser-D9sEGz5w.js +2 -0
- package/dist/assets/MakeItYoursSection-CMO4319_.js +2 -0
- package/dist/assets/MaterialGraphCanvas-BFzl4wWR.js +23 -0
- package/dist/assets/MaterialGraphCanvas-BIUD59ik.css +1 -0
- package/dist/assets/MaterialWorkspace-CqTWqaRA.js +14 -0
- package/dist/assets/PreferencesDialog-CBf9_OoD.js +1 -0
- package/dist/assets/PresetsSection-CUEaoCVU.js +1 -0
- package/dist/assets/SoundHistory-BeS8EW1h.js +1 -0
- package/dist/assets/SoundMode-BmcZru3i.js +3 -0
- package/dist/assets/StockSounds-DdAUGRYK.js +1 -0
- package/dist/assets/TemplateFieldSection-CaeXpLFo.js +1 -0
- package/dist/assets/VersionHistoryPanel-CxG6XhNP.js +1 -0
- package/dist/assets/board-B88EPuLO.css +1 -0
- package/dist/assets/board-CL-he7SL.js +3 -0
- package/dist/assets/components-0oOSPN0l.js +1 -0
- package/dist/assets/googleFontRanges-gp32wJMo.js +1 -0
- package/dist/assets/googleFonts-BNon6M7X.js +1 -0
- package/dist/assets/iconsAndAliases-DL7gXP5j.js +1863 -0
- package/dist/assets/index-4F5NQmI8.css +1 -0
- package/dist/assets/index-BGHQGV5z.js +117 -0
- package/dist/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- package/dist/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- package/dist/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- package/dist/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- package/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- package/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/dist/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- package/dist/assets/libraryModel-CpIwfFha.js +1 -0
- package/dist/assets/lucide-6iK64tTB.js +9319 -0
- package/dist/assets/motionStamp-CH-Hew4E.js +1 -0
- package/dist/assets/takeMeasure-Tdg0TxEi.js +1 -0
- package/dist/assets/webmcp-BsB9gxWC.js +2 -0
- package/dist/index.html +34 -3
- package/package.json +12 -9
- package/src/addFamilies.ts +66 -0
- package/src/api.ts +1631 -55
- package/src/app/App.tsx +977 -198
- package/src/app/lazyPanel.tsx +137 -0
- package/src/app/lazySurfaces.tsx +203 -0
- package/src/audio/index.tsx +41 -10
- package/src/audio/meter.ts +485 -0
- package/src/audio/meterMath.ts +273 -0
- package/src/audio/meterStore.ts +155 -0
- package/src/audio/offlineMix.ts +289 -0
- package/src/audio/preview.ts +928 -118
- package/src/bridge.ts +15 -5
- package/src/bridgeTypes.ts +1611 -36
- package/src/channels.ts +17 -32
- package/src/fonts/googleFonts.ts +4 -0
- package/src/hostApi.ts +174 -0
- package/src/icons.tsx +770 -0
- package/src/layerWords.ts +61 -0
- package/src/main.tsx +56 -4
- package/src/panels/ResizeHandle.tsx +33 -43
- package/src/panels/agent/AgentDots.tsx +36 -0
- package/src/panels/agent/AgentPanel.tsx +119 -43
- package/src/panels/agent/AgentQuestionDialog.tsx +202 -0
- package/src/panels/agent/agent.css +113 -97
- package/src/panels/agent/agentGate.ts +35 -0
- package/src/panels/code/CodeDefaultsPanel.tsx +345 -0
- package/src/panels/code/CodeStudio.tsx +373 -0
- package/src/panels/code/FileTree.tsx +265 -0
- package/src/panels/code/codeBufferGuard.ts +52 -0
- package/src/panels/code/codeParts.tsx +321 -0
- package/src/panels/comments/CommentComposer.tsx +239 -0
- package/src/panels/comments/CommentThread.tsx +346 -0
- package/src/panels/comments/CommentsPanel.tsx +336 -0
- package/src/panels/comments/InboxButton.tsx +131 -0
- package/src/panels/comments/MentionMenu.tsx +98 -0
- package/src/panels/comments/parts.tsx +171 -0
- package/src/panels/export/ExportDialog.tsx +919 -96
- package/src/panels/export/ExportScrim.tsx +57 -0
- package/src/panels/files/AssetsTab.tsx +656 -0
- package/src/panels/files/FilePanel.tsx +1466 -216
- package/src/panels/files/LayerMarks.tsx +40 -0
- package/src/panels/files/LeftColumn.tsx +37 -0
- package/src/panels/files/McpSection.tsx +690 -47
- package/src/panels/files/MissingAssetsNotice.tsx +60 -0
- package/src/panels/files/assetDrag.ts +79 -0
- package/src/panels/files/resourceParts.tsx +255 -0
- package/src/panels/fonts/FontPicker.tsx +762 -0
- package/src/panels/fonts/faceCache.ts +207 -0
- package/src/panels/fonts/fontPicker.css +271 -0
- package/src/panels/fonts/pickerModel.ts +265 -0
- package/src/panels/fonts/pickerRequest.ts +46 -0
- package/src/panels/header/AccountAvatar.tsx +76 -0
- package/src/panels/header/AgentChip.tsx +77 -0
- package/src/panels/header/AppMenu.tsx +213 -0
- package/src/panels/header/Header.tsx +564 -84
- package/src/panels/header/HistoryControls.tsx +31 -5
- package/src/panels/header/PresenceStack.tsx +329 -0
- package/src/panels/header/ProjectTitle.tsx +232 -0
- package/src/panels/header/ShareButton.tsx +59 -0
- package/src/panels/header/StatusBadges.tsx +358 -27
- package/src/panels/header/SyncDeletedDialog.tsx +88 -0
- package/src/panels/header/ZoomControls.tsx +46 -15
- package/src/panels/header/agentHandoff.ts +52 -0
- package/src/panels/header/leave.ts +26 -0
- package/src/panels/help/HelpSection.tsx +189 -163
- package/src/panels/insert/InsertDrawer.tsx +723 -0
- package/src/panels/insert/iconSet.ts +109 -0
- package/src/panels/inspector/ExposeDot.tsx +117 -0
- package/src/panels/inspector/FramingRows.tsx +314 -44
- package/src/panels/inspector/InspectorFooter.tsx +91 -0
- package/src/panels/inspector/InspectorHead.tsx +425 -0
- package/src/panels/inspector/InspectorPanel.tsx +408 -124
- package/src/panels/inspector/KeyframeDot.tsx +373 -0
- package/src/panels/inspector/KeyframeSection.tsx +257 -0
- package/src/panels/inspector/MarkerSection.tsx +55 -11
- package/src/panels/inspector/ParamRow.tsx +84 -53
- package/src/panels/inspector/RightColumn.tsx +152 -0
- package/src/panels/inspector/TimeSection.tsx +866 -0
- package/src/panels/inspector/animate/EaseControl.tsx +244 -0
- package/src/panels/inspector/animate/PresetsSection.tsx +135 -0
- package/src/panels/inspector/animate/ShotSection.tsx +562 -48
- package/src/panels/inspector/design/AlignRow.tsx +95 -31
- package/src/panels/inspector/design/AppearanceSection.tsx +118 -9
- package/src/panels/inspector/design/ClonerSection.tsx +323 -62
- package/src/panels/inspector/design/ComponentSection.tsx +50 -43
- package/src/panels/inspector/design/CornerSection.tsx +45 -14
- package/src/panels/inspector/design/DefinitionProps.tsx +315 -0
- package/src/panels/inspector/design/DefinitionSection.tsx +136 -0
- package/src/panels/inspector/design/DeviceSection.tsx +210 -0
- package/src/panels/inspector/design/FillSection.tsx +83 -51
- package/src/panels/inspector/design/FontField.tsx +196 -0
- package/src/panels/inspector/design/HtmlSection.tsx +23 -12
- package/src/panels/inspector/design/InstanceSection.tsx +373 -154
- package/src/panels/inspector/design/MaskSection.tsx +84 -0
- package/src/panels/inspector/design/MaterialSection.tsx +177 -0
- package/src/panels/inspector/design/MediaSection.tsx +273 -120
- package/src/panels/inspector/design/MeshSection.tsx +910 -82
- package/src/panels/inspector/design/PaintRows.tsx +550 -0
- package/src/panels/inspector/design/PlateStyleSection.tsx +177 -0
- package/src/panels/inspector/design/ShadowSection.tsx +221 -48
- package/src/panels/inspector/design/SplineFollowSection.tsx +235 -0
- package/src/panels/inspector/design/StrokeSection.tsx +228 -43
- package/src/panels/inspector/design/TemplateFieldSection.tsx +57 -0
- package/src/panels/inspector/design/TransformSection.tsx +244 -50
- package/src/panels/inspector/design/TypographySection.tsx +679 -110
- package/src/panels/inspector/layout.ts +295 -0
- package/src/panels/inspector/propValue.ts +199 -0
- package/src/panels/inspector/scene/CameraSection.tsx +232 -22
- package/src/panels/inspector/scene/EffectsSection.tsx +722 -71
- package/src/panels/inspector/scene/MakeItYoursSection.tsx +180 -0
- package/src/panels/inspector/scene/SceneDocSection.tsx +191 -0
- package/src/panels/inspector/scene/SceneSection.tsx +219 -44
- package/src/panels/inspector/scene/TransitionSection.tsx +523 -72
- package/src/panels/inspector/storyboard/StoryboardReferenceSection.tsx +593 -0
- package/src/panels/inspector/storyboard/VideoStill.tsx +120 -0
- package/src/panels/inspector/timeModel.ts +190 -0
- package/src/panels/inspector/useDocWrite.ts +438 -146
- package/src/panels/library/LibraryBrowser.tsx +435 -0
- package/src/panels/library/LibraryGrid.tsx +190 -0
- package/src/panels/library/LibraryPreviewPanel.tsx +349 -0
- package/src/panels/library/LibraryRail.tsx +84 -0
- package/src/panels/library/LibrarySoundPanel.tsx +293 -0
- package/src/panels/library/SoundHistory.tsx +245 -0
- package/src/panels/library/StockSounds.tsx +390 -0
- package/src/panels/library/libraryModel.ts +147 -0
- package/src/panels/library/stockSearch.ts +77 -0
- package/src/panels/maskMarks.tsx +117 -0
- package/src/panels/material/MaterialCommentNode.tsx +54 -0
- package/src/panels/material/MaterialGraphCanvas.tsx +861 -0
- package/src/panels/material/MaterialGraphMenu.tsx +45 -0
- package/src/panels/material/MaterialGroupNode.tsx +56 -0
- package/src/panels/material/MaterialLibrary.tsx +253 -0
- package/src/panels/material/MaterialNode.tsx +453 -0
- package/src/panels/material/MaterialNodePalette.tsx +349 -0
- package/src/panels/material/MaterialParameterControls.tsx +525 -0
- package/src/panels/material/MaterialRerouteNode.tsx +42 -0
- package/src/panels/material/MaterialWorkspace.tsx +501 -0
- package/src/panels/material/NodePreview.tsx +70 -0
- package/src/panels/material/flowModel.ts +441 -0
- package/src/panels/material/parameterValues.ts +231 -0
- package/src/panels/material/previewScheduler.ts +235 -0
- package/src/panels/material/reactFlow.css +6 -0
- package/src/panels/material/socketGlyph.ts +85 -0
- package/src/panels/material/viewportMemory.ts +58 -0
- package/src/panels/preferences/PreferencesDialog.tsx +215 -0
- package/src/panels/share/ShareComponentDialog.tsx +247 -0
- package/src/panels/stage/StageMenu.tsx +35 -5
- package/src/panels/stage/StagePanel.tsx +338 -23
- package/src/panels/stage/cellSlots.tsx +70 -0
- package/src/panels/storyboard/BoardCanvas.tsx +739 -0
- package/src/panels/storyboard/BoardCursors.tsx +158 -0
- package/src/panels/storyboard/BoardDropDialog.tsx +161 -0
- package/src/panels/storyboard/BoardPlan.tsx +42 -0
- package/src/panels/storyboard/ComponentSheet.tsx +187 -0
- package/src/panels/storyboard/ComponentStage.tsx +235 -0
- package/src/panels/storyboard/MiniSketch.tsx +173 -0
- package/src/panels/storyboard/PartPicker.tsx +64 -0
- package/src/panels/storyboard/PlanPanel.tsx +617 -0
- package/src/panels/storyboard/ReferencePreview.tsx +217 -0
- package/src/panels/storyboard/ShotBrief.tsx +360 -0
- package/src/panels/storyboard/ShotCard.tsx +251 -0
- package/src/panels/storyboard/StagePreview.tsx +348 -0
- package/src/panels/storyboard/StillThumbs.tsx +162 -0
- package/src/panels/storyboard/StoryboardView.tsx +79 -0
- package/src/panels/storyboard/board.css +1065 -0
- package/src/panels/storyboard/board.ts +12 -0
- package/src/panels/storyboard/boardClipboard.ts +111 -0
- package/src/panels/storyboard/boardOrder.ts +68 -0
- package/src/panels/storyboard/boardRows.tsx +425 -0
- package/src/panels/storyboard/liveCap.ts +90 -0
- package/src/panels/storyboard/plan.css +295 -0
- package/src/panels/storyboard/posterCache.ts +133 -0
- package/src/panels/storyboard/shotboard/ComponentPicker.tsx +373 -0
- package/src/panels/storyboard/shotboard/ImagePicker.tsx +145 -0
- package/src/panels/storyboard/shotboard/ShotBoard.tsx +292 -0
- package/src/panels/storyboard/shotboard/SketchCanvas.tsx +443 -0
- package/src/panels/storyboard/shotboard/SketchInspector.tsx +471 -0
- package/src/panels/storyboard/shotboard/SketchItem.tsx +240 -0
- package/src/panels/storyboard/shotboard/SketchLayers.tsx +130 -0
- package/src/panels/storyboard/shotboard/SketchPlay.tsx +108 -0
- package/src/panels/storyboard/shotboard/SketchStrip.tsx +178 -0
- package/src/panels/storyboard/shotboard/SketchToolFooter.tsx +99 -0
- package/src/panels/storyboard/shotboard/sketch.css +1392 -0
- package/src/panels/storyboard/shotboard/sketchModel.ts +219 -0
- package/src/panels/storyboard/storyboardDrop.ts +164 -0
- package/src/panels/studio/ComponentLibraryRail.tsx +346 -0
- package/src/panels/studio/ComponentSidePanel.tsx +355 -0
- package/src/panels/studio/ComponentStudio.tsx +243 -0
- package/src/panels/studio/ComponentsHome.tsx +318 -0
- package/src/panels/studio/ComponentsPreview.tsx +179 -0
- package/src/panels/studio/EveryUse.tsx +244 -0
- package/src/panels/studio/ThisUsePanel.tsx +679 -0
- package/src/panels/studio/componentParts.tsx +341 -0
- package/src/panels/studio/components.ts +6 -0
- package/src/panels/studio/newCodeComponent.ts +66 -0
- package/src/panels/timeline/AudioFxButton.tsx +290 -0
- package/src/panels/timeline/AudioLane.tsx +674 -85
- package/src/panels/timeline/BlockRow.tsx +154 -85
- package/src/panels/timeline/ChannelRow.tsx +378 -129
- package/src/panels/timeline/CurvePane.tsx +508 -0
- package/src/panels/timeline/Filmstrip.tsx +361 -0
- package/src/panels/timeline/GenerateDrawer.tsx +407 -0
- package/src/panels/timeline/KeyStretch.tsx +115 -0
- package/src/panels/timeline/LifespanRow.tsx +730 -47
- package/src/panels/timeline/MarkerLane.tsx +46 -8
- package/src/panels/timeline/PlayButton.tsx +91 -0
- package/src/panels/timeline/PropertyPicker.tsx +254 -0
- package/src/panels/timeline/ShotRow.tsx +400 -0
- package/src/panels/timeline/SoundMode.tsx +1406 -0
- package/src/panels/timeline/TimelineDock.tsx +2338 -272
- package/src/panels/timeline/TransportVolume.tsx +163 -0
- package/src/panels/timeline/beatRuler.ts +74 -0
- package/src/panels/timeline/curveModel.ts +500 -0
- package/src/panels/timeline/duckUnderVoice.ts +68 -0
- package/src/panels/timeline/keyframeMenu.ts +138 -0
- package/src/panels/timeline/labelWidth.ts +49 -0
- package/src/panels/timeline/marquee.ts +155 -0
- package/src/panels/timeline/model.ts +1860 -199
- package/src/panels/timeline/snap.ts +125 -43
- package/src/panels/timeline/staggerPreview.tsx +92 -0
- package/src/panels/timeline/takeMeasure.ts +72 -0
- package/src/panels/timeline/useLive.ts +48 -0
- package/src/panels/timeline/viewport.ts +234 -0
- package/src/panels/toolbar/BackChip.tsx +116 -0
- package/src/panels/toolbar/CameraViewBar.tsx +118 -49
- package/src/panels/toolbar/CanvasBar.tsx +117 -0
- package/src/panels/toolbar/InsertButton.tsx +38 -0
- package/src/panels/toolbar/IsolationTrail.tsx +156 -0
- package/src/panels/toolbar/PanelToggles.tsx +86 -0
- package/src/panels/toolbar/ShadingBar.tsx +119 -26
- package/src/panels/toolbar/ToolDockBar.tsx +329 -80
- package/src/panels/toolbar/ToolFooter.tsx +102 -0
- package/src/panels/toolbar/ViewModeBar.tsx +247 -38
- package/src/panels/toolbar/WorkPlaneBar.tsx +94 -0
- package/src/panels/toolbar/useAddRows.tsx +326 -0
- package/src/panels/toolbar/useDismiss.ts +122 -0
- package/src/panels/versions/VersionHistoryPanel.tsx +274 -0
- package/src/panels/versions/versionModel.ts +103 -0
- package/src/panels/virtualWindow.ts +271 -0
- package/src/shotTokens.css +151 -60
- package/src/stage/FrustumGizmo.tsx +504 -84
- package/src/stage/GridPlane.tsx +225 -16
- package/src/stage/NodePreviews.tsx +628 -0
- package/src/stage/PerfHud.tsx +437 -47
- package/src/stage/SplitRenderer.tsx +816 -169
- package/src/stage/StageApp.tsx +4274 -486
- package/src/stage/StageRootBoundary.tsx +114 -0
- package/src/stage/StageTools.tsx +651 -151
- package/src/stage/TransformGizmo.tsx +696 -0
- package/src/stage/adaptivePresentation.ts +145 -0
- package/src/stage/bridgeClient.ts +2522 -41
- package/src/stage/exportWorker.ts +9 -0
- package/src/stage/feed.ts +305 -14
- package/src/stage/fontPreview.ts +176 -0
- package/src/stage/frustumGeometry.ts +319 -1
- package/src/stage/furnitureTokens.ts +33 -0
- package/src/stage/gizmoMath.ts +508 -16
- package/src/stage/gpuTimer.ts +276 -0
- package/src/stage/instanceModules.ts +194 -33
- package/src/stage/interactions/move.ts +24 -5
- package/src/stage/isolation.ts +291 -33
- package/src/stage/jsSelfProfiler.ts +325 -0
- package/src/stage/main.tsx +116 -16
- package/src/stage/modalState.ts +222 -23
- package/src/stage/moduleEpoch.ts +60 -0
- package/src/stage/motionPathStage.ts +279 -0
- package/src/stage/motionPreview.ts +74 -0
- package/src/stage/overlays/Overlays.tsx +1112 -159
- package/src/stage/overlays/Rulers.tsx +495 -0
- package/src/stage/panes.ts +159 -10
- package/src/stage/project.ts +68 -3
- package/src/stage/renderScaleController.ts +730 -0
- package/src/stage/rulers.ts +129 -0
- package/src/stage/sceneExtent.ts +75 -0
- package/src/stage/shading.ts +20 -0
- package/src/stage/splineStage.ts +632 -0
- package/src/stage/stagePerformanceTelemetry.ts +717 -0
- package/src/stage/stageTheme.ts +36 -0
- package/src/stage/stageWarmup.ts +11 -0
- package/src/stage/textAutoSize.ts +124 -0
- package/src/stage/tier1Export.ts +641 -0
- package/src/stage/tokenColor.ts +14 -9
- package/src/stage/view.ts +56 -9
- package/src/stage/workingView.ts +277 -33
- package/src/state/accountPrefs.ts +261 -0
- package/src/state/agentActions.ts +173 -15
- package/src/state/agentActivity.ts +198 -0
- package/src/state/agentQuestion.ts +239 -0
- package/src/state/bootOpen.ts +42 -0
- package/src/state/cameraVerbs.ts +139 -0
- package/src/state/clientId.ts +6 -1
- package/src/state/comments.ts +1099 -0
- package/src/state/context.tsx +19 -1
- package/src/state/editorPrefs.ts +220 -0
- package/src/state/errorReports.ts +121 -0
- package/src/state/fieldWrite.ts +161 -0
- package/src/state/fontRecents.ts +70 -0
- package/src/state/fonts.ts +656 -0
- package/src/state/graphClipboard.ts +414 -0
- package/src/state/imageThumbs.ts +216 -0
- package/src/state/insert.ts +444 -67
- package/src/state/isolationTrail.ts +92 -0
- package/src/state/keyMoves.ts +134 -0
- package/src/state/keyframeClipboard.ts +177 -0
- package/src/state/keymap.ts +1153 -117
- package/src/state/layoutPrefs.ts +272 -0
- package/src/state/layouts.ts +205 -0
- package/src/state/measureMedia.ts +167 -0
- package/src/state/menu.ts +2341 -28
- package/src/state/motionStamp.ts +7 -0
- package/src/state/panelSize.ts +375 -5
- package/src/state/pendingChanges.ts +119 -0
- package/src/state/playHold.ts +78 -0
- package/src/state/playbackPreparation.ts +562 -0
- package/src/state/posterCapture.ts +242 -0
- package/src/state/presence.ts +254 -0
- package/src/state/projectComponents.ts +160 -0
- package/src/state/renderEvents.ts +298 -18
- package/src/state/runtimeErrors.ts +46 -0
- package/src/state/selection.ts +12 -0
- package/src/state/shortcuts.ts +1606 -0
- package/src/state/sketchGuide.ts +51 -0
- package/src/state/splineRefs.ts +87 -0
- package/src/state/stageHealth.ts +259 -0
- package/src/state/stageWatchdog.ts +47 -0
- package/src/state/stillStage.ts +330 -0
- package/src/state/stillThumbs.ts +474 -0
- package/src/state/store.ts +23591 -2982
- package/src/state/storyboardModel.ts +528 -0
- package/src/state/sync.ts +1257 -0
- package/src/state/syncCache.ts +159 -0
- package/src/state/triangles.ts +13 -0
- package/src/state/types.ts +1698 -55
- package/src/state/unsavedGuard.ts +49 -0
- package/src/state/viewMode.ts +200 -0
- package/src/state/webmcp.ts +1128 -0
- package/src/state/workspace.ts +1147 -0
- package/src/styles/assets-tab.css +304 -0
- package/src/styles/canvas.css +545 -0
- package/src/styles/comments.css +1435 -0
- package/src/styles/controls.css +162 -0
- package/src/styles/dialogs.css +390 -0
- package/src/styles/insert-drawer.css +586 -0
- package/src/styles/inspector-board.css +207 -0
- package/src/styles/inspector-object.css +486 -0
- package/src/styles/inspector-shot.css +252 -0
- package/src/styles/inspector.css +360 -0
- package/src/styles/left-column.css +1628 -0
- package/src/styles/library-preview.css +575 -0
- package/src/styles/library.css +512 -0
- package/src/styles/modes/code.css +569 -0
- package/src/styles/modes/component.css +1561 -0
- package/src/styles/modes/material.css +1201 -0
- package/src/styles/modes/storyboard.css +518 -0
- package/src/styles/share-component.css +49 -0
- package/src/styles/shell.css +653 -0
- package/src/styles/shot-brief.css +360 -0
- package/src/styles/states.css +76 -0
- package/src/styles/sync.css +380 -0
- package/src/styles/timeline-graph.css +384 -0
- package/src/styles/timeline-meters.css +89 -0
- package/src/styles/timeline-rows.css +1959 -0
- package/src/styles/timeline-sound.css +1003 -0
- package/src/styles/timeline.css +754 -0
- package/src/styles/tool-footer.css +525 -0
- package/src/styles/topbar.css +886 -0
- package/src/styles/versions.css +262 -0
- package/src/styles.css +72 -2921
- package/src/tools.ts +108 -23
- package/dist/assets/index-B7notge7.css +0 -1
- package/dist/assets/index-BBE6Ta9L.js +0 -52
- package/src/panels/files/icons.tsx +0 -118
- package/src/panels/header/DocSwitcher.tsx +0 -85
- package/src/panels/header/TransportBar.tsx +0 -86
- package/src/panels/inspector/animate/BlockTiming.tsx +0 -341
- package/src/panels/inspector/animate/DuringBlocks.tsx +0 -85
- package/src/panels/inspector/animate/InOutPresets.tsx +0 -57
- package/src/panels/inspector/animate/ResolvedChannels.tsx +0 -150
- package/src/panels/inspector/animate/useBlocks.ts +0 -43
- package/src/panels/material/MaterialPicker.tsx +0 -172
- package/src/panels/timeline/SceneStrip.tsx +0 -145
- package/src/panels/toolbar/AddMenu.tsx +0 -230
- package/src/stage/MoveGizmo.tsx +0 -411
package/src/state/menu.ts
CHANGED
|
@@ -15,9 +15,60 @@
|
|
|
15
15
|
// drift from the shortcuts, and a fix to `duplicateSelection` fixes it in three
|
|
16
16
|
// places at once. A menu entry that reached into the document itself would be a
|
|
17
17
|
// second implementation of an edit that already exists.
|
|
18
|
-
import
|
|
18
|
+
import {
|
|
19
|
+
ALIGN_LABELS,
|
|
20
|
+
ALIGN_MODES,
|
|
21
|
+
DISTRIBUTE_MODES,
|
|
22
|
+
GENERATED_SHOTS_DIR,
|
|
23
|
+
MATERIAL_GRAPH_PARAMETER_TYPES,
|
|
24
|
+
MIN_DISTRIBUTE,
|
|
25
|
+
SKETCHES_MAX,
|
|
26
|
+
componentDefsOf,
|
|
27
|
+
copyAnimation,
|
|
28
|
+
copyEffects,
|
|
29
|
+
homeOf,
|
|
30
|
+
maskTargetOf,
|
|
31
|
+
materialGraphNodeSpec,
|
|
32
|
+
parentOf,
|
|
33
|
+
sceneWindows,
|
|
34
|
+
sketchesOf,
|
|
35
|
+
} from "@dailies-studio/shot/doc";
|
|
36
|
+
import type { Doc3D, RestackTarget } from "@dailies-studio/shot/doc";
|
|
37
|
+
import type { MenuEntry, OpenMenuOptions } from "@dailies-studio/ui";
|
|
38
|
+
import type { ReactNode } from "react";
|
|
39
|
+
import type { StudioThemePreference } from "@dailies-studio/ui/theme";
|
|
40
|
+
import { SHADING_MODES, shadingLabel } from "../stage/shading.js";
|
|
41
|
+
import {
|
|
42
|
+
ORBIT_STEP_DEG,
|
|
43
|
+
orbitByDegrees,
|
|
44
|
+
presetWorkingView,
|
|
45
|
+
} from "../stage/workingView.js";
|
|
46
|
+
import type { WorkingViewPreset } from "../stage/workingView.js";
|
|
47
|
+
import { SHOT_TOOLS, specOf } from "../tools.js";
|
|
48
|
+
import { MASK_ALPHA_SWATCH, MASK_ICON, MASK_LUMA_SWATCH } from "../icons.js";
|
|
49
|
+
import { toolRefusal } from "./keymap.js";
|
|
50
|
+
import { isolationInsertRefusal } from "./isolationTrail.js";
|
|
51
|
+
import { cameraVerb } from "./cameraVerbs.js";
|
|
52
|
+
// The layout table and its two refusals: the words, the menu ids and the
|
|
53
|
+
// sentences this menu shares with the digits and the segmented control.
|
|
54
|
+
import { LAYOUT_MODES, layoutRefusal, layoutSpec, paneOnlyRefusal } from "./layouts.js";
|
|
55
|
+
import {
|
|
56
|
+
VIEW_MODE_SPECS,
|
|
57
|
+
inDefinition,
|
|
58
|
+
inShotContext,
|
|
59
|
+
isSceneContext,
|
|
60
|
+
shownLayoutMode,
|
|
61
|
+
toolLayoutMode,
|
|
62
|
+
} from "./viewMode.js";
|
|
63
|
+
import { isCloudWorkspace } from "./posterCapture.js";
|
|
64
|
+
import { formatCombo, probeFor, shortcutFor, toolKey } from "./shortcuts.js";
|
|
65
|
+
import { boardTargetOf, isPartId } from "./storyboardModel.js";
|
|
66
|
+
import { agentGate, agentInEditor } from "../panels/agent/agentGate.js";
|
|
67
|
+
import { BOARD_CLOSED, LAST_SHOT, NO_SHOT_BOARD, TEMPLATES_DO_NOT_STACK, instanceDriftOf, storyboardBlocker } from "./store.js";
|
|
68
|
+
import { commandBlocker, readOnlyReason, shotBoardOf, viewOnlyBlocker, workspaceOf } from "./workspace.js";
|
|
19
69
|
import type { ShotEditorStore } from "./store.js";
|
|
20
70
|
import type { ShotEditorState } from "./types.js";
|
|
71
|
+
import { SCENE_WAITING, playHold, playOn, preparingTitle } from "./playHold.js";
|
|
21
72
|
|
|
22
73
|
/** The layers a menu acts on, and what they are. Computed by the caller,
|
|
23
74
|
* because the stage knows the raw hit stack and the outliner knows the row. */
|
|
@@ -30,38 +81,201 @@ export interface MenuTargets {
|
|
|
30
81
|
meshIds: readonly string[];
|
|
31
82
|
/** Do any of the mesh targets already carry a material? Drives "Remove". */
|
|
32
83
|
hasMaterial: boolean;
|
|
84
|
+
/** The FIRST target's flags, resolved against the container the outline is
|
|
85
|
+
* actually showing. They live here rather than being re-read in the entry
|
|
86
|
+
* builder because that second lookup only knew about scenes, so both labels
|
|
87
|
+
* read "Hide"/"Lock" for every layer inside an open component definition. */
|
|
88
|
+
hidden: boolean;
|
|
89
|
+
locked: boolean;
|
|
90
|
+
/** The first target places a component (Studio-made or code), so it opens
|
|
91
|
+
* in Components. Drives "Open in Components". */
|
|
92
|
+
opensComponent: boolean;
|
|
93
|
+
/** ACROSS ALL TARGETS, unlike the flags above: does any target qualify? A
|
|
94
|
+
* row that acts on the whole selection shows when any member can take it. */
|
|
95
|
+
hasGroup: boolean;
|
|
96
|
+
/** A group or a component instance, the two kinds Flatten dissolves. */
|
|
97
|
+
hasFlattenable: boolean;
|
|
98
|
+
/** The label of the layer Copy animation copies from: the first target
|
|
99
|
+
* with a track (`store.copyAnimation`'s own rule), or absent. */
|
|
100
|
+
animationSource?: string;
|
|
101
|
+
/** The same for Copy effects: the first target with a shadow or a glow. */
|
|
102
|
+
effectsSource?: string;
|
|
103
|
+
/** Does any target name a mask (`maskTargetOf`: a mask, or a group holding
|
|
104
|
+
* one)? Drives Invert mask and Release mask. */
|
|
105
|
+
hasMask: boolean;
|
|
106
|
+
/** Why "Use as mask" cannot run on these targets, or absent when it can. */
|
|
107
|
+
maskRefusal?: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A cloner's children are its templates, and a mask there would be cloned
|
|
111
|
+
* with them (`clonerMaskRefusal`). The menu's short form of that refusal. */
|
|
112
|
+
export const CLONER_MASK_REFUSAL = "A cloner child cannot be a mask. Mask the cloner instead.";
|
|
113
|
+
|
|
114
|
+
/** What only the surface that opened the layer menu can do. */
|
|
115
|
+
export interface LayerMenuHooks {
|
|
116
|
+
/** Rename this one layer the surface's own way: the outliner's inline edit,
|
|
117
|
+
* the stage's prompt. Absent, the menu has no Rename row (the app menu has
|
|
118
|
+
* its own, and an agent renames through `renameLayer`). */
|
|
119
|
+
onRename?(id: string): void;
|
|
120
|
+
/** The layer the author right-clicked, when it is in the selection: "Use
|
|
121
|
+
* as mask" makes THAT layer the mask (Paper page 25). Absent, the store
|
|
122
|
+
* picks the backmost selected layer. */
|
|
123
|
+
subject?: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Arrange's four rows, in Figma's order. */
|
|
127
|
+
const ARRANGE: readonly (readonly [RestackTarget, string])[] = [
|
|
128
|
+
["front", "Bring to front"],
|
|
129
|
+
["forward", "Bring forward"],
|
|
130
|
+
["backward", "Send backward"],
|
|
131
|
+
["back", "Send to back"],
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Why Arrange cannot run on these layers, or null when it can: the sentence a
|
|
136
|
+
* dimmed Arrange row carries, in the app menu and the right-click menu alike.
|
|
137
|
+
*
|
|
138
|
+
* A cloner's direct children are its TEMPLATES. Clone i draws `children[i % n]`,
|
|
139
|
+
* so their order picks which clone draws which template and two of them never
|
|
140
|
+
* meet on screen: they do not stack, and `restackLayers` refuses a selection of
|
|
141
|
+
* nothing else (founder decision 6). A mixed selection runs, and leaves its
|
|
142
|
+
* templates where they are. The way to reorder templates is the outline drag.
|
|
143
|
+
*/
|
|
144
|
+
function arrangeRefusal(
|
|
145
|
+
state: ShotEditorState,
|
|
146
|
+
ids: readonly string[],
|
|
147
|
+
): string | null {
|
|
148
|
+
if (ids.length === 0) return "Select a layer to arrange it";
|
|
149
|
+
const container = state.editingDef ?? state.sceneId;
|
|
150
|
+
const home =
|
|
151
|
+
state.doc === null || container === null ? null : homeOf(state.doc, container);
|
|
152
|
+
if (home === null) return null;
|
|
153
|
+
const templatesOnly = ids.every((id) => {
|
|
154
|
+
const parent = parentOf(home, id);
|
|
155
|
+
return parent !== null && home.layersById[parent]?.kind === "cloner";
|
|
156
|
+
});
|
|
157
|
+
return templatesOnly ? TEMPLATES_DO_NOT_STACK : null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* ARRANGE, Figma's: each selected layer among its own siblings, per parent,
|
|
162
|
+
* which is paint order (`store.restack`, `restackLayers`). The app menu's
|
|
163
|
+
* Arrange and the right-click menu build the same four rows here, so
|
|
164
|
+
* the two cannot disagree about when they run; `idOf` only names them, because
|
|
165
|
+
* the right-click menu keys its rows on bare ids. The accelerator is always the
|
|
166
|
+
* table's `layout.arrange.*` row.
|
|
167
|
+
*/
|
|
168
|
+
function arrangeRows(
|
|
169
|
+
store: ShotEditorStore,
|
|
170
|
+
state: ShotEditorState,
|
|
171
|
+
ids: readonly string[],
|
|
172
|
+
idOf: (to: RestackTarget) => string,
|
|
173
|
+
): MenuEntry[] {
|
|
174
|
+
const no = arrangeRefusal(state, ids);
|
|
175
|
+
return ARRANGE.map(([to, label]) => ({
|
|
176
|
+
kind: "action" as const,
|
|
177
|
+
id: idOf(to),
|
|
178
|
+
label,
|
|
179
|
+
shortcut: shortcutFor(`layout.arrange.${to}`),
|
|
180
|
+
disabled: no !== null,
|
|
181
|
+
...(no === null ? {} : { title: no }),
|
|
182
|
+
onSelect: () => store.restack(ids, to),
|
|
183
|
+
}));
|
|
33
184
|
}
|
|
34
185
|
|
|
35
186
|
export function layerMenuEntries(
|
|
36
187
|
store: ShotEditorStore,
|
|
37
188
|
state: ShotEditorState,
|
|
38
189
|
targets: MenuTargets,
|
|
190
|
+
hooks: LayerMenuHooks = {},
|
|
39
191
|
): MenuEntry[] {
|
|
40
192
|
if (targets.ids.length === 0) return [];
|
|
41
|
-
const layers = state.sceneId === null ? undefined : state.doc?.scenesById[state.sceneId]?.layersById;
|
|
42
|
-
const first = layers?.[targets.ids[0]!];
|
|
43
193
|
// The label follows what the FIRST target is, not what the whole set is. A
|
|
44
194
|
// mixed selection has no honest single word, and "Hide/Show" reading off the
|
|
45
195
|
// subject the author right-clicked is the behaviour every outliner has.
|
|
46
|
-
const hidden
|
|
47
|
-
const
|
|
196
|
+
const { hidden, locked } = targets;
|
|
197
|
+
const many = targets.ids.length > 1;
|
|
198
|
+
const one = many ? undefined : targets.ids[0];
|
|
199
|
+
// With several layers selected a copy still reads ONE of them, so the row
|
|
200
|
+
// names which.
|
|
201
|
+
const from = (verb: string, source: string): string => (many ? `${verb} from ${source}` : verb);
|
|
202
|
+
const row = (id: string, label: string, onSelect: () => void, shortcut?: string): MenuEntry => ({
|
|
203
|
+
kind: "action",
|
|
204
|
+
id,
|
|
205
|
+
label,
|
|
206
|
+
shortcut,
|
|
207
|
+
onSelect,
|
|
208
|
+
});
|
|
48
209
|
|
|
49
210
|
// The `id`s are STABLE and independent of the label, because the label flips
|
|
50
211
|
// (Hide/Show, Lock/Unlock) and the menu keys its rows on the id. A key that
|
|
51
212
|
// changed with the word would remount the row every time the flag did.
|
|
213
|
+
//
|
|
214
|
+
// Rows that do not apply are ABSENT, not dimmed: a right-click menu's
|
|
215
|
+
// subject is what was clicked, so a dimmed row could only explain itself.
|
|
216
|
+
// The mask rows are the exception Paper draws (page 25): Invert and Release
|
|
217
|
+
// stay in place, dimmed, beside the "Use as mask" that made them, so the
|
|
218
|
+
// three read as one family wherever the author looks for them.
|
|
52
219
|
const entries: MenuEntry[] = [
|
|
53
|
-
|
|
220
|
+
row("copy", "Copy", () => store.copyLayers(), shortcutFor("edit.copy")),
|
|
221
|
+
...(store.hasLayerClip() ? [row("paste", "Paste", () => store.pasteLayers(), shortcutFor("edit.paste"))] : []),
|
|
222
|
+
row("duplicate", "Duplicate", () => store.duplicateSelection(), shortcutFor("edit.duplicate")),
|
|
223
|
+
{ kind: "separator" },
|
|
224
|
+
// ── Motion and effects, from one layer onto others ──
|
|
225
|
+
...(targets.animationSource === undefined
|
|
226
|
+
? []
|
|
227
|
+
: [
|
|
228
|
+
row(
|
|
229
|
+
"copy-animation",
|
|
230
|
+
from("Copy animation", targets.animationSource),
|
|
231
|
+
() => store.copyAnimation(),
|
|
232
|
+
shortcutFor("edit.copyAnimation"),
|
|
233
|
+
),
|
|
234
|
+
]),
|
|
235
|
+
...(store.hasAnimationClip() ? [pasteAnimationMenu(store, "paste-animation-")] : []),
|
|
236
|
+
...(targets.effectsSource === undefined
|
|
237
|
+
? []
|
|
238
|
+
: [
|
|
239
|
+
row(
|
|
240
|
+
"copy-effects",
|
|
241
|
+
from("Copy effects", targets.effectsSource),
|
|
242
|
+
() => store.copyEffects(),
|
|
243
|
+
shortcutFor("edit.copyEffects"),
|
|
244
|
+
),
|
|
245
|
+
]),
|
|
246
|
+
...(store.hasEffectsClip()
|
|
247
|
+
? [row("paste-effects", "Paste effects", () => store.pasteEffects(), shortcutFor("edit.pasteEffects"))]
|
|
248
|
+
: []),
|
|
249
|
+
{ kind: "separator" },
|
|
250
|
+
...(one !== undefined && hooks.onRename !== undefined
|
|
251
|
+
? [row("rename", "Rename…", () => hooks.onRename?.(one)), { kind: "separator" as const }]
|
|
252
|
+
: []),
|
|
253
|
+
// ── Structure ──
|
|
254
|
+
...(many ? [row("group", "Group", () => store.groupSelection(), shortcutFor("edit.group"))] : []),
|
|
255
|
+
...(targets.hasGroup ? [row("ungroup", "Ungroup", () => store.ungroupSelection(), shortcutFor("edit.ungroup"))] : []),
|
|
256
|
+
...(targets.hasFlattenable ? [row("flatten", "Flatten", () => store.flattenSelection())] : []),
|
|
257
|
+
row("make-component", "Make component", () => store.createComponentFromSelection()),
|
|
258
|
+
...maskRows(store, targets, hooks.subject),
|
|
259
|
+
{ kind: "separator" },
|
|
260
|
+
// ARRANGE, where Figma's right-click has it: the menu is on the layer the
|
|
261
|
+
// author is pointing at, and "put it in front of that" is the question.
|
|
262
|
+
...arrangeRows(store, state, targets.ids, (to) => `arrange-${to}`),
|
|
54
263
|
{ kind: "separator" },
|
|
55
264
|
{
|
|
56
265
|
kind: "action",
|
|
57
266
|
id: "hidden",
|
|
58
267
|
label: hidden ? "Show" : "Hide",
|
|
268
|
+
// The accelerator is H's, which HIDES. It is printed on the hide reading
|
|
269
|
+
// only: ⌘⇧H is the reveal key and it reveals EVERYTHING, so claiming it for
|
|
270
|
+
// "Show this one" would name a key that does something wider.
|
|
271
|
+
shortcut: hidden ? undefined : shortcutFor("layer.hide"),
|
|
59
272
|
onSelect: () => store.toggleFlag("hidden"),
|
|
60
273
|
},
|
|
61
274
|
{
|
|
62
275
|
kind: "action",
|
|
63
276
|
id: "locked",
|
|
64
277
|
label: locked ? "Unlock" : "Lock",
|
|
278
|
+
shortcut: shortcutFor("layer.lock"),
|
|
65
279
|
onSelect: () => store.toggleFlag("locked"),
|
|
66
280
|
},
|
|
67
281
|
{ kind: "separator" },
|
|
@@ -72,6 +286,7 @@ export function layerMenuEntries(
|
|
|
72
286
|
kind: "action",
|
|
73
287
|
id: "isolation",
|
|
74
288
|
label: "Exit isolation",
|
|
289
|
+
shortcut: shortcutFor("layer.isolate.exit"),
|
|
75
290
|
onSelect: () => store.exitIsolation(),
|
|
76
291
|
});
|
|
77
292
|
} else {
|
|
@@ -79,6 +294,7 @@ export function layerMenuEntries(
|
|
|
79
294
|
kind: "action",
|
|
80
295
|
id: "isolation",
|
|
81
296
|
label: "Isolate",
|
|
297
|
+
shortcut: shortcutFor("layer.isolate"),
|
|
82
298
|
onSelect: () => {
|
|
83
299
|
const id = targets.ids[0];
|
|
84
300
|
if (id !== undefined) store.enterIsolation(id);
|
|
@@ -86,13 +302,37 @@ export function layerMenuEntries(
|
|
|
86
302
|
});
|
|
87
303
|
}
|
|
88
304
|
|
|
305
|
+
// ── Editing this layer somewhere else ────────────────────────────────────
|
|
306
|
+
//
|
|
307
|
+
// Isolate above is "edit it here, alone". This one is "open what it
|
|
308
|
+
// places": a component layer places a component that has no camera and no
|
|
309
|
+
// slot in this scene's timeline, so it opens alone in Components.
|
|
310
|
+
//
|
|
311
|
+
// Absent, not disabled, on layers that place nothing, the convention the
|
|
312
|
+
// material rows already follow.
|
|
313
|
+
if (targets.opensComponent) {
|
|
314
|
+
const instance = targets.ids[0]!;
|
|
315
|
+
entries.push({ kind: "separator" });
|
|
316
|
+
entries.push({
|
|
317
|
+
kind: "action",
|
|
318
|
+
id: "open-3d",
|
|
319
|
+
label: "Open in Components",
|
|
320
|
+
onSelect: () => void store.openComponentContext(instance),
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
89
324
|
if (targets.meshIds.length > 0) {
|
|
90
325
|
entries.push({ kind: "separator" });
|
|
91
326
|
entries.push({
|
|
92
327
|
kind: "action",
|
|
93
328
|
id: "material",
|
|
94
|
-
label: "
|
|
95
|
-
|
|
329
|
+
label: "Material Nodes…",
|
|
330
|
+
disabled: targets.meshIds.length !== 1,
|
|
331
|
+
onSelect: () => {
|
|
332
|
+
const meshId = targets.meshIds[0];
|
|
333
|
+
if (targets.meshIds.length === 1 && meshId !== undefined)
|
|
334
|
+
store.openMaterialWorkspace(meshId);
|
|
335
|
+
},
|
|
96
336
|
});
|
|
97
337
|
if (targets.hasMaterial) {
|
|
98
338
|
entries.push({
|
|
@@ -110,32 +350,2105 @@ export function layerMenuEntries(
|
|
|
110
350
|
id: "delete",
|
|
111
351
|
label: "Delete",
|
|
112
352
|
danger: true,
|
|
353
|
+
shortcut: shortcutFor("edit.delete"),
|
|
113
354
|
onSelect: () => store.deleteSelection(),
|
|
114
355
|
});
|
|
115
|
-
|
|
356
|
+
// A VIEWER'S layer menu LEAVES OUT what it cannot run, rather than dimming
|
|
357
|
+
// it (Paper page 23): what is left is the copy rows and the ones that only
|
|
358
|
+
// open or look. The other menus dim, through `gateForViewer`.
|
|
359
|
+
return state.readOnly
|
|
360
|
+
? omitRows(entries, (id) => viewOnlyBlocker(id) !== null)
|
|
361
|
+
: gateForViewer(state, entries);
|
|
116
362
|
}
|
|
117
363
|
|
|
118
364
|
/**
|
|
119
|
-
*
|
|
365
|
+
* THE MASK ROWS (gap 7, Paper page 25): "Use as mask" with its two modes, then
|
|
366
|
+
* Invert mask and Release mask. Shared by the right-click menu and the app
|
|
367
|
+
* menu's Layer branch, which splices `layerMenuEntries` whole. No shortcut:
|
|
368
|
+
* Paper draws none and the keymap binds none.
|
|
369
|
+
*/
|
|
370
|
+
function maskRows(store: ShotEditorStore, targets: MenuTargets, subject: string | undefined): MenuEntry[] {
|
|
371
|
+
const refused = targets.maskRefusal;
|
|
372
|
+
const mode = (
|
|
373
|
+
id: string,
|
|
374
|
+
label: string,
|
|
375
|
+
detail: string,
|
|
376
|
+
m: "alpha" | "luma",
|
|
377
|
+
icon: ReactNode,
|
|
378
|
+
def?: string,
|
|
379
|
+
): MenuEntry => ({
|
|
380
|
+
kind: "action",
|
|
381
|
+
id,
|
|
382
|
+
label,
|
|
383
|
+
detail,
|
|
384
|
+
icon,
|
|
385
|
+
...(def === undefined ? {} : { shortcut: def }),
|
|
386
|
+
disabled: refused !== undefined,
|
|
387
|
+
...(refused === undefined ? {} : { title: refused }),
|
|
388
|
+
onSelect: () => store.maskSelection(m, subject),
|
|
389
|
+
});
|
|
390
|
+
// Invert and Release keep an EMPTY icon slot, so their labels sit under
|
|
391
|
+
// "Use as mask"'s and the three read as one family.
|
|
392
|
+
const noMask = targets.hasMask ? {} : { disabled: true, title: "Select a mask or its Mask group" };
|
|
393
|
+
return [
|
|
394
|
+
{
|
|
395
|
+
kind: "submenu",
|
|
396
|
+
label: "Use as mask",
|
|
397
|
+
icon: MASK_ICON,
|
|
398
|
+
...(refused === undefined ? {} : { disabled: true, title: refused, detail: refused }),
|
|
399
|
+
items: [
|
|
400
|
+
mode("mask-alpha", "Alpha", "Uses the mask's opacity", "alpha", MASK_ALPHA_SWATCH, "Default"),
|
|
401
|
+
mode("mask-luma", "Luma", "Uses the mask's brightness", "luma", MASK_LUMA_SWATCH),
|
|
402
|
+
],
|
|
403
|
+
},
|
|
404
|
+
{ kind: "action", id: "mask-invert", label: "Invert mask", icon: null, ...noMask, onSelect: () => store.editMask("invert") },
|
|
405
|
+
{ kind: "action", id: "mask-release", label: "Release mask", icon: null, ...noMask, onSelect: () => store.editMask(null) },
|
|
406
|
+
];
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** The right-click menu on a motion path dot (gap 3). It acts on that one
|
|
410
|
+
* Position key, so it is short: seek to it, curve the path, make the key a
|
|
411
|
+
* corner on a bezier path (schema v40), and delete it. A viewer keeps the
|
|
412
|
+
* seek, which writes nothing, and sees the rest dimmed. */
|
|
413
|
+
export function pathKeyMenuEntries(
|
|
414
|
+
store: ShotEditorStore,
|
|
415
|
+
state: ShotEditorState,
|
|
416
|
+
ref: { layerId: string; keyId: string },
|
|
417
|
+
): MenuEntry[] {
|
|
418
|
+
const container = state.editingDef ?? state.sceneId;
|
|
419
|
+
const track =
|
|
420
|
+
state.doc === null || container === null
|
|
421
|
+
? undefined
|
|
422
|
+
: homeOf(state.doc, container)?.layersById[ref.layerId]?.tracks?.["transform.position"];
|
|
423
|
+
const curved = track?.spatialInterp !== undefined;
|
|
424
|
+
// On a bezier path the dot's double-click is corner/smooth, not the seek.
|
|
425
|
+
const corner = store.pathKeyCorner(ref.layerId, ref.keyId);
|
|
426
|
+
return gateForViewer(state, [
|
|
427
|
+
{
|
|
428
|
+
kind: "action",
|
|
429
|
+
id: "path-key-seek",
|
|
430
|
+
label: "Seek to key",
|
|
431
|
+
...(corner === null ? { shortcut: "Double-click" } : {}),
|
|
432
|
+
onSelect: () => store.seekPathKey(ref.layerId, ref.keyId),
|
|
433
|
+
},
|
|
434
|
+
{
|
|
435
|
+
kind: "action",
|
|
436
|
+
id: "path-key-curve",
|
|
437
|
+
// `MenuEntry` has no checked state: the on marker sits in the shortcut
|
|
438
|
+
// column, the register the keyframe menu's "current" uses.
|
|
439
|
+
label: "Curved path",
|
|
440
|
+
...(curved ? { shortcut: "on" } : {}),
|
|
441
|
+
onSelect: () =>
|
|
442
|
+
store.setPathCurve([ref.layerId], curved ? null : { type: "catmull-rom", tension: 0.5 }),
|
|
443
|
+
},
|
|
444
|
+
...(corner === null
|
|
445
|
+
? []
|
|
446
|
+
: [
|
|
447
|
+
{
|
|
448
|
+
kind: "action" as const,
|
|
449
|
+
id: "path-key-corner",
|
|
450
|
+
label: corner ? "Smooth" : "Corner",
|
|
451
|
+
shortcut: "Double-click",
|
|
452
|
+
onSelect: () => store.setPathKeyCorner(ref.layerId, ref.keyId, !corner),
|
|
453
|
+
},
|
|
454
|
+
]),
|
|
455
|
+
{ kind: "separator" },
|
|
456
|
+
{
|
|
457
|
+
kind: "action",
|
|
458
|
+
id: "path-key-delete",
|
|
459
|
+
label: "Delete key",
|
|
460
|
+
danger: true,
|
|
461
|
+
onSelect: () => store.deletePathKey(ref.layerId, ref.keyId),
|
|
462
|
+
},
|
|
463
|
+
]);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** Paste animation's two rows, under one submenu: the playhead is the default
|
|
467
|
+
* and carries the key; the original time is the menu-only reading. */
|
|
468
|
+
function pasteAnimationMenu(store: ShotEditorStore, idPrefix: string): MenuEntry {
|
|
469
|
+
return {
|
|
470
|
+
kind: "submenu",
|
|
471
|
+
label: "Paste animation",
|
|
472
|
+
items: [
|
|
473
|
+
{
|
|
474
|
+
kind: "action",
|
|
475
|
+
id: `${idPrefix}playhead`,
|
|
476
|
+
label: "At playhead",
|
|
477
|
+
shortcut: shortcutFor("edit.pasteAnimation.playhead"),
|
|
478
|
+
onSelect: () => store.pasteAnimation("playhead"),
|
|
479
|
+
},
|
|
480
|
+
{
|
|
481
|
+
kind: "action",
|
|
482
|
+
id: `${idPrefix}original`,
|
|
483
|
+
label: "At original time",
|
|
484
|
+
onSelect: () => store.pasteAnimation("original"),
|
|
485
|
+
},
|
|
486
|
+
],
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Drop each row `refused` names, a submenu left empty, and the separators
|
|
491
|
+
* that leaves doubled or at either end. */
|
|
492
|
+
function omitRows(entries: MenuEntry[], refused: (id: string) => boolean): MenuEntry[] {
|
|
493
|
+
const kept: MenuEntry[] = [];
|
|
494
|
+
for (const entry of entries) {
|
|
495
|
+
if (entry.kind === "action" && refused(entry.id)) continue;
|
|
496
|
+
if (entry.kind === "submenu") {
|
|
497
|
+
const items = omitRows(entry.items, refused);
|
|
498
|
+
if (items.length > 0) kept.push({ ...entry, items });
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
if (entry.kind === "separator" && (kept.length === 0 || kept[kept.length - 1]!.kind === "separator")) continue;
|
|
502
|
+
kept.push(entry);
|
|
503
|
+
}
|
|
504
|
+
while (kept.length > 0 && kept[kept.length - 1]!.kind === "separator") kept.pop();
|
|
505
|
+
return kept;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** How both shot surfaces open `shotMenuEntries`: Paper's 432px wide surface.
|
|
509
|
+
* One constant so the outline row and the timeline row cannot drift apart on
|
|
510
|
+
* the box any more than on the rows. */
|
|
511
|
+
export const SHOT_MENU_OPTIONS: OpenMenuOptions = { width: "wide" };
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* What a right-click on a SHOT offers. Two surfaces open it — the outline's
|
|
515
|
+
* shot rows and the timeline's shot rows — and they have to agree completely,
|
|
516
|
+
* which is `layerMenuEntries`' argument applied to the other subject.
|
|
120
517
|
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
518
|
+
* `onRename` is a callback rather than a store verb because renaming is an
|
|
519
|
+
* INLINE edit: the store has `renameScene`, but which row goes into edit mode is
|
|
520
|
+
* a property of the surface that was clicked. The menu says "Rename" and the
|
|
521
|
+
* surface decides what that looks like.
|
|
522
|
+
*
|
|
523
|
+
* Every other entry is an existing verb, the same rule the layer menu follows.
|
|
524
|
+
*
|
|
525
|
+
* Both surfaces open it with `SHOT_MENU_OPTIONS`, for the same reason.
|
|
124
526
|
*/
|
|
125
|
-
export function
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
527
|
+
export function shotMenuEntries(
|
|
528
|
+
store: ShotEditorStore,
|
|
529
|
+
state: ShotEditorState,
|
|
530
|
+
sceneId: string,
|
|
531
|
+
onRename: () => void,
|
|
532
|
+
): MenuEntry[] {
|
|
533
|
+
const doc = state.doc;
|
|
534
|
+
const scene = doc?.scenesById[sceneId];
|
|
535
|
+
if (doc === undefined || doc === null || scene === undefined) return [];
|
|
536
|
+
const only = doc.scenes.length <= 1;
|
|
537
|
+
const last = doc.scenes[doc.scenes.length - 1] === sceneId;
|
|
538
|
+
// ON THE BOARD a Shot and its planned shot are one thing, so Duplicate and
|
|
539
|
+
// Delete take both, the strip's pairing; off it, the Shot alone.
|
|
540
|
+
const part = state.storyboardOpen ? doc.storyboard?.parts.find((p) => p.scene === sceneId) : undefined;
|
|
541
|
+
|
|
542
|
+
const entries: MenuEntry[] = [
|
|
543
|
+
{
|
|
544
|
+
kind: "action",
|
|
545
|
+
id: "shot-open",
|
|
546
|
+
label: "Open",
|
|
547
|
+
onSelect: () => store.enterScene(sceneId),
|
|
548
|
+
},
|
|
549
|
+
{ kind: "action", id: "shot-rename", label: "Rename", onSelect: onRename },
|
|
550
|
+
{
|
|
551
|
+
kind: "action",
|
|
552
|
+
id: "shot-duplicate",
|
|
553
|
+
label: "Duplicate",
|
|
554
|
+
onSelect: () => (part === undefined ? store.duplicateScene(sceneId) : store.duplicateBoardShot(part.id)),
|
|
555
|
+
},
|
|
556
|
+
{ kind: "separator" },
|
|
557
|
+
// The double-click used to do this and now opens the shot. It keeps the `\`
|
|
558
|
+
// key and it keeps a name here, because "show me this shot end to end" is a
|
|
559
|
+
// real request and it no longer has a gesture of its own.
|
|
560
|
+
{
|
|
561
|
+
kind: "action",
|
|
562
|
+
id: "shot-zoom",
|
|
563
|
+
label: "Zoom the timeline to this shot",
|
|
564
|
+
// The board's strip stands where the timeline does.
|
|
565
|
+
disabled: state.storyboardOpen,
|
|
566
|
+
title: state.storyboardOpen ? "The timeline is not on screen on the Storyboard." : undefined,
|
|
567
|
+
onSelect: () => store.fitDock(sceneId),
|
|
568
|
+
},
|
|
569
|
+
];
|
|
570
|
+
|
|
571
|
+
// A seam belongs to the shot BEFORE it, so the last shot has none. Selecting
|
|
572
|
+
// the outgoing shot and seeking to its final frame is what the strip's cut
|
|
573
|
+
// diamond has always done: it puts the inspector's Transition section on the
|
|
574
|
+
// seam and the stage on the frame the blend starts from.
|
|
575
|
+
if (!last) {
|
|
576
|
+
entries.push({
|
|
577
|
+
kind: "action",
|
|
578
|
+
id: "shot-transition",
|
|
579
|
+
label:
|
|
580
|
+
scene.transition === undefined
|
|
581
|
+
? "Add a transition after this shot"
|
|
582
|
+
: "Edit the transition after this shot",
|
|
583
|
+
onSelect: () => {
|
|
584
|
+
const window =
|
|
585
|
+
state.doc === null
|
|
586
|
+
? undefined
|
|
587
|
+
: sceneWindows(state.doc).find((w) => w.id === sceneId);
|
|
588
|
+
store.setScene(sceneId);
|
|
589
|
+
if (window !== undefined)
|
|
590
|
+
store.seek(Math.max(window.start, window.end - 1));
|
|
591
|
+
},
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// THE SHOT'S POST EFFECTS, copied whole onto another Shot. No chord: the
|
|
596
|
+
// keys belong to the layer pair. Paste is absent until there is a copy.
|
|
597
|
+
entries.push({ kind: "separator" });
|
|
598
|
+
entries.push({
|
|
599
|
+
kind: "action",
|
|
600
|
+
id: "shot-copy-effects",
|
|
601
|
+
label: "Copy effects",
|
|
602
|
+
onSelect: () => store.copyShotEffects(sceneId),
|
|
603
|
+
});
|
|
604
|
+
if (store.hasShotEffectsClip()) {
|
|
605
|
+
entries.push({
|
|
606
|
+
kind: "action",
|
|
607
|
+
id: "shot-paste-effects",
|
|
608
|
+
label: "Paste effects",
|
|
609
|
+
onSelect: () => store.pasteShotEffects([sceneId]),
|
|
610
|
+
});
|
|
139
611
|
}
|
|
140
|
-
|
|
612
|
+
|
|
613
|
+
entries.push({ kind: "separator" });
|
|
614
|
+
entries.push({
|
|
615
|
+
kind: "action",
|
|
616
|
+
id: "shot-delete",
|
|
617
|
+
label: "Delete",
|
|
618
|
+
danger: true,
|
|
619
|
+
// A courtesy, never the guard: `deleteScene` refuses the last shot BY NAME,
|
|
620
|
+
// which is the message an author who reaches it should get.
|
|
621
|
+
disabled: only,
|
|
622
|
+
title: only ? LAST_SHOT : undefined,
|
|
623
|
+
onSelect: () => (part === undefined ? store.deleteScene(sceneId) : store.deleteBoardShot(part.id)),
|
|
624
|
+
});
|
|
625
|
+
return gateForViewer(state, entries);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* The surface-local callbacks the app menu cannot express as store verbs.
|
|
630
|
+
*
|
|
631
|
+
* Every one of them ASKS THE AUTHOR SOMETHING — a name, a file — and neither a
|
|
632
|
+
* prompt nor a file dialog is a store verb: `<input type=file>` can only be
|
|
633
|
+
* opened by a click on a real element, and which row goes into edit mode is a
|
|
634
|
+
* property of the surface that was clicked. `shotMenuEntries`' `onRename`
|
|
635
|
+
* established the pattern; this is the same hatch with more doors.
|
|
636
|
+
*/
|
|
637
|
+
export interface AppMenuHooks {
|
|
638
|
+
/** Chrome preference lives with StudioThemeProvider, never in the shot document. */
|
|
639
|
+
themePreference: StudioThemePreference;
|
|
640
|
+
onThemePreference(preference: StudioThemePreference): void;
|
|
641
|
+
onRegenerate(): void;
|
|
642
|
+
onCopyPath(path: string): void;
|
|
643
|
+
onRenameScene(sceneId: string): void;
|
|
644
|
+
onRenameLayer(layerId: string): void;
|
|
645
|
+
onRenameComponent(defId: string): void;
|
|
646
|
+
/** Click the hidden audio `<input>`; the menu itself is not one. */
|
|
647
|
+
onImportAudio(): void;
|
|
648
|
+
onOpenHelp(): void;
|
|
649
|
+
/** Leave the editor for a Worker route. A hook and not `location.assign` in
|
|
650
|
+
* here, because `menu.ts` is a pure function and a test that called a row
|
|
651
|
+
* would navigate the test runner's own jsdom. */
|
|
652
|
+
onNavigate(href: string): void;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* THE APP MENU: every verb this chrome has, under one button in the top-left.
|
|
657
|
+
*
|
|
658
|
+
* ── WHY IT EXISTS AT ALL, GIVEN EVERY VERB ALREADY HAS A KEY ────────────
|
|
659
|
+
*
|
|
660
|
+
* Because a key is not an affordance. `restack` (bring to front / send to back)
|
|
661
|
+
* was fully implemented with zero buttons and zero keys until this menu gave it
|
|
662
|
+
* rows (the bracket chords came later); `closeShot`,
|
|
663
|
+
* `deleteComponent`, `exitIsolationAll`, `toggleWorkingOrtho`, the six
|
|
664
|
+
* working-view presets and the three panel-size resets are each reachable by
|
|
665
|
+
* exactly one gesture nobody discovers, or by none. A laptop with no numpad
|
|
666
|
+
* cannot press Numpad1. The manual lists 60 bindings, which is a reference, not
|
|
667
|
+
* a way in.
|
|
668
|
+
*
|
|
669
|
+
* ── THE SAME THREE RULES AS THE MENUS ABOVE ─────────────────────────────
|
|
670
|
+
*
|
|
671
|
+
* DATA, not JSX, so the whole tree is one testable function. EVERY LEAF IS AN
|
|
672
|
+
* EXISTING VERB, so the menu can never be a second implementation of an edit
|
|
673
|
+
* (the six that have no verb call `store.handleKey` with their own key's event
|
|
674
|
+
* — see `probeFor`). And REBUILT ON EVERY OPEN, so `disabled` and `checked` are
|
|
675
|
+
* computed rather than subscribed.
|
|
676
|
+
*
|
|
677
|
+
* ── AND ONE RULE THE RIGHT-CLICK MENUS DO NOT HAVE ──────────────────────
|
|
678
|
+
*
|
|
679
|
+
* THE TOP-LEVEL ROWS NEVER MOVE. A right-click menu's shape may follow its
|
|
680
|
+
* subject (`layerMenuEntries` omits the material rows on a non-mesh selection,
|
|
681
|
+
* deliberately). A global menu's may not: an author learns "Layer is the sixth
|
|
682
|
+
* row", and a tree that reorders itself with the selection makes that knowledge
|
|
683
|
+
* wrong. So a whole branch that does not apply is DIMMED, never dropped — which
|
|
684
|
+
* is what Framer's dimmed Text and Vector submenus are, and why
|
|
685
|
+
* `MenuEntry`'s submenu arm grew a `disabled` field. Inside a submenu the
|
|
686
|
+
* contents may still vary; nobody counts rows two levels down.
|
|
687
|
+
*/
|
|
688
|
+
export function appMenuEntries(
|
|
689
|
+
store: ShotEditorStore,
|
|
690
|
+
state: ShotEditorState,
|
|
691
|
+
hooks: AppMenuHooks,
|
|
692
|
+
): MenuEntry[] {
|
|
693
|
+
const doc = state.doc;
|
|
694
|
+
const sel = state.selection;
|
|
695
|
+
const targets = menuTargetsFor(state, sel);
|
|
696
|
+
const one = sel.length === 1 ? sel[0] : undefined;
|
|
697
|
+
const open = doc !== null;
|
|
698
|
+
// The three contexts, from the one place that spells them (the store's own
|
|
699
|
+
// `inSceneContext` is private to its closure). Local names because the rest
|
|
700
|
+
// of this function reads them dozens of times. `isSceneContext` already
|
|
701
|
+
// carries the "a document is open" term, so `open &&` would be saying it
|
|
702
|
+
// twice.
|
|
703
|
+
const inDef = inDefinition(state);
|
|
704
|
+
const inShot = inShotContext(state);
|
|
705
|
+
const sceneContext = isSceneContext(state);
|
|
706
|
+
// A VISIBLE working pane, which is `noWorkingPane()`'s condition inverted. Not
|
|
707
|
+
// a call to that helper: it is module-private AND it writes the error strip as
|
|
708
|
+
// a side effect, so asking it a question would paint a refusal every time the
|
|
709
|
+
// menu opened.
|
|
710
|
+
const pane = shownLayoutMode(state) !== "camera";
|
|
711
|
+
// The lead clause stays local on purpose: three callers refuse for three
|
|
712
|
+
// different reasons and each owns its own opening ("Rect draws in…", "that
|
|
713
|
+
// key aims…"). Only the tail is one rule, so only the tail is shared.
|
|
714
|
+
const noPane = formatCombo(`That aims ${layoutSpec("working").noun}, and ` + paneOnlyRefusal("camera"));
|
|
715
|
+
const defs = doc === null ? {} : componentDefsOf(doc);
|
|
716
|
+
const defIds = Object.keys(defs);
|
|
717
|
+
const drifted = one !== undefined && instanceDriftOf(state, one).length > 0;
|
|
718
|
+
// Read once per open, not subscribed: the Worker stamps the marker into the
|
|
719
|
+
// HTML, so it cannot change without a reload.
|
|
720
|
+
const hosted = isCloudWorkspace();
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* A branch, dimmed when NOTHING INSIDE IT CAN BE USED.
|
|
724
|
+
*
|
|
725
|
+
* One derived rule instead of a hand-written predicate per submenu. The
|
|
726
|
+
* hand-written version was already wrong on its second use: "Camera" carried
|
|
727
|
+
* `disabled: inDef`, which is true, and in the scene context it opened onto
|
|
728
|
+
* four dimmed rows anyway — a branch you can enter that offers nothing, which
|
|
729
|
+
* is worse than a dimmed one because it costs a click to find out.
|
|
730
|
+
*
|
|
731
|
+
* Applied BOTTOM-UP: an inner branch built with this already carries its own
|
|
732
|
+
* `disabled`, so an outer one reading its children gets the right answer with
|
|
733
|
+
* no recursion. `[].every()` is true, which is the behaviour an empty branch
|
|
734
|
+
* wants: a submenu with no reachable rows dims itself.
|
|
735
|
+
*
|
|
736
|
+
* `also` is for the conditions no row expresses: a submenu whose every row is
|
|
737
|
+
* legal but whose SUBJECT is missing (no document open).
|
|
738
|
+
*/
|
|
739
|
+
const branch = (
|
|
740
|
+
label: string,
|
|
741
|
+
items: MenuEntry[],
|
|
742
|
+
also = false,
|
|
743
|
+
): MenuEntry => ({
|
|
744
|
+
kind: "submenu",
|
|
745
|
+
label,
|
|
746
|
+
items,
|
|
747
|
+
disabled:
|
|
748
|
+
also ||
|
|
749
|
+
items
|
|
750
|
+
.filter((e) => e.kind !== "separator")
|
|
751
|
+
.every((e) => e.disabled === true),
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
/** A row whose only home is `handleKey`'s switch. See `probeFor`. */
|
|
755
|
+
const keyRow = (
|
|
756
|
+
menuId: string,
|
|
757
|
+
label: string,
|
|
758
|
+
extra?: { disabled?: boolean; title?: string },
|
|
759
|
+
): MenuEntry => {
|
|
760
|
+
const probe = probeFor(menuId);
|
|
761
|
+
return {
|
|
762
|
+
kind: "action",
|
|
763
|
+
id: menuId,
|
|
764
|
+
label,
|
|
765
|
+
shortcut: shortcutFor(menuId),
|
|
766
|
+
disabled: extra?.disabled === true || probe === undefined,
|
|
767
|
+
...(extra?.title === undefined ? {} : { title: extra.title }),
|
|
768
|
+
onSelect: () => {
|
|
769
|
+
if (probe !== undefined) store.handleKey(probe);
|
|
770
|
+
},
|
|
771
|
+
};
|
|
772
|
+
};
|
|
773
|
+
|
|
774
|
+
// ── File ────────────────────────────────────────────────────────────────
|
|
775
|
+
const file: MenuEntry[] = [
|
|
776
|
+
{
|
|
777
|
+
kind: "action",
|
|
778
|
+
id: "file.save",
|
|
779
|
+
label: "Save version…",
|
|
780
|
+
shortcut: shortcutFor("file.save"),
|
|
781
|
+
// The pending autosave lands first, as ⌘S always did; then the Version
|
|
782
|
+
// history panel asks for a name.
|
|
783
|
+
disabled: !open,
|
|
784
|
+
onSelect: () => {
|
|
785
|
+
void store.flush().catch(() => {});
|
|
786
|
+
store.askVersionName();
|
|
787
|
+
},
|
|
788
|
+
},
|
|
789
|
+
{
|
|
790
|
+
kind: "action",
|
|
791
|
+
id: "file.versions",
|
|
792
|
+
label: "Version history",
|
|
793
|
+
disabled: !open,
|
|
794
|
+
onSelect: () => store.openVersions(!state.versionsOpen),
|
|
795
|
+
},
|
|
796
|
+
// No separators in here: File is one short run, and its rows need no
|
|
797
|
+
// grouping to be read.
|
|
798
|
+
{
|
|
799
|
+
kind: "action",
|
|
800
|
+
id: "file.import.audio",
|
|
801
|
+
label: "Import audio…",
|
|
802
|
+
// The audio lane's own import button is the other door, and that lane
|
|
803
|
+
// is only drawn once there is a clip in it, so until this row existed
|
|
804
|
+
// the first import had no discoverable gesture.
|
|
805
|
+
disabled: !open,
|
|
806
|
+
onSelect: hooks.onImportAudio,
|
|
807
|
+
},
|
|
808
|
+
{
|
|
809
|
+
kind: "action",
|
|
810
|
+
id: "file.export",
|
|
811
|
+
label: "Export…",
|
|
812
|
+
shortcut: shortcutFor("file.export"),
|
|
813
|
+
disabled: !open,
|
|
814
|
+
onSelect: () => store.setExportOpen(true),
|
|
815
|
+
},
|
|
816
|
+
];
|
|
817
|
+
|
|
818
|
+
// ── Edit ────────────────────────────────────────────────────────────────
|
|
819
|
+
const edit: MenuEntry[] = [
|
|
820
|
+
{
|
|
821
|
+
kind: "action",
|
|
822
|
+
id: "edit.undo",
|
|
823
|
+
label: "Undo",
|
|
824
|
+
shortcut: shortcutFor("edit.undo"),
|
|
825
|
+
disabled: !state.canUndo,
|
|
826
|
+
onSelect: () => store.undo(),
|
|
827
|
+
},
|
|
828
|
+
{
|
|
829
|
+
kind: "action",
|
|
830
|
+
id: "edit.redo",
|
|
831
|
+
label: "Redo",
|
|
832
|
+
shortcut: shortcutFor("edit.redo"),
|
|
833
|
+
disabled: !state.canRedo,
|
|
834
|
+
onSelect: () => store.redo(),
|
|
835
|
+
},
|
|
836
|
+
{ kind: "separator" },
|
|
837
|
+
{
|
|
838
|
+
kind: "action",
|
|
839
|
+
id: "edit.copy",
|
|
840
|
+
label: "Copy",
|
|
841
|
+
shortcut: shortcutFor("edit.copy"),
|
|
842
|
+
disabled: sel.length === 0,
|
|
843
|
+
onSelect: () => store.copyLayers(),
|
|
844
|
+
},
|
|
845
|
+
{
|
|
846
|
+
kind: "action",
|
|
847
|
+
id: "edit.cut",
|
|
848
|
+
label: "Cut",
|
|
849
|
+
shortcut: shortcutFor("edit.cut"),
|
|
850
|
+
disabled: sel.length === 0,
|
|
851
|
+
onSelect: () => store.cutLayers(),
|
|
852
|
+
},
|
|
853
|
+
{
|
|
854
|
+
kind: "action",
|
|
855
|
+
id: "edit.paste",
|
|
856
|
+
label: "Paste",
|
|
857
|
+
shortcut: shortcutFor("edit.paste"),
|
|
858
|
+
disabled: !store.hasLayerClip(),
|
|
859
|
+
onSelect: () => store.pasteLayers(),
|
|
860
|
+
},
|
|
861
|
+
{
|
|
862
|
+
kind: "action",
|
|
863
|
+
id: "edit.duplicate",
|
|
864
|
+
label: "Duplicate",
|
|
865
|
+
shortcut: shortcutFor("edit.duplicate"),
|
|
866
|
+
disabled: sel.length === 0,
|
|
867
|
+
onSelect: () => store.duplicateSelection(),
|
|
868
|
+
},
|
|
869
|
+
{
|
|
870
|
+
kind: "action",
|
|
871
|
+
id: "edit.delete",
|
|
872
|
+
label: "Delete",
|
|
873
|
+
shortcut: shortcutFor("edit.delete"),
|
|
874
|
+
danger: true,
|
|
875
|
+
// NOT gated on the layer selection: `deleteSelection` has a subject ladder
|
|
876
|
+
// (marker → audio → keyframe → camera move → layers), so a focused marker
|
|
877
|
+
// with nothing selected is a real subject and the row must stay live for it.
|
|
878
|
+
disabled: !open,
|
|
879
|
+
onSelect: () => store.deleteSelection(),
|
|
880
|
+
},
|
|
881
|
+
{ kind: "separator" },
|
|
882
|
+
// The motion clip, the layer menu's pair of pairs. Dimmed here and absent
|
|
883
|
+
// there, by the rule each kind of menu follows.
|
|
884
|
+
{
|
|
885
|
+
kind: "action",
|
|
886
|
+
id: "edit.copyAnimation",
|
|
887
|
+
label: "Copy animation",
|
|
888
|
+
shortcut: shortcutFor("edit.copyAnimation"),
|
|
889
|
+
disabled: targets.animationSource === undefined,
|
|
890
|
+
title: sel.length > 0 && targets.animationSource === undefined ? "The selected layers have no animation" : undefined,
|
|
891
|
+
onSelect: () => store.copyAnimation(),
|
|
892
|
+
},
|
|
893
|
+
{
|
|
894
|
+
kind: "submenu",
|
|
895
|
+
label: "Paste animation",
|
|
896
|
+
disabled: sel.length === 0 || !store.hasAnimationClip(),
|
|
897
|
+
items: [
|
|
898
|
+
{
|
|
899
|
+
kind: "action",
|
|
900
|
+
id: "edit.pasteAnimation.playhead",
|
|
901
|
+
label: "At playhead",
|
|
902
|
+
shortcut: shortcutFor("edit.pasteAnimation.playhead"),
|
|
903
|
+
onSelect: () => store.pasteAnimation("playhead"),
|
|
904
|
+
},
|
|
905
|
+
{
|
|
906
|
+
kind: "action",
|
|
907
|
+
id: "edit.pasteAnimation.original",
|
|
908
|
+
label: "At original time",
|
|
909
|
+
onSelect: () => store.pasteAnimation("original"),
|
|
910
|
+
},
|
|
911
|
+
],
|
|
912
|
+
},
|
|
913
|
+
{
|
|
914
|
+
kind: "action",
|
|
915
|
+
id: "edit.copyEffects",
|
|
916
|
+
label: "Copy effects",
|
|
917
|
+
shortcut: shortcutFor("edit.copyEffects"),
|
|
918
|
+
disabled: targets.effectsSource === undefined,
|
|
919
|
+
title: sel.length > 0 && targets.effectsSource === undefined ? "The selected layers have no shadow or glow" : undefined,
|
|
920
|
+
onSelect: () => store.copyEffects(),
|
|
921
|
+
},
|
|
922
|
+
{
|
|
923
|
+
kind: "action",
|
|
924
|
+
id: "edit.pasteEffects",
|
|
925
|
+
label: "Paste effects",
|
|
926
|
+
shortcut: shortcutFor("edit.pasteEffects"),
|
|
927
|
+
disabled: sel.length === 0 || !store.hasEffectsClip(),
|
|
928
|
+
onSelect: () => store.pasteEffects(),
|
|
929
|
+
},
|
|
930
|
+
{ kind: "separator" },
|
|
931
|
+
{
|
|
932
|
+
kind: "action",
|
|
933
|
+
id: "edit.selectAll",
|
|
934
|
+
label: "Select all",
|
|
935
|
+
shortcut: shortcutFor("edit.selectAll"),
|
|
936
|
+
disabled: !open,
|
|
937
|
+
onSelect: () => store.selectAll(),
|
|
938
|
+
},
|
|
939
|
+
{
|
|
940
|
+
kind: "action",
|
|
941
|
+
id: "edit.deselect",
|
|
942
|
+
label: "Deselect",
|
|
943
|
+
shortcut: shortcutFor("edit.deselect"),
|
|
944
|
+
disabled: sel.length === 0,
|
|
945
|
+
onSelect: () => store.select([]),
|
|
946
|
+
},
|
|
947
|
+
// Paper's grouping: Split sits in the selection run, right under Deselect.
|
|
948
|
+
// Group and Ungroup are not in Paper's Edit specimen, but they are shipped
|
|
949
|
+
// verbs with no other menu door, so they stay as the last run.
|
|
950
|
+
{
|
|
951
|
+
kind: "action",
|
|
952
|
+
id: "edit.split",
|
|
953
|
+
label: "Split at playhead",
|
|
954
|
+
shortcut: shortcutFor("edit.split"),
|
|
955
|
+
disabled: !open,
|
|
956
|
+
onSelect: () => store.splitAtPlayhead(),
|
|
957
|
+
},
|
|
958
|
+
{ kind: "separator" },
|
|
959
|
+
{
|
|
960
|
+
kind: "action",
|
|
961
|
+
id: "edit.group",
|
|
962
|
+
label: "Group",
|
|
963
|
+
shortcut: shortcutFor("edit.group"),
|
|
964
|
+
// Two members minimum, the floor `groupSelection` itself enforces.
|
|
965
|
+
disabled: sel.length < 2,
|
|
966
|
+
title: sel.length < 2 ? "Select two or more layers to group" : undefined,
|
|
967
|
+
onSelect: () => store.groupSelection(),
|
|
968
|
+
},
|
|
969
|
+
{
|
|
970
|
+
kind: "action",
|
|
971
|
+
id: "edit.ungroup",
|
|
972
|
+
label: "Ungroup",
|
|
973
|
+
shortcut: shortcutFor("edit.ungroup"),
|
|
974
|
+
// No button anywhere in the chrome before this row.
|
|
975
|
+
disabled: sel.length === 0,
|
|
976
|
+
onSelect: () => store.ungroupSelection(),
|
|
977
|
+
},
|
|
978
|
+
];
|
|
979
|
+
|
|
980
|
+
// ── Arrange: order, align, distribute ───────────────────────────────────
|
|
981
|
+
//
|
|
982
|
+
// Not "Layout": View ▸ Layout is the 3D / Split / Camera switch. The order
|
|
983
|
+
// rows act on the whole selection, each layer among its own siblings
|
|
984
|
+
// (`arrangeRows`), and dim only with nothing to act on or nothing but cloner
|
|
985
|
+
// templates, saying which. Row ids keep `layout.*` for the shortcut table.
|
|
986
|
+
const arrange = arrangeRows(store, state, sel, (to) => `layout.arrange.${to}`);
|
|
987
|
+
|
|
988
|
+
const alignRows = (distribute: boolean): MenuEntry[] =>
|
|
989
|
+
ALIGN_MODES.filter((mode) => DISTRIBUTE_MODES.has(mode) === distribute).map(
|
|
990
|
+
(mode) => {
|
|
991
|
+
// A distribution needs a first, a last and something between them. An
|
|
992
|
+
// alignment does not: ONE layer aligns to the canvas, which is the rule
|
|
993
|
+
// `alignLayers` states and the align grid already follows.
|
|
994
|
+
const short = distribute && sel.length < MIN_DISTRIBUTE;
|
|
995
|
+
return {
|
|
996
|
+
kind: "action" as const,
|
|
997
|
+
id: `layout.align.${mode}`,
|
|
998
|
+
label: ALIGN_LABELS[mode],
|
|
999
|
+
shortcut: shortcutFor(`layout.align.${mode}`),
|
|
1000
|
+
disabled: sel.length === 0 || short,
|
|
1001
|
+
...(short
|
|
1002
|
+
? {
|
|
1003
|
+
title: `${ALIGN_LABELS[mode]}: needs ${MIN_DISTRIBUTE} or more layers`,
|
|
1004
|
+
}
|
|
1005
|
+
: {}),
|
|
1006
|
+
onSelect: () => store.alignSelection(mode),
|
|
1007
|
+
};
|
|
1008
|
+
},
|
|
1009
|
+
);
|
|
1010
|
+
|
|
1011
|
+
const arrangeMenu: MenuEntry[] = [
|
|
1012
|
+
...arrange,
|
|
1013
|
+
{ kind: "separator" },
|
|
1014
|
+
...alignRows(false),
|
|
1015
|
+
{ kind: "separator" },
|
|
1016
|
+
...alignRows(true),
|
|
1017
|
+
];
|
|
1018
|
+
|
|
1019
|
+
// ── View ────────────────────────────────────────────────────────────────
|
|
1020
|
+
// THREE LAYOUTS AND ONE FLAG, which is the shape of the thing rather than
|
|
1021
|
+
// four alternatives. The 3D view, the split and the frame are three editors
|
|
1022
|
+
// and exactly one is on screen, so they are a radio set answered by
|
|
1023
|
+
// `stageLayout.mode`. Video is drawn OVER whichever one the author left and
|
|
1024
|
+
// hands it back untouched, so it is a checkbox and it sits under a rule.
|
|
1025
|
+
//
|
|
1026
|
+
// The bottom strip's control carries the same three words beside Edit and
|
|
1027
|
+
// Video; these rows are where the digits are taught.
|
|
1028
|
+
const stageLayoutRows: MenuEntry[] = LAYOUT_MODES.map((mode) => {
|
|
1029
|
+
// The table's rows, in key order, so the menu lists them the way the digits
|
|
1030
|
+
// and the toolbar do. `title` and not `label`: the chip's word has to fit a
|
|
1031
|
+
// segmented track, a menu row has to read as a noun beside "Split".
|
|
1032
|
+
const spec = layoutSpec(mode);
|
|
1033
|
+
// The SAME sentence the digits and the control refuse with, from the same
|
|
1034
|
+
// function: only a definition refuses, and it refuses all three. A menu
|
|
1035
|
+
// that invented its own wording would be a second explanation of one rule.
|
|
1036
|
+
const no = layoutRefusal(inDef);
|
|
1037
|
+
return {
|
|
1038
|
+
kind: "action" as const,
|
|
1039
|
+
id: spec.menuId,
|
|
1040
|
+
label: spec.title,
|
|
1041
|
+
shortcut: shortcutFor(spec.menuId),
|
|
1042
|
+
// EXACTLY ONE ROW: three rows name three layouts, so a split ticks Split
|
|
1043
|
+
// and nothing else. It reads the LAYOUT and not what is on screen,
|
|
1044
|
+
// deliberately: under Video these rows say which editor is waiting
|
|
1045
|
+
// underneath, and the Video row below says that one is on top.
|
|
1046
|
+
checked: state.stageLayout.mode === mode,
|
|
1047
|
+
disabled: no !== null,
|
|
1048
|
+
...(no === null ? {} : { title: no }),
|
|
1049
|
+
onSelect: () => store.setViewLayout(mode),
|
|
1050
|
+
};
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
// AND THE FLAG, under its own rule. Checkable rather than a fourth radio row:
|
|
1054
|
+
// it is orthogonal to the three above, and a menu that folded it in would be
|
|
1055
|
+
// saying the author chose between the camera view and Video when the camera
|
|
1056
|
+
// view is what Video is drawn on.
|
|
1057
|
+
const videoRefusal = layoutRefusal(inDef);
|
|
1058
|
+
const videoRow: MenuEntry = {
|
|
1059
|
+
kind: "action",
|
|
1060
|
+
id: "view.video",
|
|
1061
|
+
label: VIEW_MODE_SPECS.video.label,
|
|
1062
|
+
shortcut: shortcutFor("view.video"),
|
|
1063
|
+
checked: state.video,
|
|
1064
|
+
disabled: videoRefusal !== null,
|
|
1065
|
+
...(videoRefusal === null ? {} : { title: videoRefusal }),
|
|
1066
|
+
onSelect: () => store.setViewMode(state.video ? "edit" : "video"),
|
|
1067
|
+
};
|
|
1068
|
+
|
|
1069
|
+
const shadingRows: MenuEntry[] = SHADING_MODES.map((mode) => ({
|
|
1070
|
+
kind: "action" as const,
|
|
1071
|
+
id: `view.shading.${mode}`,
|
|
1072
|
+
label: shadingLabel(mode),
|
|
1073
|
+
checked: state.shadingMode === mode,
|
|
1074
|
+
// The camera pane is always the graded render and is not addressable.
|
|
1075
|
+
disabled: !pane,
|
|
1076
|
+
...(pane ? {} : { title: noPane }),
|
|
1077
|
+
onSelect: () => store.setShadingMode(mode),
|
|
1078
|
+
}));
|
|
1079
|
+
|
|
1080
|
+
const presetRows: MenuEntry[] = (
|
|
1081
|
+
[
|
|
1082
|
+
["front", "Front"],
|
|
1083
|
+
["back", "Back"],
|
|
1084
|
+
["right", "Right"],
|
|
1085
|
+
["left", "Left"],
|
|
1086
|
+
["top", "Top"],
|
|
1087
|
+
["bottom", "Bottom"],
|
|
1088
|
+
] as const satisfies ReadonlyArray<readonly [WorkingViewPreset, string]>
|
|
1089
|
+
).map(([preset, label]) => ({
|
|
1090
|
+
kind: "action" as const,
|
|
1091
|
+
id: `view.preset.${preset}`,
|
|
1092
|
+
label,
|
|
1093
|
+
disabled: !pane,
|
|
1094
|
+
...(pane ? {} : { title: noPane }),
|
|
1095
|
+
onSelect: () =>
|
|
1096
|
+
store.setWorkingView(presetWorkingView(state.workingView, preset)),
|
|
1097
|
+
}));
|
|
1098
|
+
|
|
1099
|
+
const orbitRows: MenuEntry[] = (
|
|
1100
|
+
[
|
|
1101
|
+
["left", -1, 0],
|
|
1102
|
+
["right", 1, 0],
|
|
1103
|
+
["up", 0, -1],
|
|
1104
|
+
["down", 0, 1],
|
|
1105
|
+
] as const
|
|
1106
|
+
).map(([dir, dx, dy]) => ({
|
|
1107
|
+
kind: "action" as const,
|
|
1108
|
+
id: `view.orbit.${dir}`,
|
|
1109
|
+
label: `Orbit ${dir} ${ORBIT_STEP_DEG}°`,
|
|
1110
|
+
disabled: !pane,
|
|
1111
|
+
...(pane ? {} : { title: noPane }),
|
|
1112
|
+
onSelect: () =>
|
|
1113
|
+
store.setWorkingView(
|
|
1114
|
+
orbitByDegrees(
|
|
1115
|
+
state.workingView,
|
|
1116
|
+
dx * ORBIT_STEP_DEG,
|
|
1117
|
+
dy * ORBIT_STEP_DEG,
|
|
1118
|
+
),
|
|
1119
|
+
),
|
|
1120
|
+
}));
|
|
1121
|
+
|
|
1122
|
+
// ── THE WORK PLANE'S SECOND HOME ─────────────────────────────────────────
|
|
1123
|
+
//
|
|
1124
|
+
// The canvas bar shows the plane select only while the spline tool is armed
|
|
1125
|
+
// (the moment it matters), so the plane needs a home that is always there.
|
|
1126
|
+
// Same gate as `WorkPlaneBar`: an entered Shot whose working pane is on
|
|
1127
|
+
// screen, or a definition, which occupies that pane whatever the layout.
|
|
1128
|
+
const planeKind = state.sceneId === null ? "xy" : (state.workPlanes[state.sceneId]?.kind ?? "xy");
|
|
1129
|
+
const planeOn = state.sceneId !== null && state.enteredScene !== null && (pane || inDef);
|
|
1130
|
+
const planeNo = planeOn ? null : state.enteredScene === null ? "The work plane belongs to a Shot. Open one first." : noPane;
|
|
1131
|
+
const planeNote = "The plane Shift+P draws spline anchors on. Selection needs one selected layer.";
|
|
1132
|
+
const workPlaneRows: MenuEntry[] = (
|
|
1133
|
+
[
|
|
1134
|
+
["xy", "XY"],
|
|
1135
|
+
["xz", "XZ"],
|
|
1136
|
+
["yz", "YZ"],
|
|
1137
|
+
["view", "View"],
|
|
1138
|
+
] as const
|
|
1139
|
+
).map(([kind, label]) => ({
|
|
1140
|
+
kind: "action" as const,
|
|
1141
|
+
id: `view.workPlane.${kind}`,
|
|
1142
|
+
label,
|
|
1143
|
+
checked: planeKind === kind,
|
|
1144
|
+
disabled: planeNo !== null,
|
|
1145
|
+
title: planeNo ?? planeNote,
|
|
1146
|
+
onSelect: () => store.setWorkPlane({ kind }),
|
|
1147
|
+
}));
|
|
1148
|
+
workPlaneRows.push({
|
|
1149
|
+
kind: "action",
|
|
1150
|
+
id: "view.workPlane.selection",
|
|
1151
|
+
label: "Selection",
|
|
1152
|
+
checked: planeKind === "selection",
|
|
1153
|
+
disabled: planeNo !== null || sel.length !== 1,
|
|
1154
|
+
title: planeNo ?? planeNote,
|
|
1155
|
+
onSelect: () => {
|
|
1156
|
+
if (sel.length === 1) store.setWorkPlane({ kind: "selection", layerId: sel[0]! });
|
|
1157
|
+
},
|
|
1158
|
+
});
|
|
1159
|
+
|
|
1160
|
+
const workingView: MenuEntry[] = [
|
|
1161
|
+
...presetRows,
|
|
1162
|
+
{ kind: "separator" },
|
|
1163
|
+
...orbitRows,
|
|
1164
|
+
{
|
|
1165
|
+
kind: "action",
|
|
1166
|
+
id: "view.orbit.half",
|
|
1167
|
+
label: "Orbit 180°",
|
|
1168
|
+
disabled: !pane,
|
|
1169
|
+
...(pane ? {} : { title: noPane }),
|
|
1170
|
+
onSelect: () =>
|
|
1171
|
+
store.setWorkingView(orbitByDegrees(state.workingView, 180, 0)),
|
|
1172
|
+
},
|
|
1173
|
+
{ kind: "separator" },
|
|
1174
|
+
{
|
|
1175
|
+
kind: "action",
|
|
1176
|
+
id: "view.ortho",
|
|
1177
|
+
label: "Orthographic",
|
|
1178
|
+
shortcut: shortcutFor("view.ortho"),
|
|
1179
|
+
checked: state.workingView.ortho === true,
|
|
1180
|
+
// No button anywhere before this row: Num5 was the only way in, and a
|
|
1181
|
+
// laptop has no Num5.
|
|
1182
|
+
disabled: !pane,
|
|
1183
|
+
...(pane ? {} : { title: noPane }),
|
|
1184
|
+
onSelect: () =>
|
|
1185
|
+
store.setWorkingView({
|
|
1186
|
+
...state.workingView,
|
|
1187
|
+
ortho: state.workingView.ortho !== true,
|
|
1188
|
+
}),
|
|
1189
|
+
},
|
|
1190
|
+
];
|
|
1191
|
+
|
|
1192
|
+
// ── THE CAMERA VERBS, FROM THE ONE TABLE ────────────────────────────────
|
|
1193
|
+
//
|
|
1194
|
+
// Words, sentences and menu ids come out of `cameraVerbs.ts`, which the
|
|
1195
|
+
// Camera bar over the stage and the shortcut manual read too. Only the
|
|
1196
|
+
// gating is decided here, because only the menu knows the state.
|
|
1197
|
+
const noShotCamera = "A component definition has no shot camera.";
|
|
1198
|
+
const driven =
|
|
1199
|
+
state.cameraDriver === null
|
|
1200
|
+
? null
|
|
1201
|
+
: `The camera is driven by ${state.cameraDriver}, so the shot camera has no effect while it is.`;
|
|
1202
|
+
const pilot = cameraVerb("pilot");
|
|
1203
|
+
const toView = cameraVerb("toView");
|
|
1204
|
+
const frameSel = cameraVerb("frameSelection");
|
|
1205
|
+
const addMove = cameraVerb("addMove");
|
|
1206
|
+
const camera: MenuEntry[] = [
|
|
1207
|
+
{
|
|
1208
|
+
kind: "action",
|
|
1209
|
+
id: pilot.menuId,
|
|
1210
|
+
label: pilot.label,
|
|
1211
|
+
shortcut: shortcutFor(pilot.menuId),
|
|
1212
|
+
checked: state.pilot,
|
|
1213
|
+
// No pane gate: the store brings the Camera pane on screen itself when
|
|
1214
|
+
// the 3D layout has none. A definition has no shot camera to pilot, and
|
|
1215
|
+
// a driven camera would move nothing the picture shows.
|
|
1216
|
+
disabled: !open || inDef || driven !== null,
|
|
1217
|
+
...(inDef ? { title: noShotCamera } : driven !== null ? { title: driven } : { title: pilot.sentence }),
|
|
1218
|
+
onSelect: () => store.setPilot(!state.pilot),
|
|
1219
|
+
},
|
|
1220
|
+
{
|
|
1221
|
+
kind: "action",
|
|
1222
|
+
id: toView.menuId,
|
|
1223
|
+
label: toView.label,
|
|
1224
|
+
shortcut: shortcutFor(toView.menuId),
|
|
1225
|
+
disabled: !pane || inDef || driven !== null,
|
|
1226
|
+
...(inDef
|
|
1227
|
+
? { title: noShotCamera }
|
|
1228
|
+
: driven !== null
|
|
1229
|
+
? { title: driven }
|
|
1230
|
+
: pane
|
|
1231
|
+
? { title: toView.sentence }
|
|
1232
|
+
: { title: noPane }),
|
|
1233
|
+
onSelect: () => store.alignCameraToWorkingView(),
|
|
1234
|
+
},
|
|
1235
|
+
{
|
|
1236
|
+
kind: "action",
|
|
1237
|
+
id: frameSel.menuId,
|
|
1238
|
+
label: frameSel.label,
|
|
1239
|
+
disabled: sel.length === 0 || inDef || driven !== null,
|
|
1240
|
+
...(inDef
|
|
1241
|
+
? { title: noShotCamera }
|
|
1242
|
+
: driven !== null
|
|
1243
|
+
? { title: driven }
|
|
1244
|
+
: sel.length === 0
|
|
1245
|
+
? { title: "Select a layer first." }
|
|
1246
|
+
: { title: frameSel.sentence }),
|
|
1247
|
+
onSelect: () => store.fitCameraToSelection(),
|
|
1248
|
+
},
|
|
1249
|
+
{ kind: "separator" },
|
|
1250
|
+
{
|
|
1251
|
+
kind: "action",
|
|
1252
|
+
id: addMove.menuId,
|
|
1253
|
+
label: addMove.label,
|
|
1254
|
+
// ⇧C was reassigned to frame-all, so this verb has NO key producer left.
|
|
1255
|
+
// The dock's "+ move" button and this row are its only doors.
|
|
1256
|
+
disabled: !inShot,
|
|
1257
|
+
title: inShot ? addMove.sentence : "Open a shot to add a move to it",
|
|
1258
|
+
onSelect: () => store.addShotAtPlayhead(state.enteredScene ?? undefined),
|
|
1259
|
+
},
|
|
1260
|
+
];
|
|
1261
|
+
|
|
1262
|
+
const appearance: MenuEntry[] = (
|
|
1263
|
+
[
|
|
1264
|
+
["system", "System"],
|
|
1265
|
+
["light", "Light"],
|
|
1266
|
+
["dark", "Dark"],
|
|
1267
|
+
] as const satisfies ReadonlyArray<readonly [StudioThemePreference, string]>
|
|
1268
|
+
).map(([preference, label]) => ({
|
|
1269
|
+
kind: "action" as const,
|
|
1270
|
+
id: `view.appearance.${preference}`,
|
|
1271
|
+
label,
|
|
1272
|
+
checked: hooks.themePreference === preference,
|
|
1273
|
+
onSelect: () => hooks.onThemePreference(preference),
|
|
1274
|
+
}));
|
|
1275
|
+
|
|
1276
|
+
const panels: MenuEntry[] = [
|
|
1277
|
+
{
|
|
1278
|
+
kind: "action",
|
|
1279
|
+
// One name per panel, the one its toggle button and its docs use: the
|
|
1280
|
+
// column holds the outline, the board's rail or a mode's library, so
|
|
1281
|
+
// "Outline" named only one of its occupants. The id never changes.
|
|
1282
|
+
id: "view.panel.rail",
|
|
1283
|
+
label: "Left panel",
|
|
1284
|
+
shortcut: shortcutFor("view.panel.rail"),
|
|
1285
|
+
checked: !state.railCollapsed,
|
|
1286
|
+
onSelect: () => store.setRailCollapsed(!state.railCollapsed),
|
|
1287
|
+
},
|
|
1288
|
+
{
|
|
1289
|
+
kind: "action",
|
|
1290
|
+
id: "view.panel.inspector",
|
|
1291
|
+
label: "Right panel",
|
|
1292
|
+
shortcut: shortcutFor("view.panel.inspector"),
|
|
1293
|
+
checked: !state.inspectorCollapsed,
|
|
1294
|
+
onSelect: () => store.setInspectorCollapsed(!state.inspectorCollapsed),
|
|
1295
|
+
},
|
|
1296
|
+
{
|
|
1297
|
+
kind: "action",
|
|
1298
|
+
id: "view.panel.dock",
|
|
1299
|
+
// The one panel toggle with no key bound. `PanelToggles` reads its hint
|
|
1300
|
+
// from this id, so binding a key here shows it on that button too.
|
|
1301
|
+
label: "Timeline",
|
|
1302
|
+
checked: !state.dockCollapsed,
|
|
1303
|
+
onSelect: () => store.setDockCollapsed(!state.dockCollapsed),
|
|
1304
|
+
},
|
|
1305
|
+
{
|
|
1306
|
+
kind: "action",
|
|
1307
|
+
id: "view.panel.all",
|
|
1308
|
+
label: "Every panel",
|
|
1309
|
+
shortcut: shortcutFor("view.panel.all"),
|
|
1310
|
+
checked:
|
|
1311
|
+
!state.railCollapsed &&
|
|
1312
|
+
!state.inspectorCollapsed &&
|
|
1313
|
+
!state.dockCollapsed,
|
|
1314
|
+
onSelect: () => store.togglePanels(),
|
|
1315
|
+
},
|
|
1316
|
+
{ kind: "separator" },
|
|
1317
|
+
...(agentInEditor()
|
|
1318
|
+
? [
|
|
1319
|
+
{
|
|
1320
|
+
kind: "action" as const,
|
|
1321
|
+
id: "view.panel.agent",
|
|
1322
|
+
label: "Agent",
|
|
1323
|
+
checked: state.agentOpen,
|
|
1324
|
+
// The credentials probe is the only thing that says there is an agent lane
|
|
1325
|
+
// on this server at all; a null answer means the route never replied.
|
|
1326
|
+
disabled: state.agentCredentials === null,
|
|
1327
|
+
title:
|
|
1328
|
+
state.agentCredentials === null
|
|
1329
|
+
? (state.agentCredentialsError ?? "Checking for the agent backend…")
|
|
1330
|
+
: undefined,
|
|
1331
|
+
onSelect: () => store.setAgentOpen(!state.agentOpen),
|
|
1332
|
+
},
|
|
1333
|
+
]
|
|
1334
|
+
: []),
|
|
1335
|
+
{
|
|
1336
|
+
kind: "action",
|
|
1337
|
+
// The shipped library, which had a top-bar button until Paper's bar
|
|
1338
|
+
// dropped it. It picks what a Project uses, so it needs one open.
|
|
1339
|
+
id: "view.panel.library",
|
|
1340
|
+
label: "Library",
|
|
1341
|
+
checked: state.libraryBrowserOpen,
|
|
1342
|
+
disabled: state.name === null,
|
|
1343
|
+
onSelect: () => store.setLibraryBrowserOpen(!state.libraryBrowserOpen),
|
|
1344
|
+
},
|
|
1345
|
+
// NO "Inspector: Design / Animate" ROWS. They set `inspectorMode`, which
|
|
1346
|
+
// was the deleted tab bit — the inspector has one face now, so there is
|
|
1347
|
+
// nothing for a view command to switch. ⇧A's `tool.animate` row is a
|
|
1348
|
+
// different verb that happens to share a word: it turns record mode on, so
|
|
1349
|
+
// edits write keyframes, and it lives under Tool.
|
|
1350
|
+
];
|
|
1351
|
+
|
|
1352
|
+
// THE TWO DIAGNOSTIC CHIPS, and only those. The save-state and codegen chips
|
|
1353
|
+
// are deliberately absent: they exist to tell the author something is unsaved
|
|
1354
|
+
// or broken, and a warning with an off switch is a warning that is off on the
|
|
1355
|
+
// day it matters. These two describe the MACHINE, which is exactly what an
|
|
1356
|
+
// author recording their film does not want in the recording.
|
|
1357
|
+
const statusBadges: MenuEntry[] = [
|
|
1358
|
+
{
|
|
1359
|
+
kind: "action",
|
|
1360
|
+
id: "view.badges.fps",
|
|
1361
|
+
label: "Playback rate",
|
|
1362
|
+
checked: state.showFpsBadge,
|
|
1363
|
+
onSelect: () => store.setFpsBadge(!state.showFpsBadge),
|
|
1364
|
+
},
|
|
1365
|
+
{
|
|
1366
|
+
kind: "action",
|
|
1367
|
+
id: "view.badges.backend",
|
|
1368
|
+
label: "Renderer backend",
|
|
1369
|
+
checked: state.showBackendBadge,
|
|
1370
|
+
onSelect: () => store.setBackendBadge(!state.showBackendBadge),
|
|
1371
|
+
},
|
|
1372
|
+
];
|
|
1373
|
+
|
|
1374
|
+
const view: MenuEntry[] = [
|
|
1375
|
+
branch("Layout", stageLayoutRows),
|
|
1376
|
+
videoRow,
|
|
1377
|
+
branch("Shading", shadingRows),
|
|
1378
|
+
{
|
|
1379
|
+
// The grid floor lives in the working pane only, the key's own gate.
|
|
1380
|
+
kind: "action",
|
|
1381
|
+
id: "view.grid",
|
|
1382
|
+
label: "Grid",
|
|
1383
|
+
shortcut: shortcutFor("view.grid"),
|
|
1384
|
+
checked: state.showGrid,
|
|
1385
|
+
disabled: !pane,
|
|
1386
|
+
...(pane ? {} : { title: noPane }),
|
|
1387
|
+
onSelect: () => store.toggleGrid(),
|
|
1388
|
+
},
|
|
1389
|
+
branch(layoutSpec("working").title, workingView),
|
|
1390
|
+
branch("Work plane", workPlaneRows),
|
|
1391
|
+
branch("Camera", camera),
|
|
1392
|
+
branch("Status badges", statusBadges),
|
|
1393
|
+
branch("Appearance", appearance),
|
|
1394
|
+
{ kind: "separator" },
|
|
1395
|
+
{
|
|
1396
|
+
// Paper page 24: the rulers draw on the camera pane, and the guides
|
|
1397
|
+
// they create are only dragged out of them.
|
|
1398
|
+
kind: "action",
|
|
1399
|
+
id: "view.rulers",
|
|
1400
|
+
label: "Rulers",
|
|
1401
|
+
shortcut: shortcutFor("view.rulers"),
|
|
1402
|
+
checked: state.rulers,
|
|
1403
|
+
onSelect: () => store.setEditorPref("rulers", !state.rulers),
|
|
1404
|
+
},
|
|
1405
|
+
{
|
|
1406
|
+
// Snapping to the frame and the other layers. User guides snap either
|
|
1407
|
+
// way, and Ctrl drops both for one drag.
|
|
1408
|
+
kind: "action",
|
|
1409
|
+
id: "view.smartGuides",
|
|
1410
|
+
label: "Smart guides",
|
|
1411
|
+
checked: state.smartGuides,
|
|
1412
|
+
onSelect: () => store.setEditorPref("smartGuides", !state.smartGuides),
|
|
1413
|
+
},
|
|
1414
|
+
{
|
|
1415
|
+
// Paper page 24: the selected layers' Position paths on the stage. A
|
|
1416
|
+
// viewer sees them too; only the editor can drag a key.
|
|
1417
|
+
kind: "action",
|
|
1418
|
+
id: "view.motionPaths",
|
|
1419
|
+
label: "Motion paths",
|
|
1420
|
+
checked: state.motionPaths,
|
|
1421
|
+
onSelect: () => store.setEditorPref("motionPaths", !state.motionPaths),
|
|
1422
|
+
},
|
|
1423
|
+
{
|
|
1424
|
+
// Paper page 23: the editor's behaviour toggles sit in one block after
|
|
1425
|
+
// Appearance. A single plain pick moves a paused playhead onto it.
|
|
1426
|
+
kind: "action",
|
|
1427
|
+
id: "view.selectMovesPlayhead",
|
|
1428
|
+
label: "Move playhead to selection",
|
|
1429
|
+
checked: state.selectMovesPlayhead,
|
|
1430
|
+
onSelect: () => store.setSelectMovesPlayhead(!state.selectMovesPlayhead),
|
|
1431
|
+
},
|
|
1432
|
+
{ kind: "separator" },
|
|
1433
|
+
{
|
|
1434
|
+
kind: "action",
|
|
1435
|
+
id: "view.zoomIn",
|
|
1436
|
+
label: "Zoom in",
|
|
1437
|
+
shortcut: shortcutFor("view.zoomIn"),
|
|
1438
|
+
disabled: !open,
|
|
1439
|
+
onSelect: () => store.zoomActivePane(1),
|
|
1440
|
+
},
|
|
1441
|
+
{
|
|
1442
|
+
kind: "action",
|
|
1443
|
+
id: "view.zoomOut",
|
|
1444
|
+
label: "Zoom out",
|
|
1445
|
+
shortcut: shortcutFor("view.zoomOut"),
|
|
1446
|
+
disabled: !open,
|
|
1447
|
+
onSelect: () => store.zoomActivePane(-1),
|
|
1448
|
+
},
|
|
1449
|
+
keyRow("view.zoomReset", "Zoom to 100%", { disabled: !open }),
|
|
1450
|
+
{
|
|
1451
|
+
kind: "action",
|
|
1452
|
+
id: "view.fit",
|
|
1453
|
+
label: "Fit the active pane",
|
|
1454
|
+
shortcut: shortcutFor("view.fit"),
|
|
1455
|
+
disabled: !open,
|
|
1456
|
+
onSelect: () => store.fitActivePane(),
|
|
1457
|
+
},
|
|
1458
|
+
{
|
|
1459
|
+
kind: "action",
|
|
1460
|
+
id: "view.fitSelection",
|
|
1461
|
+
// NAMED FOR ITS PANE, because "Frame selection with camera" sits one
|
|
1462
|
+
// branch away and the two used to read as one verb: this one moves the
|
|
1463
|
+
// author's own view and writes nothing.
|
|
1464
|
+
label: `Frame the selection in ${layoutSpec("working").noun}`,
|
|
1465
|
+
shortcut: shortcutFor("view.fitSelection"),
|
|
1466
|
+
disabled: sel.length === 0 || !pane,
|
|
1467
|
+
...(pane ? {} : { title: noPane }),
|
|
1468
|
+
onSelect: () => store.fitWorkingViewToSelection(),
|
|
1469
|
+
},
|
|
1470
|
+
{ kind: "separator" },
|
|
1471
|
+
branch("Panels", panels),
|
|
1472
|
+
];
|
|
1473
|
+
|
|
1474
|
+
// ── Tool ────────────────────────────────────────────────────────────────
|
|
1475
|
+
const tool: MenuEntry[] = [
|
|
1476
|
+
...SHOT_TOOLS.map((t) => {
|
|
1477
|
+
const spec = specOf(t);
|
|
1478
|
+
const no = toolRefusal(
|
|
1479
|
+
t,
|
|
1480
|
+
toolLayoutMode(state),
|
|
1481
|
+
sceneContext,
|
|
1482
|
+
state.readOnly && readOnlyReason(state),
|
|
1483
|
+
isolationInsertRefusal(state),
|
|
1484
|
+
);
|
|
1485
|
+
return {
|
|
1486
|
+
kind: "action" as const,
|
|
1487
|
+
id: `tool.${t}`,
|
|
1488
|
+
label: spec.label,
|
|
1489
|
+
...(spec.key === null ? {} : { shortcut: toolKey(t)! }),
|
|
1490
|
+
checked: state.tool === t,
|
|
1491
|
+
disabled: no !== null,
|
|
1492
|
+
...(no === null ? {} : { title: no }),
|
|
1493
|
+
onSelect: () => store.setTool(t),
|
|
1494
|
+
};
|
|
1495
|
+
}),
|
|
1496
|
+
{ kind: "separator" },
|
|
1497
|
+
{
|
|
1498
|
+
kind: "action",
|
|
1499
|
+
id: "tool.animate",
|
|
1500
|
+
label: "Animate mode",
|
|
1501
|
+
shortcut: shortcutFor("tool.animate"),
|
|
1502
|
+
// A checkbox and NOT a member of the tool radio above: it changes what an
|
|
1503
|
+
// edit MEANS, not what a drag does. See tools.ts.
|
|
1504
|
+
checked: state.animateMode,
|
|
1505
|
+
title: "Animate: edits write keyframes at the playhead",
|
|
1506
|
+
onSelect: () => store.setAnimateMode(!state.animateMode),
|
|
1507
|
+
},
|
|
1508
|
+
];
|
|
1509
|
+
|
|
1510
|
+
// ── Layer: our dimmed Text / Vector ─────────────────────────────────────
|
|
1511
|
+
//
|
|
1512
|
+
// The right-click menu, spliced in whole, plus the verbs it has no room for.
|
|
1513
|
+
// Splicing rather than rebuilding is what keeps the two from disagreeing about
|
|
1514
|
+
// what Delete does — this branch IS the right-click menu.
|
|
1515
|
+
const layer: MenuEntry[] = [
|
|
1516
|
+
{
|
|
1517
|
+
kind: "action",
|
|
1518
|
+
id: "layer.rename",
|
|
1519
|
+
label: "Rename…",
|
|
1520
|
+
disabled: one === undefined,
|
|
1521
|
+
onSelect: () => {
|
|
1522
|
+
if (one !== undefined) hooks.onRenameLayer(one);
|
|
1523
|
+
},
|
|
1524
|
+
},
|
|
1525
|
+
{ kind: "separator" },
|
|
1526
|
+
...layerMenuEntries(store, state, targets),
|
|
1527
|
+
{ kind: "separator" },
|
|
1528
|
+
keyRow("layer.trim.in", "Trim in to playhead", { disabled: sel.length === 0 }),
|
|
1529
|
+
keyRow("layer.trim.out", "Trim out to playhead", { disabled: sel.length === 0 }),
|
|
1530
|
+
{ kind: "separator" },
|
|
1531
|
+
keyRow("layer.flip.x", "Flip horizontal", { disabled: sel.length === 0 }),
|
|
1532
|
+
keyRow("layer.flip.y", "Flip vertical", { disabled: sel.length === 0 }),
|
|
1533
|
+
{ kind: "separator" },
|
|
1534
|
+
keyRow("layer.hideOthers", "Hide everything else", {
|
|
1535
|
+
disabled: sel.length === 0,
|
|
1536
|
+
}),
|
|
1537
|
+
keyRow("layer.reveal", "Reveal everything"),
|
|
1538
|
+
{ kind: "separator" },
|
|
1539
|
+
keyRow("layer.clear.position", "Clear the position", {
|
|
1540
|
+
disabled: sel.length === 0,
|
|
1541
|
+
}),
|
|
1542
|
+
keyRow("layer.clear.rotation", "Clear the rotation", {
|
|
1543
|
+
disabled: sel.length === 0,
|
|
1544
|
+
}),
|
|
1545
|
+
{ kind: "separator" },
|
|
1546
|
+
{
|
|
1547
|
+
kind: "action",
|
|
1548
|
+
id: "layer.isolate.exitAll",
|
|
1549
|
+
label: "Exit every level of isolation",
|
|
1550
|
+
// Escape is the one-level version and has always existed; this verb had no
|
|
1551
|
+
// gesture but the breadcrumb's first crumb.
|
|
1552
|
+
disabled: state.isolation === null,
|
|
1553
|
+
onSelect: () => store.exitIsolationAll(),
|
|
1554
|
+
},
|
|
1555
|
+
];
|
|
1556
|
+
|
|
1557
|
+
// ── Timeline ────────────────────────────────────────────────────────────
|
|
1558
|
+
const timeline: MenuEntry[] = [
|
|
1559
|
+
{
|
|
1560
|
+
kind: "action",
|
|
1561
|
+
id: "time.play",
|
|
1562
|
+
// The Play control's own rules (PlayButton.tsx): a queued Play reads as
|
|
1563
|
+
// the wait it is, and a second press calls it off.
|
|
1564
|
+
label: state.playing ? "Pause" : state.playIntent !== null ? "Cancel play" : "Play",
|
|
1565
|
+
shortcut: shortcutFor("time.play"),
|
|
1566
|
+
// A capture loop owns the clock while an export runs, and the board does
|
|
1567
|
+
// not play. `setPlaying` refuses underneath this on the same `playHold`,
|
|
1568
|
+
// so the greying-out is the menu agreeing with it rather than a second
|
|
1569
|
+
// rule. A scene still loading greys nothing: the press is queued. It
|
|
1570
|
+
// gates the START only: a running transport or a queued Play stays
|
|
1571
|
+
// stoppable from here.
|
|
1572
|
+
//
|
|
1573
|
+
// `exportLock` used to be tested a second time, unconditionally. It was
|
|
1574
|
+
// always redundant, because both lock sites write `playing: false` in the
|
|
1575
|
+
// same `set` as the lock, and it contradicted the sentence above by
|
|
1576
|
+
// greying out Pause as well.
|
|
1577
|
+
disabled: !open || (!playOn(state) && playHold(state) !== null),
|
|
1578
|
+
title:
|
|
1579
|
+
!open || state.playing
|
|
1580
|
+
? undefined
|
|
1581
|
+
: state.playIntent === null
|
|
1582
|
+
? (playHold(state) ?? undefined)
|
|
1583
|
+
: state.playIntent.phase === "preparing"
|
|
1584
|
+
? preparingTitle(state.playIntent)
|
|
1585
|
+
: SCENE_WAITING,
|
|
1586
|
+
onSelect: () => store.setPlaying(!playOn(state)),
|
|
1587
|
+
},
|
|
1588
|
+
{
|
|
1589
|
+
kind: "action",
|
|
1590
|
+
id: "time.step.next",
|
|
1591
|
+
label: "Next frame",
|
|
1592
|
+
disabled: !open,
|
|
1593
|
+
onSelect: () => store.step(1),
|
|
1594
|
+
},
|
|
1595
|
+
{
|
|
1596
|
+
kind: "action",
|
|
1597
|
+
id: "time.step.prev",
|
|
1598
|
+
label: "Previous frame",
|
|
1599
|
+
disabled: !open,
|
|
1600
|
+
onSelect: () => store.step(-1),
|
|
1601
|
+
},
|
|
1602
|
+
{ kind: "separator" },
|
|
1603
|
+
{
|
|
1604
|
+
kind: "action",
|
|
1605
|
+
id: "time.marker.add",
|
|
1606
|
+
label: "Add a marker here",
|
|
1607
|
+
shortcut: shortcutFor("time.marker.add"),
|
|
1608
|
+
disabled: !open,
|
|
1609
|
+
onSelect: () => store.addMarkerAtPlayhead(),
|
|
1610
|
+
},
|
|
1611
|
+
{ kind: "separator" },
|
|
1612
|
+
// ── THE WORK AREA ────────────────────────────────────────────────────
|
|
1613
|
+
//
|
|
1614
|
+
// Session state and never the document: it is what playback loops inside
|
|
1615
|
+
// while an author watches one stretch over and over. The bracket is drawn
|
|
1616
|
+
// on the dock's ruler (`WorkAreaBracket`, Header.tsx) and the three keys
|
|
1617
|
+
// are in the Transport group of the shortcut table; without these rows the
|
|
1618
|
+
// capability existed only as a chord nobody would find, which is the state
|
|
1619
|
+
// the app menu exists to prevent.
|
|
1620
|
+
//
|
|
1621
|
+
// NOT GREYED OUT INSIDE A SHOT OR A DEFINITION, unlike Play. The two are
|
|
1622
|
+
// different kinds of refusal. `setPlaying` refuses silently, so the menu
|
|
1623
|
+
// dims the row to agree with it; `setWorkAreaEdge` refuses BY NAME with the
|
|
1624
|
+
// sentence that says an entered shot already loops on its own and to come
|
|
1625
|
+
// back out. A dimmed row swallows that sentence and leaves the author with
|
|
1626
|
+
// a grey word and no reason, so these stay live wherever a document is
|
|
1627
|
+
// open and let the verb do the talking.
|
|
1628
|
+
{
|
|
1629
|
+
kind: "action",
|
|
1630
|
+
id: "time.workArea.in",
|
|
1631
|
+
label: "Work area in at the playhead",
|
|
1632
|
+
shortcut: shortcutFor("time.workArea.in"),
|
|
1633
|
+
disabled: !open,
|
|
1634
|
+
onSelect: () => store.setWorkAreaEdge("in"),
|
|
1635
|
+
},
|
|
1636
|
+
{
|
|
1637
|
+
kind: "action",
|
|
1638
|
+
id: "time.workArea.out",
|
|
1639
|
+
label: "Work area out at the playhead",
|
|
1640
|
+
shortcut: shortcutFor("time.workArea.out"),
|
|
1641
|
+
disabled: !open,
|
|
1642
|
+
onSelect: () => store.setWorkAreaEdge("out"),
|
|
1643
|
+
},
|
|
1644
|
+
{
|
|
1645
|
+
kind: "action",
|
|
1646
|
+
id: "time.workArea.clear",
|
|
1647
|
+
label: "Clear the work area",
|
|
1648
|
+
shortcut: shortcutFor("time.workArea.clear"),
|
|
1649
|
+
// THE ONE THAT DOES DIM, because `clearWorkArea` returns on a null area
|
|
1650
|
+
// and has nothing to say about it: there is no bracket, so "clear it" is
|
|
1651
|
+
// a row with no subject rather than a refusal with a reason.
|
|
1652
|
+
disabled: !open || state.workArea === null,
|
|
1653
|
+
onSelect: () => store.clearWorkArea(),
|
|
1654
|
+
},
|
|
1655
|
+
{ kind: "separator" },
|
|
1656
|
+
{
|
|
1657
|
+
kind: "action",
|
|
1658
|
+
id: "time.mode.rows",
|
|
1659
|
+
label: "Rows",
|
|
1660
|
+
checked: state.dockMode === "rows",
|
|
1661
|
+
onSelect: () => store.setDockMode("rows"),
|
|
1662
|
+
},
|
|
1663
|
+
{
|
|
1664
|
+
kind: "action",
|
|
1665
|
+
id: "time.mode.graph",
|
|
1666
|
+
label: "Graph",
|
|
1667
|
+
checked: state.dockMode === "graph",
|
|
1668
|
+
onSelect: () => store.setDockMode("graph"),
|
|
1669
|
+
},
|
|
1670
|
+
{
|
|
1671
|
+
kind: "action",
|
|
1672
|
+
id: "time.mode.sound",
|
|
1673
|
+
label: "Sound",
|
|
1674
|
+
shortcut: shortcutFor("time.mode.sound"),
|
|
1675
|
+
checked: state.dockMode === "sound",
|
|
1676
|
+
onSelect: () => store.setDockMode("sound"),
|
|
1677
|
+
},
|
|
1678
|
+
{
|
|
1679
|
+
kind: "action",
|
|
1680
|
+
id: "time.fit",
|
|
1681
|
+
label: "Fit the timeline",
|
|
1682
|
+
shortcut: shortcutFor("time.fit"),
|
|
1683
|
+
disabled: !open,
|
|
1684
|
+
onSelect: () => store.fitDock(),
|
|
1685
|
+
},
|
|
1686
|
+
{ kind: "separator" },
|
|
1687
|
+
{
|
|
1688
|
+
kind: "action",
|
|
1689
|
+
id: "time.previewMuted",
|
|
1690
|
+
label: "Mute the preview",
|
|
1691
|
+
// MONITORING ONLY, never the mix and never the export.
|
|
1692
|
+
checked: state.previewMuted,
|
|
1693
|
+
onSelect: () => store.setPreviewMuted(!state.previewMuted),
|
|
1694
|
+
},
|
|
1695
|
+
];
|
|
1696
|
+
|
|
1697
|
+
// ── Shot: the timeline row's own menu, for the shot at the playhead ─────
|
|
1698
|
+
const shot: MenuEntry[] = [
|
|
1699
|
+
...(state.sceneId === null
|
|
1700
|
+
? []
|
|
1701
|
+
: shotMenuEntries(store, state, state.sceneId, () => {
|
|
1702
|
+
if (state.sceneId !== null) hooks.onRenameScene(state.sceneId);
|
|
1703
|
+
})),
|
|
1704
|
+
...(open && state.sceneId !== null ? [{ kind: "separator" } as const] : []),
|
|
1705
|
+
...(open ? boardMenuEntries(store, state) : []),
|
|
1706
|
+
];
|
|
1707
|
+
|
|
1708
|
+
// ── Component ───────────────────────────────────────────────────────────
|
|
1709
|
+
const defRows = (
|
|
1710
|
+
idPrefix: string,
|
|
1711
|
+
run: (defId: string) => void,
|
|
1712
|
+
): MenuEntry[] =>
|
|
1713
|
+
defIds.map((defId) => ({
|
|
1714
|
+
kind: "action" as const,
|
|
1715
|
+
id: `${idPrefix}:${defId}`,
|
|
1716
|
+
label: defs[defId]?.name ?? defId,
|
|
1717
|
+
checked: state.editingDef === defId ? true : undefined,
|
|
1718
|
+
onSelect: () => run(defId),
|
|
1719
|
+
}));
|
|
1720
|
+
|
|
1721
|
+
const component: MenuEntry[] = [
|
|
1722
|
+
{
|
|
1723
|
+
kind: "action",
|
|
1724
|
+
id: "comp.create",
|
|
1725
|
+
label: "Make a component from the selection",
|
|
1726
|
+
disabled: sel.length === 0,
|
|
1727
|
+
title:
|
|
1728
|
+
sel.length === 0
|
|
1729
|
+
? "Select the layers to turn into a component first"
|
|
1730
|
+
: undefined,
|
|
1731
|
+
onSelect: () => store.createComponentFromSelection(),
|
|
1732
|
+
},
|
|
1733
|
+
{
|
|
1734
|
+
kind: "action",
|
|
1735
|
+
id: "comp.createEmpty",
|
|
1736
|
+
label: "New empty component",
|
|
1737
|
+
// Never in the scene context: a definition's layers live inside a shot's
|
|
1738
|
+
// container, and `insertContext` is what refuses otherwise.
|
|
1739
|
+
disabled: sceneContext,
|
|
1740
|
+
title: sceneContext
|
|
1741
|
+
? "Open a shot first: a component is made of its elements"
|
|
1742
|
+
: undefined,
|
|
1743
|
+
onSelect: () => store.createEmptyComponent("Component"),
|
|
1744
|
+
},
|
|
1745
|
+
{ kind: "separator" },
|
|
1746
|
+
branch(
|
|
1747
|
+
"Place",
|
|
1748
|
+
defRows("comp.place", (defId) => store.placeComponent(defId)),
|
|
1749
|
+
sceneContext,
|
|
1750
|
+
),
|
|
1751
|
+
branch(
|
|
1752
|
+
"Open in Components",
|
|
1753
|
+
defRows("comp.edit", (defId) => store.editComponent(defId)),
|
|
1754
|
+
),
|
|
1755
|
+
{
|
|
1756
|
+
kind: "action",
|
|
1757
|
+
id: "comp.rebind",
|
|
1758
|
+
// The inspector's word for the same verb, offered under the same
|
|
1759
|
+
// condition: one code-component layer whose contract has drifted
|
|
1760
|
+
// (`instanceDriftOf`, which the drift notice reads too). Never
|
|
1761
|
+
// automatic: it is a statement that the live contract is the one this
|
|
1762
|
+
// instance means, and only the author can make it.
|
|
1763
|
+
label: "Accept current contract",
|
|
1764
|
+
disabled: !drifted,
|
|
1765
|
+
title: drifted ? undefined : "Select one code component whose contract has changed",
|
|
1766
|
+
onSelect: () => {
|
|
1767
|
+
if (one === undefined) return;
|
|
1768
|
+
// The verb answers with its refusal rather than writing the error
|
|
1769
|
+
// strip, so the row says it the way the inspector's button does.
|
|
1770
|
+
void store.rebindInstance(one).then((refusal) => {
|
|
1771
|
+
if (refusal !== null) store.reportError(refusal);
|
|
1772
|
+
});
|
|
1773
|
+
},
|
|
1774
|
+
},
|
|
1775
|
+
{ kind: "separator" },
|
|
1776
|
+
{
|
|
1777
|
+
kind: "action",
|
|
1778
|
+
id: "comp.rename",
|
|
1779
|
+
label: "Rename this definition…",
|
|
1780
|
+
disabled: !inDef,
|
|
1781
|
+
onSelect: () => {
|
|
1782
|
+
if (state.editingDef !== null)
|
|
1783
|
+
hooks.onRenameComponent(state.editingDef);
|
|
1784
|
+
},
|
|
1785
|
+
},
|
|
1786
|
+
{
|
|
1787
|
+
kind: "action",
|
|
1788
|
+
id: "comp.delete",
|
|
1789
|
+
label: "Delete this definition",
|
|
1790
|
+
danger: true,
|
|
1791
|
+
// Implemented with zero callers before this row. It refuses while
|
|
1792
|
+
// instances exist, naming where they are, so this is safe to offer bare.
|
|
1793
|
+
disabled: !inDef,
|
|
1794
|
+
onSelect: () => {
|
|
1795
|
+
if (state.editingDef !== null) store.deleteComponent(state.editingDef);
|
|
1796
|
+
},
|
|
1797
|
+
},
|
|
1798
|
+
];
|
|
1799
|
+
|
|
1800
|
+
// ── Code ────────────────────────────────────────────────────────────────
|
|
1801
|
+
//
|
|
1802
|
+
// Every save regenerates the shot's contracted component at
|
|
1803
|
+
// `src/shots/<doc.name>.tsx`. When codegen refuses to overwrite a hand edit it
|
|
1804
|
+
// sets `emitWarning` and stays refused, and until this submenu the only way
|
|
1805
|
+
// out was the CLI.
|
|
1806
|
+
const code: MenuEntry[] = [
|
|
1807
|
+
{
|
|
1808
|
+
kind: "action",
|
|
1809
|
+
id: "code.path",
|
|
1810
|
+
label: "Copy the generated file's path",
|
|
1811
|
+
disabled: doc === null,
|
|
1812
|
+
onSelect: () => {
|
|
1813
|
+
if (doc !== null) hooks.onCopyPath(`${GENERATED_SHOTS_DIR}/${doc.name}.tsx`);
|
|
1814
|
+
},
|
|
1815
|
+
},
|
|
1816
|
+
{
|
|
1817
|
+
kind: "action",
|
|
1818
|
+
id: "code.regenerate",
|
|
1819
|
+
label: "Regenerate the component…",
|
|
1820
|
+
danger: true,
|
|
1821
|
+
// Dimmed until there is something to un-stick, and it SAYS so: this
|
|
1822
|
+
// discards hand edits, and offering it against a file nobody has touched
|
|
1823
|
+
// is offering a destructive verb with nothing to destroy.
|
|
1824
|
+
disabled: !open || state.emitWarning === null,
|
|
1825
|
+
title:
|
|
1826
|
+
state.emitWarning === null
|
|
1827
|
+
? "Nothing to regenerate: the generated file matches this document"
|
|
1828
|
+
: state.emitWarning,
|
|
1829
|
+
onSelect: hooks.onRegenerate,
|
|
1830
|
+
},
|
|
1831
|
+
];
|
|
1832
|
+
|
|
1833
|
+
// ── The root ────────────────────────────────────────────────────────────
|
|
1834
|
+
//
|
|
1835
|
+
// Group 1 is Framer's surface switcher, answered honestly: `/api/surfaces`
|
|
1836
|
+
// returns at most `["shot"]`, so there is no second surface to switch to — but
|
|
1837
|
+
// there ARE three nested CONTEXTS, they are mutually exclusive, they are the
|
|
1838
|
+
// product's real "where am I", and until this menu they had no global
|
|
1839
|
+
// affordance at all (a double-click in, a breadcrumb out).
|
|
1840
|
+
// The store's own refusal: nothing holds the board open, and opening hands
|
|
1841
|
+
// over from any mode except Code Studio with unsaved buffers
|
|
1842
|
+
// (`storyboardBlocker`), which the header's switch asks about first.
|
|
1843
|
+
const boardHeld = state.storyboardOpen ? null : storyboardBlocker(state);
|
|
1844
|
+
return gateForWorkspace(state, [
|
|
1845
|
+
{
|
|
1846
|
+
kind: "action",
|
|
1847
|
+
id: "ctx.scene",
|
|
1848
|
+
label: "Project",
|
|
1849
|
+
checked: sceneContext,
|
|
1850
|
+
disabled: !open || sceneContext,
|
|
1851
|
+
onSelect: () => {
|
|
1852
|
+
store.editComponent(null);
|
|
1853
|
+
store.leaveScene();
|
|
1854
|
+
},
|
|
1855
|
+
},
|
|
1856
|
+
{
|
|
1857
|
+
kind: "action",
|
|
1858
|
+
id: "ctx.shot",
|
|
1859
|
+
label: "Shot",
|
|
1860
|
+
checked: inShot,
|
|
1861
|
+
disabled: doc === null || doc.scenes.length === 0 || inShot,
|
|
1862
|
+
onSelect: () => {
|
|
1863
|
+
// From a definition, "Shot" is one level out. From the scene context it
|
|
1864
|
+
// is the shot the PLAYHEAD is in, which is what `sceneId` answers and
|
|
1865
|
+
// what a double-click on the strip already uses.
|
|
1866
|
+
if (inDef) store.editComponent(null);
|
|
1867
|
+
else if (state.sceneId !== null) store.enterScene(state.sceneId);
|
|
1868
|
+
},
|
|
1869
|
+
},
|
|
1870
|
+
{
|
|
1871
|
+
kind: "action",
|
|
1872
|
+
id: "ctx.component",
|
|
1873
|
+
label: "Component",
|
|
1874
|
+
checked: inDef,
|
|
1875
|
+
// OUR DIMMED "Text": there is nothing to enter without one instance
|
|
1876
|
+
// selected, and the row stays where an author learned it either way.
|
|
1877
|
+
disabled:
|
|
1878
|
+
inDef || one === undefined || !targets.opensComponent,
|
|
1879
|
+
title:
|
|
1880
|
+
inDef || one !== undefined
|
|
1881
|
+
? undefined
|
|
1882
|
+
: "Select one component layer to open it in Components",
|
|
1883
|
+
onSelect: () => {
|
|
1884
|
+
if (one !== undefined) void store.openComponentContext(one);
|
|
1885
|
+
},
|
|
1886
|
+
},
|
|
1887
|
+
// A fourth "where am I": the board is a per-Project place, and the
|
|
1888
|
+
// top-level rows never move, so it is added here and never reordered.
|
|
1889
|
+
{
|
|
1890
|
+
kind: "action",
|
|
1891
|
+
id: "ctx.storyboard",
|
|
1892
|
+
label: "Storyboard",
|
|
1893
|
+
shortcut: shortcutFor("ctx.storyboard"),
|
|
1894
|
+
checked: state.storyboardOpen,
|
|
1895
|
+
// A menu row cannot ask, so unsaved code in Code Studio holds it.
|
|
1896
|
+
disabled: !open || boardHeld !== null,
|
|
1897
|
+
...(open && boardHeld !== null ? { title: boardHeld } : {}),
|
|
1898
|
+
onSelect: () => store.setStoryboardOpen(!state.storyboardOpen),
|
|
1899
|
+
},
|
|
1900
|
+
{ kind: "separator" },
|
|
1901
|
+
branch("File", file),
|
|
1902
|
+
branch("Edit", edit),
|
|
1903
|
+
branch("View", view),
|
|
1904
|
+
branch("Arrange", arrangeMenu, sel.length === 0),
|
|
1905
|
+
branch("Tool", tool),
|
|
1906
|
+
branch("Layer", layer, sel.length === 0),
|
|
1907
|
+
branch("Timeline", timeline, !open),
|
|
1908
|
+
branch("Shot", shot),
|
|
1909
|
+
branch("Component", component),
|
|
1910
|
+
branch("Code", code),
|
|
1911
|
+
{ kind: "separator" },
|
|
1912
|
+
{
|
|
1913
|
+
kind: "action",
|
|
1914
|
+
id: "prefs.open",
|
|
1915
|
+
label: "Preferences…",
|
|
1916
|
+
shortcut: shortcutFor("prefs.open"),
|
|
1917
|
+
onSelect: () => store.setPrefsOpen(true),
|
|
1918
|
+
},
|
|
1919
|
+
{ kind: "action", id: "help", label: "Help", onSelect: hooks.onOpenHelp },
|
|
1920
|
+
// ── PROJECT HOME ALWAYS; AUTH HOSTED ONLY ─────────────────────────────
|
|
1921
|
+
//
|
|
1922
|
+
// Local and hosted workspaces both own `/projects/`, so Project Home is a
|
|
1923
|
+
// fixed row. Profile and Sign out are the exception to the "top-level rows
|
|
1924
|
+
// never move" rule: `isCloudWorkspace()` reads a meta tag fixed for the
|
|
1925
|
+
// page, and both stay hidden locally because the CLI has neither
|
|
1926
|
+
// `/auth/logout` nor an account to have a profile.
|
|
1927
|
+
//
|
|
1928
|
+
// This narrows a stated decision in docs/cloud.md ("no editor cloud-awareness
|
|
1929
|
+
// chrome"). These rows are NAVIGATION AND IDENTITY, not state or status:
|
|
1930
|
+
// nothing here polls, nothing lands in the header, and the workspace's
|
|
1931
|
+
// state still surfaces only as each call's own answer, as that note says.
|
|
1932
|
+
{ kind: "separator" },
|
|
1933
|
+
{
|
|
1934
|
+
kind: "action",
|
|
1935
|
+
id: "acct.projects",
|
|
1936
|
+
label: "Go to Projects",
|
|
1937
|
+
onSelect: () => hooks.onNavigate("/projects/"),
|
|
1938
|
+
},
|
|
1939
|
+
...(hosted
|
|
1940
|
+
? ([
|
|
1941
|
+
{
|
|
1942
|
+
kind: "action",
|
|
1943
|
+
id: "acct.profile",
|
|
1944
|
+
label: "Profile",
|
|
1945
|
+
// The dashboard's own view, reached the way any other surface
|
|
1946
|
+
// reaches it. onNavigate flushes autosave first, so leaving the
|
|
1947
|
+
// editor from a menu never drops an edit.
|
|
1948
|
+
onSelect: () => hooks.onNavigate("/projects/?view=profile"),
|
|
1949
|
+
},
|
|
1950
|
+
{
|
|
1951
|
+
kind: "action",
|
|
1952
|
+
id: "acct.signout",
|
|
1953
|
+
label: "Sign out",
|
|
1954
|
+
// A GET, because sign-out has to work as a plain link — and it still
|
|
1955
|
+
// revokes the session row server-side (worker/src/auth.ts).
|
|
1956
|
+
onSelect: () => hooks.onNavigate("/auth/logout"),
|
|
1957
|
+
},
|
|
1958
|
+
] satisfies MenuEntry[])
|
|
1959
|
+
: []),
|
|
1960
|
+
]);
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
/**
|
|
1964
|
+
* THE WORKSPACE GATE over the whole menu. The top bar stays on screen in every
|
|
1965
|
+
* mode now, so this menu is reachable from inside the material nodes and both
|
|
1966
|
+
* studios, where most of its rows would act on a scene that is not on screen.
|
|
1967
|
+
* A row the mode refuses is dimmed with the reason (`commandBlocker`), never
|
|
1968
|
+
* dropped (top-level rows never move), and a branch left with nothing to use
|
|
1969
|
+
* dims too, the rule `branch` states.
|
|
1970
|
+
*/
|
|
1971
|
+
function gateForWorkspace(state: ShotEditorState, entries: MenuEntry[]): MenuEntry[] {
|
|
1972
|
+
// A viewer's menu is gated in every workspace, the stage one too.
|
|
1973
|
+
if (workspaceOf(state) === "stage" && !state.readOnly) return entries;
|
|
1974
|
+
return gateRows(entries, (id) => commandBlocker(state, id));
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
/**
|
|
1978
|
+
* A VIEWER'S RIGHT-CLICK: every row that writes is dimmed with `VIEW_ONLY`.
|
|
1979
|
+
* Each context-menu builder returns through this, so no surface that opens
|
|
1980
|
+
* one has to know. The app menu gets the same answer from `commandBlocker`.
|
|
1981
|
+
*/
|
|
1982
|
+
export function gateForViewer(
|
|
1983
|
+
state: Pick<ShotEditorState, "readOnly"> & Partial<Pick<ShotEditorState, "versionPreview" | "syncStatus">>,
|
|
1984
|
+
entries: MenuEntry[],
|
|
1985
|
+
): MenuEntry[] {
|
|
1986
|
+
const reason = readOnlyReason(state);
|
|
1987
|
+
return state.readOnly ? gateRows(entries, (id) => viewOnlyBlocker(id) && reason) : entries;
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
/** Dim each row `reasonOf` refuses, with the reason. A submenu left with
|
|
1991
|
+
* nothing to use dims too. */
|
|
1992
|
+
function gateRows(entries: MenuEntry[], reasonOf: (id: string) => string | null): MenuEntry[] {
|
|
1993
|
+
return entries.map((entry) => {
|
|
1994
|
+
if (entry.kind === "separator") return entry;
|
|
1995
|
+
if (entry.kind === "submenu") {
|
|
1996
|
+
const inner = gateRows(entry.items, reasonOf);
|
|
1997
|
+
const rows = inner.filter((e) => e.kind !== "separator");
|
|
1998
|
+
const empty = rows.every((e) => e.disabled === true);
|
|
1999
|
+
// A branch dimmed because every row is dimmed says why, in the words of
|
|
2000
|
+
// its first row: a dimmed branch with no reason is what the rule forbids.
|
|
2001
|
+
const why = entry.title ?? (empty ? rows.find((e) => e.title !== undefined)?.title : undefined);
|
|
2002
|
+
return { ...entry, items: inner, disabled: entry.disabled === true || empty, ...(why === undefined ? {} : { title: why }) };
|
|
2003
|
+
}
|
|
2004
|
+
const reason = reasonOf(entry.id);
|
|
2005
|
+
return reason === null ? entry : { ...entry, disabled: true, title: reason };
|
|
2006
|
+
});
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
// ---- The storyboard's menu rows ----------------------------------------------
|
|
2010
|
+
|
|
2011
|
+
/**
|
|
2012
|
+
* THE BOARD'S OWN ROWS (A v2): the still, cut and motion verbs, in the app
|
|
2013
|
+
* menu's Shot branch so WebMCP `run_command` reaches every one. A row that
|
|
2014
|
+
* cannot run is dimmed WITH ITS REASON, never dropped.
|
|
2015
|
+
*/
|
|
2016
|
+
export function boardMenuEntries(store: ShotEditorStore, state: ShotEditorState): MenuEntry[] {
|
|
2017
|
+
const doc = state.doc;
|
|
2018
|
+
const one = state.storyboardSelection.length === 1 ? state.storyboardSelection[0]! : null;
|
|
2019
|
+
// THE SHOT BOARD'S ROWS act on the open shot board and its sketch; Open in
|
|
2020
|
+
// Studio and Add motion on that shot, else the selected one, else the one
|
|
2021
|
+
// that links the entered Shot.
|
|
2022
|
+
const plan = shotBoardOf(state);
|
|
2023
|
+
const partId = plan ?? (one !== null && isPartId(one) ? one : boardTargetOf(state));
|
|
2024
|
+
const gate = agentGate(state.agentCredentials, state.agentCredentialsError);
|
|
2025
|
+
// THE BOARD CLOSED dims every row with why: each one writes to a board that
|
|
2026
|
+
// is not on screen.
|
|
2027
|
+
const noBoard = state.storyboardOpen ? undefined : BOARD_CLOSED;
|
|
2028
|
+
const noPlan = noBoard ?? (plan === null ? NO_SHOT_BOARD : undefined);
|
|
2029
|
+
const sketches = plan === null || doc === null ? [] : sketchesOf(doc, plan);
|
|
2030
|
+
const sketch = sketches.find((s) => s.id === state.storyboardSketch);
|
|
2031
|
+
const full = sketches.length >= SKETCHES_MAX ? `A shot holds at most ${SKETCHES_MAX} sketches.` : undefined;
|
|
2032
|
+
const noSketch = noPlan ?? (sketch === undefined ? "Add a sketch first." : undefined);
|
|
2033
|
+
const playing = state.sketchPlaying;
|
|
2034
|
+
const noPlay = playing ? undefined : (noSketch ?? (sketch?.items.length === 0 ? "Put something on the sketch to play it." : undefined));
|
|
2035
|
+
const noShot = noBoard ?? (partId === null ? "Select a planned shot first" : undefined);
|
|
2036
|
+
const noMotion = noBoard ?? (!gate.ready ? gate.title : partId === null ? "Select a planned shot first" : undefined);
|
|
2037
|
+
return [
|
|
2038
|
+
{
|
|
2039
|
+
kind: "action",
|
|
2040
|
+
id: "board.sketch.add",
|
|
2041
|
+
label: "Add sketch",
|
|
2042
|
+
disabled: (noPlan ?? full) !== undefined,
|
|
2043
|
+
title: noPlan ?? full,
|
|
2044
|
+
onSelect: () => {
|
|
2045
|
+
if (plan !== null) store.addSketch(plan);
|
|
2046
|
+
},
|
|
2047
|
+
},
|
|
2048
|
+
{
|
|
2049
|
+
kind: "action",
|
|
2050
|
+
id: "board.sketch.duplicate",
|
|
2051
|
+
label: "Duplicate sketch",
|
|
2052
|
+
disabled: (noSketch ?? full) !== undefined,
|
|
2053
|
+
title: noSketch ?? full,
|
|
2054
|
+
onSelect: () => {
|
|
2055
|
+
if (sketch !== undefined) store.duplicateSketch(sketch.id);
|
|
2056
|
+
},
|
|
2057
|
+
},
|
|
2058
|
+
{
|
|
2059
|
+
kind: "action",
|
|
2060
|
+
id: "board.sketch.remove",
|
|
2061
|
+
label: "Delete sketch",
|
|
2062
|
+
danger: true,
|
|
2063
|
+
disabled: noSketch !== undefined,
|
|
2064
|
+
title: noSketch,
|
|
2065
|
+
onSelect: () => {
|
|
2066
|
+
if (sketch !== undefined) store.removeSketch(sketch.id);
|
|
2067
|
+
},
|
|
2068
|
+
},
|
|
2069
|
+
{
|
|
2070
|
+
kind: "action",
|
|
2071
|
+
id: "board.sketch.play",
|
|
2072
|
+
label: playing ? "Stop playing" : "Play sketch",
|
|
2073
|
+
disabled: noPlay !== undefined,
|
|
2074
|
+
title: noPlay,
|
|
2075
|
+
onSelect: () => store.setSketchPlaying(!playing),
|
|
2076
|
+
},
|
|
2077
|
+
{
|
|
2078
|
+
kind: "action",
|
|
2079
|
+
id: "board.openStudio",
|
|
2080
|
+
label: "Open in Studio",
|
|
2081
|
+
disabled: noShot !== undefined,
|
|
2082
|
+
title: noShot,
|
|
2083
|
+
onSelect: () => {
|
|
2084
|
+
if (partId !== null) store.openPlannedShot(partId);
|
|
2085
|
+
},
|
|
2086
|
+
},
|
|
2087
|
+
...(agentInEditor()
|
|
2088
|
+
? [
|
|
2089
|
+
{
|
|
2090
|
+
kind: "action" as const,
|
|
2091
|
+
id: "board.motion",
|
|
2092
|
+
label: "Add motion",
|
|
2093
|
+
disabled: noMotion !== undefined,
|
|
2094
|
+
title: noMotion,
|
|
2095
|
+
onSelect: () => {
|
|
2096
|
+
if (partId !== null) store.requestMotionForPart(partId);
|
|
2097
|
+
},
|
|
2098
|
+
},
|
|
2099
|
+
]
|
|
2100
|
+
: []),
|
|
2101
|
+
];
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
|
|
2105
|
+
/** What a right-click in the node canvas landed on. Computed by the canvas,
|
|
2106
|
+
* which is the only half that knows the hit and the point it happened at. */
|
|
2107
|
+
export interface MaterialNodeMenuTargets {
|
|
2108
|
+
/** The material being edited. Carried rather than re-derived, so a menu built
|
|
2109
|
+
* in one slot cannot act on another's graph after the rail moved. */
|
|
2110
|
+
materialId: string;
|
|
2111
|
+
/** The scope trail the canvas was showing — `[]` is the main graph. */
|
|
2112
|
+
path: readonly string[];
|
|
2113
|
+
nodeIds: readonly string[];
|
|
2114
|
+
edgeIds: readonly string[];
|
|
2115
|
+
/** Where the press happened, in GRAPH coordinates: what "Add node here" and
|
|
2116
|
+
* "Insert reroute" both mean by "here". */
|
|
2117
|
+
at: { x: number; y: number };
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
/**
|
|
2121
|
+
* What a right-click on the node canvas offers.
|
|
2122
|
+
*
|
|
2123
|
+
* `layerMenuEntries`' three rules, verbatim: DATA and not JSX, every leaf an
|
|
2124
|
+
* existing store verb, and OMIT rather than dim. The last one is why this
|
|
2125
|
+
* function is a pile of `if` blocks instead of a list with `disabled` flags —
|
|
2126
|
+
* "Expose as control" over a Colour node is a real offer, over a Subgraph node
|
|
2127
|
+
* it is nothing, and a dimmed row invites a click to find out why.
|
|
2128
|
+
*
|
|
2129
|
+
* The one place a `disabled` survives is Delete over a selection that is only
|
|
2130
|
+
* the output node, and even there the row is ABSENT: the store's refusal names
|
|
2131
|
+
* the subject better than a tooltip could, and a menu that offered it would be
|
|
2132
|
+
* offering something that cannot happen.
|
|
2133
|
+
*/
|
|
2134
|
+
export function materialNodeMenuEntries(
|
|
2135
|
+
store: ShotEditorStore,
|
|
2136
|
+
state: ShotEditorState,
|
|
2137
|
+
targets: MaterialNodeMenuTargets,
|
|
2138
|
+
): MenuEntry[] {
|
|
2139
|
+
const { materialId, path, nodeIds, edgeIds, at } = targets;
|
|
2140
|
+
const graph = state.doc?.materials?.[materialId];
|
|
2141
|
+
if (graph === undefined) return [];
|
|
2142
|
+
const nodes = nodeIds
|
|
2143
|
+
.map((id) => graph.nodesById[id])
|
|
2144
|
+
.filter((node) => node !== undefined);
|
|
2145
|
+
const one = nodes.length === 1 ? nodes[0]! : undefined;
|
|
2146
|
+
const point: [number, number] = [at.x, at.y];
|
|
2147
|
+
|
|
2148
|
+
const entries: MenuEntry[] = [
|
|
2149
|
+
{
|
|
2150
|
+
kind: "action",
|
|
2151
|
+
id: "graph-add",
|
|
2152
|
+
label: "Add node here…",
|
|
2153
|
+
shortcut: shortcutFor("graph.add"),
|
|
2154
|
+
onSelect: () => store.openMaterialPalette({ x: at.x, y: at.y }),
|
|
2155
|
+
},
|
|
2156
|
+
];
|
|
2157
|
+
|
|
2158
|
+
if (nodes.length > 0) {
|
|
2159
|
+
entries.push({ kind: "separator" });
|
|
2160
|
+
entries.push({
|
|
2161
|
+
kind: "action",
|
|
2162
|
+
id: "graph-duplicate",
|
|
2163
|
+
label: "Duplicate",
|
|
2164
|
+
shortcut: shortcutFor("graph.duplicate"),
|
|
2165
|
+
onSelect: () => store.duplicateMaterialNodes(materialId, path, nodeIds),
|
|
2166
|
+
});
|
|
2167
|
+
entries.push({
|
|
2168
|
+
kind: "action",
|
|
2169
|
+
id: "graph-copy",
|
|
2170
|
+
label: "Copy",
|
|
2171
|
+
shortcut: shortcutFor("graph.copy"),
|
|
2172
|
+
onSelect: () => store.copyMaterialNodes(materialId, path, nodeIds),
|
|
2173
|
+
});
|
|
2174
|
+
entries.push({
|
|
2175
|
+
kind: "action",
|
|
2176
|
+
id: "graph-cut",
|
|
2177
|
+
label: "Cut",
|
|
2178
|
+
shortcut: shortcutFor("graph.cut"),
|
|
2179
|
+
onSelect: () => store.cutMaterialNodes(materialId, path, nodeIds),
|
|
2180
|
+
});
|
|
2181
|
+
}
|
|
2182
|
+
entries.push({
|
|
2183
|
+
kind: "action",
|
|
2184
|
+
id: "graph-paste",
|
|
2185
|
+
label: "Paste here",
|
|
2186
|
+
shortcut: shortcutFor("graph.paste"),
|
|
2187
|
+
onSelect: () =>
|
|
2188
|
+
store.pasteMaterialNodes(materialId, path, { x: at.x, y: at.y }),
|
|
2189
|
+
});
|
|
2190
|
+
|
|
2191
|
+
// ── The value seam, and it only ever offers ONE direction ───────────────
|
|
2192
|
+
//
|
|
2193
|
+
// A literal can become a control and a control can become a literal, and no
|
|
2194
|
+
// node is both — so the two rows are never present together and neither is
|
|
2195
|
+
// ever dimmed.
|
|
2196
|
+
if (
|
|
2197
|
+
one !== undefined &&
|
|
2198
|
+
MATERIAL_GRAPH_PARAMETER_TYPES.some((type) => type === one.kind)
|
|
2199
|
+
) {
|
|
2200
|
+
entries.push({ kind: "separator" });
|
|
2201
|
+
entries.push({
|
|
2202
|
+
kind: "action",
|
|
2203
|
+
id: "graph-promote",
|
|
2204
|
+
label: "Expose as control",
|
|
2205
|
+
onSelect: () => store.promoteMaterialLiteral(materialId, path, one.id),
|
|
2206
|
+
});
|
|
2207
|
+
} else if (one?.kind === "parameter") {
|
|
2208
|
+
entries.push({ kind: "separator" });
|
|
2209
|
+
entries.push({
|
|
2210
|
+
kind: "action",
|
|
2211
|
+
id: "graph-demote",
|
|
2212
|
+
label: "Inline value",
|
|
2213
|
+
onSelect: () =>
|
|
2214
|
+
store.demoteMaterialParameterNode(materialId, path, one.id),
|
|
2215
|
+
});
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
// ── Boxes and functions ────────────────────────────────────────────────
|
|
2219
|
+
const grouped = nodes.find(
|
|
2220
|
+
(node) => node.kind === "group" || node.groupId !== undefined,
|
|
2221
|
+
);
|
|
2222
|
+
const groupId = grouped?.kind === "group" ? grouped.id : grouped?.groupId;
|
|
2223
|
+
const boxable = nodes.filter((node) => node.kind !== "group");
|
|
2224
|
+
if (boxable.length > 0 || groupId !== undefined)
|
|
2225
|
+
entries.push({ kind: "separator" });
|
|
2226
|
+
if (boxable.length > 0) {
|
|
2227
|
+
entries.push({
|
|
2228
|
+
kind: "action",
|
|
2229
|
+
id: "graph-group",
|
|
2230
|
+
label: "Group",
|
|
2231
|
+
shortcut: shortcutFor("graph.group"),
|
|
2232
|
+
onSelect: () =>
|
|
2233
|
+
store.groupMaterialNodes(
|
|
2234
|
+
materialId,
|
|
2235
|
+
path,
|
|
2236
|
+
boxable.map((node) => node.id),
|
|
2237
|
+
),
|
|
2238
|
+
});
|
|
2239
|
+
}
|
|
2240
|
+
if (groupId !== undefined) {
|
|
2241
|
+
entries.push({
|
|
2242
|
+
kind: "action",
|
|
2243
|
+
id: "graph-ungroup",
|
|
2244
|
+
label: "Ungroup",
|
|
2245
|
+
shortcut: shortcutFor("graph.ungroup"),
|
|
2246
|
+
onSelect: () => store.ungroupMaterialNodes(materialId, path, groupId),
|
|
2247
|
+
});
|
|
2248
|
+
}
|
|
2249
|
+
if (nodes.length > 0 && nodes.every((node) => node.kind !== "subgraph")) {
|
|
2250
|
+
entries.push({
|
|
2251
|
+
kind: "action",
|
|
2252
|
+
id: "graph-collapse",
|
|
2253
|
+
label: "Collapse to subgraph",
|
|
2254
|
+
onSelect: () =>
|
|
2255
|
+
store.createMaterialSubgraph(materialId, nodeIds, "Function"),
|
|
2256
|
+
});
|
|
2257
|
+
}
|
|
2258
|
+
if (one?.kind === "subgraph") {
|
|
2259
|
+
entries.push({
|
|
2260
|
+
kind: "action",
|
|
2261
|
+
id: "graph-open",
|
|
2262
|
+
label: "Open subgraph",
|
|
2263
|
+
shortcut: shortcutFor("graph.enter"),
|
|
2264
|
+
onSelect: () => store.enterMaterialSubgraph(one.id),
|
|
2265
|
+
});
|
|
2266
|
+
entries.push({
|
|
2267
|
+
kind: "action",
|
|
2268
|
+
id: "graph-expand",
|
|
2269
|
+
label: "Expand subgraph",
|
|
2270
|
+
onSelect: () => store.expandMaterialSubgraph(materialId, path, one.id),
|
|
2271
|
+
});
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
if (one !== undefined) {
|
|
2275
|
+
entries.push({ kind: "separator" });
|
|
2276
|
+
entries.push({
|
|
2277
|
+
kind: "action",
|
|
2278
|
+
id: "graph-preview",
|
|
2279
|
+
label: "Preview on card",
|
|
2280
|
+
shortcut: shortcutFor("graph.preview"),
|
|
2281
|
+
checked: one.preview === true,
|
|
2282
|
+
onSelect: () =>
|
|
2283
|
+
store.setMaterialNodePreview(
|
|
2284
|
+
materialId,
|
|
2285
|
+
path,
|
|
2286
|
+
one.id,
|
|
2287
|
+
one.preview !== true,
|
|
2288
|
+
),
|
|
2289
|
+
});
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
// ── Layout ─────────────────────────────────────────────────────────────
|
|
2293
|
+
if (nodes.length > 1) {
|
|
2294
|
+
entries.push({ kind: "separator" });
|
|
2295
|
+
entries.push({
|
|
2296
|
+
kind: "submenu",
|
|
2297
|
+
label: "Align",
|
|
2298
|
+
items: ALIGN_MODES.map((mode) => ({
|
|
2299
|
+
kind: "action" as const,
|
|
2300
|
+
id: `graph-align-${mode}`,
|
|
2301
|
+
label: ALIGN_LABELS[mode],
|
|
2302
|
+
// A distribution needs three, and this is the ONE dimmed row in the
|
|
2303
|
+
// menu: it is inside a submenu, where the rule is about the branch
|
|
2304
|
+
// rather than the rows, and dropping half the grid would move the
|
|
2305
|
+
// alignments an author has learned by position.
|
|
2306
|
+
disabled: DISTRIBUTE_MODES.has(mode) && nodes.length < MIN_DISTRIBUTE,
|
|
2307
|
+
onSelect: () =>
|
|
2308
|
+
store.alignMaterialNodes(materialId, path, nodeIds, mode),
|
|
2309
|
+
})),
|
|
2310
|
+
});
|
|
2311
|
+
}
|
|
2312
|
+
// UNCONDITIONAL, unlike every other layout row: with nothing selected this is
|
|
2313
|
+
// still the answer to "put this graph back in order", which is exactly when
|
|
2314
|
+
// an author reaches for it. The verb reads the same empty selection as "all
|
|
2315
|
+
// of it in this scope".
|
|
2316
|
+
if (nodes.length <= 1) entries.push({ kind: "separator" });
|
|
2317
|
+
entries.push({
|
|
2318
|
+
kind: "action",
|
|
2319
|
+
id: "graph-tidy",
|
|
2320
|
+
label: nodes.length > 1 ? "Tidy selection" : "Tidy graph",
|
|
2321
|
+
shortcut: shortcutFor("graph.tidy"),
|
|
2322
|
+
onSelect: () => store.tidyMaterialNodes(materialId, path, nodeIds),
|
|
2323
|
+
});
|
|
2324
|
+
if (nodes.length > 0) {
|
|
2325
|
+
entries.push({
|
|
2326
|
+
kind: "action",
|
|
2327
|
+
id: "graph-snap-now",
|
|
2328
|
+
label: "Snap to grid",
|
|
2329
|
+
onSelect: () => store.snapMaterialNodesToGrid(materialId, path, nodeIds),
|
|
2330
|
+
});
|
|
2331
|
+
}
|
|
2332
|
+
entries.push({
|
|
2333
|
+
kind: "action",
|
|
2334
|
+
id: "graph-snap",
|
|
2335
|
+
label: "Snap while dragging",
|
|
2336
|
+
checked: state.materialSnapToGrid,
|
|
2337
|
+
onSelect: () => store.setMaterialSnapToGrid(!state.materialSnapToGrid),
|
|
2338
|
+
});
|
|
2339
|
+
|
|
2340
|
+
// ── Wires ──────────────────────────────────────────────────────────────
|
|
2341
|
+
if (edgeIds.length > 0) {
|
|
2342
|
+
entries.push({ kind: "separator" });
|
|
2343
|
+
if (edgeIds.length === 1) {
|
|
2344
|
+
const edgeId = edgeIds[0]!;
|
|
2345
|
+
entries.push({
|
|
2346
|
+
kind: "action",
|
|
2347
|
+
id: "graph-reroute",
|
|
2348
|
+
label: "Insert reroute",
|
|
2349
|
+
onSelect: () =>
|
|
2350
|
+
store.insertMaterialReroute(materialId, path, edgeId, point),
|
|
2351
|
+
});
|
|
2352
|
+
}
|
|
2353
|
+
entries.push({
|
|
2354
|
+
kind: "action",
|
|
2355
|
+
id: "graph-disconnect",
|
|
2356
|
+
label:
|
|
2357
|
+
edgeIds.length === 1
|
|
2358
|
+
? "Disconnect"
|
|
2359
|
+
: `Disconnect ${edgeIds.length} wires`,
|
|
2360
|
+
onSelect: () => store.disconnectMaterialEdges(materialId, path, edgeIds),
|
|
2361
|
+
});
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2364
|
+
// ABSENT rather than dimmed when nothing in the selection can go — which is
|
|
2365
|
+
// the output node on its own, and a function's boundary nodes.
|
|
2366
|
+
const deletable = nodes.some(
|
|
2367
|
+
(node) =>
|
|
2368
|
+
node.kind !== "fn-input" &&
|
|
2369
|
+
node.kind !== "fn-output" &&
|
|
2370
|
+
materialGraphNodeSpec(node.kind)?.category !== "output",
|
|
2371
|
+
);
|
|
2372
|
+
if (deletable || edgeIds.length > 0) {
|
|
2373
|
+
entries.push({ kind: "separator" });
|
|
2374
|
+
entries.push({
|
|
2375
|
+
kind: "action",
|
|
2376
|
+
id: "graph-delete",
|
|
2377
|
+
label: "Delete",
|
|
2378
|
+
danger: true,
|
|
2379
|
+
shortcut: shortcutFor("graph.delete"),
|
|
2380
|
+
onSelect: () =>
|
|
2381
|
+
store.deleteMaterialGraphItems(materialId, path, nodeIds, edgeIds),
|
|
2382
|
+
});
|
|
2383
|
+
}
|
|
2384
|
+
return gateForViewer(state, entries);
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
/**
|
|
2388
|
+
* The targets for a set of layer ids, read off the open document.
|
|
2389
|
+
*
|
|
2390
|
+
* Shared by both callers so "which of these can take a material" is answered
|
|
2391
|
+
* once. A component definition's interior is deliberately not searched: the
|
|
2392
|
+
* menu acts on what the outline is showing, which `editingDef` already scopes.
|
|
2393
|
+
*/
|
|
2394
|
+
export function menuTargetsFor(
|
|
2395
|
+
state: ShotEditorState,
|
|
2396
|
+
ids: readonly string[],
|
|
2397
|
+
): MenuTargets {
|
|
2398
|
+
const layers =
|
|
2399
|
+
state.editingDef !== null
|
|
2400
|
+
? state.doc?.components?.[state.editingDef]?.layersById
|
|
2401
|
+
: state.sceneId === null
|
|
2402
|
+
? undefined
|
|
2403
|
+
: state.doc?.scenesById[state.sceneId]?.layersById;
|
|
2404
|
+
const meshIds: string[] = [];
|
|
2405
|
+
let hasMaterial = false;
|
|
2406
|
+
for (const id of ids) {
|
|
2407
|
+
const layer = layers?.[id];
|
|
2408
|
+
if (layer === undefined || layer.kind !== "mesh") continue;
|
|
2409
|
+
meshIds.push(id);
|
|
2410
|
+
if (layer.mesh.material !== undefined) hasMaterial = true;
|
|
2411
|
+
}
|
|
2412
|
+
// Everything below reads the FIRST target only. Opening N definitions from
|
|
2413
|
+
// one click is not a thing an author asked for; and the flag labels already
|
|
2414
|
+
// follow the first target by the same reasoning.
|
|
2415
|
+
const first = ids.length === 0 ? undefined : layers?.[ids[0]!];
|
|
2416
|
+
// The flags below read EVERY target. The copy sources follow the store's
|
|
2417
|
+
// own rule (the first selected layer that has some), so the row names the
|
|
2418
|
+
// layer the verb will actually read.
|
|
2419
|
+
const kinds = ids.map((id) => layers?.[id]?.kind);
|
|
2420
|
+
const container = state.editingDef ?? state.sceneId;
|
|
2421
|
+
const doc = state.doc;
|
|
2422
|
+
const sourceOf = (copy: (d: Doc3D, c: string, id: string) => { source: string } | null): string | undefined => {
|
|
2423
|
+
if (doc === null || container === null) return undefined;
|
|
2424
|
+
for (const id of ids) {
|
|
2425
|
+
const clip = copy(doc, container, id);
|
|
2426
|
+
if (clip !== null) return clip.source;
|
|
2427
|
+
}
|
|
2428
|
+
return undefined;
|
|
2429
|
+
};
|
|
2430
|
+
const animationSource = sourceOf(copyAnimation);
|
|
2431
|
+
const effectsSource = sourceOf(copyEffects);
|
|
2432
|
+
const home = doc === null || container === null ? null : homeOf(doc, container);
|
|
2433
|
+
const hasMask = home !== null && ids.some((id) => maskTargetOf(home, id) !== null);
|
|
2434
|
+
const underCloner =
|
|
2435
|
+
home !== null &&
|
|
2436
|
+
ids.some((id) => {
|
|
2437
|
+
const parent = parentOf(home, id);
|
|
2438
|
+
return parent !== null && home.layersById[parent]?.kind === "cloner";
|
|
2439
|
+
});
|
|
2440
|
+
return {
|
|
2441
|
+
ids,
|
|
2442
|
+
meshIds,
|
|
2443
|
+
hasMaterial,
|
|
2444
|
+
hidden: first?.hidden === true,
|
|
2445
|
+
locked: first?.locked === true,
|
|
2446
|
+
opensComponent: first?.kind === "component" || first?.kind === "instance",
|
|
2447
|
+
hasGroup: kinds.includes("group"),
|
|
2448
|
+
hasFlattenable: kinds.includes("group") || kinds.includes("component"),
|
|
2449
|
+
...(animationSource === undefined ? {} : { animationSource }),
|
|
2450
|
+
...(effectsSource === undefined ? {} : { effectsSource }),
|
|
2451
|
+
hasMask,
|
|
2452
|
+
...(underCloner ? { maskRefusal: CLONER_MASK_REFUSAL } : {}),
|
|
2453
|
+
};
|
|
141
2454
|
}
|