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