@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.
Files changed (432) hide show
  1. package/dist/.vite/manifest.json +382 -0
  2. package/dist/assets/AgentPanel-CQejLbiX.js +2 -0
  3. package/dist/assets/AgentPanel-DJEphah0.css +1 -0
  4. package/dist/assets/AgentQuestionDialog-BqkD0kXF.js +1 -0
  5. package/dist/assets/CodeStudio-Dv4ihHpz.js +23 -0
  6. package/dist/assets/CommentsPanel-B4yhhT1T.js +1 -0
  7. package/dist/assets/ComponentLibraryRail-Ch_dsAru.js +1 -0
  8. package/dist/assets/ComponentStage-BRhRotyH.js +1 -0
  9. package/dist/assets/CurvePane-BjFVabKX.css +1 -0
  10. package/dist/assets/CurvePane-R22WzllJ.js +1 -0
  11. package/dist/assets/ExportDialog-CWLQWTmG.js +2 -0
  12. package/dist/assets/FontPicker-DWKaYL7F.js +1 -0
  13. package/dist/assets/FontPicker-v0IOIArM.css +1 -0
  14. package/dist/assets/GenerateDrawer-Bh3SqDKt.js +2 -0
  15. package/dist/assets/InsertDrawer-Ce1fod1L.js +1 -0
  16. package/dist/assets/LibraryBrowser-D9sEGz5w.js +2 -0
  17. package/dist/assets/MakeItYoursSection-CMO4319_.js +2 -0
  18. package/dist/assets/MaterialGraphCanvas-BFzl4wWR.js +23 -0
  19. package/dist/assets/MaterialGraphCanvas-BIUD59ik.css +1 -0
  20. package/dist/assets/MaterialWorkspace-CqTWqaRA.js +14 -0
  21. package/dist/assets/PreferencesDialog-CBf9_OoD.js +1 -0
  22. package/dist/assets/PresetsSection-CUEaoCVU.js +1 -0
  23. package/dist/assets/SoundHistory-BeS8EW1h.js +1 -0
  24. package/dist/assets/SoundMode-BmcZru3i.js +3 -0
  25. package/dist/assets/StockSounds-DdAUGRYK.js +1 -0
  26. package/dist/assets/TemplateFieldSection-CaeXpLFo.js +1 -0
  27. package/dist/assets/VersionHistoryPanel-CxG6XhNP.js +1 -0
  28. package/dist/assets/board-B88EPuLO.css +1 -0
  29. package/dist/assets/board-CL-he7SL.js +3 -0
  30. package/dist/assets/components-0oOSPN0l.js +1 -0
  31. package/dist/assets/googleFontRanges-gp32wJMo.js +1 -0
  32. package/dist/assets/googleFonts-BNon6M7X.js +1 -0
  33. package/dist/assets/iconsAndAliases-DL7gXP5j.js +1863 -0
  34. package/dist/assets/index-4F5NQmI8.css +1 -0
  35. package/dist/assets/index-BGHQGV5z.js +117 -0
  36. package/dist/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  37. package/dist/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  38. package/dist/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  39. package/dist/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  40. package/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  41. package/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  42. package/dist/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  43. package/dist/assets/libraryModel-CpIwfFha.js +1 -0
  44. package/dist/assets/lucide-6iK64tTB.js +9319 -0
  45. package/dist/assets/motionStamp-CH-Hew4E.js +1 -0
  46. package/dist/assets/takeMeasure-Tdg0TxEi.js +1 -0
  47. package/dist/assets/webmcp-BsB9gxWC.js +2 -0
  48. package/dist/index.html +34 -3
  49. package/package.json +12 -9
  50. package/src/addFamilies.ts +66 -0
  51. package/src/api.ts +1631 -55
  52. package/src/app/App.tsx +977 -198
  53. package/src/app/lazyPanel.tsx +137 -0
  54. package/src/app/lazySurfaces.tsx +203 -0
  55. package/src/audio/index.tsx +41 -10
  56. package/src/audio/meter.ts +485 -0
  57. package/src/audio/meterMath.ts +273 -0
  58. package/src/audio/meterStore.ts +155 -0
  59. package/src/audio/offlineMix.ts +289 -0
  60. package/src/audio/preview.ts +928 -118
  61. package/src/bridge.ts +15 -5
  62. package/src/bridgeTypes.ts +1611 -36
  63. package/src/channels.ts +17 -32
  64. package/src/fonts/googleFonts.ts +4 -0
  65. package/src/hostApi.ts +174 -0
  66. package/src/icons.tsx +770 -0
  67. package/src/layerWords.ts +61 -0
  68. package/src/main.tsx +56 -4
  69. package/src/panels/ResizeHandle.tsx +33 -43
  70. package/src/panels/agent/AgentDots.tsx +36 -0
  71. package/src/panels/agent/AgentPanel.tsx +119 -43
  72. package/src/panels/agent/AgentQuestionDialog.tsx +202 -0
  73. package/src/panels/agent/agent.css +113 -97
  74. package/src/panels/agent/agentGate.ts +35 -0
  75. package/src/panels/code/CodeDefaultsPanel.tsx +345 -0
  76. package/src/panels/code/CodeStudio.tsx +373 -0
  77. package/src/panels/code/FileTree.tsx +265 -0
  78. package/src/panels/code/codeBufferGuard.ts +52 -0
  79. package/src/panels/code/codeParts.tsx +321 -0
  80. package/src/panels/comments/CommentComposer.tsx +239 -0
  81. package/src/panels/comments/CommentThread.tsx +346 -0
  82. package/src/panels/comments/CommentsPanel.tsx +336 -0
  83. package/src/panels/comments/InboxButton.tsx +131 -0
  84. package/src/panels/comments/MentionMenu.tsx +98 -0
  85. package/src/panels/comments/parts.tsx +171 -0
  86. package/src/panels/export/ExportDialog.tsx +919 -96
  87. package/src/panels/export/ExportScrim.tsx +57 -0
  88. package/src/panels/files/AssetsTab.tsx +656 -0
  89. package/src/panels/files/FilePanel.tsx +1466 -216
  90. package/src/panels/files/LayerMarks.tsx +40 -0
  91. package/src/panels/files/LeftColumn.tsx +37 -0
  92. package/src/panels/files/McpSection.tsx +690 -47
  93. package/src/panels/files/MissingAssetsNotice.tsx +60 -0
  94. package/src/panels/files/assetDrag.ts +79 -0
  95. package/src/panels/files/resourceParts.tsx +255 -0
  96. package/src/panels/fonts/FontPicker.tsx +762 -0
  97. package/src/panels/fonts/faceCache.ts +207 -0
  98. package/src/panels/fonts/fontPicker.css +271 -0
  99. package/src/panels/fonts/pickerModel.ts +265 -0
  100. package/src/panels/fonts/pickerRequest.ts +46 -0
  101. package/src/panels/header/AccountAvatar.tsx +76 -0
  102. package/src/panels/header/AgentChip.tsx +77 -0
  103. package/src/panels/header/AppMenu.tsx +213 -0
  104. package/src/panels/header/Header.tsx +564 -84
  105. package/src/panels/header/HistoryControls.tsx +31 -5
  106. package/src/panels/header/PresenceStack.tsx +329 -0
  107. package/src/panels/header/ProjectTitle.tsx +232 -0
  108. package/src/panels/header/ShareButton.tsx +59 -0
  109. package/src/panels/header/StatusBadges.tsx +358 -27
  110. package/src/panels/header/SyncDeletedDialog.tsx +88 -0
  111. package/src/panels/header/ZoomControls.tsx +46 -15
  112. package/src/panels/header/agentHandoff.ts +52 -0
  113. package/src/panels/header/leave.ts +26 -0
  114. package/src/panels/help/HelpSection.tsx +189 -163
  115. package/src/panels/insert/InsertDrawer.tsx +723 -0
  116. package/src/panels/insert/iconSet.ts +109 -0
  117. package/src/panels/inspector/ExposeDot.tsx +117 -0
  118. package/src/panels/inspector/FramingRows.tsx +314 -44
  119. package/src/panels/inspector/InspectorFooter.tsx +91 -0
  120. package/src/panels/inspector/InspectorHead.tsx +425 -0
  121. package/src/panels/inspector/InspectorPanel.tsx +408 -124
  122. package/src/panels/inspector/KeyframeDot.tsx +373 -0
  123. package/src/panels/inspector/KeyframeSection.tsx +257 -0
  124. package/src/panels/inspector/MarkerSection.tsx +55 -11
  125. package/src/panels/inspector/ParamRow.tsx +84 -53
  126. package/src/panels/inspector/RightColumn.tsx +152 -0
  127. package/src/panels/inspector/TimeSection.tsx +866 -0
  128. package/src/panels/inspector/animate/EaseControl.tsx +244 -0
  129. package/src/panels/inspector/animate/PresetsSection.tsx +135 -0
  130. package/src/panels/inspector/animate/ShotSection.tsx +562 -48
  131. package/src/panels/inspector/design/AlignRow.tsx +95 -31
  132. package/src/panels/inspector/design/AppearanceSection.tsx +118 -9
  133. package/src/panels/inspector/design/ClonerSection.tsx +323 -62
  134. package/src/panels/inspector/design/ComponentSection.tsx +50 -43
  135. package/src/panels/inspector/design/CornerSection.tsx +45 -14
  136. package/src/panels/inspector/design/DefinitionProps.tsx +315 -0
  137. package/src/panels/inspector/design/DefinitionSection.tsx +136 -0
  138. package/src/panels/inspector/design/DeviceSection.tsx +210 -0
  139. package/src/panels/inspector/design/FillSection.tsx +83 -51
  140. package/src/panels/inspector/design/FontField.tsx +196 -0
  141. package/src/panels/inspector/design/HtmlSection.tsx +23 -12
  142. package/src/panels/inspector/design/InstanceSection.tsx +373 -154
  143. package/src/panels/inspector/design/MaskSection.tsx +84 -0
  144. package/src/panels/inspector/design/MaterialSection.tsx +177 -0
  145. package/src/panels/inspector/design/MediaSection.tsx +273 -120
  146. package/src/panels/inspector/design/MeshSection.tsx +910 -82
  147. package/src/panels/inspector/design/PaintRows.tsx +550 -0
  148. package/src/panels/inspector/design/PlateStyleSection.tsx +177 -0
  149. package/src/panels/inspector/design/ShadowSection.tsx +221 -48
  150. package/src/panels/inspector/design/SplineFollowSection.tsx +235 -0
  151. package/src/panels/inspector/design/StrokeSection.tsx +228 -43
  152. package/src/panels/inspector/design/TemplateFieldSection.tsx +57 -0
  153. package/src/panels/inspector/design/TransformSection.tsx +244 -50
  154. package/src/panels/inspector/design/TypographySection.tsx +679 -110
  155. package/src/panels/inspector/layout.ts +295 -0
  156. package/src/panels/inspector/propValue.ts +199 -0
  157. package/src/panels/inspector/scene/CameraSection.tsx +232 -22
  158. package/src/panels/inspector/scene/EffectsSection.tsx +722 -71
  159. package/src/panels/inspector/scene/MakeItYoursSection.tsx +180 -0
  160. package/src/panels/inspector/scene/SceneDocSection.tsx +191 -0
  161. package/src/panels/inspector/scene/SceneSection.tsx +219 -44
  162. package/src/panels/inspector/scene/TransitionSection.tsx +523 -72
  163. package/src/panels/inspector/storyboard/StoryboardReferenceSection.tsx +593 -0
  164. package/src/panels/inspector/storyboard/VideoStill.tsx +120 -0
  165. package/src/panels/inspector/timeModel.ts +190 -0
  166. package/src/panels/inspector/useDocWrite.ts +438 -146
  167. package/src/panels/library/LibraryBrowser.tsx +435 -0
  168. package/src/panels/library/LibraryGrid.tsx +190 -0
  169. package/src/panels/library/LibraryPreviewPanel.tsx +349 -0
  170. package/src/panels/library/LibraryRail.tsx +84 -0
  171. package/src/panels/library/LibrarySoundPanel.tsx +293 -0
  172. package/src/panels/library/SoundHistory.tsx +245 -0
  173. package/src/panels/library/StockSounds.tsx +390 -0
  174. package/src/panels/library/libraryModel.ts +147 -0
  175. package/src/panels/library/stockSearch.ts +77 -0
  176. package/src/panels/maskMarks.tsx +117 -0
  177. package/src/panels/material/MaterialCommentNode.tsx +54 -0
  178. package/src/panels/material/MaterialGraphCanvas.tsx +861 -0
  179. package/src/panels/material/MaterialGraphMenu.tsx +45 -0
  180. package/src/panels/material/MaterialGroupNode.tsx +56 -0
  181. package/src/panels/material/MaterialLibrary.tsx +253 -0
  182. package/src/panels/material/MaterialNode.tsx +453 -0
  183. package/src/panels/material/MaterialNodePalette.tsx +349 -0
  184. package/src/panels/material/MaterialParameterControls.tsx +525 -0
  185. package/src/panels/material/MaterialRerouteNode.tsx +42 -0
  186. package/src/panels/material/MaterialWorkspace.tsx +501 -0
  187. package/src/panels/material/NodePreview.tsx +70 -0
  188. package/src/panels/material/flowModel.ts +441 -0
  189. package/src/panels/material/parameterValues.ts +231 -0
  190. package/src/panels/material/previewScheduler.ts +235 -0
  191. package/src/panels/material/reactFlow.css +6 -0
  192. package/src/panels/material/socketGlyph.ts +85 -0
  193. package/src/panels/material/viewportMemory.ts +58 -0
  194. package/src/panels/preferences/PreferencesDialog.tsx +215 -0
  195. package/src/panels/share/ShareComponentDialog.tsx +247 -0
  196. package/src/panels/stage/StageMenu.tsx +35 -5
  197. package/src/panels/stage/StagePanel.tsx +338 -23
  198. package/src/panels/stage/cellSlots.tsx +70 -0
  199. package/src/panels/storyboard/BoardCanvas.tsx +739 -0
  200. package/src/panels/storyboard/BoardCursors.tsx +158 -0
  201. package/src/panels/storyboard/BoardDropDialog.tsx +161 -0
  202. package/src/panels/storyboard/BoardPlan.tsx +42 -0
  203. package/src/panels/storyboard/ComponentSheet.tsx +187 -0
  204. package/src/panels/storyboard/ComponentStage.tsx +235 -0
  205. package/src/panels/storyboard/MiniSketch.tsx +173 -0
  206. package/src/panels/storyboard/PartPicker.tsx +64 -0
  207. package/src/panels/storyboard/PlanPanel.tsx +617 -0
  208. package/src/panels/storyboard/ReferencePreview.tsx +217 -0
  209. package/src/panels/storyboard/ShotBrief.tsx +360 -0
  210. package/src/panels/storyboard/ShotCard.tsx +251 -0
  211. package/src/panels/storyboard/StagePreview.tsx +348 -0
  212. package/src/panels/storyboard/StillThumbs.tsx +162 -0
  213. package/src/panels/storyboard/StoryboardView.tsx +79 -0
  214. package/src/panels/storyboard/board.css +1065 -0
  215. package/src/panels/storyboard/board.ts +12 -0
  216. package/src/panels/storyboard/boardClipboard.ts +111 -0
  217. package/src/panels/storyboard/boardOrder.ts +68 -0
  218. package/src/panels/storyboard/boardRows.tsx +425 -0
  219. package/src/panels/storyboard/liveCap.ts +90 -0
  220. package/src/panels/storyboard/plan.css +295 -0
  221. package/src/panels/storyboard/posterCache.ts +133 -0
  222. package/src/panels/storyboard/shotboard/ComponentPicker.tsx +373 -0
  223. package/src/panels/storyboard/shotboard/ImagePicker.tsx +145 -0
  224. package/src/panels/storyboard/shotboard/ShotBoard.tsx +292 -0
  225. package/src/panels/storyboard/shotboard/SketchCanvas.tsx +443 -0
  226. package/src/panels/storyboard/shotboard/SketchInspector.tsx +471 -0
  227. package/src/panels/storyboard/shotboard/SketchItem.tsx +240 -0
  228. package/src/panels/storyboard/shotboard/SketchLayers.tsx +130 -0
  229. package/src/panels/storyboard/shotboard/SketchPlay.tsx +108 -0
  230. package/src/panels/storyboard/shotboard/SketchStrip.tsx +178 -0
  231. package/src/panels/storyboard/shotboard/SketchToolFooter.tsx +99 -0
  232. package/src/panels/storyboard/shotboard/sketch.css +1392 -0
  233. package/src/panels/storyboard/shotboard/sketchModel.ts +219 -0
  234. package/src/panels/storyboard/storyboardDrop.ts +164 -0
  235. package/src/panels/studio/ComponentLibraryRail.tsx +346 -0
  236. package/src/panels/studio/ComponentSidePanel.tsx +355 -0
  237. package/src/panels/studio/ComponentStudio.tsx +243 -0
  238. package/src/panels/studio/ComponentsHome.tsx +318 -0
  239. package/src/panels/studio/ComponentsPreview.tsx +179 -0
  240. package/src/panels/studio/EveryUse.tsx +244 -0
  241. package/src/panels/studio/ThisUsePanel.tsx +679 -0
  242. package/src/panels/studio/componentParts.tsx +341 -0
  243. package/src/panels/studio/components.ts +6 -0
  244. package/src/panels/studio/newCodeComponent.ts +66 -0
  245. package/src/panels/timeline/AudioFxButton.tsx +290 -0
  246. package/src/panels/timeline/AudioLane.tsx +674 -85
  247. package/src/panels/timeline/BlockRow.tsx +154 -85
  248. package/src/panels/timeline/ChannelRow.tsx +378 -129
  249. package/src/panels/timeline/CurvePane.tsx +508 -0
  250. package/src/panels/timeline/Filmstrip.tsx +361 -0
  251. package/src/panels/timeline/GenerateDrawer.tsx +407 -0
  252. package/src/panels/timeline/KeyStretch.tsx +115 -0
  253. package/src/panels/timeline/LifespanRow.tsx +730 -47
  254. package/src/panels/timeline/MarkerLane.tsx +46 -8
  255. package/src/panels/timeline/PlayButton.tsx +91 -0
  256. package/src/panels/timeline/PropertyPicker.tsx +254 -0
  257. package/src/panels/timeline/ShotRow.tsx +400 -0
  258. package/src/panels/timeline/SoundMode.tsx +1406 -0
  259. package/src/panels/timeline/TimelineDock.tsx +2338 -272
  260. package/src/panels/timeline/TransportVolume.tsx +163 -0
  261. package/src/panels/timeline/beatRuler.ts +74 -0
  262. package/src/panels/timeline/curveModel.ts +500 -0
  263. package/src/panels/timeline/duckUnderVoice.ts +68 -0
  264. package/src/panels/timeline/keyframeMenu.ts +138 -0
  265. package/src/panels/timeline/labelWidth.ts +49 -0
  266. package/src/panels/timeline/marquee.ts +155 -0
  267. package/src/panels/timeline/model.ts +1860 -199
  268. package/src/panels/timeline/snap.ts +125 -43
  269. package/src/panels/timeline/staggerPreview.tsx +92 -0
  270. package/src/panels/timeline/takeMeasure.ts +72 -0
  271. package/src/panels/timeline/useLive.ts +48 -0
  272. package/src/panels/timeline/viewport.ts +234 -0
  273. package/src/panels/toolbar/BackChip.tsx +116 -0
  274. package/src/panels/toolbar/CameraViewBar.tsx +118 -49
  275. package/src/panels/toolbar/CanvasBar.tsx +117 -0
  276. package/src/panels/toolbar/InsertButton.tsx +38 -0
  277. package/src/panels/toolbar/IsolationTrail.tsx +156 -0
  278. package/src/panels/toolbar/PanelToggles.tsx +86 -0
  279. package/src/panels/toolbar/ShadingBar.tsx +119 -26
  280. package/src/panels/toolbar/ToolDockBar.tsx +329 -80
  281. package/src/panels/toolbar/ToolFooter.tsx +102 -0
  282. package/src/panels/toolbar/ViewModeBar.tsx +247 -38
  283. package/src/panels/toolbar/WorkPlaneBar.tsx +94 -0
  284. package/src/panels/toolbar/useAddRows.tsx +326 -0
  285. package/src/panels/toolbar/useDismiss.ts +122 -0
  286. package/src/panels/versions/VersionHistoryPanel.tsx +274 -0
  287. package/src/panels/versions/versionModel.ts +103 -0
  288. package/src/panels/virtualWindow.ts +271 -0
  289. package/src/shotTokens.css +151 -60
  290. package/src/stage/FrustumGizmo.tsx +504 -84
  291. package/src/stage/GridPlane.tsx +225 -16
  292. package/src/stage/NodePreviews.tsx +628 -0
  293. package/src/stage/PerfHud.tsx +437 -47
  294. package/src/stage/SplitRenderer.tsx +816 -169
  295. package/src/stage/StageApp.tsx +4274 -486
  296. package/src/stage/StageRootBoundary.tsx +114 -0
  297. package/src/stage/StageTools.tsx +651 -151
  298. package/src/stage/TransformGizmo.tsx +696 -0
  299. package/src/stage/adaptivePresentation.ts +145 -0
  300. package/src/stage/bridgeClient.ts +2522 -41
  301. package/src/stage/exportWorker.ts +9 -0
  302. package/src/stage/feed.ts +305 -14
  303. package/src/stage/fontPreview.ts +176 -0
  304. package/src/stage/frustumGeometry.ts +319 -1
  305. package/src/stage/furnitureTokens.ts +33 -0
  306. package/src/stage/gizmoMath.ts +508 -16
  307. package/src/stage/gpuTimer.ts +276 -0
  308. package/src/stage/instanceModules.ts +194 -33
  309. package/src/stage/interactions/move.ts +24 -5
  310. package/src/stage/isolation.ts +291 -33
  311. package/src/stage/jsSelfProfiler.ts +325 -0
  312. package/src/stage/main.tsx +116 -16
  313. package/src/stage/modalState.ts +222 -23
  314. package/src/stage/moduleEpoch.ts +60 -0
  315. package/src/stage/motionPathStage.ts +279 -0
  316. package/src/stage/motionPreview.ts +74 -0
  317. package/src/stage/overlays/Overlays.tsx +1112 -159
  318. package/src/stage/overlays/Rulers.tsx +495 -0
  319. package/src/stage/panes.ts +159 -10
  320. package/src/stage/project.ts +68 -3
  321. package/src/stage/renderScaleController.ts +730 -0
  322. package/src/stage/rulers.ts +129 -0
  323. package/src/stage/sceneExtent.ts +75 -0
  324. package/src/stage/shading.ts +20 -0
  325. package/src/stage/splineStage.ts +632 -0
  326. package/src/stage/stagePerformanceTelemetry.ts +717 -0
  327. package/src/stage/stageTheme.ts +36 -0
  328. package/src/stage/stageWarmup.ts +11 -0
  329. package/src/stage/textAutoSize.ts +124 -0
  330. package/src/stage/tier1Export.ts +641 -0
  331. package/src/stage/tokenColor.ts +14 -9
  332. package/src/stage/view.ts +56 -9
  333. package/src/stage/workingView.ts +277 -33
  334. package/src/state/accountPrefs.ts +261 -0
  335. package/src/state/agentActions.ts +173 -15
  336. package/src/state/agentActivity.ts +198 -0
  337. package/src/state/agentQuestion.ts +239 -0
  338. package/src/state/bootOpen.ts +42 -0
  339. package/src/state/cameraVerbs.ts +139 -0
  340. package/src/state/clientId.ts +6 -1
  341. package/src/state/comments.ts +1099 -0
  342. package/src/state/context.tsx +19 -1
  343. package/src/state/editorPrefs.ts +220 -0
  344. package/src/state/errorReports.ts +121 -0
  345. package/src/state/fieldWrite.ts +161 -0
  346. package/src/state/fontRecents.ts +70 -0
  347. package/src/state/fonts.ts +656 -0
  348. package/src/state/graphClipboard.ts +414 -0
  349. package/src/state/imageThumbs.ts +216 -0
  350. package/src/state/insert.ts +444 -67
  351. package/src/state/isolationTrail.ts +92 -0
  352. package/src/state/keyMoves.ts +134 -0
  353. package/src/state/keyframeClipboard.ts +177 -0
  354. package/src/state/keymap.ts +1153 -117
  355. package/src/state/layoutPrefs.ts +272 -0
  356. package/src/state/layouts.ts +205 -0
  357. package/src/state/measureMedia.ts +167 -0
  358. package/src/state/menu.ts +2341 -28
  359. package/src/state/motionStamp.ts +7 -0
  360. package/src/state/panelSize.ts +375 -5
  361. package/src/state/pendingChanges.ts +119 -0
  362. package/src/state/playHold.ts +78 -0
  363. package/src/state/playbackPreparation.ts +562 -0
  364. package/src/state/posterCapture.ts +242 -0
  365. package/src/state/presence.ts +254 -0
  366. package/src/state/projectComponents.ts +160 -0
  367. package/src/state/renderEvents.ts +298 -18
  368. package/src/state/runtimeErrors.ts +46 -0
  369. package/src/state/selection.ts +12 -0
  370. package/src/state/shortcuts.ts +1606 -0
  371. package/src/state/sketchGuide.ts +51 -0
  372. package/src/state/splineRefs.ts +87 -0
  373. package/src/state/stageHealth.ts +259 -0
  374. package/src/state/stageWatchdog.ts +47 -0
  375. package/src/state/stillStage.ts +330 -0
  376. package/src/state/stillThumbs.ts +474 -0
  377. package/src/state/store.ts +23591 -2982
  378. package/src/state/storyboardModel.ts +528 -0
  379. package/src/state/sync.ts +1257 -0
  380. package/src/state/syncCache.ts +159 -0
  381. package/src/state/triangles.ts +13 -0
  382. package/src/state/types.ts +1698 -55
  383. package/src/state/unsavedGuard.ts +49 -0
  384. package/src/state/viewMode.ts +200 -0
  385. package/src/state/webmcp.ts +1128 -0
  386. package/src/state/workspace.ts +1147 -0
  387. package/src/styles/assets-tab.css +304 -0
  388. package/src/styles/canvas.css +545 -0
  389. package/src/styles/comments.css +1435 -0
  390. package/src/styles/controls.css +162 -0
  391. package/src/styles/dialogs.css +390 -0
  392. package/src/styles/insert-drawer.css +586 -0
  393. package/src/styles/inspector-board.css +207 -0
  394. package/src/styles/inspector-object.css +486 -0
  395. package/src/styles/inspector-shot.css +252 -0
  396. package/src/styles/inspector.css +360 -0
  397. package/src/styles/left-column.css +1628 -0
  398. package/src/styles/library-preview.css +575 -0
  399. package/src/styles/library.css +512 -0
  400. package/src/styles/modes/code.css +569 -0
  401. package/src/styles/modes/component.css +1561 -0
  402. package/src/styles/modes/material.css +1201 -0
  403. package/src/styles/modes/storyboard.css +518 -0
  404. package/src/styles/share-component.css +49 -0
  405. package/src/styles/shell.css +653 -0
  406. package/src/styles/shot-brief.css +360 -0
  407. package/src/styles/states.css +76 -0
  408. package/src/styles/sync.css +380 -0
  409. package/src/styles/timeline-graph.css +384 -0
  410. package/src/styles/timeline-meters.css +89 -0
  411. package/src/styles/timeline-rows.css +1959 -0
  412. package/src/styles/timeline-sound.css +1003 -0
  413. package/src/styles/timeline.css +754 -0
  414. package/src/styles/tool-footer.css +525 -0
  415. package/src/styles/topbar.css +886 -0
  416. package/src/styles/versions.css +262 -0
  417. package/src/styles.css +72 -2921
  418. package/src/tools.ts +108 -23
  419. package/dist/assets/index-B7notge7.css +0 -1
  420. package/dist/assets/index-BBE6Ta9L.js +0 -52
  421. package/src/panels/files/icons.tsx +0 -118
  422. package/src/panels/header/DocSwitcher.tsx +0 -85
  423. package/src/panels/header/TransportBar.tsx +0 -86
  424. package/src/panels/inspector/animate/BlockTiming.tsx +0 -341
  425. package/src/panels/inspector/animate/DuringBlocks.tsx +0 -85
  426. package/src/panels/inspector/animate/InOutPresets.tsx +0 -57
  427. package/src/panels/inspector/animate/ResolvedChannels.tsx +0 -150
  428. package/src/panels/inspector/animate/useBlocks.ts +0 -43
  429. package/src/panels/material/MaterialPicker.tsx +0 -172
  430. package/src/panels/timeline/SceneStrip.tsx +0 -145
  431. package/src/panels/toolbar/AddMenu.tsx +0 -230
  432. package/src/stage/MoveGizmo.tsx +0 -411
