@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
|
@@ -1,23 +1,42 @@
|
|
|
1
|
-
// The timeline dock: ONE
|
|
1
|
+
// The timeline dock: A MASTER STRIP, AND ONE SHOT INSIDE IT.
|
|
2
2
|
//
|
|
3
|
-
// ── WHAT DIED HERE, AND
|
|
3
|
+
// ── WHAT DIED HERE, AND WHAT CAME BACK ──────────────────────────────────
|
|
4
4
|
//
|
|
5
|
-
// This used to be two levels.
|
|
6
|
-
// tracks of WHICHEVER SCENE THE PLAYHEAD WAS IN
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// the join in their head.
|
|
5
|
+
// This used to be two levels keyed on `state.sceneId` — a scene strip on top and
|
|
6
|
+
// underneath it the tracks of WHICHEVER SCENE THE PLAYHEAD WAS IN. That
|
|
7
|
+
// was deleted, and the argument for deleting it stands and is worth restating:
|
|
8
|
+
// the author's question is almost never "what is in scene 2", it is "what
|
|
9
|
+
// happens between the logo landing and the price appearing", and those two
|
|
10
|
+
// things are in different scenes. A lower half that changed under the author
|
|
11
|
+
// every time they scrubbed across a cut answered that question by making them
|
|
12
|
+
// flip between two pictures and hold the join in their head.
|
|
13
13
|
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
14
|
+
// Two levels are back, and the difference is the BIT. `state.enteredScene` is
|
|
15
|
+
// written ONLY by the enter/leave verbs, never derived from the playhead, so
|
|
16
|
+
// nothing but an explicit gesture can change what this panel is showing:
|
|
17
|
+
//
|
|
18
|
+
// SCENE CONTEXT (`enteredScene === null`) — ONE ROW PER SHOT, stacked, each
|
|
19
|
+
// drawn at its own master position, boundaries through the tracks. The
|
|
20
|
+
// sequence read. Per-element rows are behind each shot's own disclosure:
|
|
21
|
+
// the subject is the arrangement of shots, and the deleted-dock argument is
|
|
22
|
+
// answered by making the whole-video picture available rather than
|
|
23
|
+
// mandatory — with an element that survives a cut visibly TWO rows, which
|
|
24
|
+
// is the honest drawing of a Layer3D belonging to exactly one shot.
|
|
25
|
+
// SHOT CONTEXT — one group, ruler re-zeroed on the shot the author OPENED,
|
|
26
|
+
// its elements expandable, PLUS the master-timed marker and audio lanes
|
|
27
|
+
// clipped to its window. Those two are kept and not pruned: the component
|
|
28
|
+
// context prunes them because a definition runs on a second clock with no
|
|
29
|
+
// master position, and a shot is a window OF master time. Beat-synced
|
|
30
|
+
// authoring inside a shot is the whole point of the tool.
|
|
31
|
+
//
|
|
32
|
+
// ── THE SCENE STRIP IS GONE, AND WHERE ITS VERBS WENT ────────────────────
|
|
33
|
+
//
|
|
34
|
+
// There used to be a 26px `SceneStrip` above these tracks drawing every shot as
|
|
35
|
+
// a bar in ONE lane, while the tracks below already rendered one group per
|
|
36
|
+
// shot. Two drawings of one list, and only the thin one had the verbs. Each
|
|
37
|
+
// group's first row IS its shot now (`ShotRow`), carrying create, reorder,
|
|
38
|
+
// trim, delete, transition, rename, duplicate and open — the same set, still
|
|
39
|
+
// in one place, no longer duplicated as a picture.
|
|
21
40
|
//
|
|
22
41
|
// ── WHERE THE SCENE COMES FROM NOW ──────────────────────────────────────
|
|
23
42
|
//
|
|
@@ -28,97 +47,796 @@
|
|
|
28
47
|
//
|
|
29
48
|
// Everything geometric is @dailies-studio/timeline's, unchanged: `Ruler`,
|
|
30
49
|
// `PlayheadLine`, `frameToPx`, `pxToFrame` and `useAnchoredZoom` are about
|
|
31
|
-
// frames and pixels, which the keyframe lane and the
|
|
50
|
+
// frames and pixels, which the keyframe lane and the bar lanes agree about.
|
|
32
51
|
// What is NOT reused is `TimelineModel` — see model.ts for why.
|
|
33
|
-
import {
|
|
34
|
-
|
|
52
|
+
import {
|
|
53
|
+
Fragment,
|
|
54
|
+
useCallback,
|
|
55
|
+
useEffect,
|
|
56
|
+
useLayoutEffect,
|
|
57
|
+
useMemo,
|
|
58
|
+
useRef,
|
|
59
|
+
useState,
|
|
60
|
+
} from "react";
|
|
61
|
+
import type { PointerEvent as ReactPointerEvent, ReactNode } from "react";
|
|
62
|
+
import {
|
|
63
|
+
PlayheadChip,
|
|
64
|
+
PlayheadLine,
|
|
65
|
+
anchoredScrollLeft,
|
|
66
|
+
frameToPx,
|
|
67
|
+
pxToFrame,
|
|
68
|
+
startDrag,
|
|
69
|
+
useAnchoredZoom,
|
|
70
|
+
} from "@dailies-studio/timeline";
|
|
71
|
+
import {
|
|
72
|
+
IconChevron,
|
|
73
|
+
IconChevronDown,
|
|
74
|
+
IconSkipStart,
|
|
75
|
+
PanelBoundary,
|
|
76
|
+
SegmentedControl,
|
|
77
|
+
useContextMenu,
|
|
78
|
+
} from "@dailies-studio/ui";
|
|
79
|
+
import {
|
|
80
|
+
ADD_ICON,
|
|
81
|
+
CHEVRON_DOWN_ICON,
|
|
82
|
+
CHEVRON_RIGHT_ICON,
|
|
83
|
+
iconFor,
|
|
84
|
+
LOOP_ICON,
|
|
85
|
+
MINUS_ICON,
|
|
86
|
+
} from "../../icons.js";
|
|
35
87
|
import type { Rational } from "@dailies-studio/engine";
|
|
36
|
-
import {
|
|
88
|
+
import {
|
|
89
|
+
useShotEditor,
|
|
90
|
+
useShotEditorHot,
|
|
91
|
+
useStore,
|
|
92
|
+
} from "../../state/context.js";
|
|
37
93
|
import { ResizeHandle } from "../ResizeHandle.js";
|
|
38
|
-
|
|
39
|
-
|
|
94
|
+
// The bracket is defined beside `playbackWindow`, which is the one place the
|
|
95
|
+
// looped window is computed; see its header for why the two may not be two
|
|
96
|
+
// readings. This file only says where on the ruler it hangs.
|
|
97
|
+
import { WorkAreaBracket } from "../header/Header.js";
|
|
98
|
+
import { DOCK_PPF, fitShell, layoutFor } from "../../state/panelSize.js";
|
|
99
|
+
import { inShotContext } from "../../state/viewMode.js";
|
|
100
|
+
import { AudioLane, SceneAudioRow, SHOT_AUDIO_ROW_H } from "./AudioLane.js";
|
|
40
101
|
import { BlockRow } from "./BlockRow.js";
|
|
41
|
-
import { ChannelRows } from "./ChannelRow.js";
|
|
102
|
+
import { ChannelRows, SceneChannelRows } from "./ChannelRow.js";
|
|
42
103
|
import { LifespanRow } from "./LifespanRow.js";
|
|
104
|
+
import { maskMarksOf } from "../maskMarks.js";
|
|
43
105
|
import { MarkerLane } from "./MarkerLane.js";
|
|
44
|
-
import {
|
|
45
|
-
import {
|
|
106
|
+
import { barBeatAt, beatRulerTicks } from "./beatRuler.js";
|
|
107
|
+
import type { BeatTick } from "./beatRuler.js";
|
|
108
|
+
import { lazyChunk, lazyPanel } from "../../app/lazyPanel.js";
|
|
109
|
+
import { PlayButton } from "./PlayButton.js";
|
|
110
|
+
import { TransportVolume } from "./TransportVolume.js";
|
|
111
|
+
import { labelWidthOf, useLabelWidth } from "./labelWidth.js";
|
|
112
|
+
import { ShotRow } from "./ShotRow.js";
|
|
113
|
+
import {
|
|
114
|
+
BLOCK_ROW_H,
|
|
115
|
+
CHANNEL_ROW_H,
|
|
116
|
+
LAYER_ROW_H,
|
|
117
|
+
VIDEO_ROW_H,
|
|
118
|
+
channelRowsCached,
|
|
119
|
+
clipRowOf,
|
|
120
|
+
DOCK_RULER_H,
|
|
121
|
+
defContentFrames,
|
|
122
|
+
defGroup,
|
|
123
|
+
dockRulerTicks,
|
|
124
|
+
clock,
|
|
125
|
+
followScrollLeft,
|
|
126
|
+
followScrollTop,
|
|
127
|
+
laneFrame,
|
|
128
|
+
rowOffsetOf,
|
|
129
|
+
appearedBand,
|
|
130
|
+
FOLLOW_MARGIN,
|
|
131
|
+
isolationSpan,
|
|
132
|
+
masterContentFrames,
|
|
133
|
+
masterDuration,
|
|
134
|
+
masterRows,
|
|
135
|
+
ppfToSlider,
|
|
136
|
+
rulerLabel,
|
|
137
|
+
rulerLabelPlaces,
|
|
138
|
+
sceneBars,
|
|
139
|
+
sceneChannelRows,
|
|
140
|
+
shotContentFrames,
|
|
141
|
+
sliderToPpf,
|
|
142
|
+
SHOT_ROW_H,
|
|
143
|
+
playheadLabel,
|
|
144
|
+
} from "./model.js";
|
|
145
|
+
import type { FollowRow, MasterRow } from "./model.js";
|
|
146
|
+
import {
|
|
147
|
+
defDurationFrames,
|
|
148
|
+
homeOf,
|
|
149
|
+
sceneStart,
|
|
150
|
+
} from "@dailies-studio/shot/doc";
|
|
151
|
+
import type { Doc3D, Layer3D, Tempo3D } from "@dailies-studio/shot/doc";
|
|
46
152
|
import { collectSnapTargets, snapFrame, snapThresholdFrames } from "./snap.js";
|
|
47
153
|
import type { SnapPress, SnapScope } from "./snap.js";
|
|
154
|
+
import { marqueeBars, marqueeHits, rectBetween } from "./marquee.js";
|
|
155
|
+
import type { MarqueeRect } from "./marquee.js";
|
|
156
|
+
import { KeyStretchGuides, KeyStretchTabs } from "./KeyStretch.js";
|
|
157
|
+
import { useVariableVirtualWindow } from "../virtualWindow.js";
|
|
158
|
+
|
|
159
|
+
// ── THE VIEWS THAT LOAD WHEN THEY OPEN ──────────────────────────────────
|
|
160
|
+
//
|
|
161
|
+
// Sound mode (its lanes, mixer, tempo chip and head tools), the Graph and the
|
|
162
|
+
// Generate drawer are chunks of their own (app/lazyPanel.tsx): Rows is what
|
|
163
|
+
// almost every visit uses. Each stand-in keeps its box, so the dock does not
|
|
164
|
+
// move when the view lands.
|
|
165
|
+
const soundMode = lazyChunk(() => import("./SoundMode.js"));
|
|
166
|
+
const SoundLanes = soundMode.panel("Sound", (m) => m.SoundLanes, <div className="sound-lanes" />);
|
|
167
|
+
const SoundMixer = soundMode.panel("Mixer", (m) => m.SoundMixer, <div className="sound-mixer" />);
|
|
168
|
+
const SoundHeadTools = soundMode.panel("Sound tools", (m) => m.SoundHeadTools);
|
|
169
|
+
const TempoChip = soundMode.panel("Tempo", (m) => m.TempoChip);
|
|
170
|
+
const CurvePane = lazyPanel("Graph", () => import("./CurvePane.js").then((m) => m.CurvePane), <div className="curve-pane" />);
|
|
171
|
+
const GenerateDrawer = lazyPanel("Generate", () => import("./GenerateDrawer.js").then((m) => m.GenerateDrawer), <div className="sound-gen" />);
|
|
172
|
+
|
|
173
|
+
// ── WHAT "EMPTY LANE SPACE" IS, AND WHY THE LIST IS A LIST ────────────────
|
|
174
|
+
//
|
|
175
|
+
// The dock already spends its presses: a bar body, a trim edge, a roll handle,
|
|
176
|
+
// a keyframe diamond, a shot bar, a marker, an audio clip and its four fades.
|
|
177
|
+
// A marquee that occasionally ate one of those would be worse than no marquee,
|
|
178
|
+
// so it is the LAST claimant here in two independent ways, and either one alone
|
|
179
|
+
// would do it.
|
|
180
|
+
//
|
|
181
|
+
// THE TARGET MUST BE A LANE ITSELF. Every bar and handle in this dock is a
|
|
182
|
+
// POSITIONED CHILD of one of these three elements, so a press on any of them
|
|
183
|
+
// reports that child as `e.target` and fails this test outright. Only the
|
|
184
|
+
// lane's own background passes, which is what "empty" means in a lane whose
|
|
185
|
+
// contents are all absolutely placed.
|
|
186
|
+
//
|
|
187
|
+
// AND EVERY OTHER GESTURE STOPS THE EVENT. `LifespanRow`, `BlockRow`,
|
|
188
|
+
// `ChannelRow`, `ShotRow`, `MarkerLane` and `AudioLane` all call
|
|
189
|
+
// `stopPropagation` on their own pointerdown, so nothing they claim reaches
|
|
190
|
+
// this handler to be tested in the first place.
|
|
191
|
+
//
|
|
192
|
+
// The RULER is not on this list and needs nothing: its ⇧drag for the work area
|
|
193
|
+
// lives in `DockRuler`, outside `.dock-tracks`, so a press there can never
|
|
194
|
+
// bubble here.
|
|
195
|
+
//
|
|
196
|
+
// Three lanes rather than "anything under the tracks": the marquee selects
|
|
197
|
+
// LAYERS, and these are the three lanes a layer's rows are drawn in. The
|
|
198
|
+
// master-timed marker and audio lanes sit outside every scene group, so a press
|
|
199
|
+
// there finds no group to scope to and starts nothing, which is the right
|
|
200
|
+
// answer for lanes whose contents are not layers.
|
|
201
|
+
const MARQUEE_LANES = ["lifespan-lane", "block-row-lane", "channel-lane"];
|
|
202
|
+
|
|
203
|
+
/** How far the pointer must travel before a press becomes a band. Below it a
|
|
204
|
+
* press is still a click, which is what stops a one-pixel wobble on the way to
|
|
205
|
+
* a mouseup from painting a rect and clearing the selection. */
|
|
206
|
+
const MARQUEE_MIN_PX = 3;
|
|
48
207
|
|
|
49
|
-
|
|
50
|
-
|
|
208
|
+
const TIMELINE_GROUP_WINDOW_THRESHOLD = 40;
|
|
209
|
+
const TIMELINE_LAYER_WINDOW_THRESHOLD = 60;
|
|
210
|
+
const TIMELINE_GROUP_SEPARATOR_HEIGHT = 1;
|
|
211
|
+
|
|
212
|
+
/** The shared timeline window shell. Below the threshold it returns the
|
|
213
|
+
* original sibling DOM exactly; above it spacers preserve scroll geometry and
|
|
214
|
+
* only the viewport slice mounts. */
|
|
215
|
+
function TimelineWindow<T>(props: {
|
|
216
|
+
items: readonly T[];
|
|
217
|
+
heights: readonly number[];
|
|
218
|
+
threshold: number;
|
|
219
|
+
keyOf(item: T): string;
|
|
220
|
+
children(item: T, index: number): ReactNode;
|
|
221
|
+
testId: string;
|
|
222
|
+
}) {
|
|
223
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
224
|
+
const enabled = props.items.length > props.threshold;
|
|
225
|
+
const range = useVariableVirtualWindow({
|
|
226
|
+
listRef: ref,
|
|
227
|
+
scrollSelector: ".dock-scroll",
|
|
228
|
+
heights: props.heights,
|
|
229
|
+
enabled,
|
|
230
|
+
});
|
|
231
|
+
if (!enabled) {
|
|
232
|
+
return (
|
|
233
|
+
<>
|
|
234
|
+
{props.items.map((item, index) => (
|
|
235
|
+
<Fragment key={props.keyOf(item)}>
|
|
236
|
+
{props.children(item, index)}
|
|
237
|
+
</Fragment>
|
|
238
|
+
))}
|
|
239
|
+
</>
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
return (
|
|
243
|
+
<div ref={ref} data-testid={props.testId}>
|
|
244
|
+
{range.before > 0 ? (
|
|
245
|
+
<div aria-hidden="true" style={{ height: range.before }} />
|
|
246
|
+
) : null}
|
|
247
|
+
{props.items.slice(range.start, range.end).map((item, index) => (
|
|
248
|
+
<Fragment key={props.keyOf(item)}>
|
|
249
|
+
{props.children(item, range.start + index)}
|
|
250
|
+
</Fragment>
|
|
251
|
+
))}
|
|
252
|
+
{range.after > 0 ? (
|
|
253
|
+
<div aria-hidden="true" style={{ height: range.after }} />
|
|
254
|
+
) : null}
|
|
255
|
+
</div>
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* How tall one layer's row is, collapsed or expanded.
|
|
261
|
+
*
|
|
262
|
+
* THE VIRTUAL WINDOW'S PREFIX SUMS ARE BUILT FROM THIS, so the number here and
|
|
263
|
+
* the row's actual height are one fact: a video row that draws 56px while this
|
|
264
|
+
* says 28 puts every spacer below it 28px out, and the error accumulates one
|
|
265
|
+
* row at a time. Both sides read `LAYER_ROW_H` / `VIDEO_ROW_H` from model.ts
|
|
266
|
+
* rather than repeating a literal.
|
|
267
|
+
*
|
|
268
|
+
* Exported for the test that holds those two numbers against each other.
|
|
269
|
+
*/
|
|
270
|
+
export function expandedLayerHeight(
|
|
271
|
+
layer: Layer3D | undefined,
|
|
272
|
+
row: MasterRow,
|
|
273
|
+
doc: Doc3D,
|
|
274
|
+
expanded: boolean,
|
|
275
|
+
): number {
|
|
276
|
+
// A CLIP ROW is a filmstrip over the recording's own waveform, and it needs
|
|
277
|
+
// real height whether or not the channel rows are open. Through `clipRowOf`
|
|
278
|
+
// and not through the layer's kind, because those are different questions
|
|
279
|
+
// inside a component definition: a video layer there has a def-local clock,
|
|
280
|
+
// no sound and no seam, so `defGroup` gives its row no clip facts and
|
|
281
|
+
// `LifespanRow` draws an ordinary 28px bar. Keying the height on the kind
|
|
282
|
+
// alone reserved 56 for a row that drew 28.
|
|
283
|
+
const base = clipRowOf(layer, row) === undefined ? LAYER_ROW_H : VIDEO_ROW_H;
|
|
284
|
+
if (!expanded || layer === undefined) return base;
|
|
285
|
+
const rows = channelRowsCached(layer);
|
|
286
|
+
// One row per animated property, or the one "No animation" hint row when
|
|
287
|
+
// there are none, and the "+ property" row under either.
|
|
288
|
+
const channelHeight = (Math.max(1, rows.length) + 1) * CHANNEL_ROW_H;
|
|
289
|
+
return base + channelHeight;
|
|
290
|
+
}
|
|
51
291
|
|
|
52
292
|
// ── THE FRAME STAYS OUT OF THIS COMPONENT ─────────────────────────────────
|
|
53
293
|
//
|
|
54
294
|
// During playback the frame changes ~30 times a second, and this component
|
|
55
|
-
// renders the ENTIRE dock — every scene group, camera bar, track row and
|
|
56
|
-
//
|
|
57
|
-
// frame. The
|
|
58
|
-
// (DockTime,
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
295
|
+
// renders the ENTIRE dock — every scene group, camera bar, track row and
|
|
296
|
+
// keyframe. When it subscribed to `frame`, all of that reconciled per playback
|
|
297
|
+
// frame. The two things that actually show the frame are leaves below
|
|
298
|
+
// (DockTime, DockPlayhead), each subscribing to `frame` alone, so a playhead
|
|
299
|
+
// tick re-renders a span and a line and nothing else. If a future reader adds
|
|
300
|
+
// `useShotEditor((s) => s.frame)` back here, playback perf regresses to where
|
|
301
|
+
// it was — don't.
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* The lane frame a playhead leaf draws. PAUSED it is React's, as it always
|
|
305
|
+
* was. PLAYING, the frame React renders holds still and `paint` writes each
|
|
306
|
+
* played frame onto the leaf's own DOM from a raw subscription, the way the
|
|
307
|
+
* follow writes `scrollLeft`: a commit per frame for a translate and a few
|
|
308
|
+
* characters was the dock's whole cost while the transport ran, and it landed
|
|
309
|
+
* after the stage's draw on the shared thread. A render while playing (a zoom)
|
|
310
|
+
* is painted over at once, and Pause renders the leaf again where it stopped.
|
|
311
|
+
*
|
|
312
|
+
* `paint` writes exactly what the render writes, the same style fields and
|
|
313
|
+
* the same text nodes' `nodeValue` (never `textContent`, which would swap out
|
|
314
|
+
* the node React updates), so each writer leaves the DOM the other expects.
|
|
315
|
+
*
|
|
316
|
+
* THE FIRST PAUSED RENDER PAINTS ITS OWN FRAME too. React writes only what
|
|
317
|
+
* differs from its last render, and that render was the frame Play started
|
|
318
|
+
* on: a stop that lands back there (a Pause and a seek in one batch, Go to
|
|
319
|
+
* an edit point) rendered the same values, wrote nothing, and left the frame
|
|
320
|
+
* playback reached on screen.
|
|
321
|
+
*/
|
|
322
|
+
function useLanePlayhead(origin: number, paint: (frame: number) => void): number {
|
|
323
|
+
const store = useStore();
|
|
324
|
+
const paused = useShotEditorHot((s) => (s.playing ? null : laneFrame(s, origin)));
|
|
325
|
+
const held = useRef(0);
|
|
326
|
+
if (paused !== null) held.current = paused;
|
|
327
|
+
const shown = paused ?? held.current;
|
|
328
|
+
const live = useRef({ origin, paint });
|
|
329
|
+
live.current = { origin, paint };
|
|
330
|
+
const painted = useRef(Number.NaN);
|
|
331
|
+
/** A played frame is on the DOM, not the one React last rendered. */
|
|
332
|
+
const playedOnScreen = useRef(false);
|
|
333
|
+
useEffect(
|
|
334
|
+
() =>
|
|
335
|
+
store.subscribe(() => {
|
|
336
|
+
const s = store.getState();
|
|
337
|
+
if (!s.playing) {
|
|
338
|
+
painted.current = Number.NaN;
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
const at = laneFrame(s, live.current.origin);
|
|
342
|
+
if (at === painted.current) return;
|
|
343
|
+
painted.current = at;
|
|
344
|
+
playedOnScreen.current = true;
|
|
345
|
+
live.current.paint(at);
|
|
346
|
+
}),
|
|
347
|
+
[store],
|
|
70
348
|
);
|
|
349
|
+
useLayoutEffect(() => {
|
|
350
|
+
const s = store.getState();
|
|
351
|
+
if (!s.playing) {
|
|
352
|
+
if (playedOnScreen.current) {
|
|
353
|
+
playedOnScreen.current = false;
|
|
354
|
+
paint(shown);
|
|
355
|
+
}
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
painted.current = laneFrame(s, origin);
|
|
359
|
+
playedOnScreen.current = true;
|
|
360
|
+
paint(painted.current);
|
|
361
|
+
});
|
|
362
|
+
return shown;
|
|
71
363
|
}
|
|
72
364
|
|
|
73
|
-
/**
|
|
74
|
-
|
|
75
|
-
|
|
365
|
+
/**
|
|
366
|
+
* THE ONE PLAYHEAD READOUT, the transport's timecode chip. Isolated so its
|
|
367
|
+
* per-frame tick stays its own.
|
|
368
|
+
*
|
|
369
|
+
* The clock is always padded ("00:02.20", `clock`), so the chip is a fixed
|
|
370
|
+
* slot that does not shove its neighbours at each whole second. The suffix
|
|
371
|
+
* says what the clock is measured against: the Project's whole length
|
|
372
|
+
* ("/ 00:18.00"), or inside a shot or a definition the integer frame the
|
|
373
|
+
* document model is built on ("66 f"), muted beside the clock. The suffix is
|
|
374
|
+
* its own span so a narrow head can drop it (a container query in
|
|
375
|
+
* timeline.css) and keep the clock.
|
|
376
|
+
*/
|
|
377
|
+
function DockTime({
|
|
378
|
+
fps,
|
|
379
|
+
origin,
|
|
380
|
+
total,
|
|
381
|
+
tempo,
|
|
382
|
+
}: {
|
|
383
|
+
fps: Rational;
|
|
384
|
+
origin: number;
|
|
385
|
+
/** The Project's length in frames, or null inside a shot or a definition. */
|
|
386
|
+
total: number | null;
|
|
387
|
+
/** Sound mode with a tempo: the suffix reads the bar and beat instead. */
|
|
388
|
+
tempo?: Tempo3D | undefined;
|
|
389
|
+
}) {
|
|
390
|
+
// The DEF-LOCAL clock while a definition is open — the same one-time-base
|
|
391
|
+
// rule the playback loop follows (unify P2). `origin` is the shot context's
|
|
392
|
+
// re-zero: still master time, read from the shot's own start, because a
|
|
393
|
+
// readout that disagreed with the ruler above it would be worse than either.
|
|
394
|
+
// Through `laneFrame`, which is that rule's one spelling — see model.ts.
|
|
395
|
+
const suffix = (at: number): string =>
|
|
396
|
+
tempo !== undefined ? ` bar ${barBeatAt(tempo, fps, at + origin)}` : total === null ? ` ${at} f` : ` / ${clock(total, fps)}`;
|
|
397
|
+
const time = useRef<HTMLSpanElement | null>(null);
|
|
398
|
+
const frame = useLanePlayhead(origin, (at) => {
|
|
399
|
+
const el = time.current;
|
|
400
|
+
const reading = el?.firstChild;
|
|
401
|
+
const tail = el?.lastElementChild?.firstChild;
|
|
402
|
+
if (reading != null) reading.nodeValue = clock(at, fps);
|
|
403
|
+
if (tail != null) tail.nodeValue = suffix(at);
|
|
404
|
+
});
|
|
76
405
|
return (
|
|
77
|
-
<span className="dock-
|
|
78
|
-
{frame
|
|
406
|
+
<span ref={time} className="dock-time" data-testid="dock-time">
|
|
407
|
+
{clock(frame, fps)}
|
|
408
|
+
<span className="dock-time-frame">{suffix(frame)}</span>
|
|
79
409
|
</span>
|
|
80
410
|
);
|
|
81
411
|
}
|
|
82
412
|
|
|
83
413
|
/** The playhead, subscribing to the frame itself so its two mount sites ride
|
|
84
|
-
* a per-frame tick without dragging the dock along.
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
414
|
+
* a per-frame tick without dragging the dock along. `laneFrame` is shared with
|
|
415
|
+
* the follow effect below, so the lane cannot scroll to one frame and draw
|
|
416
|
+
* another.
|
|
417
|
+
*
|
|
418
|
+
* THE RULER COPY WEARS THE HEAD, the one playhead spec every lane shares
|
|
419
|
+
* (`.tl-playhead-chip` and `.tl-playhead-pointer`, @dailies-studio/timeline's
|
|
420
|
+
* playhead.css): the package's `PlayheadChip`, an accent pill labelled in
|
|
421
|
+
* decimal seconds (`playheadLabel`: "2.20", "8.00"), where the Graph reads
|
|
422
|
+
* the frame number. The track copy stays a bare line. */
|
|
423
|
+
function DockPlayhead({
|
|
424
|
+
ppf,
|
|
425
|
+
origin,
|
|
426
|
+
fps,
|
|
427
|
+
head = false,
|
|
428
|
+
}: {
|
|
429
|
+
ppf: number;
|
|
430
|
+
origin: number;
|
|
431
|
+
fps?: Rational;
|
|
432
|
+
head?: boolean;
|
|
433
|
+
}) {
|
|
434
|
+
const line = useRef<HTMLDivElement | null>(null);
|
|
435
|
+
const frame = useLanePlayhead(origin, (at) => {
|
|
436
|
+
const el = line.current;
|
|
437
|
+
if (el === null) return;
|
|
438
|
+
// `PlayheadLine`'s own two numbers, at its own offset (0 here).
|
|
439
|
+
const x = frameToPx(at, ppf);
|
|
440
|
+
el.style.translate = `${x}px 0`;
|
|
441
|
+
el.style.setProperty("--playhead-x", `${x}px`);
|
|
442
|
+
const label = el.firstElementChild?.firstChild;
|
|
443
|
+
if (head && fps !== undefined && label != null) label.nodeValue = playheadLabel(at, fps);
|
|
444
|
+
});
|
|
445
|
+
return (
|
|
446
|
+
<PlayheadLine
|
|
447
|
+
ref={line}
|
|
448
|
+
frame={frame}
|
|
449
|
+
ppf={ppf}
|
|
450
|
+
offset={0}
|
|
451
|
+
className="dock-playhead"
|
|
452
|
+
>
|
|
453
|
+
{head && fps !== undefined ? (
|
|
454
|
+
<PlayheadChip frame={frame} label={playheadLabel(frame, fps)} />
|
|
455
|
+
) : null}
|
|
456
|
+
</PlayheadLine>
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* THE DOCK'S RULER: three tiers of ticks on a seconds ladder (`dockRulerTicks`),
|
|
462
|
+
* labelled by `rulerLabel` ("0:02") in every context.
|
|
463
|
+
*
|
|
464
|
+
* ⇧drag draws the work area between the press and the pointer when the caller
|
|
465
|
+
* passes `onWorkArea` (the Project only); a plain press seeks.
|
|
466
|
+
*
|
|
467
|
+
* The package `Ruler` steps ONE labelled tier over a frame ladder, which is
|
|
468
|
+
* the keyframe lane's scale. Its press is kept exactly: the `.tl-ruler` and
|
|
469
|
+
* `.tl-overhang` boxes, the button guard, the cancelled press (so a scrub
|
|
470
|
+
* never paints a text selection), the rect read once at the press, and
|
|
471
|
+
* `startDrag` for the move.
|
|
472
|
+
*/
|
|
473
|
+
function DockRuler(props: {
|
|
474
|
+
durationFrames: number;
|
|
475
|
+
contentF: number;
|
|
476
|
+
ppf: number;
|
|
477
|
+
timeW: number;
|
|
478
|
+
fps: Rational;
|
|
479
|
+
onSeek(frame: number): void;
|
|
480
|
+
onWorkArea?: ((a: number, b: number) => void) | undefined;
|
|
481
|
+
/** The Project's ruler, which draws the taller ticks of the boards' Project
|
|
482
|
+
* strip (`.is-project`); a shot's and a definition's keep the shot board's. */
|
|
483
|
+
project: boolean;
|
|
484
|
+
/** Sound mode's bar grid (`beatRulerTicks`), drawn in place of the seconds
|
|
485
|
+
* ladder when the film has a tempo. */
|
|
486
|
+
beats?: readonly BeatTick[] | undefined;
|
|
487
|
+
/** Isolation's shot range in lane frames: the strip before `from` is shaded
|
|
488
|
+
* like the overhang after `durationFrames`, which is then `to`. */
|
|
489
|
+
boundsFrom?: number | undefined;
|
|
490
|
+
/** Isolation's markers, as ticks: their lane is hidden there. */
|
|
491
|
+
markers?: readonly { id: string; frame: number; title: string }[] | undefined;
|
|
492
|
+
children?: ReactNode;
|
|
493
|
+
}) {
|
|
494
|
+
const { ppf, timeW, fps, onSeek, onWorkArea, beats, boundsFrom, markers } = props;
|
|
495
|
+
// Only as far as the lane lays out, for `Ruler`'s reason: an absurd stored
|
|
496
|
+
// extent capped at 2^25 px must not grow the ladder past every tick inside it.
|
|
497
|
+
const extentF = Math.min(props.contentF, Math.ceil(timeW / ppf));
|
|
498
|
+
const ticks = useMemo(
|
|
499
|
+
() => dockRulerTicks(extentF, ppf, fps),
|
|
500
|
+
[extentF, ppf, fps],
|
|
501
|
+
);
|
|
502
|
+
// One place per MAJOR, in tick order (see `rulerLabelPlaces`).
|
|
503
|
+
const places = useMemo(
|
|
504
|
+
() =>
|
|
505
|
+
rulerLabelPlaces(
|
|
506
|
+
ticks
|
|
507
|
+
.filter((t) => t.tier === 0)
|
|
508
|
+
.map((t) => ({ x: frameToPx(t.frame, ppf), text: rulerLabel(t.seconds) })),
|
|
509
|
+
timeW,
|
|
510
|
+
),
|
|
511
|
+
[ticks, ppf, timeW],
|
|
512
|
+
);
|
|
513
|
+
let major = 0;
|
|
514
|
+
const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>): void => {
|
|
515
|
+
if (e.button !== 0) return;
|
|
516
|
+
e.preventDefault();
|
|
517
|
+
const left = e.currentTarget.getBoundingClientRect().left;
|
|
518
|
+
const at = (clientX: number): number => pxToFrame(clientX - left, ppf);
|
|
519
|
+
const pressed = at(e.clientX);
|
|
520
|
+
const move =
|
|
521
|
+
e.shiftKey && onWorkArea !== undefined
|
|
522
|
+
? (clientX: number): void => onWorkArea(pressed, at(clientX))
|
|
523
|
+
: (clientX: number): void => onSeek(at(clientX));
|
|
524
|
+
move(e.clientX);
|
|
525
|
+
startDrag(e, { onMove: (ev) => move(ev.clientX), onEnd: () => {} });
|
|
526
|
+
};
|
|
527
|
+
return (
|
|
528
|
+
<div
|
|
529
|
+
className={`tl-ruler${props.project ? " is-project" : ""}`}
|
|
530
|
+
style={{ minWidth: timeW }}
|
|
531
|
+
onPointerDown={onPointerDown}
|
|
532
|
+
>
|
|
533
|
+
<div
|
|
534
|
+
className="tl-overhang"
|
|
535
|
+
data-testid="dock-overhang"
|
|
536
|
+
style={{ left: frameToPx(props.durationFrames, ppf) }}
|
|
537
|
+
/>
|
|
538
|
+
{boundsFrom === undefined || boundsFrom <= 0 ? null : (
|
|
539
|
+
<div
|
|
540
|
+
className="tl-overhang is-before"
|
|
541
|
+
data-testid="dock-overhang-before"
|
|
542
|
+
style={{ width: frameToPx(boundsFrom, ppf) }}
|
|
543
|
+
/>
|
|
544
|
+
)}
|
|
545
|
+
{markers?.map((m) => (
|
|
546
|
+
<span
|
|
547
|
+
key={m.id}
|
|
548
|
+
className="dock-marker-tick"
|
|
549
|
+
data-testid={`dock-marker-tick-${m.id}`}
|
|
550
|
+
title={m.title}
|
|
551
|
+
style={{ left: frameToPx(m.frame, ppf) }}
|
|
552
|
+
/>
|
|
553
|
+
))}
|
|
554
|
+
{beats !== undefined
|
|
555
|
+
? beats.map((t) => (
|
|
556
|
+
<span key={t.frame} className="dock-tick" data-tier={t.tier} style={{ left: frameToPx(t.frame, ppf) }}>
|
|
557
|
+
{t.label === undefined ? null : <span className="dock-tick-label">{t.label}</span>}
|
|
558
|
+
</span>
|
|
559
|
+
))
|
|
560
|
+
: ticks.map((t) => {
|
|
561
|
+
const x = frameToPx(t.frame, ppf);
|
|
562
|
+
const place = t.tier === 0 ? places[major++] : undefined;
|
|
563
|
+
return (
|
|
564
|
+
<span
|
|
565
|
+
key={t.seconds}
|
|
566
|
+
className="dock-tick"
|
|
567
|
+
data-tier={t.tier}
|
|
568
|
+
style={{ left: x }}
|
|
569
|
+
>
|
|
570
|
+
{place === "start" || place === "end" ? (
|
|
571
|
+
// The last label right-aligns on its tick, so it never runs off
|
|
572
|
+
// the end of the lane it labels, and a label it would land on is
|
|
573
|
+
// not drawn (`rulerLabelPlaces`).
|
|
574
|
+
<span className={`dock-tick-label${place === "end" ? " is-end" : ""}`}>
|
|
575
|
+
{rulerLabel(t.seconds)}
|
|
576
|
+
</span>
|
|
577
|
+
) : null}
|
|
578
|
+
</span>
|
|
579
|
+
);
|
|
580
|
+
})}
|
|
581
|
+
{props.children}
|
|
582
|
+
</div>
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* THE ZOOM, AS A THING YOU CAN SEE AND GRAB.
|
|
588
|
+
*
|
|
589
|
+
* The lane has zoomed since it was built (ctrl/⌘+wheel, and `Fit`), and
|
|
590
|
+
* neither of those says at rest that the lane HAS a zoom, or where in its
|
|
591
|
+
* range this document is sitting. An author who has never held ctrl over the
|
|
592
|
+
* timeline has no way to find out that it zooms at all, and `Fit` reads as a
|
|
593
|
+
* one-shot rather than as one end of a scale. A track with a thumb on it
|
|
594
|
+
* answers both at once, which is the argument for a control that duplicates
|
|
595
|
+
* two existing gestures: it is the only one of the three that is visible.
|
|
596
|
+
*
|
|
597
|
+
* It is a READOUT as much as an input, and that half matters more than it
|
|
598
|
+
* looks. The wheel and `Fit` write `dockPpf` and this renders from it, so the
|
|
599
|
+
* thumb tracks every other way of zooming and the range teaches itself.
|
|
600
|
+
*
|
|
601
|
+
* A leaf: `dockPpf` changes once per wheel notch and once per slider pixel,
|
|
602
|
+
* and subscribing to it up in `TimelineDock` is the whole dock re-rendering
|
|
603
|
+
* per pixel of a drag.
|
|
604
|
+
*/
|
|
605
|
+
function DockZoom({ onZoom }: { onZoom: (ppf: number) => void }) {
|
|
606
|
+
const ppf = useShotEditor((s) => s.dockPpf);
|
|
607
|
+
const at = ppfToSlider(ppf, DOCK_PPF.min, DOCK_PPF.max);
|
|
608
|
+
// The two ends step a tenth of the track, in the slider's own (log) space,
|
|
609
|
+
// so a press is the same visible zoom anywhere in the range.
|
|
610
|
+
const stepBy = (d: number): void =>
|
|
611
|
+
onZoom(sliderToPpf(Math.min(1, Math.max(0, at + d)), DOCK_PPF.min, DOCK_PPF.max));
|
|
612
|
+
// AT AN END THE STEP IS REFUSED, NOT DISABLED. `disabled` on the button that
|
|
613
|
+
// has focus drops focus to <body> the press it reaches the end, so a keyboard
|
|
614
|
+
// user stepping in loses their place. `aria-disabled` keeps it focusable and
|
|
615
|
+
// says the same thing (`.btn[aria-disabled="true"]` dims it); the click
|
|
616
|
+
// handler is what refuses.
|
|
617
|
+
const atMin = at <= 0;
|
|
618
|
+
const atMax = at >= 1;
|
|
619
|
+
return (
|
|
620
|
+
<div className="dock-zoom-group">
|
|
621
|
+
<button
|
|
622
|
+
type="button"
|
|
623
|
+
className="btn is-icon is-ghost dock-zoom-step"
|
|
624
|
+
aria-label="Zoom out"
|
|
625
|
+
title="Zoom out"
|
|
626
|
+
aria-disabled={atMin}
|
|
627
|
+
onClick={() => {
|
|
628
|
+
if (!atMin) stepBy(-0.1);
|
|
629
|
+
}}
|
|
630
|
+
>
|
|
631
|
+
{MINUS_ICON}
|
|
632
|
+
</button>
|
|
633
|
+
<input
|
|
634
|
+
className="dock-zoom"
|
|
635
|
+
type="range"
|
|
636
|
+
min={0}
|
|
637
|
+
max={1}
|
|
638
|
+
// ~1000 steps across a 300x range, which is about a quarter of a percent
|
|
639
|
+
// of zoom per step: fine enough that a drag reads as continuous, coarse
|
|
640
|
+
// enough that a keyboard arrow moves something visible.
|
|
641
|
+
step={0.001}
|
|
642
|
+
value={at}
|
|
643
|
+
aria-label="Timeline zoom"
|
|
644
|
+
title="Zoom the timeline (ctrl+wheel over the lane)"
|
|
645
|
+
onChange={(e) =>
|
|
646
|
+
onZoom(
|
|
647
|
+
sliderToPpf(
|
|
648
|
+
Number(e.currentTarget.value),
|
|
649
|
+
DOCK_PPF.min,
|
|
650
|
+
DOCK_PPF.max,
|
|
651
|
+
),
|
|
652
|
+
)
|
|
653
|
+
}
|
|
654
|
+
// ── AND IT GIVES THE KEYBOARD BACK WHEN THE DRAG ENDS ──────────────
|
|
655
|
+
//
|
|
656
|
+
// `isEditable` treats every <input> as a field being typed into, which is
|
|
657
|
+
// what stops the keymap eating a character mid-rename. It also means a
|
|
658
|
+
// focused slider swallows the whole chrome's shortcuts. Space is the one
|
|
659
|
+
// that matters: drag the zoom, press space to watch what you just framed,
|
|
660
|
+
// and nothing happens. Blurring on pointer-up costs a mouse user nothing
|
|
661
|
+
// (the thumb is still there to grab) and leaves TAB focus alone, so the
|
|
662
|
+
// arrow keys still work for anyone who reached it from the keyboard.
|
|
663
|
+
onPointerUp={(e) => e.currentTarget.blur()}
|
|
664
|
+
/>
|
|
665
|
+
<button
|
|
666
|
+
type="button"
|
|
667
|
+
className="btn is-icon is-ghost dock-zoom-step"
|
|
668
|
+
aria-label="Zoom in"
|
|
669
|
+
title="Zoom in"
|
|
670
|
+
aria-disabled={atMax}
|
|
671
|
+
onClick={() => {
|
|
672
|
+
if (!atMax) stepBy(0.1);
|
|
673
|
+
}}
|
|
674
|
+
>
|
|
675
|
+
{ADD_ICON}
|
|
676
|
+
</button>
|
|
677
|
+
</div>
|
|
678
|
+
);
|
|
88
679
|
}
|
|
89
680
|
|
|
90
681
|
export function TimelineDock() {
|
|
91
682
|
const store = useStore();
|
|
92
683
|
const doc = useShotEditor((s) => s.doc);
|
|
684
|
+
/** The label column: the rail track's shown width (see labelWidth.ts). For
|
|
685
|
+
* rendering only; effects and handlers read `labelWidthOf` at call time. */
|
|
686
|
+
const labelWidth = useLabelWidth();
|
|
687
|
+
/** WHICH document, for the fit below. Off `doc` rather than a second
|
|
688
|
+
* subscription, and the id and not the object: this component re-renders on
|
|
689
|
+
* every keystroke of an edit, and a fit keyed on the document's identity
|
|
690
|
+
* would re-zoom the lane on each one. */
|
|
691
|
+
const docId = doc?.id ?? null;
|
|
93
692
|
const isolation = useShotEditor((s) => s.isolation);
|
|
94
|
-
|
|
693
|
+
/** The shot holding the isolation, which is the one on screen (the store
|
|
694
|
+
* drops an isolation the moment the playhead leaves its scene). Null
|
|
695
|
+
* outside isolation, so playback across cuts re-renders nothing here. A
|
|
696
|
+
* layer id can repeat across shots, so the id alone does not name it. */
|
|
697
|
+
const isoScene = useShotEditor((s) => (s.isolation === null ? null : s.sceneId));
|
|
698
|
+
const editingDef = useShotEditor((s) => s.editingDef);
|
|
699
|
+
const enteredScene = useShotEditor((s) => s.enteredScene);
|
|
700
|
+
const mode = useShotEditor((s) => s.dockMode);
|
|
95
701
|
const collapsed = useShotEditor((s) => s.dockCollapsed);
|
|
702
|
+
const loop = useShotEditor((s) => s.loopPlayback);
|
|
96
703
|
const ppf = useShotEditor((s) => s.dockPpf);
|
|
97
|
-
const previewMuted = useShotEditor((s) => s.previewMuted);
|
|
98
|
-
const previewVolume = useShotEditor((s) => s.previewVolume);
|
|
99
704
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
100
705
|
/**
|
|
101
|
-
* Which layers show their
|
|
706
|
+
* Which layers show their keyframe rows. LOCAL, not store state: it is about
|
|
102
707
|
* this panel's shape and nothing else reads it, and the body is hidden rather
|
|
103
708
|
* than unmounted on collapse (see below) so it survives the one event that
|
|
104
709
|
* would otherwise lose it.
|
|
105
710
|
*/
|
|
106
|
-
const [open, setOpen] = useState<ReadonlySet<string>>(
|
|
711
|
+
const [open, setOpen] = useState<ReadonlySet<string>>(
|
|
712
|
+
() => new Set<string>(),
|
|
713
|
+
);
|
|
714
|
+
const reveal = useShotEditor((s) => s.dockReveal);
|
|
715
|
+
/** A row a reveal is waiting to scroll to and flash, held for the render
|
|
716
|
+
* that lays it out: the group it opened has no heights until then. */
|
|
717
|
+
const [revealTarget, setRevealTarget] = useState<{
|
|
718
|
+
layerId: string;
|
|
719
|
+
scroll: boolean;
|
|
720
|
+
flash: boolean;
|
|
721
|
+
} | null>(null);
|
|
722
|
+
/** The row flashing now (`is-revealed`), until its animation ends. */
|
|
723
|
+
const [flashed, setFlashed] = useState<string | null>(null);
|
|
107
724
|
/** The frame a live drag has snapped onto, for the guide. Null between drags. */
|
|
108
725
|
const [guide, setGuide] = useState<number | null>(null);
|
|
726
|
+
/** The live rubber band, in `.dock-tracks` content pixels. Null between
|
|
727
|
+
* drags, and null for a press that never travelled far enough to be one. */
|
|
728
|
+
const [marquee, setMarquee] = useState<MarqueeRect | null>(null);
|
|
729
|
+
/**
|
|
730
|
+
* ONCE THE MARKER LANE HAS HAD CONTENT IT KEEPS ITS PLACE.
|
|
731
|
+
*
|
|
732
|
+
* It used to draw always, spending its fixed height off the tracks to hold a
|
|
733
|
+
* sentence of placeholder prose. It is now drawn when there is something in
|
|
734
|
+
* it — and it does not go away again, because a lane that vanished as the
|
|
735
|
+
* author removed its last mark would move every row under the pointer, and
|
|
736
|
+
* its empty prose is precisely what the next marker is learned from.
|
|
737
|
+
*
|
|
738
|
+
* ── THE AUDIO LANE IS NOT GATED, AND THAT IS A REVERSAL ────────────────
|
|
739
|
+
*
|
|
740
|
+
* It was, on the same argument, and the argument was wrong for this one.
|
|
741
|
+
* Not one document in `examples/demo` has a single clip in it — the 52-second
|
|
742
|
+
* music ad this tool was built to ship included — so the gate meant the lane
|
|
743
|
+
* had never once been on screen, and audio read as a feature the product did
|
|
744
|
+
* not have. The two doors it left (a popover item and a drag over the dock)
|
|
745
|
+
* are doors you have to already know about.
|
|
746
|
+
*
|
|
747
|
+
* A marker is an annotation you reach for once you have audio to mark. Audio
|
|
748
|
+
* is a medium the product is FOR, and 26px is the cheapest possible way to
|
|
749
|
+
* say so. Its own empty prose is now the thing that teaches the import.
|
|
750
|
+
*
|
|
751
|
+
* A REF written in render rather than state: the bit is monotonic and
|
|
752
|
+
* idempotent, so a re-render (StrictMode's double invoke included) computes
|
|
753
|
+
* the same value and there is nothing to tear or to schedule.
|
|
754
|
+
*/
|
|
755
|
+
const laneShown = useRef({ marker: false });
|
|
756
|
+
/**
|
|
757
|
+
* Playhead following is off for the rest of THIS playback run.
|
|
758
|
+
*
|
|
759
|
+
* A ref and not state: nothing renders from it, and it is written from a
|
|
760
|
+
* native wheel listener that must not schedule a re-render of the whole dock
|
|
761
|
+
* on every notch of a pan. Set by the pan below, cleared whenever `playing`
|
|
762
|
+
* changes (in the follow's store subscription, which says why).
|
|
763
|
+
*/
|
|
764
|
+
const followOff = useRef(false);
|
|
765
|
+
/** The shot-row geometry the vertical follow reads. Written in render below,
|
|
766
|
+
* once `groupHeights` exists. */
|
|
767
|
+
const followGeom = useRef<{ ids: string[]; heights: number[] }>({
|
|
768
|
+
ids: [],
|
|
769
|
+
heights: [],
|
|
770
|
+
});
|
|
771
|
+
/** The isolated subtree's lifespan union in LANE frames, or null outside
|
|
772
|
+
* isolation. Written in render beside `followGeom`: the horizontal follow
|
|
773
|
+
* holds still while the playhead is outside it (see the follow effect). */
|
|
774
|
+
const followSpan = useRef<{ from: number; to: number } | null>(null);
|
|
775
|
+
/** The layer rows of the ONE container the dock shows (an entered shot, a
|
|
776
|
+
* definition, an isolated subtree), for the follow's layer half. Null over
|
|
777
|
+
* the Project's shots, which the shot reveal follows; no rows while they
|
|
778
|
+
* are folded. Written in render beside `followGeom`. */
|
|
779
|
+
const followRows = useRef<FollowRow[] | null>(null);
|
|
780
|
+
/** A press is held on the dock's rows (not the ruler, which stays put):
|
|
781
|
+
* nothing scrolls them under it. Set and cleared by the listeners beside
|
|
782
|
+
* the wheel's. */
|
|
783
|
+
const pressHeld = useRef(false);
|
|
784
|
+
/** The layer whose lanes isolation opened on entry, so leaving closes the
|
|
785
|
+
* ones it opened and never ones the author opened. */
|
|
786
|
+
const autoOpened = useRef<string | null>(null);
|
|
787
|
+
/**
|
|
788
|
+
* WHICH SHOTS SHOW THEIR ELEMENTS, in the scene context.
|
|
789
|
+
*
|
|
790
|
+
* This is the recorded answer to the deleted two-level dock's argument. That
|
|
791
|
+
* argument was right — "what happens between the logo landing and the price
|
|
792
|
+
* appearing" spans a cut — and it is answered by keeping the whole-video
|
|
793
|
+
* picture one disclosure away rather than by making it the only picture.
|
|
794
|
+
* Empty by default because the scene context's subject is the arrangement of
|
|
795
|
+
* shots, and forty rows of elements is not that.
|
|
796
|
+
*
|
|
797
|
+
* THE SHOT CONTEXT READS THE SAME SET. Its first look is three rows (Shot,
|
|
798
|
+
* Camera, Sound) with the layers folded into the Shot row's "N layers", so
|
|
799
|
+
* an open shot here is open there too and one disclosure answers both.
|
|
800
|
+
*
|
|
801
|
+
* ONE SET, not a set plus a global flag: the corner button expands ALL or
|
|
802
|
+
* collapses all by writing this same set, so "is this shot open" has one
|
|
803
|
+
* answer rather than an OR over two states that can disagree.
|
|
804
|
+
*
|
|
805
|
+
* LOCAL, like `open` above and for the same reason: it is about this panel's
|
|
806
|
+
* shape, nothing else reads it, and the body is hidden rather than unmounted
|
|
807
|
+
* on collapse so it survives the one event that would lose it.
|
|
808
|
+
*/
|
|
809
|
+
const [openShots, setOpenShots] = useState<ReadonlySet<string>>(
|
|
810
|
+
() => new Set<string>(),
|
|
811
|
+
);
|
|
812
|
+
/** ONE MENU FOR EVERY SHOT ROW. The hook holds an open/closed state and a
|
|
813
|
+
* focus-restore ref, and only one menu can be open at a time — so a document
|
|
814
|
+
* with fourteen shots would otherwise mount fourteen of them. */
|
|
815
|
+
const shotMenu = useContextMenu();
|
|
816
|
+
/** Sound mode's Generate drawer, in the mixer's column while open. Local:
|
|
817
|
+
* it is this panel's shape, and a reload starts on the mixer. */
|
|
818
|
+
const [generateOpen, setGenerateOpen] = useState(false);
|
|
109
819
|
|
|
110
820
|
// Imperative reads, so the wheel listener never closes over a stale zoom.
|
|
111
821
|
const read = useCallback(() => store.getState().dockPpf, [store]);
|
|
112
|
-
const setPxPerFrame = useCallback(
|
|
822
|
+
const setPxPerFrame = useCallback(
|
|
823
|
+
(next: number) => store.setDockPpf(next),
|
|
824
|
+
[store],
|
|
825
|
+
);
|
|
113
826
|
useAnchoredZoom({
|
|
114
827
|
scrollRef,
|
|
115
|
-
|
|
828
|
+
// A prop and not a getter: the hook re-binds its wheel listener when the
|
|
829
|
+
// width changes, which only a panel drag or a window resize does.
|
|
830
|
+
labelWidth,
|
|
116
831
|
ppf,
|
|
117
|
-
min:
|
|
118
|
-
max:
|
|
832
|
+
min: DOCK_PPF.min,
|
|
833
|
+
max: DOCK_PPF.max,
|
|
119
834
|
read,
|
|
120
835
|
setPxPerFrame,
|
|
121
|
-
|
|
836
|
+
// Off in Graph, which unmounts the scroller: the way back re-enables it,
|
|
837
|
+
// so the zoom binds to the one Rows mounts again rather than staying on
|
|
838
|
+
// the old one and leaving ctrl+wheel to zoom the page.
|
|
839
|
+
enabled: !collapsed && doc !== null && mode !== "graph",
|
|
122
840
|
});
|
|
123
841
|
|
|
124
842
|
// SHIFT+WHEEL PANS. Native and non-passive for `useAnchoredZoom`'s reason: a
|
|
@@ -128,18 +846,70 @@ export function TimelineDock() {
|
|
|
128
846
|
const el = scrollRef.current;
|
|
129
847
|
if (el === null) return;
|
|
130
848
|
const onWheel = (e: WheelEvent): void => {
|
|
849
|
+
// A PLAIN WHEEL IS THE VERTICAL PAN, and it is the same gesture the shift
|
|
850
|
+
// pan below is: an author looking somewhere else while the video plays.
|
|
851
|
+
// It falls through to the browser's own scrolling (no preventDefault),
|
|
852
|
+
// and the only thing taken from it is the authorship signal, which the
|
|
853
|
+
// `scroll` event cannot give — see the arming effect's note.
|
|
854
|
+
//
|
|
855
|
+
// Gated on `playing` because the ref's contract is "off for the rest of
|
|
856
|
+
// THIS run": scrolling the dock while paused is ordinary navigation, and
|
|
857
|
+
// silencing the next shot-row reveal for it would be a bug rather than
|
|
858
|
+
// deference. A press on the scrollbar is the same gesture (`onDown`).
|
|
859
|
+
if (!e.ctrlKey && !e.metaKey && store.getState().playing)
|
|
860
|
+
followOff.current = true;
|
|
131
861
|
if (!e.shiftKey || e.ctrlKey || e.metaKey) return;
|
|
132
862
|
e.preventDefault();
|
|
863
|
+
// A DELIBERATE LOOK ELSEWHERE STOPS THE FOLLOW. This is the one gesture
|
|
864
|
+
// that can fight it: an author panning ahead while the video plays, yanked
|
|
865
|
+
// back on the very next frame. Only meaningful during playback, and the
|
|
866
|
+
// arm is released by any change of `playing`.
|
|
867
|
+
followOff.current = true;
|
|
133
868
|
// A trackpad sends its own horizontal axis; a wheel with one axis sends
|
|
134
869
|
// deltaY and means the same thing with shift held.
|
|
135
870
|
el.scrollLeft += e.deltaX !== 0 ? e.deltaX : e.deltaY;
|
|
136
871
|
};
|
|
872
|
+
// A PRESS ON THE SCROLLBAR is the wheel's gesture by other means: the
|
|
873
|
+
// scrollbar sits outside the scroller's client box, and a press there
|
|
874
|
+
// lands on the scroller itself. Any other press on the rows holds them
|
|
875
|
+
// still until it lets go: nothing slides under a pressed pointer. The
|
|
876
|
+
// ruler is sticky and does not move, so a scrub keeps the follow.
|
|
877
|
+
const onDown = (e: PointerEvent): void => {
|
|
878
|
+
if (e.target === el) {
|
|
879
|
+
const box = el.getBoundingClientRect();
|
|
880
|
+
const outside =
|
|
881
|
+
e.clientX - box.left - el.clientLeft >= el.clientWidth || e.clientY - box.top - el.clientTop >= el.clientHeight;
|
|
882
|
+
if (outside) {
|
|
883
|
+
if (store.getState().playing) followOff.current = true;
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
if (!(e.target instanceof Element) || e.target.closest(".dock-ruler") === null) pressHeld.current = true;
|
|
888
|
+
};
|
|
889
|
+
const onUp = (): void => {
|
|
890
|
+
pressHeld.current = false;
|
|
891
|
+
};
|
|
137
892
|
el.addEventListener("wheel", onWheel, { passive: false });
|
|
138
|
-
|
|
893
|
+
el.addEventListener("pointerdown", onDown, true);
|
|
894
|
+
for (const type of ["pointerup", "pointercancel"]) window.addEventListener(type, onUp, true);
|
|
895
|
+
// A release the page never hears (let go outside a window that lost
|
|
896
|
+
// focus) must not hold the rows still for good. The WINDOW'S own blur,
|
|
897
|
+
// so not in the capture phase: there it hears every element's blur too,
|
|
898
|
+
// and a press that moves focus cleared the hold the moment it began.
|
|
899
|
+
window.addEventListener("blur", onUp);
|
|
900
|
+
return () => {
|
|
901
|
+
el.removeEventListener("wheel", onWheel);
|
|
902
|
+
el.removeEventListener("pointerdown", onDown, true);
|
|
903
|
+
for (const type of ["pointerup", "pointercancel"]) window.removeEventListener(type, onUp, true);
|
|
904
|
+
window.removeEventListener("blur", onUp);
|
|
905
|
+
pressHeld.current = false;
|
|
906
|
+
};
|
|
139
907
|
// Keyed on whether there IS a document, not on which one: the container
|
|
140
908
|
// does not exist until one opens, and re-binding a native listener on every
|
|
141
|
-
// keystroke of an edit would be work for nothing.
|
|
142
|
-
|
|
909
|
+
// keystroke of an edit would be work for nothing. And on the MODE: the
|
|
910
|
+
// Graph view unmounts the scroller, and the one Rows mounts again on the
|
|
911
|
+
// way back is a new element these must be bound to.
|
|
912
|
+
}, [collapsed, doc === null, mode]);
|
|
143
913
|
|
|
144
914
|
/**
|
|
145
915
|
* A scroll position that cannot be written until the NEXT layout.
|
|
@@ -174,24 +944,69 @@ export function TimelineDock() {
|
|
|
174
944
|
const fit = useCallback(
|
|
175
945
|
(sceneId?: string) => {
|
|
176
946
|
const el = scrollRef.current;
|
|
177
|
-
const
|
|
947
|
+
const s = store.getState();
|
|
948
|
+
const current = s.doc;
|
|
178
949
|
if (el === null || current === null) return;
|
|
179
|
-
const width = el.clientWidth -
|
|
950
|
+
const width = el.clientWidth - labelWidthOf(s);
|
|
180
951
|
if (width <= 0) return;
|
|
952
|
+
const openDef =
|
|
953
|
+
s.editingDef === null ? undefined : current.components?.[s.editingDef];
|
|
954
|
+
// THE SHOT CONTEXT'S LANE ALREADY IS ONE SHOT, re-zeroed on it — so "fit
|
|
955
|
+
// the timeline" and "fit this shot" are the same request there, and both
|
|
956
|
+
// land at scrollLeft 0. Without this branch the per-shot arm below would
|
|
957
|
+
// scroll to the shot's MASTER start, which in a re-zeroed lane is that
|
|
958
|
+
// many frames past its own end.
|
|
959
|
+
const entered =
|
|
960
|
+
openDef === undefined && s.enteredScene !== null
|
|
961
|
+
? sceneBars(current).find((b) => b.id === s.enteredScene)
|
|
962
|
+
: undefined;
|
|
963
|
+
// ISOLATION FITS THE SUBTREE, not the shot: the dock is about the one
|
|
964
|
+
// object then, and a card alive for a second of a ten-second shot would
|
|
965
|
+
// otherwise open as a sliver. In LANE frames, so the same arithmetic
|
|
966
|
+
// serves a re-zeroed shot and the master strip. Sound mode draws tracks,
|
|
967
|
+
// not the subtree's rows, and keeps the ordinary fit. A named shot is
|
|
968
|
+
// an explicit ask and is answered as asked.
|
|
969
|
+
const isoHome =
|
|
970
|
+
sceneId === undefined &&
|
|
971
|
+
openDef === undefined &&
|
|
972
|
+
s.isolation !== null &&
|
|
973
|
+
s.sceneId !== null &&
|
|
974
|
+
s.dockMode !== "sound"
|
|
975
|
+
? current.scenesById[s.sceneId]
|
|
976
|
+
: undefined;
|
|
977
|
+
const isoSpan = isoHome === undefined || s.isolation === null ? null : isolationSpan(isoHome, s.isolation);
|
|
978
|
+
const isoFrom =
|
|
979
|
+
isoSpan === null || s.sceneId === null
|
|
980
|
+
? null
|
|
981
|
+
: (sceneStart(current, s.sceneId) ?? 0) - (entered?.start ?? 0) + isoSpan.in;
|
|
181
982
|
|
|
182
|
-
// Whole document: the target is 0, which is
|
|
183
|
-
// it needs none of the deferral below.
|
|
184
|
-
if (sceneId === undefined) {
|
|
185
|
-
const total =
|
|
983
|
+
// Whole document (or the one open container): the target is 0, which is
|
|
984
|
+
// in range at every zoom, so it needs none of the deferral below.
|
|
985
|
+
if (isoFrom === null && (sceneId === undefined || entered !== undefined)) {
|
|
986
|
+
const total =
|
|
987
|
+
openDef !== undefined
|
|
988
|
+
? defDurationFrames(openDef)
|
|
989
|
+
: entered !== undefined
|
|
990
|
+
? entered.end - entered.start
|
|
991
|
+
: masterDuration(current);
|
|
186
992
|
if (total <= 0) return;
|
|
187
993
|
store.setDockPpf(width / total);
|
|
188
994
|
el.scrollLeft = 0;
|
|
189
995
|
return;
|
|
190
996
|
}
|
|
191
997
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
998
|
+
// One span to fit and its lane start: the subtree's, or one shot's.
|
|
999
|
+
let from: number;
|
|
1000
|
+
let span: number;
|
|
1001
|
+
if (isoFrom !== null && isoSpan !== null) {
|
|
1002
|
+
from = isoFrom;
|
|
1003
|
+
span = isoSpan.out - isoSpan.in;
|
|
1004
|
+
} else {
|
|
1005
|
+
const bar = sceneBars(current).find((b) => b.id === sceneId);
|
|
1006
|
+
if (bar === undefined) return;
|
|
1007
|
+
from = bar.start;
|
|
1008
|
+
span = bar.end - bar.start;
|
|
1009
|
+
}
|
|
195
1010
|
if (span <= 0) return;
|
|
196
1011
|
const before = store.getState().dockPpf;
|
|
197
1012
|
store.setDockPpf(width / span);
|
|
@@ -199,7 +1014,7 @@ export function TimelineDock() {
|
|
|
199
1014
|
// shot asks for more zoom than it gets and the scroll must be computed
|
|
200
1015
|
// against what actually landed.
|
|
201
1016
|
const actual = store.getState().dockPpf;
|
|
202
|
-
const target = frameToPx(
|
|
1017
|
+
const target = frameToPx(from, actual);
|
|
203
1018
|
// An unchanged ppf renders nothing, so the layout effect above would
|
|
204
1019
|
// never fire and the scroll would strand. Layout is already correct
|
|
205
1020
|
// there, so write it now.
|
|
@@ -213,6 +1028,334 @@ export function TimelineDock() {
|
|
|
213
1028
|
return () => store.attachDockFit(null);
|
|
214
1029
|
}, [store, fit]);
|
|
215
1030
|
|
|
1031
|
+
/**
|
|
1032
|
+
* Zoom to an absolute px-per-frame, holding one frame still.
|
|
1033
|
+
*
|
|
1034
|
+
* ── WHAT IT ANCHORS ON, AND WHY IT IS NOT THE LEFT EDGE ────────────────
|
|
1035
|
+
*
|
|
1036
|
+
* The wheel anchors on the pointer, because that is what the author is
|
|
1037
|
+
* looking at. A slider has no pointer over the lane, so it anchors on the
|
|
1038
|
+
* PLAYHEAD whenever the playhead is on screen: that is where the author's
|
|
1039
|
+
* attention is by definition, it is the frame the picture above is showing,
|
|
1040
|
+
* and it means a zoom never has to be followed by a scroll to find your
|
|
1041
|
+
* place again. With the playhead off screen there is no such frame, so the
|
|
1042
|
+
* middle of the visible lane holds instead: the one choice that keeps
|
|
1043
|
+
* whatever is being looked at from sliding away.
|
|
1044
|
+
*
|
|
1045
|
+
* Anchoring on `scrollLeft` (the left edge) would be the easy version and is
|
|
1046
|
+
* the wrong one: zooming in from a fitted document would walk the view
|
|
1047
|
+
* steadily towards frame 0 no matter where the work is.
|
|
1048
|
+
*
|
|
1049
|
+
* ── AND WHY THE CLAMP IS READ BACK ─────────────────────────────────────
|
|
1050
|
+
*
|
|
1051
|
+
* `setDockPpf` pins to DOCK_PPF, and `set` does nothing at all when the
|
|
1052
|
+
* value is unchanged. So at either end of the track the re-render that the
|
|
1053
|
+
* `[ppf]` layout effect below waits for never happens, and a `pendingScroll`
|
|
1054
|
+
* written for it would sit there and then fire against the NEXT zoom. `fit`
|
|
1055
|
+
* learned this first; this is the same two lines.
|
|
1056
|
+
*/
|
|
1057
|
+
const zoomTo = useCallback(
|
|
1058
|
+
(next: number) => {
|
|
1059
|
+
const el = scrollRef.current;
|
|
1060
|
+
if (el === null) return;
|
|
1061
|
+
const s = store.getState();
|
|
1062
|
+
const from = s.dockPpf;
|
|
1063
|
+
if (s.doc === null) return;
|
|
1064
|
+
// Re-zeroed on the OPEN shot, and only there: a definition runs on its own
|
|
1065
|
+
// clock and the master strip already starts at 0. The local is what carries
|
|
1066
|
+
// `inShotContext`'s guarantee into `sceneStart`, which takes a scene id.
|
|
1067
|
+
const entered = inShotContext(s) ? s.enteredScene : null;
|
|
1068
|
+
const origin = entered === null ? 0 : (sceneStart(s.doc, entered) ?? 0);
|
|
1069
|
+
// Container coordinates, which is what the anchor speaks: the playhead's
|
|
1070
|
+
// content position less however far the lane is scrolled.
|
|
1071
|
+
const label = labelWidthOf(s);
|
|
1072
|
+
const playheadX =
|
|
1073
|
+
label + frameToPx(laneFrame(s, origin), from) - el.scrollLeft;
|
|
1074
|
+
const lane = el.clientWidth;
|
|
1075
|
+
const onScreen = playheadX >= label && playheadX <= lane;
|
|
1076
|
+
const anchorX = onScreen ? playheadX : label + (lane - label) / 2;
|
|
1077
|
+
const target = anchoredScrollLeft({
|
|
1078
|
+
anchorX,
|
|
1079
|
+
scrollLeft: el.scrollLeft,
|
|
1080
|
+
labelWidth: label,
|
|
1081
|
+
from,
|
|
1082
|
+
next,
|
|
1083
|
+
});
|
|
1084
|
+
store.setDockPpf(next);
|
|
1085
|
+
if (target === null) return;
|
|
1086
|
+
if (store.getState().dockPpf === from) el.scrollLeft = target;
|
|
1087
|
+
else pendingScroll.current = target;
|
|
1088
|
+
},
|
|
1089
|
+
[store],
|
|
1090
|
+
);
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* A DOCUMENT, OR A CONTAINER, ARRIVES FITTED.
|
|
1094
|
+
*
|
|
1095
|
+
* This used to write `scrollLeft = 0` and keep the zoom, which was half the
|
|
1096
|
+
* answer. The lane's x means something different on each side of a context
|
|
1097
|
+
* change — the same `scrollLeft` that showed the end of shot 3 on the master
|
|
1098
|
+
* strip is, in a re-zeroed shot 3, a position past its own end — so the
|
|
1099
|
+
* anchor had to move. But the ZOOM is wrong across the same fork, and much
|
|
1100
|
+
* more visibly: `dockPpf` starts at 6, so a 1572-frame scene opened at 87
|
|
1101
|
+
* frames of itself, one shot filling the strip and the other thirteen off
|
|
1102
|
+
* screen. Leaving a shot put the author back in that same 1/18th of their own
|
|
1103
|
+
* video.
|
|
1104
|
+
*
|
|
1105
|
+
* `fit` answers both at once — it sets the zoom AND lands the scroll — and it
|
|
1106
|
+
* already knows the three totals (a definition's, the entered shot's, the
|
|
1107
|
+
* master), so this stays one call. Keyed on the document too, which nothing
|
|
1108
|
+
* fitted before: opening a scene is exactly the moment its whole shape is
|
|
1109
|
+
* what the author wants to see.
|
|
1110
|
+
*
|
|
1111
|
+
* Nothing persists `dockPpf`, so this overrides no saved preference. In jsdom
|
|
1112
|
+
* `clientWidth` is 0 and `fit` returns before it writes anything, which is why
|
|
1113
|
+
* the tests that measure "one frame is 6 CSS px at the default zoom" are
|
|
1114
|
+
* unaffected.
|
|
1115
|
+
*/
|
|
1116
|
+
//
|
|
1117
|
+
// Entering and leaving isolation are context changes on the same argument:
|
|
1118
|
+
// the lane fits the subtree on the way in and the shot on the way out.
|
|
1119
|
+
//
|
|
1120
|
+
// A trip to the board is NOT a context change. The dock sleeps there in a
|
|
1121
|
+
// hidden `<Activity>` (App.tsx), and waking re-runs every effect, so the fit
|
|
1122
|
+
// remembers what it last fitted and leaves the author's zoom and scroll
|
|
1123
|
+
// alone when nothing it keys on moved.
|
|
1124
|
+
const fittedFor = useRef<readonly unknown[] | null>(null);
|
|
1125
|
+
useEffect(() => {
|
|
1126
|
+
const key = [fit, docId, enteredScene, editingDef, isolation] as const;
|
|
1127
|
+
if (fittedFor.current?.every((was, i) => was === key[i])) return;
|
|
1128
|
+
fittedFor.current = key;
|
|
1129
|
+
fit();
|
|
1130
|
+
}, [fit, docId, enteredScene, editingDef, isolation]);
|
|
1131
|
+
|
|
1132
|
+
/**
|
|
1133
|
+
* ── THE LANE FOLLOWS THE PLAYHEAD ──────────────────────────────────────
|
|
1134
|
+
*
|
|
1135
|
+
* A DEADZONE (`followScrollLeft`), so a scrub inside the visible window costs
|
|
1136
|
+
* nothing and only a playhead that runs off the edge moves the view. Past the
|
|
1137
|
+
* forward edge of the band each frame scrolls by one frame's width, so the
|
|
1138
|
+
* playhead pins and the content slides under it.
|
|
1139
|
+
*
|
|
1140
|
+
* ── AND IT IS A RAW SUBSCRIPTION, NOT A HOOK ──────────────────────────
|
|
1141
|
+
*
|
|
1142
|
+
* Read this component's `THE FRAME STAYS OUT OF THIS COMPONENT` header first.
|
|
1143
|
+
* `useShotEditor((s) => s.frame)` here would re-render the ENTIRE dock — every
|
|
1144
|
+
* scene group, camera bar, track row and keyframe — thirty times a second, to
|
|
1145
|
+
* do one style write. So this subscribes to the store directly, compares the
|
|
1146
|
+
* two clocks itself, coalesces to a frame, and writes `scrollLeft`. It renders
|
|
1147
|
+
* nothing. `App.tsx` writes `--rail-w` and `--dock-row-h` the same way and for the
|
|
1148
|
+
* same reason.
|
|
1149
|
+
*
|
|
1150
|
+
* `laneFrame` is shared with `DockPlayhead`, so the lane cannot follow one
|
|
1151
|
+
* frame while drawing another.
|
|
1152
|
+
*/
|
|
1153
|
+
useEffect(() => {
|
|
1154
|
+
// TWO VARIABLES FOR ONE FRAME, and they are not the same question.
|
|
1155
|
+
// `queued` is "is an apply already on its way"; `raf` is only the handle the
|
|
1156
|
+
// cleanup cancels. Folding them into `if (raf === 0)` reads fine and is
|
|
1157
|
+
// ordering-dependent: the id is assigned AFTER `requestAnimationFrame`
|
|
1158
|
+
// returns, so any implementation that runs the callback before returning
|
|
1159
|
+
// leaves a stale non-zero id behind and the guard never opens again. Exactly
|
|
1160
|
+
// one clock move would ever be followed. A real rAF is asynchronous and hides
|
|
1161
|
+
// it; a synchronous one (the test's) does not, and a latent dependency on
|
|
1162
|
+
// when a callback happens to fire is worth not having either way.
|
|
1163
|
+
let queued = false;
|
|
1164
|
+
let raf = 0;
|
|
1165
|
+
let lastFrame = store.getState().frame;
|
|
1166
|
+
let lastDef = store.getState().defFrame;
|
|
1167
|
+
let lastScene = store.getState().sceneId;
|
|
1168
|
+
let lastPlaying = store.getState().playing;
|
|
1169
|
+
/** A shot change is waiting to be revealed. Latched rather than recomputed
|
|
1170
|
+
* in `apply`, because a scene move arriving while a frame's apply is
|
|
1171
|
+
* already queued must not be swallowed by it. */
|
|
1172
|
+
let sceneMoved = false;
|
|
1173
|
+
/** A clock position as a lane frame in the state's own context: the shot
|
|
1174
|
+
* context's re-zero applied, or the definition's own clock. */
|
|
1175
|
+
const laneOf = (s: ReturnType<typeof store.getState>, clock: { frame: number; defFrame: number } = s): number => {
|
|
1176
|
+
// The local carries `inShotContext`'s guarantee into `sceneStart`, which
|
|
1177
|
+
// takes a scene id rather than the nullable field.
|
|
1178
|
+
const entered = inShotContext(s) ? s.enteredScene : null;
|
|
1179
|
+
const origin = entered === null || s.doc === null ? 0 : (sceneStart(s.doc, entered) ?? 0);
|
|
1180
|
+
return laneFrame({ editingDef: s.editingDef, frame: clock.frame, defFrame: clock.defFrame }, origin);
|
|
1181
|
+
};
|
|
1182
|
+
/** Where the playhead was when the layer half last looked: its baseline for
|
|
1183
|
+
* which rows came on screen. Kept as the RAW clocks and read in the current
|
|
1184
|
+
* context, so entering a shot or a definition, unfolding layers or
|
|
1185
|
+
* isolating one keeps it (the playhead did not move) and the first move
|
|
1186
|
+
* after is followed. The master clock and a definition's are two fields,
|
|
1187
|
+
* so each context reads its own. */
|
|
1188
|
+
let seen = { frame: store.getState().frame, defFrame: store.getState().defFrame };
|
|
1189
|
+
const apply = (): void => {
|
|
1190
|
+
queued = false;
|
|
1191
|
+
raf = 0;
|
|
1192
|
+
const reveal = sceneMoved;
|
|
1193
|
+
// Cleared even when disarmed below: a pending reveal is dropped rather
|
|
1194
|
+
// than banked, so re-arming does not fire a scroll for a cut the author
|
|
1195
|
+
// panned away from several shots ago.
|
|
1196
|
+
sceneMoved = false;
|
|
1197
|
+
const s = store.getState();
|
|
1198
|
+
// THE LAYER BASELINE MOVES ON EVERY LOOK, with no scroller (Graph) and
|
|
1199
|
+
// while disarmed too, for the shot reveal's reason: rows that came on
|
|
1200
|
+
// screen while the author looked elsewhere are not banked.
|
|
1201
|
+
const was = seen;
|
|
1202
|
+
seen = { frame: s.frame, defFrame: s.defFrame };
|
|
1203
|
+
// THE SCROLLER IS READ LIVE TOO. Graph unmounts it and Rows mounts a new
|
|
1204
|
+
// one on the way back, and a copy captured at subscribe wrote to the
|
|
1205
|
+
// unmounted one: after one trip through Graph nothing followed at all.
|
|
1206
|
+
const el = scrollRef.current;
|
|
1207
|
+
if (el === null || s.doc === null) return;
|
|
1208
|
+
// ── EVERY INPUT READ LIVE, NONE CLOSED OVER ───────────────────────
|
|
1209
|
+
//
|
|
1210
|
+
// The zoom and the re-zero both move without this effect re-running, and
|
|
1211
|
+
// a captured copy of either would follow the wrong pixel until the next
|
|
1212
|
+
// resubscribe. It is `useAnchoredZoom`'s `read()` rule, for the same
|
|
1213
|
+
// reason: an imperative read cannot go stale.
|
|
1214
|
+
//
|
|
1215
|
+
// The origin is `sceneStart`, which is THE prefix sum — the same one
|
|
1216
|
+
// `masterRows` builds its groups from, so the number this scrolls to and
|
|
1217
|
+
// the number the bars are drawn at are one arithmetic and not two.
|
|
1218
|
+
const at = laneOf(s);
|
|
1219
|
+
const from = laneOf(s, was);
|
|
1220
|
+
const layers = followRows.current;
|
|
1221
|
+
if (followOff.current) return;
|
|
1222
|
+
// ISOLATION HOLDS THE LANE ON THE SUBTREE. Entry fits its lifespan, and
|
|
1223
|
+
// a shot that plays on past it (or loops back from before it) must not
|
|
1224
|
+
// drag the fitted rows away and back on every pass: that was "the whole
|
|
1225
|
+
// tracks move". Inside the span the follow is the ordinary one, which a
|
|
1226
|
+
// fitted lane never needs and a zoomed-in one does.
|
|
1227
|
+
const span = followSpan.current;
|
|
1228
|
+
const held = span !== null && (at < span.from || at > span.to);
|
|
1229
|
+
const next = held
|
|
1230
|
+
? null
|
|
1231
|
+
: followScrollLeft({
|
|
1232
|
+
playheadPx: labelWidthOf(s) + frameToPx(at, s.dockPpf),
|
|
1233
|
+
scrollLeft: el.scrollLeft,
|
|
1234
|
+
// Zero while the dock is collapsed (`.dock-body` takes `hidden`),
|
|
1235
|
+
// which `followScrollLeft` answers null to. So a collapsed dock
|
|
1236
|
+
// costs one property read per playback frame and writes nothing.
|
|
1237
|
+
laneW: el.clientWidth,
|
|
1238
|
+
contentW: el.scrollWidth,
|
|
1239
|
+
});
|
|
1240
|
+
if (next !== null) el.scrollLeft = next;
|
|
1241
|
+
|
|
1242
|
+
// ── THE LAYER ROWS, INSIDE ONE CONTAINER ──────────────────────────
|
|
1243
|
+
//
|
|
1244
|
+
// An entered shot, a definition and an isolated subtree each show one
|
|
1245
|
+
// list of layer rows in time order, where the shot reveal below has no
|
|
1246
|
+
// shot to follow. So the rows that came on screen are revealed
|
|
1247
|
+
// (`appearedBand`), with the shot reveal's "nearest": once per
|
|
1248
|
+
// appearance and only when out of view, never a scroll per frame, so
|
|
1249
|
+
// playback walks the list down as the Project walks down its shots.
|
|
1250
|
+
// With their open lanes, so a layer's keys come with it, but never
|
|
1251
|
+
// taller than the band under the ruler.
|
|
1252
|
+
//
|
|
1253
|
+
// Rows mode only: Graph and Sound lay out lists of their own. Never
|
|
1254
|
+
// under a held press, since nothing slides under a pointer (the sticky
|
|
1255
|
+
// ruler's scrub is not one). Never while a menu or popover is open,
|
|
1256
|
+
// which a scroll closes or moves. Read only at an appearance, so the
|
|
1257
|
+
// DOM query costs nothing per frame. And never on a SHOT CHANGE, which
|
|
1258
|
+
// inside a shot is entering it: the playhead's jump there is not rows
|
|
1259
|
+
// coming on, and the shot reveal below brings the Shot row into view.
|
|
1260
|
+
if (layers !== null && !reveal && s.dockMode === "rows" && !pressHeld.current) {
|
|
1261
|
+
const band = appearedBand(layers, from, at);
|
|
1262
|
+
if (band !== null && el.ownerDocument.querySelector(".menu-surface, .popover-float") === null) {
|
|
1263
|
+
const top = followScrollTop({
|
|
1264
|
+
rowTop: band.top,
|
|
1265
|
+
rowH: Math.min(band.height, Math.max(0, el.clientHeight - DOCK_RULER_H - 2 * FOLLOW_MARGIN)),
|
|
1266
|
+
scrollTop: el.scrollTop,
|
|
1267
|
+
viewH: el.clientHeight,
|
|
1268
|
+
contentH: el.scrollHeight,
|
|
1269
|
+
stickyH: DOCK_RULER_H,
|
|
1270
|
+
});
|
|
1271
|
+
if (top !== null) {
|
|
1272
|
+
el.scrollTop = top;
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
// ── AND THE SHOT ROWS ─────────────────────────────────────────────
|
|
1279
|
+
//
|
|
1280
|
+
// Driven by the SHOT, not by the frame. `sceneId` moves about once per
|
|
1281
|
+
// shot where the playhead moves thirty times a second, and that is the
|
|
1282
|
+
// whole difference between the two axes: this one reveals a row that
|
|
1283
|
+
// scrolled out of reach and then leaves the lane alone, rather than
|
|
1284
|
+
// holding a moving target at a fixed fraction of the viewport.
|
|
1285
|
+
//
|
|
1286
|
+
// A dock taller than its rows never scrolls at all, so on a short film
|
|
1287
|
+
// this costs two property reads per cut and writes nothing.
|
|
1288
|
+
// Sound mode draws tracks, not shot rows: `followGeom` measures rows.
|
|
1289
|
+
// Isolation draws no shot row at all, only the subtree under one group.
|
|
1290
|
+
if (!reveal || s.editingDef !== null || s.dockMode === "sound" || s.isolation !== null) return;
|
|
1291
|
+
const geom = followGeom.current;
|
|
1292
|
+
const index = s.sceneId === null ? -1 : geom.ids.indexOf(s.sceneId);
|
|
1293
|
+
if (index < 0) return;
|
|
1294
|
+
let rowTop = DOCK_RULER_H;
|
|
1295
|
+
for (let i = 0; i < index; i += 1) rowTop += geom.heights[i] ?? 0;
|
|
1296
|
+
// The separator is folded into the HEAD of every later group's height
|
|
1297
|
+
// (see `groupHeights`), so the row itself begins just after it.
|
|
1298
|
+
if (index > 0) rowTop += TIMELINE_GROUP_SEPARATOR_HEIGHT;
|
|
1299
|
+
const top = followScrollTop({
|
|
1300
|
+
rowTop,
|
|
1301
|
+
rowH: SHOT_ROW_H,
|
|
1302
|
+
scrollTop: el.scrollTop,
|
|
1303
|
+
viewH: el.clientHeight,
|
|
1304
|
+
contentH: el.scrollHeight,
|
|
1305
|
+
stickyH: DOCK_RULER_H,
|
|
1306
|
+
});
|
|
1307
|
+
if (top !== null) el.scrollTop = top;
|
|
1308
|
+
};
|
|
1309
|
+
const stop = store.subscribe(() => {
|
|
1310
|
+
const s = store.getState();
|
|
1311
|
+
// FOLLOWING IS ARMED FOR THE WHOLE OF A PLAYBACK RUN AND NO LONGER.
|
|
1312
|
+
//
|
|
1313
|
+
// The one gesture it can fight is a pan DURING playback: an author
|
|
1314
|
+
// looking ahead while the video plays, yanked back on the next frame.
|
|
1315
|
+
// So the pan disarms it (the wheel handler above) and any change of
|
|
1316
|
+
// `playing` arms it again. BOTH directions: pausing has to re-arm, or a
|
|
1317
|
+
// pan during playback would silently disable the reveal on every later
|
|
1318
|
+
// arrow-key step.
|
|
1319
|
+
//
|
|
1320
|
+
// Here, and not in an effect on a `playing` subscription, which would
|
|
1321
|
+
// re-render the whole dock on every play edge for one ref write. And
|
|
1322
|
+
// deliberately NOT on the `scroll` event, which is the obvious place and
|
|
1323
|
+
// the wrong one: a programmatic scroll is indistinguishable from a human
|
|
1324
|
+
// one there, so following would disarm itself on its own first write.
|
|
1325
|
+
if (s.playing !== lastPlaying) {
|
|
1326
|
+
lastPlaying = s.playing;
|
|
1327
|
+
followOff.current = false;
|
|
1328
|
+
}
|
|
1329
|
+
// A CLOCK MOVE OR A SHOT CHANGE, and nothing else. The store notifies on
|
|
1330
|
+
// every edit, every selection and every panel toggle; following on any of
|
|
1331
|
+
// those would yank the lane while the author dragged a bar it had nothing
|
|
1332
|
+
// to do with.
|
|
1333
|
+
//
|
|
1334
|
+
// The shot is its own test rather than a consequence of the frame: a row
|
|
1335
|
+
// click and the left rail both pin a shot through `setScene`, which
|
|
1336
|
+
// writes `sceneId` with no frame arithmetic at all when the playhead is
|
|
1337
|
+
// already inside it. Keyed on the clock alone, exactly those two paths
|
|
1338
|
+
// would leave the row they just selected off screen.
|
|
1339
|
+
const clockMoved = s.frame !== lastFrame || s.defFrame !== lastDef;
|
|
1340
|
+
const shotMoved = s.sceneId !== lastScene;
|
|
1341
|
+
if (!clockMoved && !shotMoved) return;
|
|
1342
|
+
lastFrame = s.frame;
|
|
1343
|
+
lastDef = s.defFrame;
|
|
1344
|
+
lastScene = s.sceneId;
|
|
1345
|
+
if (shotMoved) sceneMoved = true;
|
|
1346
|
+
if (queued) return;
|
|
1347
|
+
queued = true;
|
|
1348
|
+
raf = requestAnimationFrame(apply);
|
|
1349
|
+
});
|
|
1350
|
+
return () => {
|
|
1351
|
+
stop();
|
|
1352
|
+
if (raf !== 0) cancelAnimationFrame(raf);
|
|
1353
|
+
};
|
|
1354
|
+
// Keyed on whether there IS a document, not on which one: one subscription
|
|
1355
|
+
// per open document. The scroller and every geometry ref are read live in
|
|
1356
|
+
// `apply`, so nothing here goes stale when they change.
|
|
1357
|
+
}, [store, doc === null]);
|
|
1358
|
+
|
|
216
1359
|
/**
|
|
217
1360
|
* Freeze the snap targets at the press and hand a row back the one function a
|
|
218
1361
|
* drag needs.
|
|
@@ -222,12 +1365,23 @@ export function TimelineDock() {
|
|
|
222
1365
|
* start including the thing being dragged.
|
|
223
1366
|
*/
|
|
224
1367
|
const beginSnap = useCallback<SnapPress>(
|
|
225
|
-
(exclude: SnapScope["exclude"], suppressed: boolean) => {
|
|
1368
|
+
(exclude: SnapScope["exclude"], suppressed: boolean, legal?: SnapScope["legal"]) => {
|
|
226
1369
|
const s = store.getState();
|
|
1370
|
+
// Snapping is OFF inside a definition: `collectSnapTargets` walks scene
|
|
1371
|
+
// windows, which a def strip has none of; a wrong-clock target list is
|
|
1372
|
+
// worse than none. (A def-local target list is a follow-up.)
|
|
227
1373
|
const targets =
|
|
228
|
-
suppressed || s.doc === null
|
|
1374
|
+
suppressed || s.doc === null || s.editingDef !== null
|
|
229
1375
|
? []
|
|
230
|
-
: collectSnapTargets(s.doc,
|
|
1376
|
+
: collectSnapTargets(s.doc, {
|
|
1377
|
+
playhead: s.frame,
|
|
1378
|
+
// SPREAD, not assigned: `exactOptionalPropertyTypes` makes an
|
|
1379
|
+
// explicit `undefined` a different thing from an absent key, and
|
|
1380
|
+
// both of these are absent for most gestures.
|
|
1381
|
+
...(exclude === undefined ? {} : { exclude }),
|
|
1382
|
+
...(legal === undefined ? {} : { legal }),
|
|
1383
|
+
beats: s.snapBeats,
|
|
1384
|
+
});
|
|
231
1385
|
const threshold = snapThresholdFrames(s.dockPpf);
|
|
232
1386
|
return (candidate: number): number => {
|
|
233
1387
|
const hit = snapFrame(candidate, targets, threshold);
|
|
@@ -256,23 +1410,489 @@ export function TimelineDock() {
|
|
|
256
1410
|
};
|
|
257
1411
|
}, []);
|
|
258
1412
|
|
|
1413
|
+
// THE COMPONENT CONTEXT'S FORK (unify P2): with a definition open the dock
|
|
1414
|
+
// is that definition's own strip — one group at offset 0, def-local ruler,
|
|
1415
|
+
// no scene strip, no camera row, no master-timed lanes — plus the Actions
|
|
1416
|
+
// section. One derived value decides all of it.
|
|
1417
|
+
const def = editingDef === null ? undefined : doc?.components?.[editingDef];
|
|
1418
|
+
|
|
259
1419
|
// Memoised on the document identity: the rows change exactly when the doc
|
|
260
1420
|
// does, and this component still re-renders for ppf/guide/collapse changes
|
|
261
1421
|
// that should not pay a full row rebuild. Above the null return — hooks.
|
|
262
1422
|
// Keyed on the ISOLATION too: going inside a card narrows the outline and the
|
|
263
1423
|
// stage, and a dock still listing every layer in the scene is the one panel
|
|
264
|
-
// an author uses to find a
|
|
265
|
-
const
|
|
1424
|
+
// an author uses to find a layer showing rows they cannot click.
|
|
1425
|
+
const allGroups = useMemo(
|
|
1426
|
+
() =>
|
|
1427
|
+
doc === null
|
|
1428
|
+
? []
|
|
1429
|
+
: def !== undefined
|
|
1430
|
+
? [defGroup(def)]
|
|
1431
|
+
: masterRows(doc, isolation),
|
|
1432
|
+
[doc, isolation, def],
|
|
1433
|
+
);
|
|
1434
|
+
// ── THE SHOT CONTEXT NARROWS THE SAME LIST ────────────────────────────
|
|
1435
|
+
//
|
|
1436
|
+
// Derived from `allGroups` rather than from a second `masterRows` call over
|
|
1437
|
+
// one scene, so the isolation scoping stays ONE implementation: the rule
|
|
1438
|
+
// "the isolation only narrows the scene it lives in" is stated once, in
|
|
1439
|
+
// masterRows, and cannot be half-applied here. A missing group (the entered
|
|
1440
|
+
// shot was deleted between the store's write and this render) falls back to
|
|
1441
|
+
// the master strip rather than an empty dock — `onHistoryChange` will have
|
|
1442
|
+
// left the context by the next commit anyway.
|
|
1443
|
+
const shotGroup =
|
|
1444
|
+
def !== undefined || enteredScene === null
|
|
1445
|
+
? undefined
|
|
1446
|
+
: allGroups.find((g) => g.id === enteredScene);
|
|
1447
|
+
// ── ISOLATION NARROWS IT TO ONE OBJECT ────────────────────────────────
|
|
1448
|
+
//
|
|
1449
|
+
// `masterRows` already lists only the subtree's rows in the shot holding
|
|
1450
|
+
// it. The dock goes the rest of the way: that one group, with no shot row,
|
|
1451
|
+
// no camera, no shot channels and no sound, so what is left is the isolated
|
|
1452
|
+
// object and its keyed properties. The re-zero (`origin`) is untouched: the
|
|
1453
|
+
// lane keeps the clock it had, and only the rows change.
|
|
1454
|
+
const isoGroup =
|
|
1455
|
+
def !== undefined || isolation === null || isoScene === null || (shotGroup !== undefined && shotGroup.id !== isoScene)
|
|
1456
|
+
? undefined
|
|
1457
|
+
: allGroups.find((g) => g.id === isoScene && doc?.scenesById[g.id]?.layersById[isolation] !== undefined);
|
|
1458
|
+
const groups = useMemo(
|
|
1459
|
+
() => (isoGroup !== undefined ? [isoGroup] : shotGroup === undefined ? allGroups : [shotGroup]),
|
|
1460
|
+
[allGroups, shotGroup, isoGroup],
|
|
1461
|
+
);
|
|
1462
|
+
/** The shot context's re-zero. DRAWING ONLY — see model.ts's header. */
|
|
1463
|
+
const origin = shotGroup?.start ?? 0;
|
|
266
1464
|
const contentF = useMemo(
|
|
267
|
-
// The RULER ends at the
|
|
1465
|
+
// The RULER ends at the container; the CONTENT may run past it, because a
|
|
268
1466
|
// bar dragged off the end has to stay reachable.
|
|
269
|
-
() =>
|
|
270
|
-
|
|
1467
|
+
() =>
|
|
1468
|
+
doc === null
|
|
1469
|
+
? 0
|
|
1470
|
+
: def !== undefined
|
|
1471
|
+
? defContentFrames(groups[0]!)
|
|
1472
|
+
: shotGroup !== undefined
|
|
1473
|
+
? shotContentFrames(shotGroup)
|
|
1474
|
+
: masterContentFrames(doc, groups),
|
|
1475
|
+
[doc, groups, def, shotGroup],
|
|
1476
|
+
);
|
|
1477
|
+
/** Whether a group's element rows are drawn: always inside a definition and
|
|
1478
|
+
* in isolation (there is no shot row to disclose them), and per shot behind
|
|
1479
|
+
* its disclosure in the Project and shot contexts alike. */
|
|
1480
|
+
const rowsShown = useCallback(
|
|
1481
|
+
(groupId: string): boolean => def !== undefined || isoGroup !== undefined || openShots.has(groupId),
|
|
1482
|
+
[def, isoGroup, openShots],
|
|
271
1483
|
);
|
|
1484
|
+
/** The camera row: inside a shot always (the first look is Shot, Camera,
|
|
1485
|
+
* Sound), over the Project behind the shot's disclosure, never in
|
|
1486
|
+
* isolation. */
|
|
1487
|
+
const cameraShown = (groupId: string): boolean =>
|
|
1488
|
+
def === undefined && isoGroup === undefined && (shotGroup !== undefined || openShots.has(groupId));
|
|
1489
|
+
const layerHeights = useMemo(() => {
|
|
1490
|
+
const byGroup = new Map<string, number[]>();
|
|
1491
|
+
if (doc === null) return byGroup;
|
|
1492
|
+
const canExpand = shotGroup !== undefined || def !== undefined || isoGroup !== undefined;
|
|
1493
|
+
for (const group of groups) {
|
|
1494
|
+
const shown = rowsShown(group.id);
|
|
1495
|
+
const home = def ?? doc.scenesById[group.id];
|
|
1496
|
+
byGroup.set(
|
|
1497
|
+
group.id,
|
|
1498
|
+
shown
|
|
1499
|
+
? group.rows.map((row) =>
|
|
1500
|
+
expandedLayerHeight(
|
|
1501
|
+
home?.layersById[row.layerId],
|
|
1502
|
+
row,
|
|
1503
|
+
doc,
|
|
1504
|
+
canExpand && open.has(row.layerId),
|
|
1505
|
+
),
|
|
1506
|
+
)
|
|
1507
|
+
: [],
|
|
1508
|
+
);
|
|
1509
|
+
}
|
|
1510
|
+
return byGroup;
|
|
1511
|
+
}, [doc, groups, shotGroup, def, isoGroup, rowsShown, open]);
|
|
1512
|
+
/** Each group's height ABOVE its first layer row. Its own memo because the
|
|
1513
|
+
* reveal's `rowOffsetOf` needs it as well as the group's total. */
|
|
1514
|
+
const groupLeads = useMemo(() => {
|
|
1515
|
+
if (doc === null) return [];
|
|
1516
|
+
return groups.map((group, index) => {
|
|
1517
|
+
// The separator belongs to the global group index, not DOM adjacency.
|
|
1518
|
+
// Virtual spacers can sit between two groups, so an adjacent-sibling
|
|
1519
|
+
// border both vanished at a window edge and escaped the prefix sum.
|
|
1520
|
+
let lead =
|
|
1521
|
+
(def === undefined && isoGroup === undefined ? SHOT_ROW_H : 0) +
|
|
1522
|
+
(index === 0 ? 0 : TIMELINE_GROUP_SEPARATOR_HEIGHT);
|
|
1523
|
+
const scene = doc.scenesById[group.id];
|
|
1524
|
+
// Isolation draws neither the shot row nor anything the shot owns.
|
|
1525
|
+
if (scene === undefined || def !== undefined || isoGroup !== undefined) return lead;
|
|
1526
|
+
// The camera's row, then (behind the disclosure) its channel rows
|
|
1527
|
+
// and their "+ property".
|
|
1528
|
+
if (shotGroup !== undefined || openShots.has(group.id)) lead += BLOCK_ROW_H;
|
|
1529
|
+
if (openShots.has(group.id)) lead += (sceneChannelRows(scene).length + 1) * CHANNEL_ROW_H;
|
|
1530
|
+
return lead;
|
|
1531
|
+
});
|
|
1532
|
+
}, [doc, groups, shotGroup, def, isoGroup, openShots]);
|
|
1533
|
+
const groupHeights = useMemo(() => {
|
|
1534
|
+
if (doc === null) return [];
|
|
1535
|
+
return groups.map((group, index) => {
|
|
1536
|
+
let height = groupLeads[index] ?? 0;
|
|
1537
|
+
const scene = doc.scenesById[group.id];
|
|
1538
|
+
// Inside a shot the audio row draws even when empty and even folded, as
|
|
1539
|
+
// the drop box: the same predicate the row's render gate uses below.
|
|
1540
|
+
// Never in isolation, which is about the one object.
|
|
1541
|
+
const audio =
|
|
1542
|
+
def === undefined &&
|
|
1543
|
+
isoGroup === undefined &&
|
|
1544
|
+
(shotGroup !== undefined || (openShots.has(group.id) && Object.keys(scene?.audio ?? {}).length > 0));
|
|
1545
|
+
if (audio) height += SHOT_AUDIO_ROW_H;
|
|
1546
|
+
if (!rowsShown(group.id)) return height;
|
|
1547
|
+
height += (layerHeights.get(group.id) ?? []).reduce(
|
|
1548
|
+
(total, row) => total + row,
|
|
1549
|
+
0,
|
|
1550
|
+
);
|
|
1551
|
+
return height;
|
|
1552
|
+
});
|
|
1553
|
+
}, [doc, groups, shotGroup, def, isoGroup, openShots, rowsShown, groupLeads, layerHeights]);
|
|
1554
|
+
// WHERE EACH SHOT'S ROW SITS, handed to the follow effect above. Written in
|
|
1555
|
+
// render like `laneShown`, and for the same reason: the effect subscribes
|
|
1556
|
+
// once for the life of the document and must not close over a stale copy of
|
|
1557
|
+
// a list that changes whenever a shot is added, reordered or expanded.
|
|
1558
|
+
//
|
|
1559
|
+
// The heights and not the DOM: past `TIMELINE_GROUP_WINDOW_THRESHOLD` the
|
|
1560
|
+
// rows are virtualized and the one being revealed is exactly the one that
|
|
1561
|
+
// may not be mounted, while this prefix sum is what positions the spacers
|
|
1562
|
+
// holding its place.
|
|
1563
|
+
followGeom.current = {
|
|
1564
|
+
ids: groups.map((group) => group.id),
|
|
1565
|
+
heights: groupHeights,
|
|
1566
|
+
};
|
|
1567
|
+
// THE SUBTREE'S SPAN, in lane frames, for the follow's hold. Rows mode only:
|
|
1568
|
+
// Sound mode draws tracks over the whole shot and keeps the ordinary follow.
|
|
1569
|
+
// The same `isolationSpan` the entry fit reads, so the hold and the fit
|
|
1570
|
+
// agree about where the object lives.
|
|
1571
|
+
const isoLife = useMemo(() => {
|
|
1572
|
+
const home = isoGroup === undefined ? undefined : doc?.scenesById[isoGroup.id];
|
|
1573
|
+
return home === undefined || isolation === null ? null : isolationSpan(home, isolation);
|
|
1574
|
+
}, [doc, isoGroup, isolation]);
|
|
1575
|
+
followSpan.current =
|
|
1576
|
+
isoLife === null || isoGroup === undefined || mode === "sound"
|
|
1577
|
+
? null
|
|
1578
|
+
: { from: isoGroup.start - origin + isoLife.in, to: isoGroup.start - origin + isoLife.out };
|
|
1579
|
+
// THE ONE CONTAINER'S LAYER ROWS, for the follow's layer half: where each
|
|
1580
|
+
// sits and when it is on screen, in lane frames. `rowOffsetOf`'s arithmetic
|
|
1581
|
+
// over the same heights the window places its spacers by, so a row past the
|
|
1582
|
+
// virtual window is found as surely as a mounted one. Null over the
|
|
1583
|
+
// Project's shots, and no rows while they are folded.
|
|
1584
|
+
const layerFollow = useMemo(() => {
|
|
1585
|
+
const group = shotGroup !== undefined || def !== undefined || isoGroup !== undefined ? groups[0] : undefined;
|
|
1586
|
+
if (doc === null || group === undefined || groups.length !== 1) return null;
|
|
1587
|
+
const heights = layerHeights.get(group.id) ?? [];
|
|
1588
|
+
const home = def ?? doc.scenesById[group.id];
|
|
1589
|
+
const shift = group.start - origin;
|
|
1590
|
+
let top = DOCK_RULER_H + (groupLeads[0] ?? 0);
|
|
1591
|
+
// A folded group lists no heights: its rows are not drawn.
|
|
1592
|
+
const rows = (heights.length === 0 ? [] : group.rows).map((row, i): FollowRow => {
|
|
1593
|
+
const height = heights[i] ?? 0;
|
|
1594
|
+
const at = top;
|
|
1595
|
+
top += height;
|
|
1596
|
+
return {
|
|
1597
|
+
in: shift + row.lifespan.in,
|
|
1598
|
+
out: shift + row.lifespan.out,
|
|
1599
|
+
top: at,
|
|
1600
|
+
height,
|
|
1601
|
+
depth: row.depth,
|
|
1602
|
+
hidden: home?.layersById[row.layerId]?.hidden === true,
|
|
1603
|
+
};
|
|
1604
|
+
});
|
|
1605
|
+
return rows;
|
|
1606
|
+
}, [doc, groups, shotGroup, def, isoGroup, layerHeights, groupLeads, origin]);
|
|
1607
|
+
followRows.current = layerFollow;
|
|
1608
|
+
|
|
1609
|
+
/**
|
|
1610
|
+
* ISOLATION OPENS THE ROOT'S LANES, and leaving closes them again.
|
|
1611
|
+
*
|
|
1612
|
+
* Entering a card is a request to work on it, and its keyed properties are
|
|
1613
|
+
* that work, so they are on screen without a second click. The children stay
|
|
1614
|
+
* folded: they are what the card is made of, not what was asked for. A root
|
|
1615
|
+
* the author had open already is left to the author on the way out, which is
|
|
1616
|
+
* why the effect remembers what IT opened rather than closing the id.
|
|
1617
|
+
*
|
|
1618
|
+
* Keyed on the root alone and reading `open` from this render: the set
|
|
1619
|
+
* changes on every disclosure, and re-running on those would undo the
|
|
1620
|
+
* author's own closes. And only when the root MOVED: a wake from the board's
|
|
1621
|
+
* hidden `<Activity>` re-runs this with the same root, which reads as
|
|
1622
|
+
* "already open" and would close the lanes it opened.
|
|
1623
|
+
*/
|
|
1624
|
+
const isoRoot = isoGroup === undefined ? null : isolation;
|
|
1625
|
+
const isoSeen = useRef<string | null>(null);
|
|
1626
|
+
useEffect(() => {
|
|
1627
|
+
if (isoSeen.current === isoRoot) return;
|
|
1628
|
+
isoSeen.current = isoRoot;
|
|
1629
|
+
const was = autoOpened.current;
|
|
1630
|
+
const add = isoRoot !== null && !open.has(isoRoot) ? isoRoot : null;
|
|
1631
|
+
autoOpened.current = add;
|
|
1632
|
+
if (was === null && add === null) return;
|
|
1633
|
+
setOpen((prev) => {
|
|
1634
|
+
const next = new Set(prev);
|
|
1635
|
+
if (was !== null) next.delete(was);
|
|
1636
|
+
if (add !== null) next.add(add);
|
|
1637
|
+
return next;
|
|
1638
|
+
});
|
|
1639
|
+
}, [isoRoot]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
1640
|
+
|
|
1641
|
+
/**
|
|
1642
|
+
* ROWS ANOTHER PANEL ASKS TO SHOW (`dockReveal`).
|
|
1643
|
+
*
|
|
1644
|
+
* Two askers. The board's "Fine-tune in Studio" selects keys, and a key on
|
|
1645
|
+
* a closed row is a selection nobody can see, so it opens the layer's lanes
|
|
1646
|
+
* (`lanes`). A pick on the stage or in Layers
|
|
1647
|
+
* (`revealSelection`) only brings the row into view and flashes it: opening
|
|
1648
|
+
* its lanes would grow the dock under the author on every click.
|
|
1649
|
+
*
|
|
1650
|
+
* An explicit ask, not a watch on the selection: a row the author picks here
|
|
1651
|
+
* is already in view, and the inspector diamond's new key must not reopen a
|
|
1652
|
+
* row the author just closed. Lanes open and never close, and an unchanged
|
|
1653
|
+
* set is handed back.
|
|
1654
|
+
*
|
|
1655
|
+
* A collapsed dock or the Graph view is the author's choice, and a pick is
|
|
1656
|
+
* no reason to undo it, so those take the lanes and nothing else. The follow
|
|
1657
|
+
* switch (`followOff`) is playback's and is left alone.
|
|
1658
|
+
*/
|
|
1659
|
+
useEffect(() => {
|
|
1660
|
+
if (reveal === null) return;
|
|
1661
|
+
store.consumeDockReveal(reveal);
|
|
1662
|
+
if (reveal.lanes) {
|
|
1663
|
+
setOpen((was) => {
|
|
1664
|
+
const add = reveal.layerIds.filter((id) => !was.has(id));
|
|
1665
|
+
return add.length === 0 ? was : new Set([...was, ...add]);
|
|
1666
|
+
});
|
|
1667
|
+
}
|
|
1668
|
+
const s = store.getState();
|
|
1669
|
+
const first = reveal.layerIds[0];
|
|
1670
|
+
// Sound mode has no layer rows: it shows `audioIds` itself (SoundMode).
|
|
1671
|
+
if (first === undefined || s.dockCollapsed || s.dockMode !== "rows") return;
|
|
1672
|
+
if (!reveal.scroll && !reveal.flash) return;
|
|
1673
|
+
// A shot's rows sit behind its disclosure, over the Project and inside a
|
|
1674
|
+
// shot alike, so the shot holding the row opens. The shot the stage shows
|
|
1675
|
+
// first: a layer id names a row of one container.
|
|
1676
|
+
if (def === undefined) {
|
|
1677
|
+
const holds = (g: (typeof groups)[number]): boolean => g.rows.some((r) => r.layerId === first);
|
|
1678
|
+
const holder = groups.find((g) => g.id === s.sceneId && holds(g)) ?? groups.find(holds);
|
|
1679
|
+
if (holder !== undefined) {
|
|
1680
|
+
setOpenShots((was) => (was.has(holder.id) ? was : new Set([...was, holder.id])));
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
setRevealTarget({ layerId: first, scroll: reveal.scroll, flash: reveal.flash });
|
|
1684
|
+
}, [reveal, store, groups, def]);
|
|
1685
|
+
|
|
1686
|
+
// THE SCROLL RUNS AFTER THE LAYOUT PASS that drew the opened group, so the
|
|
1687
|
+
// heights it reads include it. From geometry and not the DOM: a virtualized
|
|
1688
|
+
// row is not mounted until this scroll brings its window there.
|
|
1689
|
+
useLayoutEffect(() => {
|
|
1690
|
+
if (revealTarget === null) return;
|
|
1691
|
+
setRevealTarget(null);
|
|
1692
|
+
const el = scrollRef.current;
|
|
1693
|
+
const s = store.getState();
|
|
1694
|
+
const at = rowOffsetOf(groups, groupHeights, groupLeads, layerHeights, revealTarget.layerId, s.sceneId);
|
|
1695
|
+
if (el === null || at === null) return;
|
|
1696
|
+
if (revealTarget.flash) setFlashed(revealTarget.layerId);
|
|
1697
|
+
if (!revealTarget.scroll) return;
|
|
1698
|
+
// The row with its open lanes, but never so tall that the scroll pushes
|
|
1699
|
+
// the row itself up under the ruler.
|
|
1700
|
+
const top = followScrollTop({
|
|
1701
|
+
rowTop: at.top,
|
|
1702
|
+
rowH: Math.min(at.height, Math.max(0, el.clientHeight - DOCK_RULER_H - 2 * FOLLOW_MARGIN)),
|
|
1703
|
+
scrollTop: el.scrollTop,
|
|
1704
|
+
viewH: el.clientHeight,
|
|
1705
|
+
contentH: el.scrollHeight,
|
|
1706
|
+
stickyH: DOCK_RULER_H,
|
|
1707
|
+
});
|
|
1708
|
+
if (top !== null) el.scrollTop = top;
|
|
1709
|
+
// Sideways only when no part of the bar is in view: a bar already on
|
|
1710
|
+
// screen is found, and panning the lane would lose the author's place.
|
|
1711
|
+
const group = groups.find((g) => g.id === at.groupId);
|
|
1712
|
+
const row = group?.rows.find((r) => r.layerId === revealTarget.layerId);
|
|
1713
|
+
if (group === undefined || row === undefined) return;
|
|
1714
|
+
const label = labelWidthOf(s);
|
|
1715
|
+
const left = label + frameToPx(group.start - origin + row.lifespan.in, s.dockPpf);
|
|
1716
|
+
const right = left + Math.max(8, frameToPx(row.lifespan.out - row.lifespan.in, s.dockPpf));
|
|
1717
|
+
if (right >= el.scrollLeft + label && left <= el.scrollLeft + el.clientWidth) return;
|
|
1718
|
+
const x = followScrollLeft({
|
|
1719
|
+
playheadPx: left,
|
|
1720
|
+
scrollLeft: el.scrollLeft,
|
|
1721
|
+
laneW: el.clientWidth,
|
|
1722
|
+
contentW: el.scrollWidth,
|
|
1723
|
+
});
|
|
1724
|
+
if (x !== null) el.scrollLeft = x;
|
|
1725
|
+
}, [revealTarget, store, groups, groupHeights, groupLeads, layerHeights, origin]);
|
|
272
1726
|
|
|
273
1727
|
if (doc === null) return null;
|
|
274
|
-
|
|
275
|
-
|
|
1728
|
+
// THE PROJECT CONTEXT: neither a definition nor a shot is open, so the lane
|
|
1729
|
+
// is the whole edit. Loop, `+ Shot` and the length suffix belong to it alone.
|
|
1730
|
+
const projectContext = def === undefined && shotGroup === undefined;
|
|
1731
|
+
const total =
|
|
1732
|
+
def !== undefined
|
|
1733
|
+
? defDurationFrames(def)
|
|
1734
|
+
: shotGroup !== undefined
|
|
1735
|
+
? shotGroup.end - shotGroup.start
|
|
1736
|
+
: masterDuration(doc);
|
|
1737
|
+
// BOUNDED, for `rulerTicks`' reason: the extent comes from stored windows,
|
|
1738
|
+
// and one written raw far past its Shot (validate bounds no `out`) must not
|
|
1739
|
+
// reach a `minWidth` as nine quadrillion pixels, nor a NaN as no width at
|
|
1740
|
+
// all. 2^25 px is Chrome's own layout ceiling, so no lane it could lay out
|
|
1741
|
+
// is cut short.
|
|
1742
|
+
const timeW = Number.isFinite(contentF)
|
|
1743
|
+
? Math.min(frameToPx(contentF, ppf), 2 ** 25)
|
|
1744
|
+
: frameToPx(total, ppf);
|
|
1745
|
+
|
|
1746
|
+
/**
|
|
1747
|
+
* THE RUBBER BAND. A press on empty lane space, and nothing else.
|
|
1748
|
+
*
|
|
1749
|
+
* Read `MARQUEE_LANES` above for what this will and will not claim. The rest
|
|
1750
|
+
* of the gesture is marquee.ts's: the geometry is frozen at the press, the
|
|
1751
|
+
* membership is recomputed from the live box on every move, and both are in
|
|
1752
|
+
* the track area's own content pixels so a follow scroll cannot slide the
|
|
1753
|
+
* bars out from under the band.
|
|
1754
|
+
*
|
|
1755
|
+
* ── WHY IT WRITES THE SELECTION AS IT GOES ───────────────────────────
|
|
1756
|
+
*
|
|
1757
|
+
* So that dragging back over a bar visibly gives it up. That is the whole
|
|
1758
|
+
* argument for deciding membership per bar rather than banking it: the
|
|
1759
|
+
* gesture is recoverable, and an author who overshoots by a row pulls back
|
|
1760
|
+
* instead of releasing and starting again. Written only when the SET changes,
|
|
1761
|
+
* because `select` fans out through the inspector and the outline and a write
|
|
1762
|
+
* per pointer frame would spend all of that on an unchanged answer.
|
|
1763
|
+
*
|
|
1764
|
+
* ── AND WHY ESCAPE PUTS THE OLD ONE BACK ─────────────────────────────
|
|
1765
|
+
*
|
|
1766
|
+
* `startDrag` routes Escape, pointercancel and a lost capture into
|
|
1767
|
+
* `onEnd(false)`, and every other gesture in this lane restores its base
|
|
1768
|
+
* there. A marquee's base is the selection as it stood at the press, which is
|
|
1769
|
+
* NOT the list shift extends: a plain band replaces, so it extends nothing
|
|
1770
|
+
* and still owes back everything it replaced.
|
|
1771
|
+
*
|
|
1772
|
+
* ponytail: the band is STATE, so this whole component reconciles once per
|
|
1773
|
+
* animation frame while the pointer is down, which is the cost the header's
|
|
1774
|
+
* `THE FRAME STAYS OUT OF THIS COMPONENT` note is about. It is bounded (one
|
|
1775
|
+
* frame, only under a held pointer, rows memoised on the document) and it
|
|
1776
|
+
* buys a rect that reads straight out of the render. Write it through a ref
|
|
1777
|
+
* onto the node, the way the follow effect writes `scrollLeft`, if a long
|
|
1778
|
+
* document ever makes the band lag the pointer.
|
|
1779
|
+
*/
|
|
1780
|
+
const beginMarquee = (e: ReactPointerEvent<HTMLDivElement>): void => {
|
|
1781
|
+
if (e.button !== 0) return;
|
|
1782
|
+
const target = e.target as HTMLElement;
|
|
1783
|
+
if (!MARQUEE_LANES.some((lane) => target.classList.contains(lane))) return;
|
|
1784
|
+
// The scope, and the reason the press has to be inside a group at all: a
|
|
1785
|
+
// selection is resolved against ONE container (see marqueeHits).
|
|
1786
|
+
const sceneId = target.closest<HTMLElement>("[data-scene-group]")?.dataset[
|
|
1787
|
+
"sceneGroup"
|
|
1788
|
+
];
|
|
1789
|
+
if (sceneId === undefined) return;
|
|
1790
|
+
// Native text selection would otherwise drag a highlight across the labels
|
|
1791
|
+
// under the band.
|
|
1792
|
+
e.preventDefault();
|
|
1793
|
+
const tracks = e.currentTarget;
|
|
1794
|
+
const at = tracks.getBoundingClientRect();
|
|
1795
|
+
const from = { x: e.clientX - at.left, y: e.clientY - at.top };
|
|
1796
|
+
const bars = marqueeBars(tracks);
|
|
1797
|
+
// Frozen at the press, like every other value this lane reads there: the
|
|
1798
|
+
// author's selection is what shift extends AND what an abort puts back,
|
|
1799
|
+
// and re-reading it per move would extend the one this drag just wrote.
|
|
1800
|
+
//
|
|
1801
|
+
// The two are not the same list. `add` is what the BOX is selecting into,
|
|
1802
|
+
// which is nothing at all without shift because a plain band replaces;
|
|
1803
|
+
// `base` is what was on screen before the press, which is what Escape owes
|
|
1804
|
+
// back whether or not shift was held.
|
|
1805
|
+
const base = store.getState().selection;
|
|
1806
|
+
const add = e.shiftKey ? base : [];
|
|
1807
|
+
// A DEFINITION IS NOT A SHOT. Its rows carry the def's id as their scene,
|
|
1808
|
+
// which `select` already resolves through `layerContainer()`, and handing
|
|
1809
|
+
// that id to `focusScene` would pin the playhead's scene to a container
|
|
1810
|
+
// that has no master position at all.
|
|
1811
|
+
const pinScene = def === undefined;
|
|
1812
|
+
let painting = false;
|
|
1813
|
+
let written: readonly string[] | null = null;
|
|
1814
|
+
startDrag(e, {
|
|
1815
|
+
onMove(ev) {
|
|
1816
|
+
const box = tracks.getBoundingClientRect();
|
|
1817
|
+
const to = { x: ev.clientX - box.left, y: ev.clientY - box.top };
|
|
1818
|
+
if (
|
|
1819
|
+
!painting &&
|
|
1820
|
+
Math.abs(to.x - from.x) < MARQUEE_MIN_PX &&
|
|
1821
|
+
Math.abs(to.y - from.y) < MARQUEE_MIN_PX
|
|
1822
|
+
)
|
|
1823
|
+
return;
|
|
1824
|
+
painting = true;
|
|
1825
|
+
const rect = rectBetween(from, to);
|
|
1826
|
+
setMarquee(rect);
|
|
1827
|
+
const hits = marqueeHits(rect, bars, { sceneId, add });
|
|
1828
|
+
if (
|
|
1829
|
+
written !== null &&
|
|
1830
|
+
written.length === hits.length &&
|
|
1831
|
+
written.every((id, i) => id === hits[i])
|
|
1832
|
+
)
|
|
1833
|
+
return;
|
|
1834
|
+
written = hits;
|
|
1835
|
+
// The row's own `pick()` rule: a selection in another shot has to
|
|
1836
|
+
// navigate first, or `select` orders it against a container it does not
|
|
1837
|
+
// live in and resolves it to nothing.
|
|
1838
|
+
if (pinScene) store.focusScene(sceneId);
|
|
1839
|
+
store.select(hits);
|
|
1840
|
+
},
|
|
1841
|
+
onEnd(commit) {
|
|
1842
|
+
setMarquee(null);
|
|
1843
|
+
if (!commit && written !== null) store.select(base);
|
|
1844
|
+
},
|
|
1845
|
+
});
|
|
1846
|
+
};
|
|
1847
|
+
|
|
1848
|
+
/** The two master lanes' clip window, or absent outside the shot context. */
|
|
1849
|
+
const laneWindow =
|
|
1850
|
+
shotGroup === undefined
|
|
1851
|
+
? undefined
|
|
1852
|
+
: { origin, len: shotGroup.end - shotGroup.start };
|
|
1853
|
+
/**
|
|
1854
|
+
* WHAT THE LABEL COLUMN IS LISTING, named in its corner: the open
|
|
1855
|
+
* definition, the entered shot, or (null) the shots themselves, where the
|
|
1856
|
+
* corner is the expand-all instead.
|
|
1857
|
+
*
|
|
1858
|
+
* A NAME AND NOT A WAY OUT. The one back lives in the canvas bar
|
|
1859
|
+
* (`BackChip`), which is on screen in every dock state, graph mode and a
|
|
1860
|
+
* collapsed dock included. A second exit here was a second thing to find.
|
|
1861
|
+
*/
|
|
1862
|
+
const cornerName = def?.name ?? shotGroup?.name ?? null;
|
|
1863
|
+
/** IN ISOLATION THE CORNER NAMES THE OBJECT, with its kind's glyph: the
|
|
1864
|
+
* rows under it are that object and nothing else, so the shot's name
|
|
1865
|
+
* would be naming something the column no longer lists. */
|
|
1866
|
+
const isoLayer =
|
|
1867
|
+
isoGroup === undefined || isolation === null ? undefined : doc.scenesById[isoGroup.id]?.layersById[isolation];
|
|
1868
|
+
/** The shot's range, shaded where the lane runs past it, in isolation only:
|
|
1869
|
+
* the lane is fitted to the object, and what the film actually plays is
|
|
1870
|
+
* worth keeping in sight. Lane frames. */
|
|
1871
|
+
const shotBounds =
|
|
1872
|
+
isoGroup === undefined ? undefined : { from: isoGroup.start - origin, to: isoGroup.end - origin };
|
|
1873
|
+
/** Markers survive isolation as ruler ticks, so timing to a beat still
|
|
1874
|
+
* works with their lane hidden. Lane frames, inside the lane's extent. */
|
|
1875
|
+
const markerTicks =
|
|
1876
|
+
isoGroup === undefined
|
|
1877
|
+
? undefined
|
|
1878
|
+
: (doc.markers ?? [])
|
|
1879
|
+
.map((m) => ({ id: m.id, frame: Math.trunc(m.frame) - origin, title: m.label || "Marker" }))
|
|
1880
|
+
.filter((m) => m.frame >= 0 && m.frame <= contentF);
|
|
1881
|
+
/** Every shot open, for the corner's expand-all. */
|
|
1882
|
+
const allOpen = groups.length > 0 && groups.every((g) => openShots.has(g.id));
|
|
1883
|
+
laneShown.current.marker ||= (doc.markers ?? []).length > 0;
|
|
1884
|
+
/** SOUND MODE'S BAR GRID, over master time read from the context's origin.
|
|
1885
|
+
* A definition has no master time, so no grid. Not memoized: one pass over
|
|
1886
|
+
* the beats in view is cheaper than the dock's own row model. */
|
|
1887
|
+
const sound = mode === "sound";
|
|
1888
|
+
const tempo: Tempo3D | undefined = sound && def === undefined ? doc.tempo : undefined;
|
|
1889
|
+
/** The right column holds the mixer (or the Generate drawer), Sound mode
|
|
1890
|
+
* over master time only. Otherwise the lane runs the dock's full width. */
|
|
1891
|
+
const dockSide = !collapsed && sound && def === undefined;
|
|
1892
|
+
const beatTicks: BeatTick[] | undefined =
|
|
1893
|
+
tempo === undefined
|
|
1894
|
+
? undefined
|
|
1895
|
+
: beatRulerTicks(tempo, doc.fps, origin, Math.min(contentF, Math.ceil(timeW / ppf)), ppf);
|
|
276
1896
|
|
|
277
1897
|
return (
|
|
278
1898
|
<section
|
|
@@ -280,209 +1900,655 @@ export function TimelineDock() {
|
|
|
280
1900
|
data-testid="timeline-dock"
|
|
281
1901
|
aria-label="Timeline"
|
|
282
1902
|
>
|
|
283
|
-
{/*
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
1903
|
+
{/* INSIDE the section, per the boundary's placement rule: a catch swaps
|
|
1904
|
+
the CONTENTS for the card and the positioned container keeps its
|
|
1905
|
+
place in the shell, and the stage iframe is never touched. */}
|
|
1906
|
+
<PanelBoundary label="Timeline">
|
|
1907
|
+
{/* A child, not a sibling: the handle drags the row it heads. Absolute
|
|
1908
|
+
inside the docked cell, above the head it overlays. From the SHOWN
|
|
1909
|
+
height, so a drag on a window too short for the stored one starts
|
|
1910
|
+
where the author sees the edge. */}
|
|
1911
|
+
{collapsed ? null : (
|
|
1912
|
+
<ResizeHandle
|
|
1913
|
+
axis="y"
|
|
1914
|
+
getValue={() => fitShell(window.innerWidth, window.innerHeight, store.getState()).dockRow}
|
|
1915
|
+
// Dragging the TOP edge upward is a DECREASING clientY and a TALLER
|
|
1916
|
+
// dock, so the pointer's travel is negated.
|
|
1917
|
+
sign={-1}
|
|
1918
|
+
onChange={(h) => store.setDockHeight(h)}
|
|
1919
|
+
// Back to untouched: the window's fitted height, which `fitShell`
|
|
1920
|
+
// then sizes to the folded rows again.
|
|
1921
|
+
onReset={() => store.setDockHeight(layoutFor(window.innerWidth, window.innerHeight).dockHeight)}
|
|
1922
|
+
className="dock-resize"
|
|
1923
|
+
label="Resize the timeline"
|
|
1924
|
+
/>
|
|
1925
|
+
)}
|
|
1926
|
+
{/* TWO CELLS ON THE SHELL'S THREE COLUMNS: the title under the left
|
|
1927
|
+
column, the view and the transport across the canvas and the right
|
|
1928
|
+
column. Preview monitoring is the transport's speaker, beside play;
|
|
1929
|
+
levels live in the Sound tab's mixer. The head renders in every dock
|
|
1930
|
+
state, so graph mode (which replaces the scroller) and a collapsed dock (body hidden)
|
|
1931
|
+
keep the transport with no second copy of it. */}
|
|
1932
|
+
<header className="dock-head">
|
|
1933
|
+
{/* THE LEFT CELL, under the label column: the disclosure chevron and
|
|
1934
|
+
the view switch on one 40px band. The panel's name is gone: the
|
|
1935
|
+
switch says what this is, and the band has no room to spare. */}
|
|
1936
|
+
<div className="dock-title">
|
|
1937
|
+
<button
|
|
1938
|
+
type="button"
|
|
1939
|
+
className="btn is-icon is-ghost dock-collapse"
|
|
1940
|
+
aria-expanded={!collapsed}
|
|
1941
|
+
aria-label={collapsed ? "Expand timeline" : "Collapse timeline"}
|
|
1942
|
+
title={collapsed ? "Expand timeline" : "Collapse timeline"}
|
|
1943
|
+
onClick={() => store.setDockCollapsed(!collapsed)}
|
|
1944
|
+
>
|
|
1945
|
+
{collapsed ? CHEVRON_RIGHT_ICON : CHEVRON_DOWN_ICON}
|
|
1946
|
+
</button>
|
|
1947
|
+
{/* ROWS | GRAPH | SOUND. Faces of one subject: when it happens, how
|
|
1948
|
+
it moves, and what it sounds like. A mode rather than a second
|
|
1949
|
+
panel, because a curve under forty rows gets the height neither
|
|
1950
|
+
can spare. */}
|
|
1951
|
+
<SegmentedControl
|
|
1952
|
+
className="dock-modes is-bar"
|
|
1953
|
+
ariaLabel="Timeline view"
|
|
1954
|
+
value={mode}
|
|
1955
|
+
onChange={(m) => store.setDockMode(m === "graph" || m === "sound" ? m : "rows")}
|
|
1956
|
+
options={[
|
|
1957
|
+
{
|
|
1958
|
+
value: "rows",
|
|
1959
|
+
label: "Rows",
|
|
1960
|
+
title: "The rows: when everything happens",
|
|
1961
|
+
testId: "dock-mode-rows",
|
|
1962
|
+
},
|
|
1963
|
+
{
|
|
1964
|
+
value: "graph",
|
|
1965
|
+
label: "Graph",
|
|
1966
|
+
title: "The graph: how the focused property moves",
|
|
1967
|
+
testId: "dock-mode-graph",
|
|
1968
|
+
},
|
|
1969
|
+
{
|
|
1970
|
+
value: "sound",
|
|
1971
|
+
label: "Sound",
|
|
1972
|
+
title: "Sound: one row per track, with the mixer (Alt+M)",
|
|
1973
|
+
testId: "dock-mode-sound",
|
|
1974
|
+
},
|
|
1975
|
+
]}
|
|
1976
|
+
/>
|
|
1977
|
+
</div>
|
|
1978
|
+
<div className={`dock-view is-${mode}`}>
|
|
1979
|
+
{/* The start band: Sound mode's tempo, else empty. It keeps its
|
|
1980
|
+
track either way, so the transport stays on the cell's centre. */}
|
|
1981
|
+
<div className="dock-lead">{sound && def === undefined ? <TempoChip /> : null}</div>
|
|
1982
|
+
{/* THE TRANSPORT, on the strip it drives, and the chrome's one play
|
|
1983
|
+
button: back to the start, play, the Project's loop, the clock. */}
|
|
1984
|
+
<div className="dock-transport">
|
|
1985
|
+
{/* Back to the context's frame 0: the shot's first frame inside a
|
|
1986
|
+
shot, the definition's inside one. It takes the clock off the
|
|
1987
|
+
transport first, as a ruler press does, or the rAF loop would
|
|
1988
|
+
write over the seek on its next tick. No key: Home is Fit, and
|
|
1989
|
+
the up arrow already walks the edit points back to the start. */}
|
|
1990
|
+
<button
|
|
1991
|
+
type="button"
|
|
1992
|
+
className="btn is-icon is-ghost dock-start"
|
|
1993
|
+
data-testid="dock-start"
|
|
1994
|
+
aria-label="Go to start"
|
|
1995
|
+
title="Go to start"
|
|
1996
|
+
onClick={() => {
|
|
1997
|
+
store.pauseForScrub();
|
|
1998
|
+
if (def !== undefined) store.seekDef(0);
|
|
1999
|
+
else store.seek(origin);
|
|
2000
|
+
}}
|
|
2001
|
+
>
|
|
2002
|
+
<IconSkipStart />
|
|
2003
|
+
</button>
|
|
2004
|
+
{/* Play, Pause, and a queued Play's cancel, with its own
|
|
2005
|
+
subscriptions (PlayButton.tsx): a play edge re-renders the
|
|
2006
|
+
button, not this dock. */}
|
|
2007
|
+
<PlayButton className="btn is-icon dock-play" />
|
|
2008
|
+
{/* Preview monitoring: what the author HEARS, never the mix. */}
|
|
2009
|
+
<TransportVolume />
|
|
2010
|
+
{/* LOOP, in the Project context only: a shot and a definition
|
|
2011
|
+
always loop (`playbackLoops`), so a toggle there would be a
|
|
2012
|
+
switch wired to nothing. */}
|
|
2013
|
+
{projectContext ? (
|
|
2014
|
+
<button
|
|
2015
|
+
type="button"
|
|
2016
|
+
className="btn is-icon is-ghost dock-loop"
|
|
2017
|
+
data-testid="dock-loop"
|
|
2018
|
+
aria-pressed={loop}
|
|
2019
|
+
aria-label="Loop playback"
|
|
2020
|
+
title={loop ? "Loop: on" : "Loop: off, playback stops at the end"}
|
|
2021
|
+
onClick={() => store.setLoopPlayback(!loop)}
|
|
2022
|
+
>
|
|
2023
|
+
{LOOP_ICON}
|
|
2024
|
+
</button>
|
|
2025
|
+
) : null}
|
|
2026
|
+
<DockTime
|
|
2027
|
+
fps={doc.fps}
|
|
2028
|
+
origin={origin}
|
|
2029
|
+
total={projectContext ? total : null}
|
|
2030
|
+
tempo={tempo}
|
|
2031
|
+
/>
|
|
2032
|
+
</div>
|
|
2033
|
+
<div className="dock-tools">
|
|
2034
|
+
{sound && def === undefined ? (
|
|
2035
|
+
<SoundHeadTools generateOpen={generateOpen} onGenerate={() => setGenerateOpen((v) => !v)} />
|
|
2036
|
+
) : null}
|
|
2037
|
+
{/* Wrapped, not passed by reference: `fit` takes an optional
|
|
2038
|
+
sceneId, and a bare handler would hand it the MouseEvent. The
|
|
2039
|
+
Graph frames its own curves (CurvePane), so the head's Fit
|
|
2040
|
+
steps aside there. */}
|
|
2041
|
+
{mode === "graph" ? null : (
|
|
2042
|
+
<button
|
|
2043
|
+
type="button"
|
|
2044
|
+
className="btn is-ghost dock-fit"
|
|
2045
|
+
title="Fit the timeline (\)"
|
|
2046
|
+
onClick={() => fit()}
|
|
2047
|
+
>
|
|
2048
|
+
Fit
|
|
2049
|
+
</button>
|
|
2050
|
+
)}
|
|
2051
|
+
{/* THE ONE CREATE, in the scene context only. A shot is APPENDED
|
|
2052
|
+
and the playhead follows it (see `addScene` for why a shot cannot
|
|
2053
|
+
land at the playhead). */}
|
|
2054
|
+
{projectContext && !sound ? (
|
|
2055
|
+
<button
|
|
2056
|
+
type="button"
|
|
2057
|
+
className="btn is-ghost dock-add-shot"
|
|
2058
|
+
data-testid="dock-add-shot"
|
|
2059
|
+
// The visible text is just "Shot" beside a hidden glyph, which
|
|
2060
|
+
// names the thing and not the verb.
|
|
2061
|
+
aria-label="Add shot"
|
|
2062
|
+
title="Add a shot at the end, and go to it"
|
|
2063
|
+
onClick={() => store.addScene()}
|
|
2064
|
+
>
|
|
2065
|
+
{ADD_ICON}
|
|
2066
|
+
Shot
|
|
2067
|
+
</button>
|
|
2068
|
+
) : null}
|
|
2069
|
+
{/* At the far end: the zoom is about how the author is LOOKING at
|
|
2070
|
+
the lane, not about the document in it. */}
|
|
2071
|
+
<DockZoom onZoom={zoomTo} />
|
|
2072
|
+
</div>
|
|
2073
|
+
</div>
|
|
2074
|
+
</header>
|
|
356
2075
|
|
|
357
|
-
|
|
2076
|
+
{/* The body is HIDDEN, not unmounted, when collapsed. Unmounting would
|
|
358
2077
|
throw away the scroll position and every row's state, and the author
|
|
359
|
-
would come back to a dock that had forgotten where they were
|
|
2078
|
+
would come back to a dock that had forgotten where they were.
|
|
360
2079
|
dockCollapse.test.tsx is the gate. */}
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
<
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
clocks used to meet here, and that conversion was the one place a
|
|
374
|
-
sign error put the playhead in another scene. */}
|
|
375
|
-
<Ruler
|
|
376
|
-
durationFrames={total}
|
|
377
|
-
contentF={contentF}
|
|
378
|
-
ppf={ppf}
|
|
379
|
-
timeW={timeW}
|
|
380
|
-
formatTick={(f) => timecode(f, doc.fps)}
|
|
381
|
-
onSeek={(f) => store.seek(f)}
|
|
2080
|
+
<div className={`dock-body${dockSide ? " has-side" : ""}`} hidden={collapsed} data-testid="dock-body">
|
|
2081
|
+
{/* GRAPH MODE REPLACES THE WHOLE SCROLLER, ruler included, and that is
|
|
2082
|
+
deliberate: `CurveGraph` draws its own ruler over CONTAINER-LOCAL
|
|
2083
|
+
frames while this one draws master frames, and two rulers disagreeing
|
|
2084
|
+
about what x means in one panel is worse than one ruler. */}
|
|
2085
|
+
{mode === "graph" ? (
|
|
2086
|
+
<CurvePane />
|
|
2087
|
+
) : (
|
|
2088
|
+
<div
|
|
2089
|
+
className="dock-scroll"
|
|
2090
|
+
ref={scrollRef}
|
|
2091
|
+
data-testid="dock-scroll"
|
|
382
2092
|
>
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
2093
|
+
{shotMenu.menu}
|
|
2094
|
+
{/* THE CORNER IS A CHILD, NOT PADDING. `.dock-corner` is sticky-left
|
|
2095
|
+
like every label cell below it, so it stays put while the lanes
|
|
2096
|
+
scroll under the ruler; an absolute corner would scroll away from
|
|
2097
|
+
the column it labels. Its width is the label column's, so the
|
|
2098
|
+
ruler's time area starts at exactly `labelWidth`, which is what
|
|
2099
|
+
the follow effect and `.dock-overlay` assume. */}
|
|
2100
|
+
<div
|
|
2101
|
+
className="dock-ruler"
|
|
2102
|
+
style={{ minWidth: labelWidth + timeW }}
|
|
2103
|
+
>
|
|
2104
|
+
<div className="dock-corner" style={{ width: labelWidth }}>
|
|
2105
|
+
{/* NO `stopPropagation` on the press, deliberately: the corner is
|
|
2106
|
+
a SIBLING of `.tl-ruler`, which is where `Ruler`'s seek-and-
|
|
2107
|
+
drag handler lives, so nothing from here can reach it. */}
|
|
2108
|
+
{sound ? (
|
|
2109
|
+
<span className="dock-corner-label">Tracks</span>
|
|
2110
|
+
) : isoLayer !== undefined ? (
|
|
2111
|
+
<span className="dock-corner-iso" data-testid="dock-corner-iso" title={isoLayer.label}>
|
|
2112
|
+
{iconFor(isoLayer)}
|
|
2113
|
+
<span className="dock-corner-label">{isoLayer.label}</span>
|
|
2114
|
+
</span>
|
|
2115
|
+
) : cornerName !== null ? (
|
|
2116
|
+
<span className="dock-corner-label" title={cornerName}>
|
|
2117
|
+
{cornerName}
|
|
2118
|
+
</span>
|
|
2119
|
+
) : (
|
|
2120
|
+
// THE SCENE CONTEXT'S EXPAND-ALL over the per-shot
|
|
2121
|
+
// disclosures, named for the column it opens.
|
|
2122
|
+
<button
|
|
2123
|
+
type="button"
|
|
2124
|
+
className="dock-summary"
|
|
2125
|
+
data-testid="dock-summary"
|
|
2126
|
+
aria-expanded={allOpen}
|
|
2127
|
+
title={
|
|
2128
|
+
allOpen
|
|
2129
|
+
? "Collapse every shot"
|
|
2130
|
+
: "Expand every shot: one row per element, so a logo that survives a cut reads as the two elements it is"
|
|
2131
|
+
}
|
|
2132
|
+
onClick={() =>
|
|
2133
|
+
setOpenShots(
|
|
2134
|
+
allOpen
|
|
2135
|
+
? new Set<string>()
|
|
2136
|
+
: new Set(groups.map((g) => g.id)),
|
|
2137
|
+
)
|
|
2138
|
+
}
|
|
2139
|
+
>
|
|
2140
|
+
{allOpen ? <IconChevronDown /> : <IconChevron />}
|
|
2141
|
+
<span>Tracks</span>
|
|
2142
|
+
</button>
|
|
2143
|
+
)}
|
|
2144
|
+
</div>
|
|
2145
|
+
{/* MASTER frames end to end, DEF-LOCAL frames while a definition is
|
|
2146
|
+
open (the seek routes to the def-local clock, the one-time-base
|
|
2147
|
+
rule), and master frames READ FROM THE SHOT'S START inside one.
|
|
2148
|
+
The shot arm is a re-zero and not a second clock: the seek adds
|
|
2149
|
+
`origin` straight back, so what reaches the store is the master
|
|
2150
|
+
frame it has always been. */}
|
|
2151
|
+
{/* THE GROW WRAPPER IS LOAD-BEARING. `.tl-ruler` sets `minWidth`
|
|
2152
|
+
and no width, so as a flex item it would resolve to exactly
|
|
2153
|
+
`timeW` and the strip right of the last tick — every document
|
|
2154
|
+
zoomed out below the fit, and every short doc in a wide window —
|
|
2155
|
+
would fall outside the element that carries the seek handler.
|
|
2156
|
+
As a block child of the old padded ruler it filled the row for
|
|
2157
|
+
free. `height: 100%` inside still resolves: flex items stretch,
|
|
2158
|
+
so the wrapper is full height. */}
|
|
2159
|
+
<div style={{ flex: "1 1 auto", minWidth: 0 }}>
|
|
2160
|
+
<DockRuler
|
|
2161
|
+
durationFrames={shotBounds?.to ?? total}
|
|
2162
|
+
boundsFrom={shotBounds?.from}
|
|
2163
|
+
markers={markerTicks}
|
|
2164
|
+
contentF={contentF}
|
|
2165
|
+
ppf={ppf}
|
|
2166
|
+
timeW={timeW}
|
|
2167
|
+
fps={doc.fps}
|
|
2168
|
+
project={projectContext}
|
|
2169
|
+
beats={beatTicks}
|
|
2170
|
+
// The work area loops the whole film, so only the Project's
|
|
2171
|
+
// ruler draws one (origin is 0 there: lane frames are master).
|
|
2172
|
+
onWorkArea={projectContext ? store.setWorkArea : undefined}
|
|
2173
|
+
onSeek={(f) => {
|
|
2174
|
+
// The ruler is a DRAG, so it takes the clock off the transport
|
|
2175
|
+
// first. Without this the rAF loop rewrites `frame` between
|
|
2176
|
+
// every move and the playhead springs back under the pointer.
|
|
2177
|
+
store.pauseForScrub();
|
|
2178
|
+
if (def !== undefined) store.seekDef(f);
|
|
2179
|
+
else store.seek(f + origin);
|
|
2180
|
+
}}
|
|
2181
|
+
>
|
|
2182
|
+
{/* BEFORE the playhead, so the line stays the topmost
|
|
2183
|
+
thing in the strip: the bracket is a region and the
|
|
2184
|
+
playhead is the one mark an author aims with. Only the
|
|
2185
|
+
RULER carries it — a second copy through the tracks
|
|
2186
|
+
would tint every bar it crossed. */}
|
|
2187
|
+
<WorkAreaBracket ppf={ppf} />
|
|
2188
|
+
<KeyStretchTabs ppf={ppf} origin={origin} />
|
|
2189
|
+
<DockPlayhead ppf={ppf} origin={origin} fps={doc.fps} head />
|
|
2190
|
+
</DockRuler>
|
|
2191
|
+
</div>
|
|
2192
|
+
</div>
|
|
386
2193
|
|
|
387
|
-
|
|
388
|
-
|
|
2194
|
+
{sound ? (
|
|
2195
|
+
<SoundLanes
|
|
2196
|
+
ppf={ppf}
|
|
2197
|
+
labelWidth={labelWidth}
|
|
2198
|
+
timeW={timeW}
|
|
2199
|
+
origin={origin}
|
|
2200
|
+
window={laneWindow}
|
|
2201
|
+
beginSnap={beginSnap}
|
|
2202
|
+
grid={beatTicks ?? []}
|
|
2203
|
+
openMenu={shotMenu.open}
|
|
2204
|
+
onGenerate={() => setGenerateOpen(true)}
|
|
2205
|
+
overlay={
|
|
2206
|
+
<>
|
|
2207
|
+
{guide === null ? null : (
|
|
2208
|
+
<span
|
|
2209
|
+
className="dock-snap-guide"
|
|
2210
|
+
data-testid="snap-guide"
|
|
2211
|
+
style={{ left: frameToPx(guide - origin, ppf) }}
|
|
2212
|
+
/>
|
|
2213
|
+
)}
|
|
2214
|
+
<DockPlayhead ppf={ppf} origin={origin} />
|
|
2215
|
+
</>
|
|
2216
|
+
}
|
|
2217
|
+
/>
|
|
2218
|
+
) : (
|
|
2219
|
+
<div
|
|
2220
|
+
className="dock-tracks"
|
|
2221
|
+
data-testid="dock-tracks"
|
|
2222
|
+
style={{ minWidth: labelWidth + timeW }}
|
|
2223
|
+
// THE LAST CLAIMANT, on the container every row sits in: see
|
|
2224
|
+
// `MARQUEE_LANES`. Bound here rather than on each lane so the
|
|
2225
|
+
// rule is one rule, and bubble phase rather than capture so a
|
|
2226
|
+
// row that stops its own press is obeyed rather than overruled.
|
|
2227
|
+
onPointerDown={beginMarquee}
|
|
2228
|
+
// The reveal flash is one-shot: its end hands the class back, so
|
|
2229
|
+
// the next pick of the same row flashes again.
|
|
2230
|
+
onAnimationEnd={(e) => {
|
|
2231
|
+
if (e.animationName === "row-revealed") setFlashed(null);
|
|
2232
|
+
}}
|
|
2233
|
+
>
|
|
2234
|
+
{/* The scene boundaries, drawn THROUGH the tracks. Derived from the
|
|
389
2235
|
same prefix sum the strip above uses, so the line and the bar it
|
|
390
2236
|
divides can never be a frame apart. */}
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
2237
|
+
<div
|
|
2238
|
+
className="dock-overlay"
|
|
2239
|
+
style={{ left: labelWidth }}
|
|
2240
|
+
aria-hidden="true"
|
|
2241
|
+
>
|
|
2242
|
+
{/* Isolation's shot range, shaded through the rows as on the
|
|
2243
|
+
ruler: a key past the shot's end is one the film never
|
|
2244
|
+
reaches, and the tint says so where the key is. */}
|
|
2245
|
+
{shotBounds === undefined || shotBounds.from <= 0 ? null : (
|
|
2246
|
+
<span
|
|
2247
|
+
className="dock-bounds-shade"
|
|
2248
|
+
style={{ left: 0, width: frameToPx(shotBounds.from, ppf) }}
|
|
2249
|
+
/>
|
|
2250
|
+
)}
|
|
2251
|
+
{shotBounds === undefined ? null : (
|
|
2252
|
+
<span
|
|
2253
|
+
className="dock-bounds-shade"
|
|
2254
|
+
style={{ left: frameToPx(shotBounds.to, ppf), right: 0 }}
|
|
2255
|
+
/>
|
|
2256
|
+
)}
|
|
2257
|
+
{groups.map((group) =>
|
|
2258
|
+
group.start - origin === 0 ? null : (
|
|
2259
|
+
<span
|
|
2260
|
+
key={group.id}
|
|
2261
|
+
className="dock-boundary"
|
|
2262
|
+
data-boundary={group.id}
|
|
2263
|
+
style={{ left: frameToPx(group.start - origin, ppf) }}
|
|
2264
|
+
/>
|
|
2265
|
+
),
|
|
2266
|
+
)}
|
|
2267
|
+
{/* The guide's frame is a MASTER frame (`beginSnap` freezes
|
|
2268
|
+
master targets), so it is re-zeroed for drawing like every
|
|
2269
|
+
other bar, and by the same subtraction. */}
|
|
2270
|
+
{guide === null ? null : (
|
|
2271
|
+
<span
|
|
2272
|
+
className="dock-snap-guide"
|
|
2273
|
+
data-testid="snap-guide"
|
|
2274
|
+
style={{ left: frameToPx(guide - origin, ppf) }}
|
|
2275
|
+
/>
|
|
2276
|
+
)}
|
|
2277
|
+
<DockPlayhead ppf={ppf} origin={origin} />
|
|
2278
|
+
<KeyStretchGuides ppf={ppf} origin={origin} />
|
|
2279
|
+
</div>
|
|
2280
|
+
|
|
2281
|
+
{/* THE BAND ITSELF, in this element's own content pixels, which
|
|
2282
|
+
is what `beginMarquee` measures in — so it is a direct child of
|
|
2283
|
+
`.dock-tracks` and not of `.dock-overlay`, whose box starts at
|
|
2284
|
+
the label column. After the overlay in document order for the
|
|
2285
|
+
same reason the overlay is after the rows: it is ephemeral, it
|
|
2286
|
+
belongs on top of what it is about, and it takes no pointer
|
|
2287
|
+
events of its own. */}
|
|
2288
|
+
{marquee === null ? null : (
|
|
394
2289
|
<span
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
style={{
|
|
2290
|
+
className="dock-marquee"
|
|
2291
|
+
data-testid="dock-marquee"
|
|
2292
|
+
aria-hidden="true"
|
|
2293
|
+
style={{
|
|
2294
|
+
left: marquee.left,
|
|
2295
|
+
top: marquee.top,
|
|
2296
|
+
width: marquee.right - marquee.left,
|
|
2297
|
+
height: marquee.bottom - marquee.top,
|
|
2298
|
+
}}
|
|
399
2299
|
/>
|
|
400
|
-
)
|
|
401
|
-
)}
|
|
402
|
-
{guide === null ? null : (
|
|
403
|
-
<span className="dock-snap-guide" data-testid="snap-guide" style={{ left: frameToPx(guide, ppf) }} />
|
|
404
|
-
)}
|
|
405
|
-
<DockPlayhead ppf={ppf} />
|
|
406
|
-
</div>
|
|
2300
|
+
)}
|
|
407
2301
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
2302
|
+
<TimelineWindow
|
|
2303
|
+
items={groups}
|
|
2304
|
+
heights={groupHeights}
|
|
2305
|
+
threshold={TIMELINE_GROUP_WINDOW_THRESHOLD}
|
|
2306
|
+
keyOf={(group) => group.id}
|
|
2307
|
+
testId="timeline-group-window"
|
|
2308
|
+
>
|
|
2309
|
+
{(group, groupIndex) => (
|
|
2310
|
+
<div
|
|
2311
|
+
className={`dock-scene-group${groupIndex === 0 ? "" : " has-separator"}`}
|
|
2312
|
+
key={group.id}
|
|
2313
|
+
data-scene-group={group.id}
|
|
2314
|
+
>
|
|
2315
|
+
{/* THE SHOT ITSELF, as the group's first row. One drawing of the
|
|
2316
|
+
document's coarsest structure, carrying every verb the scene
|
|
2317
|
+
strip used to own. A DEFINITION is not a shot and has none.
|
|
2318
|
+
Nor does isolation, nor its camera, channels or sound: the
|
|
2319
|
+
group there is the isolated object alone (`isoGroup`). */}
|
|
2320
|
+
{def !== undefined || isoGroup !== undefined ? null : (
|
|
2321
|
+
<ShotRow
|
|
2322
|
+
id={group.id}
|
|
2323
|
+
name={group.name}
|
|
2324
|
+
start={group.start}
|
|
2325
|
+
end={group.end}
|
|
2326
|
+
// The index into `doc.scenes`, which is what `reorderScene`
|
|
2327
|
+
// splices, NOT the index into `groups`, which is a single
|
|
2328
|
+
// entry inside a shot.
|
|
2329
|
+
index={doc.scenes.indexOf(group.id)}
|
|
2330
|
+
isLast={
|
|
2331
|
+
doc.scenes[doc.scenes.length - 1] === group.id
|
|
2332
|
+
}
|
|
2333
|
+
blendFrames={
|
|
2334
|
+
doc.scenesById[group.id]?.transition
|
|
2335
|
+
?.durationFrames ?? null
|
|
2336
|
+
}
|
|
2337
|
+
ppf={ppf}
|
|
2338
|
+
labelWidth={labelWidth}
|
|
2339
|
+
originFrames={origin}
|
|
2340
|
+
// The layers fold into the shot's row in every
|
|
2341
|
+
// context: a shot's first look is Shot, Camera, Sound.
|
|
2342
|
+
expandable
|
|
2343
|
+
expanded={rowsShown(group.id)}
|
|
2344
|
+
layerCount={group.rows.length}
|
|
2345
|
+
onToggle={() =>
|
|
2346
|
+
setOpenShots((was) => {
|
|
2347
|
+
const next = new Set(was);
|
|
2348
|
+
if (!next.delete(group.id)) next.add(group.id);
|
|
2349
|
+
return next;
|
|
2350
|
+
})
|
|
2351
|
+
}
|
|
2352
|
+
openMenu={shotMenu.open}
|
|
2353
|
+
beginSnap={beginSnap}
|
|
2354
|
+
/>
|
|
2355
|
+
)}
|
|
2356
|
+
{/* The camera is a ROW like any other, one per scene at its own
|
|
411
2357
|
master position, so a camera move trims and retimes with the
|
|
412
|
-
same gestures a
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
2358
|
+
same gestures a lifespan does. A DEFINITION has no camera.
|
|
2359
|
+
BEHIND THE SHOT'S OWN DISCLOSURE, like every other row in the
|
|
2360
|
+
group: a collapsed shot is exactly one row, or "collapsed"
|
|
2361
|
+
would mean "collapsed except for two". */}
|
|
2362
|
+
{!cameraShown(group.id) ? null : (
|
|
2363
|
+
<BlockRow
|
|
2364
|
+
label="Camera"
|
|
2365
|
+
sceneId={group.id}
|
|
2366
|
+
bars={group.camera}
|
|
2367
|
+
ppf={ppf}
|
|
2368
|
+
labelWidth={labelWidth}
|
|
2369
|
+
offsetFrames={group.start}
|
|
2370
|
+
originFrames={origin}
|
|
2371
|
+
beginSnap={beginSnap}
|
|
2372
|
+
// The camera's POSES, drawn over its bars (P4). Read-only:
|
|
2373
|
+
// a camera pose lives in `shot.from`/`shot.to`, which is the
|
|
2374
|
+
// inspector's subject, or in a path's waypoints, which only
|
|
2375
|
+
// the agent can replace. So there is nothing under a
|
|
2376
|
+
// mark for a drag to write to.
|
|
2377
|
+
marks={group.cameraKeys}
|
|
2378
|
+
// The same action ⇧C runs. The scene is passed so a row
|
|
2379
|
+
// that is not under the playhead is refused by name rather
|
|
2380
|
+
// than writing somewhere else.
|
|
2381
|
+
onAdd={() => store.addShotAtPlayhead(group.id)}
|
|
2382
|
+
/>
|
|
2383
|
+
)}
|
|
2384
|
+
{/* The SHOT's own keyframed properties (P5): its background and
|
|
2385
|
+
its post stack. Directly under the camera, because that is
|
|
2386
|
+
the order the picture is made in — the scene, the camera
|
|
2387
|
+
looking at it, then what happens to the frame after — and it
|
|
2388
|
+
is the same order the inspector's sections sit in.
|
|
2389
|
+
A DEFINITION has neither a background nor a post stack. */}
|
|
2390
|
+
{def !== undefined || isoGroup !== undefined || !rowsShown(group.id) ? null : (
|
|
2391
|
+
<SceneChannelRows
|
|
2392
|
+
scene={doc.scenesById[group.id]!}
|
|
2393
|
+
depth={0}
|
|
2394
|
+
ppf={ppf}
|
|
2395
|
+
labelWidth={labelWidth}
|
|
2396
|
+
offsetFrames={group.start}
|
|
2397
|
+
originFrames={origin}
|
|
2398
|
+
fps={doc.fps}
|
|
2399
|
+
beginSnap={beginSnap}
|
|
2400
|
+
/>
|
|
2401
|
+
)}
|
|
2402
|
+
<TimelineWindow
|
|
2403
|
+
items={rowsShown(group.id) ? group.rows : []}
|
|
2404
|
+
heights={layerHeights.get(group.id) ?? []}
|
|
2405
|
+
threshold={TIMELINE_LAYER_WINDOW_THRESHOLD}
|
|
2406
|
+
keyOf={(row) => row.layerId}
|
|
2407
|
+
testId={`timeline-layer-window-${group.id}`}
|
|
2408
|
+
>
|
|
2409
|
+
{(row) => {
|
|
2410
|
+
// The container this row's layer actually lives in: the
|
|
2411
|
+
// group id is a scene OR the open definition, and homeOf
|
|
2412
|
+
// reads both through one shape.
|
|
2413
|
+
const rowLayer = (def ?? doc.scenesById[group.id])
|
|
2414
|
+
?.layersById[row.layerId];
|
|
2415
|
+
// The mask marks read the run, so they need the
|
|
2416
|
+
// container's top list as well as its layers.
|
|
2417
|
+
const maskHome = homeOf(doc, group.id);
|
|
2418
|
+
// THE SUMMARY DOES NOT EXPAND. In the scene context a row is
|
|
2419
|
+
// a note that this element exists in this shot; its channels
|
|
2420
|
+
// are what the shot context is for, and drawing
|
|
2421
|
+
// them here would put a property lane under a ruler that
|
|
2422
|
+
// spans the whole video.
|
|
2423
|
+
const expandable =
|
|
2424
|
+
shotGroup !== undefined || def !== undefined || isoGroup !== undefined;
|
|
2425
|
+
const expanded = expandable && open.has(row.layerId);
|
|
2426
|
+
return (
|
|
2427
|
+
<Fragment key={row.layerId}>
|
|
2428
|
+
<LifespanRow
|
|
2429
|
+
row={row}
|
|
2430
|
+
layer={rowLayer}
|
|
2431
|
+
mask={maskHome === null ? undefined : maskMarksOf(maskHome, row.layerId, row.depth)}
|
|
2432
|
+
sceneId={group.id}
|
|
2433
|
+
sceneStart={group.start}
|
|
2434
|
+
originFrames={origin}
|
|
2435
|
+
ppf={ppf}
|
|
2436
|
+
// A clip row cuts its strip and its peaks on
|
|
2437
|
+
// the container's own rate. Every other row
|
|
2438
|
+
// ignores it.
|
|
2439
|
+
fps={doc.fps}
|
|
2440
|
+
labelWidth={labelWidth}
|
|
2441
|
+
laneW={timeW}
|
|
2442
|
+
expanded={expanded}
|
|
2443
|
+
revealed={flashed === row.layerId}
|
|
2444
|
+
onToggle={() =>
|
|
2445
|
+
expandable
|
|
2446
|
+
? setOpen((was) => {
|
|
2447
|
+
const next = new Set(was);
|
|
2448
|
+
if (!next.delete(row.layerId))
|
|
2449
|
+
next.add(row.layerId);
|
|
2450
|
+
return next;
|
|
2451
|
+
})
|
|
2452
|
+
: // The disclosure IS the way in from the summary:
|
|
2453
|
+
// "show me this element's animation" and "open the
|
|
2454
|
+
// shot it is in" are the same request.
|
|
2455
|
+
store.enterScene(group.id)
|
|
2456
|
+
}
|
|
2457
|
+
beginSnap={beginSnap}
|
|
2458
|
+
/>
|
|
2459
|
+
{/* The Figma/Lottie read: one row per keyframed
|
|
2460
|
+
property, diamonds at each key. See ChannelRow.tsx. */}
|
|
2461
|
+
{expanded && rowLayer !== undefined && (
|
|
2462
|
+
<ChannelRows
|
|
2463
|
+
layer={rowLayer}
|
|
2464
|
+
sceneId={group.id}
|
|
2465
|
+
depth={row.depth + 1}
|
|
2466
|
+
ppf={ppf}
|
|
2467
|
+
labelWidth={labelWidth}
|
|
2468
|
+
offsetFrames={group.start}
|
|
2469
|
+
originFrames={origin}
|
|
2470
|
+
fps={doc.fps}
|
|
2471
|
+
beginSnap={beginSnap}
|
|
2472
|
+
/>
|
|
2473
|
+
)}
|
|
2474
|
+
</Fragment>
|
|
2475
|
+
);
|
|
2476
|
+
}}
|
|
2477
|
+
</TimelineWindow>
|
|
2478
|
+
{/* THE SHOT'S OWN AUDIO, last in the group: video above, audio
|
|
2479
|
+
below, the same order the master bed keeps at the bottom of
|
|
2480
|
+
the dock. Over the Project it draws only when the shot has
|
|
2481
|
+
some (see the note in SceneAudioRow); inside a shot it is
|
|
2482
|
+
also the empty drop box for the shot's own audio, and the
|
|
2483
|
+
only audio row unless a bed overlaps the shot (the master
|
|
2484
|
+
lane steps aside then, see AudioLane).
|
|
2485
|
+
A definition has no master position and no audio at all,
|
|
2486
|
+
which is why it is gated on `def` like the two master lanes
|
|
2487
|
+
are. `groupHeights` counts the row on the same predicate. */}
|
|
2488
|
+
{def === undefined && isoGroup === undefined && (shotGroup !== undefined || rowsShown(group.id)) ? (
|
|
2489
|
+
<SceneAudioRow
|
|
2490
|
+
sceneId={group.id}
|
|
2491
|
+
ppf={ppf}
|
|
2492
|
+
labelWidth={labelWidth}
|
|
2493
|
+
offsetFrames={group.start}
|
|
2494
|
+
originFrames={origin}
|
|
2495
|
+
beginSnap={beginSnap}
|
|
2496
|
+
empty={shotGroup !== undefined}
|
|
2497
|
+
/>
|
|
2498
|
+
) : null}
|
|
2499
|
+
</div>
|
|
2500
|
+
)}
|
|
2501
|
+
</TimelineWindow>
|
|
474
2502
|
|
|
475
|
-
|
|
2503
|
+
{/* UNDER the video and OVER the audio, which is where every NLE
|
|
476
2504
|
puts the two. Both are outside the scene groups, and
|
|
477
2505
|
deliberately: `doc.audio` and `doc.markers` are MASTER-timed and
|
|
478
2506
|
belong to no scene, so a lane nested in one would be drawing a
|
|
479
|
-
lie.
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
2507
|
+
lie. THE SHOT CONTEXT KEEPS THEM — re-zeroed and clipped to its
|
|
2508
|
+
window, never pruned the way the component context prunes them.
|
|
2509
|
+
A definition has no master position at all; a shot is a window
|
|
2510
|
+
OF master time, and cutting to the beat inside one is the
|
|
2511
|
+
deliverable this tool exists for. The marks sit directly over the waveform because they are
|
|
2512
|
+
almost always beats in it. The marker lane is drawn once it has
|
|
2513
|
+
had content; the audio lane always is over the Project (see
|
|
2514
|
+
`laneShown`), and inside a shot whenever a bed overlaps it.
|
|
2515
|
+
ISOLATION HIDES BOTH: it is about one object, and the markers
|
|
2516
|
+
stay on the ruler as ticks (`markerTicks`). */}
|
|
2517
|
+
{laneShown.current.marker && def === undefined && isoGroup === undefined ? (
|
|
2518
|
+
<MarkerLane
|
|
2519
|
+
ppf={ppf}
|
|
2520
|
+
labelWidth={labelWidth}
|
|
2521
|
+
beginSnap={beginSnap}
|
|
2522
|
+
shotWindow={laneWindow}
|
|
2523
|
+
/>
|
|
2524
|
+
) : null}
|
|
2525
|
+
{def === undefined && isoGroup === undefined ? (
|
|
2526
|
+
<AudioLane
|
|
2527
|
+
ppf={ppf}
|
|
2528
|
+
labelWidth={labelWidth}
|
|
2529
|
+
beginSnap={beginSnap}
|
|
2530
|
+
shotWindow={laneWindow}
|
|
2531
|
+
/>
|
|
2532
|
+
) : null}
|
|
2533
|
+
</div>
|
|
2534
|
+
)}
|
|
2535
|
+
</div>
|
|
2536
|
+
)}
|
|
484
2537
|
</div>
|
|
485
|
-
|
|
2538
|
+
{/* OUTSIDE `.dock-scroll`, so every clientWidth reader above (fit,
|
|
2539
|
+
follow, zoomTo, the filmstrip viewport) still measures the lane and
|
|
2540
|
+
nothing else. Not mounted while collapsed: the row has no room to
|
|
2541
|
+
draw it, and an unmounted meter releases its tap. Sound mode only:
|
|
2542
|
+
the mixer is the one home for levels, so Rows and Graph give the
|
|
2543
|
+
lane the right column too (`dockSide` on `.dock-body`). */}
|
|
2544
|
+
{dockSide ? (
|
|
2545
|
+
generateOpen ? (
|
|
2546
|
+
<GenerateDrawer onClose={() => setGenerateOpen(false)} />
|
|
2547
|
+
) : (
|
|
2548
|
+
<SoundMixer />
|
|
2549
|
+
)
|
|
2550
|
+
) : null}
|
|
2551
|
+
</PanelBoundary>
|
|
486
2552
|
</section>
|
|
487
2553
|
);
|
|
488
2554
|
}
|