@@ -1,23 +1,42 @@
1
- // The timeline dock: ONE CONTINUOUS MASTER STRIP.
1
+ // The timeline dock: A MASTER STRIP, AND ONE SHOT INSIDE IT.
2
2
  //
3
- // ── WHAT DIED HERE, AND WHY ─────────────────────────────────────────────
3
+ // ── WHAT DIED HERE, AND WHAT CAME BACK ──────────────────────────────────
4
4
  //
5
- // This used to be two levels. A scene strip on top, and underneath it the block
6
- // tracks of WHICHEVER SCENE THE PLAYHEAD WAS IN — one bit, `state.sceneId`,
7
- // deciding what half the dock showed. It is a coherent model and it is the wrong
8
- // one for a video: the author's question is almost never "what is in scene 2",
9
- // it is "what happens between the logo landing and the price appearing", and
10
- // those two things are in different scenes. A dock that can only show one scene
11
- // answers that question by making the author flip between two pictures and hold
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
- // So every scene's rows are on screen at once, at their master positions,
15
- // grouped in master order, with the scene boundaries drawn through the tracks.
16
- // `state.sceneId` survives as the INSPECTOR'S SUBJECT and the URL's, and nothing
17
- // about this file's layout reads it any more. The scene strip survives too, slim
18
- // and secondary: it is the only place scenes are created, reordered and trimmed,
19
- // which is exactly the set of verbs that are about scenes rather than about
20
- // time.
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 block lane agree about.
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 { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
34
- import { PlayheadLine, Ruler, frameToPx, useAnchoredZoom } from "@dailies-studio/timeline";
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 { useShotEditor, useStore } from "../../state/context.js";
88
+ import {
89
+ useShotEditor,
90
+ useShotEditorHot,
91
+ useStore,
92
+ } from "../../state/context.js";
37
93
  import { ResizeHandle } from "../ResizeHandle.js";
38
- import { DOCK_HEIGHT } from "../../state/panelSize.js";
39
- import { AudioLane } from "./AudioLane.js";
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 { SceneStrip } from "./SceneStrip.js";
45
- import { CAMERA_ROW_ID, masterContentFrames, masterDuration, masterRows, sceneBars, timecode } from "./model.js";
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
- /** Where the track labels end and the time area begins, in px. */
50
- export const LABEL_WIDTH = 150;
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 block
56
- // bar. When it subscribed to `frame`, all of that reconciled per playback
57
- // frame. The three things that actually show the frame are leaves below
58
- // (DockTime, DockReadout, DockPlayhead), each subscribing to `frame` alone, so
59
- // a playhead tick re-renders three spans and nothing else. If a future reader
60
- // adds `useShotEditor((s) => s.frame)` back here, playback perf regresses to
61
- // where it was — don't.
62
-
63
- /** The current-time readout, isolated so its per-frame tick stays its own. */
64
- function DockTime({ fps }: { fps: Rational }) {
65
- const frame = useShotEditor((s) => s.frame);
66
- return (
67
- <span className="dock-time" data-testid="dock-time">
68
- {timecode(frame, fps)}
69
- </span>
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
- /** The frame-counter readout, same isolation. */
74
- function DockReadout({ total }: { total: number }) {
75
- const frame = useShotEditor((s) => s.frame);
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-readout" data-testid="dock-readout">
78
- {frame} / {Math.max(0, total - 1)}
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
- function DockPlayhead({ ppf }: { ppf: number }) {
86
- const frame = useShotEditor((s) => s.frame);
87
- return <PlayheadLine frame={frame} ppf={ppf} offset={0} className="dock-playhead" />;
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
- const playing = useShotEditor((s) => s.playing);
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 block lanes. LOCAL, not store state: it is about
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>>(() => new Set<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((next: number) => store.setDockPpf(next), [store]);
822
+ const setPxPerFrame = useCallback(
823
+ (next: number) => store.setDockPpf(next),
824
+ [store],
825
+ );
113
826
  useAnchoredZoom({
114
827
  scrollRef,
115
- labelWidth: LABEL_WIDTH,
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: 0.2,
118
- max: 60,
832
+ min: DOCK_PPF.min,
833
+ max: DOCK_PPF.max,
119
834
  read,
120
835
  setPxPerFrame,
121
- enabled: !collapsed && doc !== null,
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
- return () => el.removeEventListener("wheel", onWheel);
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
- }, [collapsed, doc === null]);
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 current = store.getState().doc;
947
+ const s = store.getState();
948
+ const current = s.doc;
178
949
  if (el === null || current === null) return;
179
- const width = el.clientWidth - LABEL_WIDTH;
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 in range at every zoom, so
183
- // it needs none of the deferral below.
184
- if (sceneId === undefined) {
185
- const total = masterDuration(current);
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
- const bar = sceneBars(current).find((b) => b.id === sceneId);
193
- if (bar === undefined) return;
194
- const span = bar.end - bar.start;
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(bar.start, actual);
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, exclude === undefined ? { playhead: s.frame } : { playhead: s.frame, exclude });
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 block showing rows they cannot click.
265
- const groups = useMemo(() => (doc === null ? [] : masterRows(doc, isolation)), [doc, isolation]);
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 document; the CONTENT may run past it, because a
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
- () => (doc === null ? 0 : masterContentFrames(doc, groups)),
270
- [doc, groups],
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
- const total = masterDuration(doc);
275
- const timeW = frameToPx(contentF, ppf);
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
- {/* A child, not a sibling: the dock's height is content-driven up to its
284
- max, so only the dock itself knows where its top edge actually is.
285
- Absolute inside a `position: fixed` box, above the head it overlays. */}
286
- {collapsed ? null : (
287
- <ResizeHandle
288
- axis="y"
289
- getValue={() => store.getState().dockHeight}
290
- // Dragging the TOP edge upward is a DECREASING clientY and a TALLER
291
- // dock, so the pointer's travel is negated.
292
- sign={-1}
293
- onChange={(h) => store.setDockHeight(h)}
294
- onReset={() => store.setDockHeight(DOCK_HEIGHT.default)}
295
- className="dock-resize"
296
- label="Resize the timeline"
297
- />
298
- )}
299
- <header className="dock-head">
300
- <button
301
- type="button"
302
- className="dock-collapse"
303
- aria-expanded={!collapsed}
304
- aria-label={collapsed ? "Expand timeline" : "Collapse timeline"}
305
- onClick={() => store.setDockCollapsed(!collapsed)}
306
- >
307
- {collapsed ? "▴" : "▾"}
308
- </button>
309
- {/* The transport, on the strip it drives. The header has one too and
310
- both call the same action — there is one `playing` bit, so they
311
- cannot disagree, and a timeline you have to look away from to start
312
- is a timeline you scrub with the keyboard instead. */}
313
- <button
314
- type="button"
315
- className="dock-play"
316
- aria-label={playing ? "Pause" : "Play"}
317
- title={playing ? "Pause (Space)" : "Play (Space)"}
318
- onClick={() => store.setPlaying(!playing)}
319
- >
320
- {playing ? "❚❚" : "▶"}
321
- </button>
322
- <DockTime fps={doc.fps} />
323
- <span className="dock-span" data-testid="dock-span">
324
- Start {timecode(0, doc.fps)} / End {timecode(total, doc.fps)}
325
- </span>
326
- {/* Wrapped, not passed by reference: `fit` now takes an optional
327
- sceneId, and a bare handler would hand it the MouseEvent. */}
328
- <button type="button" className="dock-fit" title="Fit the timeline (\)" onClick={() => fit()}>
329
- Fit
330
- </button>
331
- {/* MONITORING, not the mix. Neither control touches the document, so
332
- neither can change what an export sounds like — see
333
- ShotEditorState.previewMuted. */}
334
- <button
335
- type="button"
336
- className="dock-mute"
337
- aria-label={previewMuted ? "Unmute preview" : "Mute preview"}
338
- aria-pressed={previewMuted}
339
- title={previewMuted ? "Preview muted" : "Mute the preview"}
340
- onClick={() => store.setPreviewMuted(!previewMuted)}
341
- >
342
- {previewMuted ? "🔇" : "🔊"}
343
- </button>
344
- <input
345
- className="dock-volume"
346
- type="range"
347
- min={0}
348
- max={1}
349
- step={0.01}
350
- value={previewVolume}
351
- aria-label="Preview volume"
352
- onChange={(e) => store.setPreviewVolume(Number(e.currentTarget.value))}
353
- />
354
- <DockReadout total={total} />
355
- </header>
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
- {/* The body is HIDDEN, not unmounted, when collapsed. Unmounting would
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
- <div className="dock-body" hidden={collapsed} data-testid="dock-body">
362
- <div className="dock-scroll" ref={scrollRef} data-testid="dock-scroll">
363
- {/* INSIDE the scroller, with the same content width as the tracks.
364
- It used to sit outside, and that was correct while it was a macro
365
- lane over a scene-local micro one — the two never shared an x. They
366
- share one now, so a strip that did not scroll with the tracks would
367
- draw every scene bar somewhere its own boundary line is not. */}
368
- <div style={{ minWidth: LABEL_WIDTH + timeW }}>
369
- <SceneStrip ppf={ppf} labelWidth={LABEL_WIDTH} />
370
- </div>
371
- <div className="dock-ruler" style={{ marginLeft: LABEL_WIDTH, minWidth: timeW }}>
372
- {/* MASTER frames end to end. The seek is the identity now — the two
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
- <DockPlayhead ppf={ppf} />
384
- </Ruler>
385
- </div>
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
- <div className="dock-tracks" data-testid="dock-tracks" style={{ minWidth: LABEL_WIDTH + timeW }}>
388
- {/* The scene boundaries, drawn THROUGH the tracks. Derived from the
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
- <div className="dock-overlay" style={{ left: LABEL_WIDTH }} aria-hidden="true">
392
- {groups.map((group) =>
393
- group.start === 0 ? null : (
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
- key={group.id}
396
- className="dock-boundary"
397
- data-boundary={group.id}
398
- style={{ left: frameToPx(group.start, ppf) }}
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
- {groups.map((group) => (
409
- <div className="dock-scene-group" key={group.id} data-scene-group={group.id}>
410
- {/* The camera is a ROW like any other, one per scene at its own
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 block does. */}
413
- <BlockRow
414
- label="Camera"
415
- sceneId={group.id}
416
- layerId={CAMERA_ROW_ID}
417
- depth={0}
418
- bars={group.camera}
419
- ppf={ppf}
420
- labelWidth={LABEL_WIDTH}
421
- offsetFrames={group.start}
422
- beginSnap={beginSnap}
423
- // The same action ⇧C runs. A camera move has no preset to
424
- // pick, so unlike a layer's blocks it can be created from the
425
- // row; the scene is passed so a row that is not under the
426
- // playhead is refused by name rather than writing somewhere else.
427
- onAdd={() => store.addShotAtPlayhead(group.id)}
428
- addLabel="+ move"
429
- />
430
- {group.rows.map((row) => (
431
- <Fragment key={row.layerId}>
432
- <LifespanRow
433
- row={row}
434
- sceneId={group.id}
435
- sceneStart={group.start}
436
- ppf={ppf}
437
- labelWidth={LABEL_WIDTH}
438
- expanded={open.has(row.layerId)}
439
- onToggle={() =>
440
- setOpen((was) => {
441
- const next = new Set(was);
442
- if (!next.delete(row.layerId)) next.add(row.layerId);
443
- return next;
444
- })
445
- }
446
- beginSnap={beginSnap}
447
- />
448
- {/* The Figma/Lottie read: one derived row per channel the
449
- blocks write, diamonds at each block's ends. The
450
- preset-named block lane is gone from the expansion —
451
- the preset lives in the span tooltip and the Animate
452
- tab. See ChannelRow.tsx. */}
453
- {open.has(row.layerId) &&
454
- (() => {
455
- const layer = doc.scenesById[group.id]?.layersById[row.layerId];
456
- return layer === undefined ? null : (
457
- <ChannelRows
458
- layer={layer}
459
- sceneId={group.id}
460
- depth={row.depth + 1}
461
- ppf={ppf}
462
- labelWidth={LABEL_WIDTH}
463
- offsetFrames={group.start}
464
- fps={doc.fps}
465
- lifespan={row.lifespan}
466
- beginSnap={beginSnap}
467
- />
468
- );
469
- })()}
470
- </Fragment>
471
- ))}
472
- </div>
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
- {/* UNDER the video and OVER the audio, which is where every NLE
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. The marks sit directly over the waveform because they are
480
- almost always beats in it. */}
481
- <MarkerLane ppf={ppf} labelWidth={LABEL_WIDTH} beginSnap={beginSnap} />
482
- <AudioLane ppf={ppf} labelWidth={LABEL_WIDTH} beginSnap={beginSnap} />
483
- </div>
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
- </div>
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
  }