@dailies-studio/shot-editor 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.vite/manifest.json +382 -0
- package/dist/assets/AgentPanel-CQejLbiX.js +2 -0
- package/dist/assets/AgentPanel-DJEphah0.css +1 -0
- package/dist/assets/AgentQuestionDialog-BqkD0kXF.js +1 -0
- package/dist/assets/CodeStudio-Dv4ihHpz.js +23 -0
- package/dist/assets/CommentsPanel-B4yhhT1T.js +1 -0
- package/dist/assets/ComponentLibraryRail-Ch_dsAru.js +1 -0
- package/dist/assets/ComponentStage-BRhRotyH.js +1 -0
- package/dist/assets/CurvePane-BjFVabKX.css +1 -0
- package/dist/assets/CurvePane-R22WzllJ.js +1 -0
- package/dist/assets/ExportDialog-CWLQWTmG.js +2 -0
- package/dist/assets/FontPicker-DWKaYL7F.js +1 -0
- package/dist/assets/FontPicker-v0IOIArM.css +1 -0
- package/dist/assets/GenerateDrawer-Bh3SqDKt.js +2 -0
- package/dist/assets/InsertDrawer-Ce1fod1L.js +1 -0
- package/dist/assets/LibraryBrowser-D9sEGz5w.js +2 -0
- package/dist/assets/MakeItYoursSection-CMO4319_.js +2 -0
- package/dist/assets/MaterialGraphCanvas-BFzl4wWR.js +23 -0
- package/dist/assets/MaterialGraphCanvas-BIUD59ik.css +1 -0
- package/dist/assets/MaterialWorkspace-CqTWqaRA.js +14 -0
- package/dist/assets/PreferencesDialog-CBf9_OoD.js +1 -0
- package/dist/assets/PresetsSection-CUEaoCVU.js +1 -0
- package/dist/assets/SoundHistory-BeS8EW1h.js +1 -0
- package/dist/assets/SoundMode-BmcZru3i.js +3 -0
- package/dist/assets/StockSounds-DdAUGRYK.js +1 -0
- package/dist/assets/TemplateFieldSection-CaeXpLFo.js +1 -0
- package/dist/assets/VersionHistoryPanel-CxG6XhNP.js +1 -0
- package/dist/assets/board-B88EPuLO.css +1 -0
- package/dist/assets/board-CL-he7SL.js +3 -0
- package/dist/assets/components-0oOSPN0l.js +1 -0
- package/dist/assets/googleFontRanges-gp32wJMo.js +1 -0
- package/dist/assets/googleFonts-BNon6M7X.js +1 -0
- package/dist/assets/iconsAndAliases-DL7gXP5j.js +1863 -0
- package/dist/assets/index-4F5NQmI8.css +1 -0
- package/dist/assets/index-BGHQGV5z.js +117 -0
- package/dist/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- package/dist/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- package/dist/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- package/dist/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- package/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- package/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/dist/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- package/dist/assets/libraryModel-CpIwfFha.js +1 -0
- package/dist/assets/lucide-6iK64tTB.js +9319 -0
- package/dist/assets/motionStamp-CH-Hew4E.js +1 -0
- package/dist/assets/takeMeasure-Tdg0TxEi.js +1 -0
- package/dist/assets/webmcp-BsB9gxWC.js +2 -0
- package/dist/index.html +34 -3
- package/package.json +12 -9
- package/src/addFamilies.ts +66 -0
- package/src/api.ts +1631 -55
- package/src/app/App.tsx +977 -198
- package/src/app/lazyPanel.tsx +137 -0
- package/src/app/lazySurfaces.tsx +203 -0
- package/src/audio/index.tsx +41 -10
- package/src/audio/meter.ts +485 -0
- package/src/audio/meterMath.ts +273 -0
- package/src/audio/meterStore.ts +155 -0
- package/src/audio/offlineMix.ts +289 -0
- package/src/audio/preview.ts +928 -118
- package/src/bridge.ts +15 -5
- package/src/bridgeTypes.ts +1611 -36
- package/src/channels.ts +17 -32
- package/src/fonts/googleFonts.ts +4 -0
- package/src/hostApi.ts +174 -0
- package/src/icons.tsx +770 -0
- package/src/layerWords.ts +61 -0
- package/src/main.tsx +56 -4
- package/src/panels/ResizeHandle.tsx +33 -43
- package/src/panels/agent/AgentDots.tsx +36 -0
- package/src/panels/agent/AgentPanel.tsx +119 -43
- package/src/panels/agent/AgentQuestionDialog.tsx +202 -0
- package/src/panels/agent/agent.css +113 -97
- package/src/panels/agent/agentGate.ts +35 -0
- package/src/panels/code/CodeDefaultsPanel.tsx +345 -0
- package/src/panels/code/CodeStudio.tsx +373 -0
- package/src/panels/code/FileTree.tsx +265 -0
- package/src/panels/code/codeBufferGuard.ts +52 -0
- package/src/panels/code/codeParts.tsx +321 -0
- package/src/panels/comments/CommentComposer.tsx +239 -0
- package/src/panels/comments/CommentThread.tsx +346 -0
- package/src/panels/comments/CommentsPanel.tsx +336 -0
- package/src/panels/comments/InboxButton.tsx +131 -0
- package/src/panels/comments/MentionMenu.tsx +98 -0
- package/src/panels/comments/parts.tsx +171 -0
- package/src/panels/export/ExportDialog.tsx +919 -96
- package/src/panels/export/ExportScrim.tsx +57 -0
- package/src/panels/files/AssetsTab.tsx +656 -0
- package/src/panels/files/FilePanel.tsx +1466 -216
- package/src/panels/files/LayerMarks.tsx +40 -0
- package/src/panels/files/LeftColumn.tsx +37 -0
- package/src/panels/files/McpSection.tsx +690 -47
- package/src/panels/files/MissingAssetsNotice.tsx +60 -0
- package/src/panels/files/assetDrag.ts +79 -0
- package/src/panels/files/resourceParts.tsx +255 -0
- package/src/panels/fonts/FontPicker.tsx +762 -0
- package/src/panels/fonts/faceCache.ts +207 -0
- package/src/panels/fonts/fontPicker.css +271 -0
- package/src/panels/fonts/pickerModel.ts +265 -0
- package/src/panels/fonts/pickerRequest.ts +46 -0
- package/src/panels/header/AccountAvatar.tsx +76 -0
- package/src/panels/header/AgentChip.tsx +77 -0
- package/src/panels/header/AppMenu.tsx +213 -0
- package/src/panels/header/Header.tsx +564 -84
- package/src/panels/header/HistoryControls.tsx +31 -5
- package/src/panels/header/PresenceStack.tsx +329 -0
- package/src/panels/header/ProjectTitle.tsx +232 -0
- package/src/panels/header/ShareButton.tsx +59 -0
- package/src/panels/header/StatusBadges.tsx +358 -27
- package/src/panels/header/SyncDeletedDialog.tsx +88 -0
- package/src/panels/header/ZoomControls.tsx +46 -15
- package/src/panels/header/agentHandoff.ts +52 -0
- package/src/panels/header/leave.ts +26 -0
- package/src/panels/help/HelpSection.tsx +189 -163
- package/src/panels/insert/InsertDrawer.tsx +723 -0
- package/src/panels/insert/iconSet.ts +109 -0
- package/src/panels/inspector/ExposeDot.tsx +117 -0
- package/src/panels/inspector/FramingRows.tsx +314 -44
- package/src/panels/inspector/InspectorFooter.tsx +91 -0
- package/src/panels/inspector/InspectorHead.tsx +425 -0
- package/src/panels/inspector/InspectorPanel.tsx +408 -124
- package/src/panels/inspector/KeyframeDot.tsx +373 -0
- package/src/panels/inspector/KeyframeSection.tsx +257 -0
- package/src/panels/inspector/MarkerSection.tsx +55 -11
- package/src/panels/inspector/ParamRow.tsx +84 -53
- package/src/panels/inspector/RightColumn.tsx +152 -0
- package/src/panels/inspector/TimeSection.tsx +866 -0
- package/src/panels/inspector/animate/EaseControl.tsx +244 -0
- package/src/panels/inspector/animate/PresetsSection.tsx +135 -0
- package/src/panels/inspector/animate/ShotSection.tsx +562 -48
- package/src/panels/inspector/design/AlignRow.tsx +95 -31
- package/src/panels/inspector/design/AppearanceSection.tsx +118 -9
- package/src/panels/inspector/design/ClonerSection.tsx +323 -62
- package/src/panels/inspector/design/ComponentSection.tsx +50 -43
- package/src/panels/inspector/design/CornerSection.tsx +45 -14
- package/src/panels/inspector/design/DefinitionProps.tsx +315 -0
- package/src/panels/inspector/design/DefinitionSection.tsx +136 -0
- package/src/panels/inspector/design/DeviceSection.tsx +210 -0
- package/src/panels/inspector/design/FillSection.tsx +83 -51
- package/src/panels/inspector/design/FontField.tsx +196 -0
- package/src/panels/inspector/design/HtmlSection.tsx +23 -12
- package/src/panels/inspector/design/InstanceSection.tsx +373 -154
- package/src/panels/inspector/design/MaskSection.tsx +84 -0
- package/src/panels/inspector/design/MaterialSection.tsx +177 -0
- package/src/panels/inspector/design/MediaSection.tsx +273 -120
- package/src/panels/inspector/design/MeshSection.tsx +910 -82
- package/src/panels/inspector/design/PaintRows.tsx +550 -0
- package/src/panels/inspector/design/PlateStyleSection.tsx +177 -0
- package/src/panels/inspector/design/ShadowSection.tsx +221 -48
- package/src/panels/inspector/design/SplineFollowSection.tsx +235 -0
- package/src/panels/inspector/design/StrokeSection.tsx +228 -43
- package/src/panels/inspector/design/TemplateFieldSection.tsx +57 -0
- package/src/panels/inspector/design/TransformSection.tsx +244 -50
- package/src/panels/inspector/design/TypographySection.tsx +679 -110
- package/src/panels/inspector/layout.ts +295 -0
- package/src/panels/inspector/propValue.ts +199 -0
- package/src/panels/inspector/scene/CameraSection.tsx +232 -22
- package/src/panels/inspector/scene/EffectsSection.tsx +722 -71
- package/src/panels/inspector/scene/MakeItYoursSection.tsx +180 -0
- package/src/panels/inspector/scene/SceneDocSection.tsx +191 -0
- package/src/panels/inspector/scene/SceneSection.tsx +219 -44
- package/src/panels/inspector/scene/TransitionSection.tsx +523 -72
- package/src/panels/inspector/storyboard/StoryboardReferenceSection.tsx +593 -0
- package/src/panels/inspector/storyboard/VideoStill.tsx +120 -0
- package/src/panels/inspector/timeModel.ts +190 -0
- package/src/panels/inspector/useDocWrite.ts +438 -146
- package/src/panels/library/LibraryBrowser.tsx +435 -0
- package/src/panels/library/LibraryGrid.tsx +190 -0
- package/src/panels/library/LibraryPreviewPanel.tsx +349 -0
- package/src/panels/library/LibraryRail.tsx +84 -0
- package/src/panels/library/LibrarySoundPanel.tsx +293 -0
- package/src/panels/library/SoundHistory.tsx +245 -0
- package/src/panels/library/StockSounds.tsx +390 -0
- package/src/panels/library/libraryModel.ts +147 -0
- package/src/panels/library/stockSearch.ts +77 -0
- package/src/panels/maskMarks.tsx +117 -0
- package/src/panels/material/MaterialCommentNode.tsx +54 -0
- package/src/panels/material/MaterialGraphCanvas.tsx +861 -0
- package/src/panels/material/MaterialGraphMenu.tsx +45 -0
- package/src/panels/material/MaterialGroupNode.tsx +56 -0
- package/src/panels/material/MaterialLibrary.tsx +253 -0
- package/src/panels/material/MaterialNode.tsx +453 -0
- package/src/panels/material/MaterialNodePalette.tsx +349 -0
- package/src/panels/material/MaterialParameterControls.tsx +525 -0
- package/src/panels/material/MaterialRerouteNode.tsx +42 -0
- package/src/panels/material/MaterialWorkspace.tsx +501 -0
- package/src/panels/material/NodePreview.tsx +70 -0
- package/src/panels/material/flowModel.ts +441 -0
- package/src/panels/material/parameterValues.ts +231 -0
- package/src/panels/material/previewScheduler.ts +235 -0
- package/src/panels/material/reactFlow.css +6 -0
- package/src/panels/material/socketGlyph.ts +85 -0
- package/src/panels/material/viewportMemory.ts +58 -0
- package/src/panels/preferences/PreferencesDialog.tsx +215 -0
- package/src/panels/share/ShareComponentDialog.tsx +247 -0
- package/src/panels/stage/StageMenu.tsx +35 -5
- package/src/panels/stage/StagePanel.tsx +338 -23
- package/src/panels/stage/cellSlots.tsx +70 -0
- package/src/panels/storyboard/BoardCanvas.tsx +739 -0
- package/src/panels/storyboard/BoardCursors.tsx +158 -0
- package/src/panels/storyboard/BoardDropDialog.tsx +161 -0
- package/src/panels/storyboard/BoardPlan.tsx +42 -0
- package/src/panels/storyboard/ComponentSheet.tsx +187 -0
- package/src/panels/storyboard/ComponentStage.tsx +235 -0
- package/src/panels/storyboard/MiniSketch.tsx +173 -0
- package/src/panels/storyboard/PartPicker.tsx +64 -0
- package/src/panels/storyboard/PlanPanel.tsx +617 -0
- package/src/panels/storyboard/ReferencePreview.tsx +217 -0
- package/src/panels/storyboard/ShotBrief.tsx +360 -0
- package/src/panels/storyboard/ShotCard.tsx +251 -0
- package/src/panels/storyboard/StagePreview.tsx +348 -0
- package/src/panels/storyboard/StillThumbs.tsx +162 -0
- package/src/panels/storyboard/StoryboardView.tsx +79 -0
- package/src/panels/storyboard/board.css +1065 -0
- package/src/panels/storyboard/board.ts +12 -0
- package/src/panels/storyboard/boardClipboard.ts +111 -0
- package/src/panels/storyboard/boardOrder.ts +68 -0
- package/src/panels/storyboard/boardRows.tsx +425 -0
- package/src/panels/storyboard/liveCap.ts +90 -0
- package/src/panels/storyboard/plan.css +295 -0
- package/src/panels/storyboard/posterCache.ts +133 -0
- package/src/panels/storyboard/shotboard/ComponentPicker.tsx +373 -0
- package/src/panels/storyboard/shotboard/ImagePicker.tsx +145 -0
- package/src/panels/storyboard/shotboard/ShotBoard.tsx +292 -0
- package/src/panels/storyboard/shotboard/SketchCanvas.tsx +443 -0
- package/src/panels/storyboard/shotboard/SketchInspector.tsx +471 -0
- package/src/panels/storyboard/shotboard/SketchItem.tsx +240 -0
- package/src/panels/storyboard/shotboard/SketchLayers.tsx +130 -0
- package/src/panels/storyboard/shotboard/SketchPlay.tsx +108 -0
- package/src/panels/storyboard/shotboard/SketchStrip.tsx +178 -0
- package/src/panels/storyboard/shotboard/SketchToolFooter.tsx +99 -0
- package/src/panels/storyboard/shotboard/sketch.css +1392 -0
- package/src/panels/storyboard/shotboard/sketchModel.ts +219 -0
- package/src/panels/storyboard/storyboardDrop.ts +164 -0
- package/src/panels/studio/ComponentLibraryRail.tsx +346 -0
- package/src/panels/studio/ComponentSidePanel.tsx +355 -0
- package/src/panels/studio/ComponentStudio.tsx +243 -0
- package/src/panels/studio/ComponentsHome.tsx +318 -0
- package/src/panels/studio/ComponentsPreview.tsx +179 -0
- package/src/panels/studio/EveryUse.tsx +244 -0
- package/src/panels/studio/ThisUsePanel.tsx +679 -0
- package/src/panels/studio/componentParts.tsx +341 -0
- package/src/panels/studio/components.ts +6 -0
- package/src/panels/studio/newCodeComponent.ts +66 -0
- package/src/panels/timeline/AudioFxButton.tsx +290 -0
- package/src/panels/timeline/AudioLane.tsx +674 -85
- package/src/panels/timeline/BlockRow.tsx +154 -85
- package/src/panels/timeline/ChannelRow.tsx +378 -129
- package/src/panels/timeline/CurvePane.tsx +508 -0
- package/src/panels/timeline/Filmstrip.tsx +361 -0
- package/src/panels/timeline/GenerateDrawer.tsx +407 -0
- package/src/panels/timeline/KeyStretch.tsx +115 -0
- package/src/panels/timeline/LifespanRow.tsx +730 -47
- package/src/panels/timeline/MarkerLane.tsx +46 -8
- package/src/panels/timeline/PlayButton.tsx +91 -0
- package/src/panels/timeline/PropertyPicker.tsx +254 -0
- package/src/panels/timeline/ShotRow.tsx +400 -0
- package/src/panels/timeline/SoundMode.tsx +1406 -0
- package/src/panels/timeline/TimelineDock.tsx +2338 -272
- package/src/panels/timeline/TransportVolume.tsx +163 -0
- package/src/panels/timeline/beatRuler.ts +74 -0
- package/src/panels/timeline/curveModel.ts +500 -0
- package/src/panels/timeline/duckUnderVoice.ts +68 -0
- package/src/panels/timeline/keyframeMenu.ts +138 -0
- package/src/panels/timeline/labelWidth.ts +49 -0
- package/src/panels/timeline/marquee.ts +155 -0
- package/src/panels/timeline/model.ts +1860 -199
- package/src/panels/timeline/snap.ts +125 -43
- package/src/panels/timeline/staggerPreview.tsx +92 -0
- package/src/panels/timeline/takeMeasure.ts +72 -0
- package/src/panels/timeline/useLive.ts +48 -0
- package/src/panels/timeline/viewport.ts +234 -0
- package/src/panels/toolbar/BackChip.tsx +116 -0
- package/src/panels/toolbar/CameraViewBar.tsx +118 -49
- package/src/panels/toolbar/CanvasBar.tsx +117 -0
- package/src/panels/toolbar/InsertButton.tsx +38 -0
- package/src/panels/toolbar/IsolationTrail.tsx +156 -0
- package/src/panels/toolbar/PanelToggles.tsx +86 -0
- package/src/panels/toolbar/ShadingBar.tsx +119 -26
- package/src/panels/toolbar/ToolDockBar.tsx +329 -80
- package/src/panels/toolbar/ToolFooter.tsx +102 -0
- package/src/panels/toolbar/ViewModeBar.tsx +247 -38
- package/src/panels/toolbar/WorkPlaneBar.tsx +94 -0
- package/src/panels/toolbar/useAddRows.tsx +326 -0
- package/src/panels/toolbar/useDismiss.ts +122 -0
- package/src/panels/versions/VersionHistoryPanel.tsx +274 -0
- package/src/panels/versions/versionModel.ts +103 -0
- package/src/panels/virtualWindow.ts +271 -0
- package/src/shotTokens.css +151 -60
- package/src/stage/FrustumGizmo.tsx +504 -84
- package/src/stage/GridPlane.tsx +225 -16
- package/src/stage/NodePreviews.tsx +628 -0
- package/src/stage/PerfHud.tsx +437 -47
- package/src/stage/SplitRenderer.tsx +816 -169
- package/src/stage/StageApp.tsx +4274 -486
- package/src/stage/StageRootBoundary.tsx +114 -0
- package/src/stage/StageTools.tsx +651 -151
- package/src/stage/TransformGizmo.tsx +696 -0
- package/src/stage/adaptivePresentation.ts +145 -0
- package/src/stage/bridgeClient.ts +2522 -41
- package/src/stage/exportWorker.ts +9 -0
- package/src/stage/feed.ts +305 -14
- package/src/stage/fontPreview.ts +176 -0
- package/src/stage/frustumGeometry.ts +319 -1
- package/src/stage/furnitureTokens.ts +33 -0
- package/src/stage/gizmoMath.ts +508 -16
- package/src/stage/gpuTimer.ts +276 -0
- package/src/stage/instanceModules.ts +194 -33
- package/src/stage/interactions/move.ts +24 -5
- package/src/stage/isolation.ts +291 -33
- package/src/stage/jsSelfProfiler.ts +325 -0
- package/src/stage/main.tsx +116 -16
- package/src/stage/modalState.ts +222 -23
- package/src/stage/moduleEpoch.ts +60 -0
- package/src/stage/motionPathStage.ts +279 -0
- package/src/stage/motionPreview.ts +74 -0
- package/src/stage/overlays/Overlays.tsx +1112 -159
- package/src/stage/overlays/Rulers.tsx +495 -0
- package/src/stage/panes.ts +159 -10
- package/src/stage/project.ts +68 -3
- package/src/stage/renderScaleController.ts +730 -0
- package/src/stage/rulers.ts +129 -0
- package/src/stage/sceneExtent.ts +75 -0
- package/src/stage/shading.ts +20 -0
- package/src/stage/splineStage.ts +632 -0
- package/src/stage/stagePerformanceTelemetry.ts +717 -0
- package/src/stage/stageTheme.ts +36 -0
- package/src/stage/stageWarmup.ts +11 -0
- package/src/stage/textAutoSize.ts +124 -0
- package/src/stage/tier1Export.ts +641 -0
- package/src/stage/tokenColor.ts +14 -9
- package/src/stage/view.ts +56 -9
- package/src/stage/workingView.ts +277 -33
- package/src/state/accountPrefs.ts +261 -0
- package/src/state/agentActions.ts +173 -15
- package/src/state/agentActivity.ts +198 -0
- package/src/state/agentQuestion.ts +239 -0
- package/src/state/bootOpen.ts +42 -0
- package/src/state/cameraVerbs.ts +139 -0
- package/src/state/clientId.ts +6 -1
- package/src/state/comments.ts +1099 -0
- package/src/state/context.tsx +19 -1
- package/src/state/editorPrefs.ts +220 -0
- package/src/state/errorReports.ts +121 -0
- package/src/state/fieldWrite.ts +161 -0
- package/src/state/fontRecents.ts +70 -0
- package/src/state/fonts.ts +656 -0
- package/src/state/graphClipboard.ts +414 -0
- package/src/state/imageThumbs.ts +216 -0
- package/src/state/insert.ts +444 -67
- package/src/state/isolationTrail.ts +92 -0
- package/src/state/keyMoves.ts +134 -0
- package/src/state/keyframeClipboard.ts +177 -0
- package/src/state/keymap.ts +1153 -117
- package/src/state/layoutPrefs.ts +272 -0
- package/src/state/layouts.ts +205 -0
- package/src/state/measureMedia.ts +167 -0
- package/src/state/menu.ts +2341 -28
- package/src/state/motionStamp.ts +7 -0
- package/src/state/panelSize.ts +375 -5
- package/src/state/pendingChanges.ts +119 -0
- package/src/state/playHold.ts +78 -0
- package/src/state/playbackPreparation.ts +562 -0
- package/src/state/posterCapture.ts +242 -0
- package/src/state/presence.ts +254 -0
- package/src/state/projectComponents.ts +160 -0
- package/src/state/renderEvents.ts +298 -18
- package/src/state/runtimeErrors.ts +46 -0
- package/src/state/selection.ts +12 -0
- package/src/state/shortcuts.ts +1606 -0
- package/src/state/sketchGuide.ts +51 -0
- package/src/state/splineRefs.ts +87 -0
- package/src/state/stageHealth.ts +259 -0
- package/src/state/stageWatchdog.ts +47 -0
- package/src/state/stillStage.ts +330 -0
- package/src/state/stillThumbs.ts +474 -0
- package/src/state/store.ts +23591 -2982
- package/src/state/storyboardModel.ts +528 -0
- package/src/state/sync.ts +1257 -0
- package/src/state/syncCache.ts +159 -0
- package/src/state/triangles.ts +13 -0
- package/src/state/types.ts +1698 -55
- package/src/state/unsavedGuard.ts +49 -0
- package/src/state/viewMode.ts +200 -0
- package/src/state/webmcp.ts +1128 -0
- package/src/state/workspace.ts +1147 -0
- package/src/styles/assets-tab.css +304 -0
- package/src/styles/canvas.css +545 -0
- package/src/styles/comments.css +1435 -0
- package/src/styles/controls.css +162 -0
- package/src/styles/dialogs.css +390 -0
- package/src/styles/insert-drawer.css +586 -0
- package/src/styles/inspector-board.css +207 -0
- package/src/styles/inspector-object.css +486 -0
- package/src/styles/inspector-shot.css +252 -0
- package/src/styles/inspector.css +360 -0
- package/src/styles/left-column.css +1628 -0
- package/src/styles/library-preview.css +575 -0
- package/src/styles/library.css +512 -0
- package/src/styles/modes/code.css +569 -0
- package/src/styles/modes/component.css +1561 -0
- package/src/styles/modes/material.css +1201 -0
- package/src/styles/modes/storyboard.css +518 -0
- package/src/styles/share-component.css +49 -0
- package/src/styles/shell.css +653 -0
- package/src/styles/shot-brief.css +360 -0
- package/src/styles/states.css +76 -0
- package/src/styles/sync.css +380 -0
- package/src/styles/timeline-graph.css +384 -0
- package/src/styles/timeline-meters.css +89 -0
- package/src/styles/timeline-rows.css +1959 -0
- package/src/styles/timeline-sound.css +1003 -0
- package/src/styles/timeline.css +754 -0
- package/src/styles/tool-footer.css +525 -0
- package/src/styles/topbar.css +886 -0
- package/src/styles/versions.css +262 -0
- package/src/styles.css +72 -2921
- package/src/tools.ts +108 -23
- package/dist/assets/index-B7notge7.css +0 -1
- package/dist/assets/index-BBE6Ta9L.js +0 -52
- package/src/panels/files/icons.tsx +0 -118
- package/src/panels/header/DocSwitcher.tsx +0 -85
- package/src/panels/header/TransportBar.tsx +0 -86
- package/src/panels/inspector/animate/BlockTiming.tsx +0 -341
- package/src/panels/inspector/animate/DuringBlocks.tsx +0 -85
- package/src/panels/inspector/animate/InOutPresets.tsx +0 -57
- package/src/panels/inspector/animate/ResolvedChannels.tsx +0 -150
- package/src/panels/inspector/animate/useBlocks.ts +0 -43
- package/src/panels/material/MaterialPicker.tsx +0 -172
- package/src/panels/timeline/SceneStrip.tsx +0 -145
- package/src/panels/toolbar/AddMenu.tsx +0 -230
- package/src/stage/MoveGizmo.tsx +0 -411
|
@@ -1,44 +1,190 @@
|
|
|
1
|
-
// The left
|
|
2
|
-
//
|
|
1
|
+
// The left column's panel: the outline of the open container. The column is
|
|
2
|
+
// fixed rows over one scroller: the context head, the search, then
|
|
3
|
+
// `.rail-scroll`. The Project's files (Assets, Branding & References) live in the
|
|
4
|
+
// Storyboard workspace now, so there is no section switch here.
|
|
5
|
+
// Inside a Shot the scan order is deliberate: the context head, then Layers,
|
|
6
|
+
// then the Components library. The header claimed "Layers first" for a release
|
|
7
|
+
// while the JSX put Components above them; the tree is the SUBJECT of a shot
|
|
8
|
+
// and the library is the same list at every level, so the subject is what
|
|
9
|
+
// leads.
|
|
3
10
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
11
|
+
// ── ONE RULE: THE OUTLINE LISTS WHAT IS INSIDE WHERE YOU ARE ─────────────
|
|
12
|
+
//
|
|
13
|
+
// the SCENE -> its shots
|
|
14
|
+
// a SHOT -> its elements
|
|
15
|
+
// a DEF -> its interior
|
|
16
|
+
//
|
|
17
|
+
// It used to list the shots in EVERY context, so inside a shot the panel showed
|
|
18
|
+
// two levels stacked in one column: a shot list whose rows navigate away, above
|
|
19
|
+
// a layer tree of the shot you are in. And the shots were already drawn twice
|
|
20
|
+
// over — the timeline's shot rows carry the same list with the verbs that
|
|
21
|
+
// matter (reorder, trim, delete, transition, open), where their durations are
|
|
22
|
+
// legible. Three pictures of one list, one of which could act on it.
|
|
23
|
+
//
|
|
24
|
+
// The two pictures that are left agree by construction: this list's right-click
|
|
25
|
+
// and the timeline row's are the same `shotMenuEntries`, and both offer the
|
|
26
|
+
// same double-click into the shot.
|
|
27
|
+
//
|
|
28
|
+
// The scene context lost nothing by the change, it GAINED the list: it used to
|
|
29
|
+
// hold a sentence telling the author to go and open a shot, under a shot list it
|
|
30
|
+
// was standing next to.
|
|
31
|
+
//
|
|
32
|
+
// The COMPONENTS block follows that rule too, and it took two goes to get
|
|
33
|
+
// there. It is drawn INSIDE a shot and inside a definition, and not above one.
|
|
34
|
+
// The counter-argument was that a definition is document-scoped and lives in no
|
|
35
|
+
// shot, so "what is inside where you are" never excludes it. That is true, and
|
|
36
|
+
// it argues for the list being reachable from the scene level, not for it being
|
|
37
|
+
// mounted there. Every verb the block offers is refused above a shot except
|
|
38
|
+
// two, and the App menu carries both (Component ▸ Open in Components opens one,
|
|
39
|
+
// and its New empty component is disabled there for the same reason the rail's
|
|
40
|
+
// was not, an inconsistency this removes). What the scene level is FOR is the
|
|
41
|
+
// cut: the shots, in order. A library of things that cannot be placed into it
|
|
42
|
+
// is a second subject in a panel whose whole rule is one.
|
|
43
|
+
//
|
|
44
|
+
// IT LISTS BOTH COMPONENT FAMILIES, in two lists. A definition is one of the
|
|
45
|
+
// two things an author calls a component; the other is a component from their
|
|
46
|
+
// own repo, which is an `instance` layer naming a registry id. Only definitions
|
|
47
|
+
// were ever listed, so a shot built entirely out of repo components showed this
|
|
48
|
+
// header with nothing under it. The second list is `usedInstanceComponentsOf`,
|
|
49
|
+
// scoped to what the document actually places — browsing the whole registry is
|
|
50
|
+
// the Add menu's job. Two lists rather than one merge, because the rows do
|
|
51
|
+
// different things when clicked (one opens an editor, one selects layers) and
|
|
52
|
+
// because "the first row under `component-list` is a definition" is a fact
|
|
53
|
+
// other surfaces resolve against.
|
|
54
|
+
//
|
|
55
|
+
// The per-row `+` is still ABSENT rather than disabled wherever it cannot run,
|
|
56
|
+
// which is this panel's rule for a verb the current level cannot do at all. It
|
|
57
|
+
// just no longer has a level of its own to be absent at.
|
|
58
|
+
//
|
|
59
|
+
// TWO LEVELS, TWO BITS, AND THEY ARE NOT THE SAME BIT.
|
|
60
|
+
//
|
|
61
|
+
// `state.enteredScene` is the CONTEXT: null lists the shots and no layers at
|
|
62
|
+
// all, because an element belongs to exactly one shot and in the scene
|
|
63
|
+
// context nobody has said which.
|
|
64
|
+
// `state.sceneId` is the SUBJECT: which shot's layers, once one is open. It
|
|
65
|
+
// is derived from the playhead, and while a shot is entered the clamp holds
|
|
66
|
+
// the playhead inside it — so here the two always agree, by construction
|
|
67
|
+
// rather than by a second copy.
|
|
68
|
+
//
|
|
69
|
+
// This header used to claim `sceneId` was "the same bit the timeline's
|
|
70
|
+
// macro/micro split reads". It was, until that split was deleted; the sentence
|
|
71
|
+
// outlived the thing it described by a release, which is the failure mode this
|
|
72
|
+
// lane treats as a bug rather than as prose.
|
|
7
73
|
//
|
|
8
74
|
// THE TREE IS RENDERED FROM `scene.layers` AND `children`, never from
|
|
9
|
-
// `layersById
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
75
|
+
// `layersById`: the outline is the forest, and from schema v35 its lists ARE
|
|
76
|
+
// paint order (back to front, a parent before its children), which `order`
|
|
77
|
+
// only numbers. Listing by `layersById` would have no order at all.
|
|
78
|
+
//
|
|
79
|
+
// FRONT FIRST, as Figma's layers panel reads: each sibling list is shown
|
|
80
|
+
// reversed (`frontFirst`), so the top row is the layer in front, and a parent's
|
|
81
|
+
// row still sits above its children. Only the DISPLAY flips. Every row keeps its
|
|
82
|
+
// index in the stored list, and a drop line is mapped back to that list: the
|
|
83
|
+
// line above a row is in front of it, the line below it is behind it.
|
|
84
|
+
import {
|
|
85
|
+
useCallback,
|
|
86
|
+
useEffect,
|
|
87
|
+
useLayoutEffect,
|
|
88
|
+
useMemo,
|
|
89
|
+
useRef,
|
|
90
|
+
useState,
|
|
91
|
+
} from "react";
|
|
92
|
+
import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent } from "react";
|
|
93
|
+
import { framesToSeconds } from "@dailies-studio/engine";
|
|
94
|
+
import { LAYER_ROW_H, formatDuration, frontFirst } from "../timeline/model.js";
|
|
95
|
+
import { IconChevron, useCollapsed, useContextMenu } from "@dailies-studio/ui";
|
|
96
|
+
import {
|
|
97
|
+
canHoldChildren,
|
|
98
|
+
componentDefsOf,
|
|
99
|
+
homeOf,
|
|
100
|
+
instanceExportOf,
|
|
101
|
+
instancesOf,
|
|
102
|
+
layerChildren,
|
|
103
|
+
} from "@dailies-studio/shot/doc";
|
|
104
|
+
import type { Layer3D, LayerHome } from "@dailies-studio/shot/doc";
|
|
105
|
+
import { useShotEditor, useShotEditorHot, useStore } from "../../state/context.js";
|
|
106
|
+
import type { ShotEditorStore } from "../../state/store.js";
|
|
107
|
+
import { isSceneContext } from "../../state/viewMode.js";
|
|
108
|
+
import { crumbsFor } from "../../state/isolationTrail.js";
|
|
109
|
+
import { usedComponentsOf } from "../../state/projectComponents.js";
|
|
110
|
+
import {
|
|
111
|
+
layerMenuEntries,
|
|
112
|
+
menuTargetsFor,
|
|
113
|
+
SHOT_MENU_OPTIONS,
|
|
114
|
+
shotMenuEntries,
|
|
115
|
+
} from "../../state/menu.js";
|
|
18
116
|
import {
|
|
19
117
|
TRIANGLE_SOFT_BUDGET,
|
|
20
118
|
formatTriangles,
|
|
21
119
|
sceneBudgetWarning,
|
|
22
|
-
|
|
120
|
+
sumTrianglesCached,
|
|
23
121
|
} from "../../state/triangles.js";
|
|
24
|
-
import {
|
|
122
|
+
import { selectionMembership } from "../../state/selection.js";
|
|
25
123
|
import {
|
|
124
|
+
CHEVRON_LEFT_ICON,
|
|
125
|
+
ADD_ICON,
|
|
26
126
|
COMPONENT_ICON,
|
|
27
127
|
EYE_ICON,
|
|
28
128
|
EYE_OFF_ICON,
|
|
29
129
|
LOCK_ICON,
|
|
130
|
+
MASK_ICON,
|
|
30
131
|
SCENE_ICON,
|
|
132
|
+
SEARCH_ICON,
|
|
31
133
|
UNLOCK_ICON,
|
|
134
|
+
iconFor,
|
|
135
|
+
isComponentGlyph,
|
|
32
136
|
kindIcon,
|
|
33
|
-
} from "
|
|
137
|
+
} from "../../icons.js";
|
|
138
|
+
import { pinnedWindowSpans, useFixedVirtualWindow } from "../virtualWindow.js";
|
|
139
|
+
import { backTarget } from "../toolbar/BackChip.js";
|
|
140
|
+
import { PeerDots } from "../header/PresenceStack.js";
|
|
141
|
+
import { LayerMarks } from "./LayerMarks.js";
|
|
142
|
+
import { MaskBracket, NO_CONTENT_HINT, maskMarksOf } from "../maskMarks.js";
|
|
143
|
+
import type { MaskMarks } from "../maskMarks.js";
|
|
34
144
|
|
|
35
145
|
export function FilePanel() {
|
|
36
146
|
const store = useStore();
|
|
37
147
|
const doc = useShotEditor((s) => s.doc);
|
|
38
|
-
const
|
|
148
|
+
const name = useShotEditor((s) => s.name);
|
|
149
|
+
// Hot: the cold lane sleeps through a cut the transport plays (store
|
|
150
|
+
// `set`), and the Shots list marks what is playing. A string, so this
|
|
151
|
+
// renders at a cut and not per frame; inside a Shot it never moves.
|
|
152
|
+
const sceneId = useShotEditorHot((s) => s.sceneId);
|
|
153
|
+
const enteredScene = useShotEditor((s) => s.enteredScene);
|
|
39
154
|
const isolation = useShotEditor((s) => s.isolation);
|
|
40
155
|
const editingDef = useShotEditor((s) => s.editingDef);
|
|
156
|
+
/** The registry, for the DISPLAY NAME of a code component. The list renders
|
|
157
|
+
* without it (the export name is parsed from the id), so this is a
|
|
158
|
+
* refinement arriving late, never a gate on the rows appearing. */
|
|
159
|
+
const library = useShotEditor((s) => s.library);
|
|
160
|
+
const libraryLoaded = useShotEditor((s) => s.libraryLoaded);
|
|
161
|
+
/** The SCENE context: arranging shots, so nothing here may insert an element.
|
|
162
|
+
* A definition counts as a container, which is why `isSceneContext` excludes
|
|
163
|
+
* one. Both are the shared predicates rather than this panel's own copy: the
|
|
164
|
+
* rail, the dock and the store answered "where am I" from three spellings. */
|
|
165
|
+
const inScene = useShotEditor(isSceneContext);
|
|
41
166
|
const [query, setQuery] = useState("");
|
|
167
|
+
/** `/` asks for the caret here (`searchFocus`, a mailbox keyed on identity),
|
|
168
|
+
* and the field takes it. The store already refused the key while focus was
|
|
169
|
+
* in a field, so this never steals the caret from one.
|
|
170
|
+
*
|
|
171
|
+
* ONE REQUEST, ONE FOCUS. The panel sleeps in a hidden `<Activity>` on the
|
|
172
|
+
* board (LeftColumn.tsx), and waking re-runs every effect: keyed on the
|
|
173
|
+
* value alone, an old request would pull the caret here on each return
|
|
174
|
+
* from the board. So the effect remembers the last request it answered,
|
|
175
|
+
* and a wake (the layout effect, which runs first) counts a request made
|
|
176
|
+
* while the panel slept as answered: it was not this panel's to take. */
|
|
177
|
+
const searchRef = useRef<HTMLInputElement>(null);
|
|
178
|
+
const searchFocus = useShotEditor((s) => s.searchFocus);
|
|
179
|
+
const answeredFocus = useRef<typeof searchFocus>(null);
|
|
180
|
+
useLayoutEffect(() => {
|
|
181
|
+
answeredFocus.current = store.getState().searchFocus;
|
|
182
|
+
}, [store]);
|
|
183
|
+
useEffect(() => {
|
|
184
|
+
if (searchFocus === null || searchFocus === answeredFocus.current) return;
|
|
185
|
+
answeredFocus.current = searchFocus;
|
|
186
|
+
searchRef.current?.focus();
|
|
187
|
+
}, [searchFocus]);
|
|
42
188
|
/** Collapsed subtree roots. PER-SESSION on purpose, never a workspace pref:
|
|
43
189
|
* prefs survive document switches and layer ids are document-scoped, so a
|
|
44
190
|
* persisted set would carry stale ids into every other document. */
|
|
@@ -46,6 +192,21 @@ export function FilePanel() {
|
|
|
46
192
|
/** The last plainly-clicked row — shift-click ranges from it over the
|
|
47
193
|
* VISIBLE flattened order, the Figma/Blender behaviour. */
|
|
48
194
|
const [anchor, setAnchor] = useState<string | null>(null);
|
|
195
|
+
/** Which shot row is being renamed inline, or null. The menu says "Rename"
|
|
196
|
+
* and the surface decides what that looks like — see `shotMenuEntries`. */
|
|
197
|
+
const [renamingShot, setRenamingShot] = useState<string | null>(null);
|
|
198
|
+
/** ONE MENU for the shot list. The layer rows own theirs per row (they carry
|
|
199
|
+
* their own drag state); this list has one opener and one open menu. */
|
|
200
|
+
const menu = useContextMenu();
|
|
201
|
+
/**
|
|
202
|
+
* COMPONENTS FOLDS, and this one IS a workspace pref, unlike the collapsed
|
|
203
|
+
* layer subtrees above. It keys on nothing document-scoped: the group is the
|
|
204
|
+
* same group in every Project, so a fold carried across a document switch is
|
|
205
|
+
* the author's answer to "do I want this library open", not a stale id.
|
|
206
|
+
*/
|
|
207
|
+
const [componentsCollapsed, toggleComponents] = useCollapsed({
|
|
208
|
+
persistKey: "shot:rail:components",
|
|
209
|
+
});
|
|
49
210
|
/**
|
|
50
211
|
* BOTH ARE DROPPED WHEN THE DOCUMENT CHANGES, and that is not tidiness.
|
|
51
212
|
* This panel is mounted unconditionally and never keyed (App.tsx), and a
|
|
@@ -54,12 +215,33 @@ export function FilePanel() {
|
|
|
54
215
|
* every shot. Kept, the set folds a group in the NEW document that nobody
|
|
55
216
|
* folded, and the anchor makes the author's first shift-click there a range
|
|
56
217
|
* measured from a click they made in a different document.
|
|
218
|
+
*
|
|
219
|
+
* Only on a REAL change: a wake from the board's hidden `<Activity>` re-runs
|
|
220
|
+
* this effect with the same id, and must not unfold the author's tree.
|
|
57
221
|
*/
|
|
58
222
|
const docId = doc?.id ?? null;
|
|
223
|
+
const clearedFor = useRef(docId);
|
|
59
224
|
useEffect(() => {
|
|
225
|
+
if (clearedFor.current === docId) return;
|
|
226
|
+
clearedFor.current = docId;
|
|
60
227
|
setCollapsed(new Set());
|
|
61
228
|
setAnchor(null);
|
|
62
229
|
}, [docId]);
|
|
230
|
+
/** The code components this document places, grouped. Memoised because it
|
|
231
|
+
* walks every layer in every scene AND every definition, and this panel
|
|
232
|
+
* re-renders on selection. */
|
|
233
|
+
const usedComponents = useMemo(
|
|
234
|
+
() => (doc === null ? [] : usedComponentsOf(doc)),
|
|
235
|
+
[doc],
|
|
236
|
+
);
|
|
237
|
+
/** Fetched ONLY when there is something to name. A shot with no code
|
|
238
|
+
* components never touches the route, which keeps the panel silent in every
|
|
239
|
+
* document that has no use for the registry. */
|
|
240
|
+
const wantsLibrary = usedComponents.length > 0 && !libraryLoaded;
|
|
241
|
+
useEffect(() => {
|
|
242
|
+
if (wantsLibrary) void store.refreshLibrary();
|
|
243
|
+
}, [wantsLibrary, store]);
|
|
244
|
+
const justPlaced = useShotEditor((s) => s.justPlaced);
|
|
63
245
|
|
|
64
246
|
if (doc === null) return null;
|
|
65
247
|
const scene = sceneId === null ? undefined : doc.scenesById[sceneId];
|
|
@@ -67,6 +249,7 @@ export function FilePanel() {
|
|
|
67
249
|
// The forest the outline shows: a definition's while one is being edited,
|
|
68
250
|
// the open shot's otherwise. ONE lookup, so the rows below never branch.
|
|
69
251
|
const home = homeOf(doc, editingDef ?? sceneId ?? "");
|
|
252
|
+
const placedHere = justPlaced !== null && home?.layersById[justPlaced] !== undefined;
|
|
70
253
|
const trimmedQuery = query.trim().toLowerCase();
|
|
71
254
|
const rootIds =
|
|
72
255
|
home === null
|
|
@@ -75,6 +258,123 @@ export function FilePanel() {
|
|
|
75
258
|
? home.layers
|
|
76
259
|
: layerChildren(home.layersById[isolation] ?? ({} as Layer3D));
|
|
77
260
|
|
|
261
|
+
// ── WHAT THE HEAD IS DESCRIBING ─────────────────────────────────────────
|
|
262
|
+
//
|
|
263
|
+
// Four contexts, ONE decision, made here rather than four times in the JSX.
|
|
264
|
+
// The chrome always knew which one it was in (`enteredScene`, `editingDef`,
|
|
265
|
+
// `isolation`); what it never did was say so in a shape the author could act
|
|
266
|
+
// on, and a head that branches inline is how the old crumb strip ended up
|
|
267
|
+
// stating each fact in a different grammar.
|
|
268
|
+
//
|
|
269
|
+
// A DEFINITION WINS over an isolation. The two have been mutually exclusive
|
|
270
|
+
// since v14, so they should never disagree; if they ever do, the definition
|
|
271
|
+
// is the one whose interior is actually on screen.
|
|
272
|
+
const editedDef = editingDef === null ? undefined : defs[editingDef];
|
|
273
|
+
const isolatedLayer =
|
|
274
|
+
editedDef !== undefined || isolation === null || scene === undefined
|
|
275
|
+
? undefined
|
|
276
|
+
: scene.layersById[isolation];
|
|
277
|
+
const subject: "project" | "shot" | "isolation" | "definition" =
|
|
278
|
+
editedDef !== undefined
|
|
279
|
+
? "definition"
|
|
280
|
+
: isolatedLayer !== undefined
|
|
281
|
+
? "isolation"
|
|
282
|
+
: enteredScene !== null && scene !== undefined
|
|
283
|
+
? "shot"
|
|
284
|
+
: "project";
|
|
285
|
+
/** The isolated layer and its ancestors, outermost first. Empty unless an
|
|
286
|
+
* isolation is the subject. Its length says whether one level up is the
|
|
287
|
+
* shot (1) or a group. */
|
|
288
|
+
const isolationTrail =
|
|
289
|
+
isolatedLayer === undefined || scene === undefined
|
|
290
|
+
? []
|
|
291
|
+
: crumbsFor(scene, isolatedLayer.id);
|
|
292
|
+
const projectName = doc.title ?? name ?? doc.name;
|
|
293
|
+
const PROJECT_TITLE = "The whole Project: arrange its Shots";
|
|
294
|
+
/** All the way out to the Project, from whichever level the author is on.
|
|
295
|
+
* The three calls are one gesture and have always travelled together. */
|
|
296
|
+
const exitToProject = (): void => {
|
|
297
|
+
store.exitIsolationAll();
|
|
298
|
+
store.editComponent(null);
|
|
299
|
+
store.leaveScene();
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* THE WAY BACK, EXACTLY ONE LEVEL, which is the rule Escape already follows.
|
|
304
|
+
*
|
|
305
|
+
* This is the control the chrome did not have. The only thing that looked
|
|
306
|
+
* like a way out was a dim 11px crumb, and the one control that read as a
|
|
307
|
+
* button was the timeline head's chip carrying the Project's own name, which
|
|
308
|
+
* reads as a title. A row with a chevron and full ink is the affordance.
|
|
309
|
+
*
|
|
310
|
+
* `testId` keeps the old crumb ids on whichever control now performs their
|
|
311
|
+
* job: `scene-crumb` is whatever lands the author in the Project context,
|
|
312
|
+
* `shot-crumb` is whatever lands them in the Shot. Between contexts the
|
|
313
|
+
* control moves between the back row and the subject; the promise the id
|
|
314
|
+
* makes does not. A back row that climbs to a group is `isolation-back`.
|
|
315
|
+
*
|
|
316
|
+
* The destination and its verb are `backTarget`'s, the one the canvas bar's
|
|
317
|
+
* back chip reads, so the two ways back cannot disagree.
|
|
318
|
+
*
|
|
319
|
+
* AN ISOLATION GETS THE SAME HEAD as a shot or a definition: one level
|
|
320
|
+
* back, then the subject. The full trail lives over the canvas
|
|
321
|
+
* (IsolationTrail.tsx), so the rail does not repeat it. The Project is the
|
|
322
|
+
* top, and a back row there would point at nothing.
|
|
323
|
+
*/
|
|
324
|
+
const backRow = (): {
|
|
325
|
+
label: string;
|
|
326
|
+
title: string;
|
|
327
|
+
testId: string;
|
|
328
|
+
onClick: () => void;
|
|
329
|
+
} | null => {
|
|
330
|
+
if (subject === "project") return null;
|
|
331
|
+
const target = backTarget(store.getState());
|
|
332
|
+
if (target === null) return null;
|
|
333
|
+
if (subject === "isolation") {
|
|
334
|
+
const toShot = isolationTrail.length <= 1;
|
|
335
|
+
return {
|
|
336
|
+
label: target.label ?? "",
|
|
337
|
+
title: `Back to ${target.label ?? ""}`,
|
|
338
|
+
testId: toShot ? "shot-crumb" : "isolation-back",
|
|
339
|
+
onClick: () => target.run(store),
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
// Inside a definition the way back is the Components home (mock C6).
|
|
343
|
+
if (subject === "definition") {
|
|
344
|
+
return {
|
|
345
|
+
label: "All components",
|
|
346
|
+
title: "Every component of this Project",
|
|
347
|
+
testId: "components-home-back",
|
|
348
|
+
onClick: () => target.run(store),
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
return {
|
|
352
|
+
label: target.label ?? "All shots",
|
|
353
|
+
title: target.label === null ? PROJECT_TITLE : `Back to ${target.label}`,
|
|
354
|
+
testId: target.label === null ? "scene-crumb" : "shot-crumb",
|
|
355
|
+
onClick: () => target.run(store),
|
|
356
|
+
};
|
|
357
|
+
};
|
|
358
|
+
const back = backRow();
|
|
359
|
+
/** The subject's glyph, from the same typed maps the outliner rows read. The
|
|
360
|
+
* Project deliberately has none: `icons.tsx` is the one source for chrome
|
|
361
|
+
* glyphs, it carries no Doc3D glyph, and minting one at a call site would
|
|
362
|
+
* put a second source of truth beside it. */
|
|
363
|
+
const subjectIcon =
|
|
364
|
+
subject === "definition"
|
|
365
|
+
? COMPONENT_ICON
|
|
366
|
+
: isolatedLayer !== undefined
|
|
367
|
+
? iconFor(isolatedLayer)
|
|
368
|
+
: subject === "shot"
|
|
369
|
+
? SCENE_ICON
|
|
370
|
+
: null;
|
|
371
|
+
/** The component family keeps the accent here for the reason it keeps it in
|
|
372
|
+
* the outliner: a definition is a different KIND of subject, not just
|
|
373
|
+
* another one. */
|
|
374
|
+
const subjectIsComponent =
|
|
375
|
+
subject === "definition" ||
|
|
376
|
+
(isolatedLayer !== undefined && isComponentGlyph(isolatedLayer));
|
|
377
|
+
|
|
78
378
|
const toggleCollapse = (id: string): void => {
|
|
79
379
|
setCollapsed((prev) => {
|
|
80
380
|
const next = new Set(prev);
|
|
@@ -84,201 +384,855 @@ export function FilePanel() {
|
|
|
84
384
|
});
|
|
85
385
|
};
|
|
86
386
|
|
|
87
|
-
const rowClick
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if (a !== -1 && b !== -1) {
|
|
97
|
-
store.select(flat.slice(Math.min(a, b), Math.max(a, b) + 1));
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
100
|
-
// A stale anchor (deleted, scoped away) falls through to a plain click.
|
|
101
|
-
}
|
|
102
|
-
if (e.ctrlKey || e.metaKey) {
|
|
103
|
-
store.select(selection.includes(id) ? selection.filter((x) => x !== id) : [...selection, id]);
|
|
104
|
-
setAnchor(id);
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
107
|
-
store.select([id]);
|
|
108
|
-
setAnchor(id);
|
|
109
|
-
};
|
|
387
|
+
const { rowClick, rowKeyMove } = layerRowSelection({
|
|
388
|
+
store,
|
|
389
|
+
scene: home,
|
|
390
|
+
ids: rootIds,
|
|
391
|
+
collapsed,
|
|
392
|
+
query: trimmedQuery,
|
|
393
|
+
anchor,
|
|
394
|
+
setAnchor,
|
|
395
|
+
});
|
|
110
396
|
|
|
111
397
|
return (
|
|
112
398
|
<div className="file-panel" data-testid="file-panel">
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
399
|
+
{/* ── NO SECTION SWITCH ──────────────────────────────────────────────
|
|
400
|
+
The column used to open on a three-way switch (the outline, Assets,
|
|
401
|
+
Branding & References). The Paper redesign moved the two Project
|
|
402
|
+
libraries to the Storyboard workspace, so this column is ONE thing in
|
|
403
|
+
every context: the outline (Shots at the Project level, Layers inside
|
|
404
|
+
a shot or a definition) and, inside a container, Components. */}
|
|
405
|
+
{/* ── THE CONTEXT HEAD ───────────────────────────────────────────────
|
|
406
|
+
It used to sit mid-panel, directly above the layer list it scoped,
|
|
407
|
+
which was right while the list below it was the only thing here. The
|
|
408
|
+
panel's contents change with the context now, so the rows that say
|
|
409
|
+
WHICH context go above all of them, and this is the panel's FIRST
|
|
410
|
+
child in every context: the column's head, one panel head tall.
|
|
411
|
+
|
|
412
|
+
IT USED TO BE A FLAT CRUMB STRIP, and that is the defect. Every level
|
|
413
|
+
was 11px --text-dim with no chevron and no back affordance, so the
|
|
414
|
+
only control on screen that read as a way out was the timeline head's
|
|
415
|
+
chip carrying the Project's own name, which reads as a title. Two
|
|
416
|
+
rows answer the two questions the one strip answered neither of: row
|
|
417
|
+
one is the way OUT, one level, in full ink; row two is the SUBJECT
|
|
418
|
+
the tree under it belongs to, at heading size.
|
|
419
|
+
|
|
420
|
+
ONE SHAPE FOR EVERY MODE. Isolation and the component context are
|
|
421
|
+
mutually exclusive since v14, but each scopes this outline, and
|
|
422
|
+
differently-shaped heads per mode would make "where am I" a lesson the
|
|
423
|
+
author has to learn once per mode. */}
|
|
424
|
+
<nav className="rail-head" data-testid="rail-head">
|
|
425
|
+
{back === null ? null : (
|
|
130
426
|
<button
|
|
131
427
|
type="button"
|
|
132
|
-
className=
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
// that pairs `state.sceneId` with `masterToScene(state.frame)`
|
|
137
|
-
// — insertion, block placement, split, camera shots — then
|
|
138
|
-
// reads its scene from one scene and its frame from another.
|
|
139
|
-
// The stage renders the playhead's scene, so a pin the playhead
|
|
140
|
-
// is not in is also a scene the author cannot see.
|
|
141
|
-
onClick={() => store.focusScene(id)}
|
|
428
|
+
className="rail-back"
|
|
429
|
+
data-testid={back.testId}
|
|
430
|
+
title={back.title}
|
|
431
|
+
onClick={back.onClick}
|
|
142
432
|
>
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
</span>
|
|
146
|
-
<span className="row-label">{s.name}</span>
|
|
433
|
+
{CHEVRON_LEFT_ICON}
|
|
434
|
+
{back.label}
|
|
147
435
|
</button>
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
436
|
+
)}
|
|
437
|
+
<div
|
|
438
|
+
className={`rail-subject${subject === "definition" ? " is-definition" : ""}`}
|
|
439
|
+
>
|
|
440
|
+
{subjectIcon === null ? null : (
|
|
441
|
+
<span
|
|
442
|
+
className={`rail-subject-icon${subjectIsComponent ? " is-component" : ""}`}
|
|
443
|
+
aria-hidden="true"
|
|
444
|
+
>
|
|
445
|
+
{subjectIcon}
|
|
446
|
+
</span>
|
|
447
|
+
)}
|
|
448
|
+
{/* THE SUBJECT'S NAME, and in two of the four contexts it is also a
|
|
449
|
+
control, so it carries the crumb id that used to name that
|
|
450
|
+
control. In the Project context it is the only thing standing for
|
|
451
|
+
"the whole Project", which is what `scene-crumb` has always
|
|
452
|
+
meant. In the Shot context it is the way back UP to the shot from
|
|
453
|
+
a subtree or a definition, unchanged. Where the subject cannot
|
|
454
|
+
act (a definition) it is a plain span: the back row above owns
|
|
455
|
+
leaving, and a heading that navigates away on click is the
|
|
456
|
+
crumb-as-title confusion again. */}
|
|
457
|
+
{subject === "project" ? (
|
|
458
|
+
<button
|
|
459
|
+
type="button"
|
|
460
|
+
className="rail-subject-name"
|
|
461
|
+
aria-current
|
|
462
|
+
data-testid="scene-crumb"
|
|
463
|
+
title={PROJECT_TITLE}
|
|
464
|
+
onClick={exitToProject}
|
|
465
|
+
>
|
|
466
|
+
{projectName}
|
|
467
|
+
</button>
|
|
468
|
+
) : subject === "shot" ? (
|
|
469
|
+
<button
|
|
470
|
+
type="button"
|
|
471
|
+
className="rail-subject-name"
|
|
472
|
+
aria-current={isolation === null && editingDef === null}
|
|
473
|
+
data-testid="shot-crumb"
|
|
474
|
+
onClick={() => {
|
|
475
|
+
store.exitIsolationAll();
|
|
476
|
+
store.editComponent(null);
|
|
477
|
+
}}
|
|
478
|
+
>
|
|
479
|
+
{scene?.name}
|
|
480
|
+
</button>
|
|
481
|
+
) : subject === "isolation" ? (
|
|
482
|
+
<span
|
|
483
|
+
className="rail-subject-name"
|
|
484
|
+
aria-current
|
|
485
|
+
data-testid="isolation-subject"
|
|
486
|
+
title="Isolated: only this layer and what is inside it can be picked"
|
|
487
|
+
>
|
|
488
|
+
{isolatedLayer?.label}
|
|
165
489
|
</span>
|
|
490
|
+
) : (
|
|
491
|
+
<span
|
|
492
|
+
className="rail-subject-name"
|
|
493
|
+
aria-current
|
|
494
|
+
data-testid="definition-crumb"
|
|
495
|
+
title="Editing this definition: every instance follows"
|
|
496
|
+
>
|
|
497
|
+
{editedDef?.name}
|
|
498
|
+
</span>
|
|
499
|
+
)}
|
|
500
|
+
</div>
|
|
501
|
+
</nav>
|
|
502
|
+
|
|
503
|
+
{/* SEARCH, IN EVERY CONTEXT, because every context has a list under it.
|
|
504
|
+
It used to render only inside a shot, on the reasoning that it belongs
|
|
505
|
+
to the layer tree. True, and it left the Project context with a list
|
|
506
|
+
and no way to filter it. One box, filtering whatever the list below
|
|
507
|
+
it is: the placeholder says which.
|
|
508
|
+
|
|
509
|
+
The ui package's `.search-field` recipe: the wrapper carries the edge
|
|
510
|
+
so the magnifier and the "/" hint sit inside it. The key is real
|
|
511
|
+
(`focusSearch` in keymap.ts, with a row in the manual); the `kbd` is
|
|
512
|
+
`aria-hidden` because it repeats that binding and names nothing. */}
|
|
513
|
+
<div className="file-search">
|
|
514
|
+
<label className="search-field">
|
|
515
|
+
{SEARCH_ICON}
|
|
516
|
+
<input
|
|
517
|
+
ref={searchRef}
|
|
518
|
+
type="search"
|
|
519
|
+
value={query}
|
|
520
|
+
placeholder={inScene ? "Search shots" : "Search layers"}
|
|
521
|
+
aria-label={inScene ? "Search shots" : "Search layers"}
|
|
522
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
523
|
+
/>
|
|
524
|
+
<kbd className="kbd" aria-hidden="true">
|
|
525
|
+
/
|
|
526
|
+
</kbd>
|
|
527
|
+
</label>
|
|
528
|
+
</div>
|
|
529
|
+
|
|
530
|
+
{/* THE ONE SCROLLER. Everything above it is fixed; its height is
|
|
531
|
+
bounded by the column, which is what lets the outline virtualise. */}
|
|
532
|
+
<div className="rail-scroll">
|
|
533
|
+
<>
|
|
534
|
+
{/* THE SCENE CONTEXT'S OUTLINE: the document's children are its shots.
|
|
535
|
+
Absent inside one, where the children are elements and the tree below
|
|
536
|
+
lists them. */}
|
|
537
|
+
{!inScene ? null : (
|
|
538
|
+
<>
|
|
539
|
+
{menu.menu}
|
|
540
|
+
{/* The count is a SIBLING of `.file-section-label`, never a child:
|
|
541
|
+
two rail tests compare that element's textContent by equality, so
|
|
542
|
+
a nested span would rename the section rather than annotate it.
|
|
543
|
+
`.file-section-head` (left-column.css) lays the row out as a flex
|
|
544
|
+
line, so the count's `margin-left: auto` pulls it to the right.
|
|
545
|
+
The Layers head below uses the same markup. */}
|
|
546
|
+
<div className="file-section-head file-section-label--actions">
|
|
547
|
+
<div className="file-section-label">Shots</div>
|
|
548
|
+
<span className="file-section-count">{doc.scenes.length}</span>
|
|
549
|
+
</div>
|
|
550
|
+
<ul className="scene-list">
|
|
551
|
+
{doc.scenes.map((id) => {
|
|
552
|
+
const s = doc.scenesById[id];
|
|
553
|
+
if (s === undefined) return null;
|
|
554
|
+
// The same predicate the layer tree filters on, on the one field
|
|
555
|
+
// a shot row shows. A shot has no children in this list, so
|
|
556
|
+
// there is no subtree rule to mirror here.
|
|
557
|
+
if (
|
|
558
|
+
trimmedQuery.length > 0 &&
|
|
559
|
+
!s.name.toLowerCase().includes(trimmedQuery)
|
|
560
|
+
) {
|
|
561
|
+
return null;
|
|
562
|
+
}
|
|
563
|
+
// The LI is the row: the band has to run under the Open
|
|
564
|
+
// button too, so hover and the current mark live here.
|
|
565
|
+
return (
|
|
566
|
+
<li
|
|
567
|
+
key={id}
|
|
568
|
+
className={`scene-list-item${id === sceneId ? " is-current" : ""}`}
|
|
569
|
+
>
|
|
570
|
+
{renamingShot === id ? (
|
|
571
|
+
<input
|
|
572
|
+
className="shot-rename"
|
|
573
|
+
defaultValue={s.name}
|
|
574
|
+
autoFocus
|
|
575
|
+
aria-label={`Rename ${s.name}`}
|
|
576
|
+
data-testid={`shot-rename-row-${id}`}
|
|
577
|
+
onBlur={(e) => {
|
|
578
|
+
setRenamingShot(null);
|
|
579
|
+
store.renameScene(id, e.currentTarget.value);
|
|
580
|
+
}}
|
|
581
|
+
onKeyDown={(e) => {
|
|
582
|
+
e.stopPropagation();
|
|
583
|
+
if (e.key === "Enter") {
|
|
584
|
+
setRenamingShot(null);
|
|
585
|
+
store.renameScene(id, e.currentTarget.value);
|
|
586
|
+
} else if (e.key === "Escape")
|
|
587
|
+
setRenamingShot(null);
|
|
588
|
+
}}
|
|
589
|
+
/>
|
|
590
|
+
) : (
|
|
591
|
+
<button
|
|
592
|
+
type="button"
|
|
593
|
+
className={`scene-row${id === sceneId ? " is-current" : ""}`}
|
|
594
|
+
aria-current={id === sceneId}
|
|
595
|
+
// THE WAY IN, on the gesture every file browser gives "go
|
|
596
|
+
// inside". The `›` beside it stays: a double-click is fast
|
|
597
|
+
// and a chevron is discoverable, and the two cost one 12px
|
|
598
|
+
// button between them.
|
|
599
|
+
onDoubleClick={() => store.enterScene(id)}
|
|
600
|
+
// ONE MENU, TWO SURFACES. The same entries the timeline's
|
|
601
|
+
// shot row opens. `shotMenuEntries` is the single builder,
|
|
602
|
+
// for `layerMenuEntries`' reason: a Delete that meant one
|
|
603
|
+
// thing here and another there is worse than one of them.
|
|
604
|
+
onContextMenu={(e) => {
|
|
605
|
+
e.preventDefault();
|
|
606
|
+
e.stopPropagation();
|
|
607
|
+
store.focusScene(id);
|
|
608
|
+
menu.open(
|
|
609
|
+
{ x: e.clientX, y: e.clientY },
|
|
610
|
+
shotMenuEntries(store, store.getState(), id, () =>
|
|
611
|
+
setRenamingShot(id),
|
|
612
|
+
),
|
|
613
|
+
SHOT_MENU_OPTIONS,
|
|
614
|
+
);
|
|
615
|
+
}}
|
|
616
|
+
// `focusScene`, not `setScene`: a bare pin leaves the
|
|
617
|
+
// playhead in whatever scene it was already in, and every
|
|
618
|
+
// store call that pairs `state.sceneId` with
|
|
619
|
+
// `masterToScene(state.frame)` (insertion, placement,
|
|
620
|
+
// split, camera shots) then reads its scene from one scene
|
|
621
|
+
// and its frame from another. The stage renders the
|
|
622
|
+
// playhead's scene, so a pin the playhead is not in is also
|
|
623
|
+
// a scene the author cannot see.
|
|
624
|
+
onClick={() => store.focusScene(id)}
|
|
625
|
+
>
|
|
626
|
+
<span className="layer-kind" aria-hidden="true">
|
|
627
|
+
{SCENE_ICON}
|
|
628
|
+
</span>
|
|
629
|
+
<span className="row-label">{s.name}</span>
|
|
630
|
+
{/* THE SHOT'S LENGTH, in the chrome's one duration
|
|
631
|
+
string (`formatDuration`), so the column and the
|
|
632
|
+
timeline never spell one duration two ways. Inside the
|
|
633
|
+
button, so the whole row stays one click and one
|
|
634
|
+
double-click target. */}
|
|
635
|
+
<span className="scene-row-duration">
|
|
636
|
+
{formatDuration(framesToSeconds(s.durationFrames, doc.fps))}
|
|
637
|
+
</span>
|
|
638
|
+
</button>
|
|
639
|
+
)}
|
|
640
|
+
{/* The SECOND door in, beside the timeline row's. A row click
|
|
641
|
+
seeks (the scene context's own gesture — look at this shot)
|
|
642
|
+
and this OPENS. Two verbs on one row rather than one that
|
|
643
|
+
guesses, because the two are different requests and the
|
|
644
|
+
second one changes the whole chrome. */}
|
|
645
|
+
<button
|
|
646
|
+
type="button"
|
|
647
|
+
className="layer-flag"
|
|
648
|
+
data-testid={`shot-open-${id}`}
|
|
649
|
+
aria-label={`Open ${s.name}`}
|
|
650
|
+
title="Open this shot: add and animate its elements"
|
|
651
|
+
onClick={() => store.enterScene(id)}
|
|
652
|
+
>
|
|
653
|
+
›
|
|
654
|
+
</button>
|
|
655
|
+
</li>
|
|
656
|
+
);
|
|
657
|
+
})}
|
|
658
|
+
</ul>
|
|
659
|
+
{/* ADD SHOT, the dock's `+ Shot` verb (`store.addScene`) where the
|
|
660
|
+
list it grows is. Paper's dashed "add one more" slot, the ui
|
|
661
|
+
package's `.btn.is-dashed`. Only here: inside a shot this list is
|
|
662
|
+
not on screen, and the dock keeps its own door. */}
|
|
663
|
+
<div className="scene-list-add">
|
|
166
664
|
<button
|
|
167
665
|
type="button"
|
|
168
|
-
className="
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
// are in no scene and have no camera to point at them.
|
|
172
|
-
onClick={() => store.editComponent(d.id === editingDef ? null : d.id)}
|
|
666
|
+
className="btn is-dashed"
|
|
667
|
+
data-testid="rail-add-shot"
|
|
668
|
+
onClick={() => store.addScene()}
|
|
173
669
|
>
|
|
174
|
-
{
|
|
670
|
+
{ADD_ICON}
|
|
671
|
+
Add shot
|
|
175
672
|
</button>
|
|
176
|
-
|
|
177
|
-
|
|
673
|
+
</div>
|
|
674
|
+
</>
|
|
675
|
+
)}
|
|
676
|
+
|
|
677
|
+
{/* ── NO LAYER TREE IN THE SCENE CONTEXT ────────────────────────────
|
|
678
|
+
An element belongs to exactly ONE shot, and in the scene context the
|
|
679
|
+
author has not said which. Listing the playhead's shot instead would
|
|
680
|
+
be a tree that silently changed under every scrub across a cut, with
|
|
681
|
+
every row a door into a context the panel does not say it is in, the
|
|
682
|
+
same failure the two-level dock was deleted for.
|
|
683
|
+
There is no placeholder here any more, and that is the point: the shots
|
|
684
|
+
list ABOVE is the scene context's outline, so there is no empty panel
|
|
685
|
+
to explain. The sentence that used to stand here pointed at the
|
|
686
|
+
timeline for a `›` this panel carries itself.
|
|
687
|
+
|
|
688
|
+
IT RENDERS BEFORE COMPONENTS, which the header at the top of this file
|
|
689
|
+
has claimed for a release while the JSX did the opposite. The layer
|
|
690
|
+
tree is the SUBJECT of the shot context; the components list is a
|
|
691
|
+
library every context shows, above and below and inside a shot alike.
|
|
692
|
+
The subject goes first. Nothing in state depends on the order. */}
|
|
693
|
+
{inScene || home === null ? null : (
|
|
694
|
+
<>
|
|
695
|
+
{/* Count outside the label, for the reason the Shots head gives. It
|
|
696
|
+
counts what is directly INSIDE this container, not what the search
|
|
697
|
+
leaves on screen: the section names the container, and a number
|
|
698
|
+
that shrank as the author typed would be naming the filter. */}
|
|
699
|
+
<div className="file-section-head file-section-label--actions">
|
|
700
|
+
<div className="file-section-label">
|
|
701
|
+
Layers
|
|
702
|
+
</div>
|
|
703
|
+
<span className="file-section-count">{rootIds.length}</span>
|
|
704
|
+
</div>
|
|
705
|
+
<WindowedLayerRows
|
|
706
|
+
// The CONTAINER, which is a definition while one is being edited.
|
|
707
|
+
// Every op the rows drive takes a container id, so the drag, the
|
|
708
|
+
// rename and the flags all work inside a definition unchanged.
|
|
709
|
+
scene={home}
|
|
710
|
+
containerId={editingDef ?? sceneId ?? ""}
|
|
711
|
+
// SCOPED to the isolated subtree's own children, so the outline
|
|
712
|
+
// says the same thing the stage does about what is being edited.
|
|
713
|
+
ids={rootIds}
|
|
714
|
+
parent={editingDef === null ? isolation : null}
|
|
715
|
+
query={trimmedQuery}
|
|
716
|
+
collapsed={collapsed}
|
|
717
|
+
onToggleCollapse={toggleCollapse}
|
|
718
|
+
onRowClick={rowClick}
|
|
719
|
+
onRowKeyMove={rowKeyMove}
|
|
720
|
+
/>
|
|
721
|
+
{/* Says where an unselected placement went and how to reach it:
|
|
722
|
+
a locked layer is skipped by stage clicks, so its row is the
|
|
723
|
+
way in. */}
|
|
724
|
+
{placedHere ? (
|
|
725
|
+
<p className="layer-placed-note" role="status">
|
|
726
|
+
Just added. Locked at the back. Unlock to edit or delete.
|
|
727
|
+
</p>
|
|
728
|
+
) : null}
|
|
729
|
+
</>
|
|
730
|
+
)}
|
|
731
|
+
|
|
732
|
+
{/* COMPONENTS, INSIDE A CONTAINER ONLY. See the file header for the
|
|
733
|
+
argument. Above a shot the block's verbs are all refused (`+` places
|
|
734
|
+
an instance, and `insertContext` refuses every insert in the scene
|
|
735
|
+
context) or better reached from the App menu (Component ▸ Edit
|
|
736
|
+
definition), and the scene level's subject is the cut.
|
|
737
|
+
|
|
738
|
+
The header is always present at the levels that DO draw it (unify P2):
|
|
739
|
+
New Component is how a definition can exist from NOTHING, so the way
|
|
740
|
+
in cannot hide behind having one already.
|
|
741
|
+
|
|
742
|
+
A NATIVE `details`, and NOT `InspectorSection`: that one unmounts its
|
|
743
|
+
body when collapsed, and these rows have to stay queryable at rest.
|
|
744
|
+
The library is the one block here that is not about where the author
|
|
745
|
+
is standing, so it is also the one worth folding out of the way, and
|
|
746
|
+
the fold persists because the answer to "do I want this open" is the
|
|
747
|
+
author's habit rather than anything document-scoped. */}
|
|
748
|
+
{/* Not inside a definition: Components is the workspace there, with
|
|
749
|
+
its own list one press away (All components). Not inside an
|
|
750
|
+
isolation either: the rail is about the subtree there, and a
|
|
751
|
+
library under it reads as part of what is isolated. Add and the
|
|
752
|
+
Assets tab still place components into it. */}
|
|
753
|
+
{inScene || editingDef !== null || subject === "isolation" ? null : (
|
|
754
|
+
<details className="rail-group" open={!componentsCollapsed}>
|
|
755
|
+
<summary
|
|
756
|
+
onClick={(e) => {
|
|
757
|
+
// The native toggle is refused and the store answers instead: `open`
|
|
758
|
+
// is a controlled prop here, so letting the browser flip the DOM
|
|
759
|
+
// attribute would leave it disagreeing with React until some
|
|
760
|
+
// unrelated render put it back.
|
|
761
|
+
e.preventDefault();
|
|
762
|
+
toggleComponents();
|
|
763
|
+
}}
|
|
764
|
+
>
|
|
765
|
+
<span className="rail-group-chevron" aria-hidden="true">
|
|
766
|
+
<IconChevron />
|
|
178
767
|
</span>
|
|
768
|
+
<span className="file-section-label">Components</span>
|
|
769
|
+
{/* BOTH LISTS, because the count exists to say what a CLOSED group
|
|
770
|
+
holds and this group holds two lists: the authored definitions
|
|
771
|
+
and the code components the document actually places. Counting
|
|
772
|
+
only `defs` read "0" over seven visible rows in every project
|
|
773
|
+
that writes its components in code rather than authoring them
|
|
774
|
+
here, which is the common case. */}
|
|
775
|
+
<span className="file-section-count">
|
|
776
|
+
{Object.keys(defs).length + usedComponents.length}
|
|
777
|
+
</span>
|
|
778
|
+
{/* NOT `.layer-flag`, which is what this was. That class rests at 0.28
|
|
779
|
+
outside a row that lifts it, and this button is not inside a row,
|
|
780
|
+
so the one way to make a definition from nothing sat permanently
|
|
781
|
+
dimmed with no hover that could reach it.
|
|
782
|
+
Both handlers matter: `stopPropagation` keeps the summary's React
|
|
783
|
+
toggle out of it, and `preventDefault` keeps the browser's own
|
|
784
|
+
summary activation out of it too. */}
|
|
179
785
|
<button
|
|
180
786
|
type="button"
|
|
181
|
-
className="
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
787
|
+
className="rail-group-action"
|
|
788
|
+
data-testid="new-component"
|
|
789
|
+
aria-label="New component"
|
|
790
|
+
title="New component"
|
|
791
|
+
onClick={(e) => {
|
|
792
|
+
e.preventDefault();
|
|
793
|
+
e.stopPropagation();
|
|
794
|
+
store.createEmptyComponent("Component");
|
|
795
|
+
}}
|
|
185
796
|
>
|
|
186
797
|
+
|
|
187
798
|
</button>
|
|
188
|
-
</
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
799
|
+
</summary>
|
|
800
|
+
{Object.keys(defs).length === 0 ? null : (
|
|
801
|
+
<ul className="component-list" data-testid="component-list">
|
|
802
|
+
{Object.values(defs).map((d) => (
|
|
803
|
+
<li
|
|
804
|
+
key={d.id}
|
|
805
|
+
className={`component-row${d.id === editingDef ? " is-current" : ""}`}
|
|
806
|
+
>
|
|
807
|
+
{/* The four-diamond is the component family's own glyph, so it
|
|
808
|
+
carries the family's accent like every other diamond. */}
|
|
809
|
+
<span
|
|
810
|
+
className="layer-kind is-component"
|
|
811
|
+
aria-hidden="true"
|
|
812
|
+
>
|
|
813
|
+
{COMPONENT_ICON}
|
|
814
|
+
</span>
|
|
815
|
+
<button
|
|
816
|
+
type="button"
|
|
817
|
+
className="layer-name"
|
|
818
|
+
aria-current={d.id === editingDef}
|
|
819
|
+
// OPENS THE COMPONENT ITSELF: this row names a definition and
|
|
820
|
+
// no layer inside it, so `editComponent` lands on the Studio.
|
|
821
|
+
// Its interior is one hand-off further in.
|
|
822
|
+
onClick={() =>
|
|
823
|
+
store.editComponent(d.id === editingDef ? null : d.id)
|
|
824
|
+
}
|
|
825
|
+
>
|
|
826
|
+
{d.name}
|
|
827
|
+
</button>
|
|
828
|
+
<span className="component-count" aria-hidden="true">
|
|
829
|
+
{instancesOf(doc, d.id).length}
|
|
830
|
+
</span>
|
|
831
|
+
{/* Placing an instance is an INSERT: it lands an element at the
|
|
832
|
+
playhead, and `insertContext` refuses every insert in the
|
|
833
|
+
scene context. ABSENT rather than disabled, which is this
|
|
834
|
+
panel's rule for a verb the current level cannot do at all:
|
|
835
|
+
the store's refusal stays the backstop underneath it. */}
|
|
836
|
+
{inScene ? null : (
|
|
837
|
+
<button
|
|
838
|
+
type="button"
|
|
839
|
+
className="layer-flag"
|
|
840
|
+
aria-label={`Place ${d.name}`}
|
|
841
|
+
title="Place another one"
|
|
842
|
+
onClick={() => store.placeComponent(d.id)}
|
|
843
|
+
>
|
|
844
|
+
+
|
|
845
|
+
</button>
|
|
846
|
+
)}
|
|
847
|
+
</li>
|
|
848
|
+
))}
|
|
849
|
+
</ul>
|
|
850
|
+
)}
|
|
238
851
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
852
|
+
{/* THE OTHER COMPONENT FAMILY, and the reason this section used to look
|
|
853
|
+
empty in a shot full of components. A definition above is authored
|
|
854
|
+
here and lives in `doc.components`; a row below is a component from
|
|
855
|
+
the author's own repo, placed as an `instance` layer, which nothing in
|
|
856
|
+
this chrome ever listed — so a shot built entirely from repo
|
|
857
|
+
components showed a header and nothing else.
|
|
858
|
+
A SEPARATE list, not merged into the one above: the two families are
|
|
859
|
+
different things (one can be entered and edited, one is a .tsx file
|
|
860
|
+
the author owns) and the rows do different things when clicked. */}
|
|
861
|
+
{usedComponents.length === 0 ? null : (
|
|
862
|
+
<ul
|
|
863
|
+
className="component-list"
|
|
864
|
+
data-testid="used-component-list"
|
|
865
|
+
>
|
|
866
|
+
{usedComponents.map((used) => {
|
|
867
|
+
// The registry's display name once it has loaded, the export name
|
|
868
|
+
// parsed out of the id until then. Never a spinner and never an
|
|
869
|
+
// empty row: the id always carries a usable name.
|
|
870
|
+
const row = library.find((r) => r.id === used.component);
|
|
871
|
+
const name = row?.name ?? instanceExportOf(used.component);
|
|
872
|
+
// The ones in the CONTAINER the author is looking at. A count is
|
|
873
|
+
// document-wide, but selecting reaches only what is in front of
|
|
874
|
+
// them, so a row whose instances all live elsewhere does not
|
|
875
|
+
// pretend the click did something.
|
|
876
|
+
const here = used.layerIds.filter(
|
|
877
|
+
(id) => home?.layersById[id] !== undefined,
|
|
878
|
+
);
|
|
879
|
+
return (
|
|
880
|
+
<li
|
|
881
|
+
key={used.component}
|
|
882
|
+
className="component-row component-row--code"
|
|
883
|
+
>
|
|
884
|
+
<span
|
|
885
|
+
className="layer-kind is-component"
|
|
886
|
+
aria-hidden="true"
|
|
887
|
+
>
|
|
888
|
+
{kindIcon("instance")}
|
|
889
|
+
</span>
|
|
890
|
+
{/* OPENS THE COMPONENT, which is what a row naming a component
|
|
891
|
+
should do — the definition row above opens one too. For this
|
|
892
|
+
family that means the code studio: the source file on the
|
|
893
|
+
left and a render of it on the right, because a code
|
|
894
|
+
component's interior IS its source.
|
|
895
|
+
NEVER DISABLED. Whether a shot happens to place this
|
|
896
|
+
component in the container the author is standing in has
|
|
897
|
+
nothing to do with whether its file can be opened. */}
|
|
898
|
+
<button
|
|
899
|
+
type="button"
|
|
900
|
+
className="layer-name"
|
|
901
|
+
// The module path, which is the "which file is this" this row
|
|
902
|
+
// owes: it is a file in the author's own repo.
|
|
903
|
+
title={used.component}
|
|
904
|
+
onClick={() =>
|
|
905
|
+
void store.openCodeComponent(used.component)
|
|
906
|
+
}
|
|
907
|
+
>
|
|
908
|
+
{name}
|
|
909
|
+
</button>
|
|
910
|
+
{/* THE COUNT IS THE OTHER DOOR. It was the whole row's click
|
|
911
|
+
before the code studio existed, and selecting the placements
|
|
912
|
+
is still worth reaching — so it moved onto the number that
|
|
913
|
+
names them rather than being lost. Disabled when they all
|
|
914
|
+
live in another container, because selection reaches only
|
|
915
|
+
what is in front of the author. */}
|
|
916
|
+
<button
|
|
917
|
+
type="button"
|
|
918
|
+
className="component-count"
|
|
919
|
+
aria-label={`Select the ${here.length} placed here`}
|
|
920
|
+
title="Select this component's layers in the open container"
|
|
921
|
+
disabled={here.length === 0}
|
|
922
|
+
onClick={() => store.select(here)}
|
|
923
|
+
>
|
|
924
|
+
{used.layerIds.length}
|
|
925
|
+
</button>
|
|
926
|
+
{/* Same insert gate as a definition's `+` above, and the same
|
|
927
|
+
verb the Add menu places registry components with. */}
|
|
928
|
+
{inScene ? null : (
|
|
929
|
+
<button
|
|
930
|
+
type="button"
|
|
931
|
+
className="layer-flag"
|
|
932
|
+
aria-label={`Place ${name}`}
|
|
933
|
+
title="Place another one"
|
|
934
|
+
onClick={() =>
|
|
935
|
+
void store
|
|
936
|
+
.addInstanceLayer(used.component)
|
|
937
|
+
.catch(() => {})
|
|
938
|
+
}
|
|
939
|
+
>
|
|
940
|
+
+
|
|
941
|
+
</button>
|
|
942
|
+
)}
|
|
943
|
+
</li>
|
|
944
|
+
);
|
|
945
|
+
})}
|
|
946
|
+
</ul>
|
|
947
|
+
)}
|
|
948
|
+
{Object.keys(defs).length === 0 && usedComponents.length === 0 ? (
|
|
949
|
+
<div className="resource-empty">
|
|
950
|
+
Nothing yet. Group layers into a component, or add one from
|
|
951
|
+
the + menu.
|
|
952
|
+
</div>
|
|
953
|
+
) : null}
|
|
954
|
+
</details>
|
|
955
|
+
)}
|
|
956
|
+
</>
|
|
957
|
+
</div>
|
|
242
958
|
</div>
|
|
243
959
|
);
|
|
244
960
|
}
|
|
245
961
|
|
|
246
|
-
|
|
962
|
+
const OUTLINE_WINDOW_THRESHOLD = 60;
|
|
963
|
+
|
|
964
|
+
interface LayerEntry {
|
|
965
|
+
layer: Layer3D;
|
|
966
|
+
/** Its index in the STORED sibling list (0 = the back), not its row. */
|
|
967
|
+
index: number;
|
|
968
|
+
parent: string | null;
|
|
969
|
+
depth: number;
|
|
970
|
+
siblingCount: number;
|
|
971
|
+
hasChildren: boolean;
|
|
972
|
+
open: boolean;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
interface VirtualLayerInteraction {
|
|
976
|
+
tabIndex: number;
|
|
977
|
+
registerName(id: string, node: HTMLButtonElement | null): void;
|
|
978
|
+
onNameFocus(id: string): void;
|
|
979
|
+
onNameKeyDown(id: string, event: ReactKeyboardEvent<HTMLButtonElement>): void;
|
|
980
|
+
onRenameChange(id: string, active: boolean): void;
|
|
981
|
+
onDragChange(id: string | null): void;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/** A cell a host panel adds at the END of every row (the board's Layers tab
|
|
985
|
+
* puts each layer's life bar there). The Files panel passes none, so its rows
|
|
986
|
+
* are unchanged. */
|
|
987
|
+
export type LayerRowTrailing = (layer: Layer3D) => React.ReactNode;
|
|
988
|
+
|
|
989
|
+
/** Large outlines are flattened to the same visible order shift-selection
|
|
990
|
+
* uses, then windowed. Small trees keep the original nested DOM and guide
|
|
991
|
+
* rails exactly. */
|
|
992
|
+
export function WindowedLayerRows(props: {
|
|
993
|
+
scene: LayerHome;
|
|
994
|
+
containerId: string;
|
|
995
|
+
ids: readonly string[];
|
|
996
|
+
parent: string | null;
|
|
997
|
+
query: string;
|
|
998
|
+
collapsed: ReadonlySet<string>;
|
|
999
|
+
onToggleCollapse: (id: string) => void;
|
|
1000
|
+
onRowClick: (
|
|
1001
|
+
id: string,
|
|
1002
|
+
e: { shiftKey: boolean; ctrlKey: boolean; metaKey: boolean },
|
|
1003
|
+
) => void;
|
|
1004
|
+
onRowKeyMove: (fromId: string, toId: string, extend: boolean) => void;
|
|
1005
|
+
trailing?: LayerRowTrailing;
|
|
1006
|
+
}) {
|
|
1007
|
+
const entries = useMemo(
|
|
1008
|
+
() =>
|
|
1009
|
+
flattenLayerEntries(
|
|
1010
|
+
props.scene,
|
|
1011
|
+
props.ids,
|
|
1012
|
+
props.parent,
|
|
1013
|
+
props.collapsed,
|
|
1014
|
+
props.query,
|
|
1015
|
+
),
|
|
1016
|
+
[props.scene, props.ids, props.parent, props.collapsed, props.query],
|
|
1017
|
+
);
|
|
1018
|
+
const listRef = useRef<HTMLUListElement>(null);
|
|
1019
|
+
const nameRefs = useRef(new Map<string, HTMLButtonElement>());
|
|
1020
|
+
const [activeId, setActiveId] = useState<string | null>(null);
|
|
1021
|
+
const [focusRequest, setFocusRequest] = useState<string | null>(null);
|
|
1022
|
+
const [renamingId, setRenamingId] = useState<string | null>(null);
|
|
1023
|
+
const [draggingId, setDraggingId] = useState<string | null>(null);
|
|
1024
|
+
const virtual = entries.length > OUTLINE_WINDOW_THRESHOLD;
|
|
1025
|
+
const range = useFixedVirtualWindow({
|
|
1026
|
+
listRef,
|
|
1027
|
+
scrollSelector: ".rail-scroll",
|
|
1028
|
+
count: entries.length,
|
|
1029
|
+
rowHeight: LAYER_ROW_H,
|
|
1030
|
+
enabled: virtual,
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
const entryIndex = useMemo(
|
|
1034
|
+
() =>
|
|
1035
|
+
new Map(entries.map((entry, index) => [entry.layer.id, index] as const)),
|
|
1036
|
+
[entries],
|
|
1037
|
+
);
|
|
1038
|
+
const effectiveActiveId =
|
|
1039
|
+
activeId !== null && entryIndex.has(activeId)
|
|
1040
|
+
? activeId
|
|
1041
|
+
: (entries[0]?.layer.id ?? null);
|
|
1042
|
+
const registerName = useCallback(
|
|
1043
|
+
(id: string, node: HTMLButtonElement | null): void => {
|
|
1044
|
+
if (node === null) nameRefs.current.delete(id);
|
|
1045
|
+
else nameRefs.current.set(id, node);
|
|
1046
|
+
},
|
|
1047
|
+
[],
|
|
1048
|
+
);
|
|
1049
|
+
|
|
1050
|
+
useLayoutEffect(() => {
|
|
1051
|
+
if (focusRequest === null) return;
|
|
1052
|
+
const button = nameRefs.current.get(focusRequest);
|
|
1053
|
+
if (button === undefined) return;
|
|
1054
|
+
// The requested row is already pinned into the correct logical position.
|
|
1055
|
+
// Let the browser reveal it, then focus without asking for a second scroll.
|
|
1056
|
+
if (typeof button.scrollIntoView === "function") {
|
|
1057
|
+
button.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
1058
|
+
}
|
|
1059
|
+
button.focus({ preventScroll: true });
|
|
1060
|
+
setFocusRequest(null);
|
|
1061
|
+
}, [focusRequest]);
|
|
1062
|
+
|
|
1063
|
+
const focusEntry = (
|
|
1064
|
+
fromId: string,
|
|
1065
|
+
index: number,
|
|
1066
|
+
extend: boolean,
|
|
1067
|
+
select: boolean,
|
|
1068
|
+
): void => {
|
|
1069
|
+
const target = entries[index];
|
|
1070
|
+
if (target === undefined) return;
|
|
1071
|
+
const id = target.layer.id;
|
|
1072
|
+
if (select) props.onRowKeyMove(fromId, id, extend);
|
|
1073
|
+
setActiveId(id);
|
|
1074
|
+
setFocusRequest(id);
|
|
1075
|
+
};
|
|
1076
|
+
|
|
1077
|
+
const onNameKeyDown = (
|
|
1078
|
+
id: string,
|
|
1079
|
+
event: ReactKeyboardEvent<HTMLButtonElement>,
|
|
1080
|
+
): void => {
|
|
1081
|
+
const index = entryIndex.get(id);
|
|
1082
|
+
if (index === undefined) return;
|
|
1083
|
+
const entry = entries[index]!;
|
|
1084
|
+
let target: number | null = null;
|
|
1085
|
+
if (event.key === "ArrowDown")
|
|
1086
|
+
target = Math.min(entries.length - 1, index + 1);
|
|
1087
|
+
else if (event.key === "ArrowUp") target = Math.max(0, index - 1);
|
|
1088
|
+
else if (event.key === "Home") target = 0;
|
|
1089
|
+
else if (event.key === "End") target = entries.length - 1;
|
|
1090
|
+
else if (event.key === "ArrowRight") {
|
|
1091
|
+
if (entry.hasChildren && !entry.open && props.query.length === 0) {
|
|
1092
|
+
event.preventDefault();
|
|
1093
|
+
event.stopPropagation();
|
|
1094
|
+
props.onToggleCollapse(id);
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
if (entry.hasChildren && entry.open)
|
|
1098
|
+
target = Math.min(entries.length - 1, index + 1);
|
|
1099
|
+
} else if (event.key === "ArrowLeft") {
|
|
1100
|
+
if (entry.hasChildren && entry.open && props.query.length === 0) {
|
|
1101
|
+
event.preventDefault();
|
|
1102
|
+
event.stopPropagation();
|
|
1103
|
+
props.onToggleCollapse(id);
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
if (entry.parent !== null) target = entryIndex.get(entry.parent) ?? null;
|
|
1107
|
+
}
|
|
1108
|
+
if (target === null || target === index) return;
|
|
1109
|
+
event.preventDefault();
|
|
1110
|
+
event.stopPropagation();
|
|
1111
|
+
// Ctrl/Cmd moves focus without changing selection. Shift extends from the
|
|
1112
|
+
// stable range anchor; an ordinary move selects and moves that anchor.
|
|
1113
|
+
focusEntry(id, target, event.shiftKey, !event.ctrlKey && !event.metaKey);
|
|
1114
|
+
};
|
|
1115
|
+
|
|
1116
|
+
const pinnedIndexes = [effectiveActiveId, renamingId, draggingId]
|
|
1117
|
+
.map((id) => (id === null ? undefined : entryIndex.get(id)))
|
|
1118
|
+
.filter((index): index is number => index !== undefined);
|
|
1119
|
+
const spans = pinnedWindowSpans(range, entries.length, pinnedIndexes);
|
|
1120
|
+
const virtualRows: React.ReactNode[] = [];
|
|
1121
|
+
let cursor = 0;
|
|
1122
|
+
for (const span of spans) {
|
|
1123
|
+
if (span.start > cursor) {
|
|
1124
|
+
virtualRows.push(
|
|
1125
|
+
<li
|
|
1126
|
+
key={`outline-gap-${cursor}-${span.start}`}
|
|
1127
|
+
aria-hidden="true"
|
|
1128
|
+
role="presentation"
|
|
1129
|
+
data-outline-spacer="true"
|
|
1130
|
+
style={{ height: (span.start - cursor) * LAYER_ROW_H }}
|
|
1131
|
+
/>,
|
|
1132
|
+
);
|
|
1133
|
+
}
|
|
1134
|
+
for (let index = span.start; index < span.end; index += 1) {
|
|
1135
|
+
const entry = entries[index]!;
|
|
1136
|
+
virtualRows.push(
|
|
1137
|
+
<LayerRow
|
|
1138
|
+
key={entry.layer.id}
|
|
1139
|
+
layer={entry.layer}
|
|
1140
|
+
index={entry.index}
|
|
1141
|
+
parent={entry.parent}
|
|
1142
|
+
containerId={props.containerId}
|
|
1143
|
+
hasChildren={entry.hasChildren && props.query.length === 0}
|
|
1144
|
+
open={entry.open}
|
|
1145
|
+
childRowBelow={entries[index + 1]?.parent === entry.layer.id}
|
|
1146
|
+
mask={maskMarksOf(props.scene, entry.layer.id, entry.depth)}
|
|
1147
|
+
onToggleCollapse={props.onToggleCollapse}
|
|
1148
|
+
onRowClick={props.onRowClick}
|
|
1149
|
+
{...(props.trailing === undefined ? {} : { trailing: props.trailing })}
|
|
1150
|
+
tree={{
|
|
1151
|
+
level: entry.depth + 1,
|
|
1152
|
+
// Where the row SITS among its siblings, which is front first; the
|
|
1153
|
+
// array index counts from the back.
|
|
1154
|
+
position: entry.siblingCount - entry.index,
|
|
1155
|
+
size: entry.siblingCount,
|
|
1156
|
+
}}
|
|
1157
|
+
interaction={{
|
|
1158
|
+
tabIndex: entry.layer.id === effectiveActiveId ? 0 : -1,
|
|
1159
|
+
registerName,
|
|
1160
|
+
onNameFocus: setActiveId,
|
|
1161
|
+
onNameKeyDown,
|
|
1162
|
+
onRenameChange: (id, active) => setRenamingId(active ? id : null),
|
|
1163
|
+
onDragChange: setDraggingId,
|
|
1164
|
+
}}
|
|
1165
|
+
/>,
|
|
1166
|
+
);
|
|
1167
|
+
}
|
|
1168
|
+
cursor = span.end;
|
|
1169
|
+
}
|
|
1170
|
+
if (cursor < entries.length) {
|
|
1171
|
+
virtualRows.push(
|
|
1172
|
+
<li
|
|
1173
|
+
key={`outline-gap-${cursor}-${entries.length}`}
|
|
1174
|
+
aria-hidden="true"
|
|
1175
|
+
role="presentation"
|
|
1176
|
+
data-outline-spacer="true"
|
|
1177
|
+
style={{ height: (entries.length - cursor) * LAYER_ROW_H }}
|
|
1178
|
+
/>,
|
|
1179
|
+
);
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
return (
|
|
1183
|
+
<ul
|
|
1184
|
+
ref={listRef}
|
|
1185
|
+
className="layer-list"
|
|
1186
|
+
data-testid="layer-list"
|
|
1187
|
+
role="tree"
|
|
1188
|
+
>
|
|
1189
|
+
{!virtual ? <LayerRows {...props} depth={0} /> : virtualRows}
|
|
1190
|
+
</ul>
|
|
1191
|
+
);
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
export function LayerRows({
|
|
247
1195
|
scene,
|
|
248
1196
|
containerId,
|
|
249
1197
|
ids,
|
|
250
|
-
depth,
|
|
251
1198
|
parent,
|
|
252
1199
|
query,
|
|
253
1200
|
collapsed,
|
|
254
1201
|
onToggleCollapse,
|
|
255
1202
|
onRowClick,
|
|
1203
|
+
trailing,
|
|
1204
|
+
depth = 0,
|
|
256
1205
|
}: {
|
|
257
|
-
scene:
|
|
1206
|
+
scene: LayerHome;
|
|
258
1207
|
/** The scene OR component definition these layers live in. Every op the rows
|
|
259
1208
|
* drive takes one, so a definition's interior is editable through exactly
|
|
260
1209
|
* the same drag, rename and flag handlers. */
|
|
261
1210
|
containerId: string;
|
|
262
1211
|
ids: readonly string[];
|
|
263
|
-
depth: number;
|
|
264
1212
|
/** The layer these rows are children of, or null at the scene root. A drop
|
|
265
1213
|
* BETWEEN rows lands here; a drop ON one lands inside that row. */
|
|
266
1214
|
parent: string | null;
|
|
267
1215
|
query: string;
|
|
268
1216
|
collapsed: ReadonlySet<string>;
|
|
269
1217
|
onToggleCollapse: (id: string) => void;
|
|
270
|
-
onRowClick: (
|
|
1218
|
+
onRowClick: (
|
|
1219
|
+
id: string,
|
|
1220
|
+
e: { shiftKey: boolean; ctrlKey: boolean; metaKey: boolean },
|
|
1221
|
+
) => void;
|
|
1222
|
+
trailing?: LayerRowTrailing;
|
|
1223
|
+
depth?: number;
|
|
271
1224
|
}) {
|
|
272
1225
|
return (
|
|
273
1226
|
<>
|
|
274
|
-
{ids.map((id, index) => {
|
|
1227
|
+
{frontFirst(ids).map(({ id, index }) => {
|
|
275
1228
|
const layer = scene.layersById[id];
|
|
276
1229
|
if (layer === undefined) return null;
|
|
277
1230
|
const children = layerChildren(layer);
|
|
278
1231
|
// A search hides a row only when NEITHER it nor anything below it
|
|
279
1232
|
// matches — hiding a matching layer because its group did not match
|
|
280
1233
|
// would make the search unable to find anything nested.
|
|
281
|
-
if (query.length > 0 && !subtreeMatches(scene, layer, query))
|
|
1234
|
+
if (query.length > 0 && !subtreeMatches(scene, layer, query))
|
|
1235
|
+
return null;
|
|
282
1236
|
// A SEARCH OVERRIDES A COLLAPSE: a match under a collapsed group must
|
|
283
1237
|
// still be findable, the same rule the subtree gate above states. The
|
|
284
1238
|
// one flattening in `flattenVisible` mirrors both, so a range select
|
|
@@ -288,7 +1242,6 @@ function LayerRows({
|
|
|
288
1242
|
<LayerRow
|
|
289
1243
|
key={id}
|
|
290
1244
|
layer={layer}
|
|
291
|
-
depth={depth}
|
|
292
1245
|
index={index}
|
|
293
1246
|
parent={parent}
|
|
294
1247
|
containerId={containerId}
|
|
@@ -300,21 +1253,38 @@ function LayerRows({
|
|
|
300
1253
|
// what is on screen, so the row simply does not offer one.
|
|
301
1254
|
hasChildren={children.length > 0 && query.length === 0}
|
|
302
1255
|
open={open}
|
|
1256
|
+
// A search hides the chevron and still lists the matching
|
|
1257
|
+
// children, and a parent matched by its own label may list none.
|
|
1258
|
+
childRowBelow={
|
|
1259
|
+
open &&
|
|
1260
|
+
children.some((c) => {
|
|
1261
|
+
const kid = scene.layersById[c];
|
|
1262
|
+
return kid !== undefined && (query.length === 0 || subtreeMatches(scene, kid, query));
|
|
1263
|
+
})
|
|
1264
|
+
}
|
|
303
1265
|
onToggleCollapse={onToggleCollapse}
|
|
304
1266
|
onRowClick={onRowClick}
|
|
1267
|
+
{...(trailing === undefined ? {} : { trailing })}
|
|
1268
|
+
mask={maskMarksOf(scene, id, depth)}
|
|
1269
|
+
tree={{ level: depth + 1 }}
|
|
305
1270
|
>
|
|
1271
|
+
{/* Each row indents by its own `--depth` (set on its LI), in the
|
|
1272
|
+
nested tree and the windowed list alike. The nesting `ul` only
|
|
1273
|
+
draws the guide, see `.layer-children`, so the step
|
|
1274
|
+
cannot be applied twice. */}
|
|
306
1275
|
{children.length > 0 && open ? (
|
|
307
|
-
<ul className="layer-children">
|
|
1276
|
+
<ul className="layer-children" role="group">
|
|
308
1277
|
<LayerRows
|
|
309
1278
|
scene={scene}
|
|
310
1279
|
containerId={containerId}
|
|
311
1280
|
ids={children}
|
|
312
|
-
depth={depth + 1}
|
|
313
1281
|
parent={layer.id}
|
|
314
1282
|
query={query}
|
|
315
1283
|
collapsed={collapsed}
|
|
316
1284
|
onToggleCollapse={onToggleCollapse}
|
|
317
1285
|
onRowClick={onRowClick}
|
|
1286
|
+
{...(trailing === undefined ? {} : { trailing })}
|
|
1287
|
+
depth={depth + 1}
|
|
318
1288
|
/>
|
|
319
1289
|
</ul>
|
|
320
1290
|
) : null}
|
|
@@ -325,6 +1295,84 @@ function LayerRows({
|
|
|
325
1295
|
);
|
|
326
1296
|
}
|
|
327
1297
|
|
|
1298
|
+
/**
|
|
1299
|
+
* THE OUTLINE'S CLICK POLICY, shared by every panel that lists layer rows
|
|
1300
|
+
* (this one and the board's Layers tab): shift = range over the visible order,
|
|
1301
|
+
* ctrl/meta = toggle, plain = select + anchor. The panel owns the anchor and
|
|
1302
|
+
* the collapsed set; this only reads them.
|
|
1303
|
+
*/
|
|
1304
|
+
export function layerRowSelection(o: {
|
|
1305
|
+
store: ShotEditorStore;
|
|
1306
|
+
scene: { layersById: Record<string, Layer3D> } | null;
|
|
1307
|
+
ids: readonly string[];
|
|
1308
|
+
collapsed: ReadonlySet<string>;
|
|
1309
|
+
query: string;
|
|
1310
|
+
anchor: string | null;
|
|
1311
|
+
setAnchor: (id: string) => void;
|
|
1312
|
+
}): {
|
|
1313
|
+
rowClick: (id: string, e: { shiftKey: boolean; ctrlKey: boolean; metaKey: boolean }) => void;
|
|
1314
|
+
rowKeyMove: (fromId: string, toId: string, extend: boolean) => void;
|
|
1315
|
+
} {
|
|
1316
|
+
const { store, scene: home, ids: rootIds, collapsed, query: trimmedQuery, anchor, setAnchor } = o;
|
|
1317
|
+
const rowClick = (
|
|
1318
|
+
id: string,
|
|
1319
|
+
e: { shiftKey: boolean; ctrlKey: boolean; metaKey: boolean },
|
|
1320
|
+
): void => {
|
|
1321
|
+
const selection = store.getState().selection;
|
|
1322
|
+
if (e.shiftKey && anchor !== null && home !== null) {
|
|
1323
|
+
// The range is over WHAT IS ON SCREEN — the same flattening the rows
|
|
1324
|
+
// render from, so a collapsed group's invisible children are never
|
|
1325
|
+
// swept into a selection the author cannot see.
|
|
1326
|
+
const flat = flattenVisible(home, rootIds, collapsed, trimmedQuery);
|
|
1327
|
+
const a = flat.indexOf(anchor);
|
|
1328
|
+
const b = flat.indexOf(id);
|
|
1329
|
+
if (a !== -1 && b !== -1) {
|
|
1330
|
+
store.select(flat.slice(Math.min(a, b), Math.max(a, b) + 1));
|
|
1331
|
+
store.revealSelection(id, false);
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1334
|
+
// A stale anchor (deleted, scoped away) falls through to a plain click.
|
|
1335
|
+
}
|
|
1336
|
+
if (e.ctrlKey || e.metaKey) {
|
|
1337
|
+
store.select(
|
|
1338
|
+
selection.includes(id)
|
|
1339
|
+
? selection.filter((x) => x !== id)
|
|
1340
|
+
: [...selection, id],
|
|
1341
|
+
);
|
|
1342
|
+
// Nothing when this click took the row OUT: `revealSelection` checks.
|
|
1343
|
+
store.revealSelection(id, false);
|
|
1344
|
+
setAnchor(id);
|
|
1345
|
+
return;
|
|
1346
|
+
}
|
|
1347
|
+
store.select([id]);
|
|
1348
|
+
store.revealSelection(id, true);
|
|
1349
|
+
setAnchor(id);
|
|
1350
|
+
};
|
|
1351
|
+
|
|
1352
|
+
/** Keyboard range navigation starts from the focused row when no pointer
|
|
1353
|
+
* anchor exists, then keeps that same anchor while Shift extends/retracts.
|
|
1354
|
+
* It operates over the complete visible flattening, not the mounted window. */
|
|
1355
|
+
const rowKeyMove = (fromId: string, toId: string, extend: boolean): void => {
|
|
1356
|
+
if (home === null) return;
|
|
1357
|
+
const flat = flattenVisible(home, rootIds, collapsed, trimmedQuery);
|
|
1358
|
+
if (!extend) {
|
|
1359
|
+
store.select([toId]);
|
|
1360
|
+
store.revealSelection(toId, true);
|
|
1361
|
+
setAnchor(toId);
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
const candidate =
|
|
1365
|
+
anchor !== null && flat.includes(anchor) ? anchor : fromId;
|
|
1366
|
+
const from = flat.indexOf(candidate);
|
|
1367
|
+
const to = flat.indexOf(toId);
|
|
1368
|
+
if (from === -1 || to === -1) return;
|
|
1369
|
+
store.select(flat.slice(Math.min(from, to), Math.max(from, to) + 1));
|
|
1370
|
+
store.revealSelection(toId, false);
|
|
1371
|
+
setAnchor(candidate);
|
|
1372
|
+
};
|
|
1373
|
+
return { rowClick, rowKeyMove };
|
|
1374
|
+
}
|
|
1375
|
+
|
|
328
1376
|
/**
|
|
329
1377
|
* The row order AS RENDERED — same recursion, same predicates as `LayerRows`
|
|
330
1378
|
* (subtree match, collapse-with-search-override) — for the shift range.
|
|
@@ -336,36 +1384,76 @@ function flattenVisible(
|
|
|
336
1384
|
collapsed: ReadonlySet<string>,
|
|
337
1385
|
query: string,
|
|
338
1386
|
): string[] {
|
|
339
|
-
|
|
340
|
-
|
|
1387
|
+
return flattenLayerEntries(scene, ids, null, collapsed, query).map(
|
|
1388
|
+
(entry) => entry.layer.id,
|
|
1389
|
+
);
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
function flattenLayerEntries(
|
|
1393
|
+
scene: { layersById: Record<string, Layer3D> },
|
|
1394
|
+
ids: readonly string[],
|
|
1395
|
+
parent: string | null,
|
|
1396
|
+
collapsed: ReadonlySet<string>,
|
|
1397
|
+
query: string,
|
|
1398
|
+
depth = 0,
|
|
1399
|
+
): LayerEntry[] {
|
|
1400
|
+
const out: LayerEntry[] = [];
|
|
1401
|
+
for (const { id, index } of frontFirst(ids)) {
|
|
341
1402
|
const layer = scene.layersById[id];
|
|
342
1403
|
if (layer === undefined) continue;
|
|
343
1404
|
if (query.length > 0 && !subtreeMatches(scene, layer, query)) continue;
|
|
344
|
-
out.push(id);
|
|
345
1405
|
const children = layerChildren(layer);
|
|
346
1406
|
const open = !collapsed.has(id) || query.length > 0;
|
|
347
|
-
|
|
1407
|
+
out.push({
|
|
1408
|
+
layer,
|
|
1409
|
+
index,
|
|
1410
|
+
parent,
|
|
1411
|
+
depth,
|
|
1412
|
+
siblingCount: ids.length,
|
|
1413
|
+
hasChildren: children.length > 0,
|
|
1414
|
+
open,
|
|
1415
|
+
});
|
|
1416
|
+
if (children.length > 0 && open) {
|
|
1417
|
+
out.push(
|
|
1418
|
+
...flattenLayerEntries(
|
|
1419
|
+
scene,
|
|
1420
|
+
children,
|
|
1421
|
+
layer.id,
|
|
1422
|
+
collapsed,
|
|
1423
|
+
query,
|
|
1424
|
+
depth + 1,
|
|
1425
|
+
),
|
|
1426
|
+
);
|
|
1427
|
+
}
|
|
348
1428
|
}
|
|
349
1429
|
return out;
|
|
350
1430
|
}
|
|
351
1431
|
|
|
352
|
-
/**
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
1432
|
+
/**
|
|
1433
|
+
* What a drag from `grabbed` carries: the whole selection when that row is part
|
|
1434
|
+
* of it, otherwise just that row — Figma's rule, and the only one that does not
|
|
1435
|
+
* lie about what is selected.
|
|
1436
|
+
*
|
|
1437
|
+
* Order is NOT decided here: `moveLayersTo` sorts into paint order (the walk)
|
|
1438
|
+
* against the container it is moving within. Doing it there and not here means
|
|
1439
|
+
* an MCP or menu caller gets the same guarantee.
|
|
1440
|
+
*/
|
|
1441
|
+
function dragPayload(
|
|
1442
|
+
state: { selection: readonly string[] },
|
|
1443
|
+
grabbed: string,
|
|
1444
|
+
): string[] {
|
|
1445
|
+
return state.selection.includes(grabbed) ? [...state.selection] : [grabbed];
|
|
363
1446
|
}
|
|
364
1447
|
|
|
365
1448
|
/** Which third of a row the pointer is over. The middle is the biggest, so
|
|
366
1449
|
* "drop it in there" is the easy gesture and reordering is the deliberate
|
|
367
|
-
* one
|
|
368
|
-
|
|
1450
|
+
* one: Figma's proportions, and for the same reason. "before" and "after"
|
|
1451
|
+
* name where the line is DRAWN, above and below the row, which in a front
|
|
1452
|
+
* first outline is in front of it and behind it. */
|
|
1453
|
+
function dropZone(
|
|
1454
|
+
e: { clientY: number },
|
|
1455
|
+
row: HTMLElement,
|
|
1456
|
+
): "before" | "into" | "after" {
|
|
369
1457
|
const rect = row.getBoundingClientRect();
|
|
370
1458
|
const t = (e.clientY - rect.top) / Math.max(1, rect.height);
|
|
371
1459
|
if (t < 0.28) return "before";
|
|
@@ -385,48 +1473,99 @@ function subtreeMatches(
|
|
|
385
1473
|
});
|
|
386
1474
|
}
|
|
387
1475
|
|
|
388
|
-
function LayerRow({
|
|
1476
|
+
export function LayerRow({
|
|
389
1477
|
layer,
|
|
390
|
-
depth,
|
|
391
1478
|
index,
|
|
392
1479
|
parent,
|
|
393
1480
|
containerId,
|
|
394
1481
|
hasChildren,
|
|
395
1482
|
open,
|
|
1483
|
+
childRowBelow,
|
|
396
1484
|
onToggleCollapse,
|
|
397
1485
|
onRowClick,
|
|
1486
|
+
trailing,
|
|
398
1487
|
children,
|
|
1488
|
+
tree,
|
|
1489
|
+
interaction,
|
|
1490
|
+
mask,
|
|
399
1491
|
}: {
|
|
400
1492
|
layer: Layer3D;
|
|
401
|
-
depth: number;
|
|
402
1493
|
index: number;
|
|
403
1494
|
parent: string | null;
|
|
404
1495
|
/** Scene or definition — see `LayerRows`. */
|
|
405
1496
|
containerId: string;
|
|
1497
|
+
/** Offers the disclosure chevron, which a search takes away. */
|
|
406
1498
|
hasChildren: boolean;
|
|
407
1499
|
open: boolean;
|
|
1500
|
+
/** The row directly below this one is one of its own children, so the
|
|
1501
|
+
* lower drop line sits over that child, not over the next row out. */
|
|
1502
|
+
childRowBelow: boolean;
|
|
408
1503
|
onToggleCollapse: (id: string) => void;
|
|
409
|
-
onRowClick: (
|
|
410
|
-
|
|
1504
|
+
onRowClick: (
|
|
1505
|
+
id: string,
|
|
1506
|
+
e: { shiftKey: boolean; ctrlKey: boolean; metaKey: boolean },
|
|
1507
|
+
) => void;
|
|
1508
|
+
/** A host panel's cell after the flags, see `LayerRowTrailing`. */
|
|
1509
|
+
trailing?: LayerRowTrailing;
|
|
1510
|
+
children?: React.ReactNode;
|
|
1511
|
+
/** `level` is 1-based (it is `aria-level`), so the depth is one less. */
|
|
1512
|
+
tree: { level: number; position?: number; size?: number };
|
|
1513
|
+
interaction?: VirtualLayerInteraction;
|
|
1514
|
+
/** The row's mask icon, badge and bracket slices (gap 7), see maskMarks. */
|
|
1515
|
+
mask?: MaskMarks;
|
|
411
1516
|
}) {
|
|
412
1517
|
const store = useStore();
|
|
413
|
-
const selected = useShotEditor((s) =>
|
|
1518
|
+
const selected = useShotEditor((s) =>
|
|
1519
|
+
selectionMembership(s.selection).has(layer.id),
|
|
1520
|
+
);
|
|
1521
|
+
// The drawer placed this WITHOUT selecting it ("Use as background"), so the
|
|
1522
|
+
// row lights once to show where it went. The next selection clears it.
|
|
1523
|
+
const justPlaced = useShotEditor((s) => s.justPlaced === layer.id);
|
|
414
1524
|
// Two primitives, never the record: `useShotEditor` compares with `Object.is`
|
|
415
1525
|
// per notification, so returning a slice of the map would re-render every row
|
|
416
1526
|
// on every unrelated change.
|
|
417
1527
|
const triangles = useShotEditor((s) => s.meshTriangles[layer.id]);
|
|
418
|
-
const sceneTriangles = useShotEditor((s) =>
|
|
419
|
-
|
|
1528
|
+
const sceneTriangles = useShotEditor((s) =>
|
|
1529
|
+
sumTrianglesCached(s.meshTriangles),
|
|
1530
|
+
);
|
|
1531
|
+
const overBudget =
|
|
1532
|
+
triangles !== undefined && sceneTriangles > TRIANGLE_SOFT_BUDGET;
|
|
420
1533
|
const [renaming, setRenaming] = useState(false);
|
|
1534
|
+
// A viewer cannot restack, so the row does not lift.
|
|
1535
|
+
const readOnly = useShotEditor((s) => s.readOnly);
|
|
421
1536
|
const [over, setOver] = useState<"before" | "into" | "after" | null>(null);
|
|
422
1537
|
const menu = useContextMenu();
|
|
1538
|
+
/** The lower line of an EXPANDED parent sits over its first child row, the
|
|
1539
|
+
* frontmost, so it drops into the parent at the front (founder decision 7),
|
|
1540
|
+
* and it is drawn at the children's indent to say so. Expanded means a
|
|
1541
|
+
* child row is on screen under it, a search's forced open included. */
|
|
1542
|
+
const intoFront = childRowBelow;
|
|
1543
|
+
const overClass =
|
|
1544
|
+
over === null
|
|
1545
|
+
? ""
|
|
1546
|
+
: over === "after" && intoFront
|
|
1547
|
+
? " is-drop-inside"
|
|
1548
|
+
: ` is-drop-${over}`;
|
|
423
1549
|
|
|
424
1550
|
return (
|
|
425
|
-
<li
|
|
1551
|
+
<li
|
|
1552
|
+
className="layer-item"
|
|
1553
|
+
role="treeitem"
|
|
1554
|
+
// On the LI and not the row, so a nested `.layer-children` inherits its
|
|
1555
|
+
// parent's depth and draws the guide at the parent's indent.
|
|
1556
|
+
// `--mask-inset` rides the same LI, so the nested guide moves with it.
|
|
1557
|
+
style={{ "--depth": tree.level - 1, "--mask-inset": `${mask?.inset ?? 0}px` } as CSSProperties}
|
|
1558
|
+
aria-level={tree.level}
|
|
1559
|
+
{...(tree.position === undefined
|
|
1560
|
+
? {}
|
|
1561
|
+
: { "aria-posinset": tree.position })}
|
|
1562
|
+
{...(tree.size === undefined ? {} : { "aria-setsize": tree.size })}
|
|
1563
|
+
{...(hasChildren ? { "aria-expanded": open } : {})}
|
|
1564
|
+
aria-selected={selected}
|
|
1565
|
+
>
|
|
426
1566
|
{menu.menu}
|
|
427
1567
|
<div
|
|
428
|
-
className={`layer-row${selected ? " is-selected" : ""}${
|
|
429
|
-
style={{ paddingLeft: 8 + depth * 12 }}
|
|
1568
|
+
className={`layer-row${selected ? " is-selected" : ""}${justPlaced ? " is-just-placed" : ""}${mask?.off === true ? " is-mask-off" : ""}${overClass}`}
|
|
430
1569
|
data-layer={layer.id}
|
|
431
1570
|
// The SAME menu the stage opens, from the same builder. A row and a
|
|
432
1571
|
// click on the canvas are two ways of pointing at one layer, and a
|
|
@@ -434,36 +1573,95 @@ function LayerRow({
|
|
|
434
1573
|
// having only one of them.
|
|
435
1574
|
onContextMenu={(e) => {
|
|
436
1575
|
e.preventDefault();
|
|
1576
|
+
interaction?.onNameFocus(layer.id);
|
|
437
1577
|
// Select first, unless the row is already part of the selection — in
|
|
438
1578
|
// which case the whole selection stands and the menu acts on all of
|
|
439
1579
|
// it. The stage's `onContextMenu` policy, deliberately identical.
|
|
440
1580
|
const state = store.getState();
|
|
441
|
-
const ids = state.selection.includes(layer.id)
|
|
1581
|
+
const ids = state.selection.includes(layer.id)
|
|
1582
|
+
? state.selection
|
|
1583
|
+
: [layer.id];
|
|
442
1584
|
if (!state.selection.includes(layer.id)) store.select([layer.id]);
|
|
443
1585
|
const next = store.getState();
|
|
444
|
-
menu.open(
|
|
1586
|
+
menu.open(
|
|
1587
|
+
{ x: e.clientX, y: e.clientY },
|
|
1588
|
+
// Rename here is the row's own inline edit, the one F2 opens.
|
|
1589
|
+
layerMenuEntries(store, next, menuTargetsFor(next, ids), {
|
|
1590
|
+
subject: layer.id,
|
|
1591
|
+
onRename: () => {
|
|
1592
|
+
interaction?.onRenameChange(layer.id, true);
|
|
1593
|
+
setRenaming(true);
|
|
1594
|
+
},
|
|
1595
|
+
}),
|
|
1596
|
+
);
|
|
445
1597
|
}}
|
|
1598
|
+
// Any focusable control owns this row while it is active. Pinning only
|
|
1599
|
+
// the name would still let a scroll unmount the disclosure or flag a
|
|
1600
|
+
// keyboard user was operating.
|
|
1601
|
+
onFocusCapture={() => interaction?.onNameFocus(layer.id)}
|
|
446
1602
|
// THE OUTLINE DRAG. Dropping on the MIDDLE of a row puts the layer
|
|
447
|
-
// inside it
|
|
448
|
-
//
|
|
449
|
-
//
|
|
450
|
-
|
|
451
|
-
|
|
1603
|
+
// inside it, at the front of its children (their top row); dropping
|
|
1604
|
+
// near an edge puts it beside it, among that row's own siblings, at the
|
|
1605
|
+
// gap the line shows. The rows are FRONT FIRST, so the line ABOVE a row
|
|
1606
|
+
// is in front of it (stored index + 1) and the line BELOW it is behind
|
|
1607
|
+
// it (its own index). The lower line of an EXPANDED parent sits over
|
|
1608
|
+
// its first child row, so it means "into it, at the front" (Figma's
|
|
1609
|
+
// rule, founder decision 7), not "behind its whole subtree". All go
|
|
1610
|
+
// through `moveLayer`, which re-bases the transform on a reparent so
|
|
1611
|
+
// the layer keeps its place in the frame; the list is paint order, so
|
|
1612
|
+
// a drop is a paint edit.
|
|
1613
|
+
//
|
|
1614
|
+
// THE WHOLE SELECTION TRAVELS, when the grabbed row is part of it.
|
|
1615
|
+
// Dragging one of three selected rows and watching one move is the
|
|
1616
|
+
// gesture lying about what is selected. `moveLayersTo` lands them
|
|
1617
|
+
// together at the gap, in the order they painted in.
|
|
1618
|
+
draggable={!readOnly}
|
|
1619
|
+
onDragStart={(e) => {
|
|
1620
|
+
e.dataTransfer.setData(
|
|
1621
|
+
"text/plain",
|
|
1622
|
+
dragPayload(store.getState(), layer.id).join("\n"),
|
|
1623
|
+
);
|
|
1624
|
+
interaction?.onDragChange(layer.id);
|
|
1625
|
+
}}
|
|
1626
|
+
onDragEnd={() => interaction?.onDragChange(null)}
|
|
452
1627
|
onDragOver={(e) => {
|
|
453
1628
|
e.preventDefault();
|
|
454
|
-
|
|
1629
|
+
const zone = dropZone(e, e.currentTarget);
|
|
1630
|
+
// REFUSE BEFORE THE DROP, not after. A leaf cannot hold children
|
|
1631
|
+
// (`canHoldChildren` — the schema's rule, not the panel's), so the
|
|
1632
|
+
// into-zone shows the not-allowed cursor and paints no ring rather
|
|
1633
|
+
// than accepting a gesture whose only outcome is an error strip.
|
|
1634
|
+
//
|
|
1635
|
+
// NAMED CEILING: this is the only refusal expressible here. A cycle
|
|
1636
|
+
// ("inside itself") needs the dragged ids, and `dataTransfer` data is
|
|
1637
|
+
// unreadable during dragover by spec, so cycles stay drop-time
|
|
1638
|
+
// refusals — `moveLayer` still produces them and `sayRefusal` still
|
|
1639
|
+
// shows them.
|
|
1640
|
+
const refused = zone === "into" && !canHoldChildren(layer);
|
|
1641
|
+
e.dataTransfer.dropEffect = refused ? "none" : "move";
|
|
1642
|
+
setOver(refused ? null : zone);
|
|
455
1643
|
}}
|
|
456
1644
|
onDragLeave={() => setOver(null)}
|
|
457
1645
|
onDrop={(e) => {
|
|
458
1646
|
e.preventDefault();
|
|
459
1647
|
const zone = dropZone(e, e.currentTarget);
|
|
460
1648
|
setOver(null);
|
|
461
|
-
const
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
1649
|
+
const ids = e.dataTransfer
|
|
1650
|
+
.getData("text/plain")
|
|
1651
|
+
.split("\n")
|
|
1652
|
+
.filter((id) => id.length > 0);
|
|
1653
|
+
interaction?.onDragChange(null);
|
|
1654
|
+
if (ids.length === 0 || ids.includes(layer.id)) return;
|
|
1655
|
+
if (zone === "into" || (zone === "after" && intoFront))
|
|
1656
|
+
store.moveLayersTo(ids, { parent: layer.id });
|
|
1657
|
+
else
|
|
1658
|
+
store.moveLayersTo(ids, {
|
|
1659
|
+
parent,
|
|
1660
|
+
index: zone === "before" ? index + 1 : index,
|
|
1661
|
+
});
|
|
465
1662
|
}}
|
|
466
1663
|
>
|
|
1664
|
+
{mask === undefined ? null : <MaskBracket segments={mask.segments} base="var(--space-6)" />}
|
|
467
1665
|
{/* The disclosure chevron, a SEPARATE button so the name button's
|
|
468
1666
|
textContent stays the label alone. Leaf rows get a spacer, so the
|
|
469
1667
|
kind icons line up whether or not a row can fold. */}
|
|
@@ -483,8 +1681,14 @@ function LayerRow({
|
|
|
483
1681
|
{/* OUTSIDE the name button, never inside it: the button's textContent is
|
|
484
1682
|
the layer's label and nothing else — filePanel.test.tsx compares it
|
|
485
1683
|
by equality, and the label is also what a screen reader announces. */}
|
|
486
|
-
<span
|
|
487
|
-
{
|
|
1684
|
+
<span
|
|
1685
|
+
className={`layer-kind${mask?.icon === "mask" ? " is-mask" : isComponentGlyph(layer) && mask?.icon == null ? " is-component" : ""}`}
|
|
1686
|
+
aria-hidden="true"
|
|
1687
|
+
>
|
|
1688
|
+
{/* Keyed on the THING, not the kind: a Box row shows a box, an
|
|
1689
|
+
emitter row its spark — the seam the shape-as-mesh decision must
|
|
1690
|
+
never leak (basics plan, Part F). */}
|
|
1691
|
+
{mask?.icon == null ? iconFor(layer) : MASK_ICON}
|
|
488
1692
|
</span>
|
|
489
1693
|
{renaming ? (
|
|
490
1694
|
<input
|
|
@@ -495,25 +1699,63 @@ function LayerRow({
|
|
|
495
1699
|
onBlur={(e) => {
|
|
496
1700
|
store.renameLayer(layer.id, e.target.value);
|
|
497
1701
|
setRenaming(false);
|
|
1702
|
+
interaction?.onRenameChange(layer.id, false);
|
|
498
1703
|
}}
|
|
499
1704
|
onKeyDown={(e) => {
|
|
500
1705
|
if (e.key === "Enter") e.currentTarget.blur();
|
|
501
|
-
if (e.key === "Escape")
|
|
1706
|
+
if (e.key === "Escape") {
|
|
1707
|
+
setRenaming(false);
|
|
1708
|
+
interaction?.onRenameChange(layer.id, false);
|
|
1709
|
+
}
|
|
502
1710
|
}}
|
|
503
1711
|
/>
|
|
504
1712
|
) : (
|
|
505
1713
|
<button
|
|
506
1714
|
type="button"
|
|
507
1715
|
className="layer-name"
|
|
1716
|
+
ref={(node) => interaction?.registerName(layer.id, node)}
|
|
1717
|
+
{...(interaction === undefined
|
|
1718
|
+
? {}
|
|
1719
|
+
: { tabIndex: interaction.tabIndex })}
|
|
508
1720
|
// The panel owns the click policy (shift = range over the visible
|
|
509
1721
|
// order, ctrl/meta = toggle, plain = select + anchor) because only
|
|
510
1722
|
// it knows the flattened row order a range is measured over.
|
|
511
1723
|
onClick={(e) => onRowClick(layer.id, e)}
|
|
512
|
-
|
|
1724
|
+
onFocus={() => interaction?.onNameFocus(layer.id)}
|
|
1725
|
+
onKeyDown={(e) => {
|
|
1726
|
+
if (e.key === "F2") {
|
|
1727
|
+
e.preventDefault();
|
|
1728
|
+
e.stopPropagation();
|
|
1729
|
+
if (store.viewOnly()) return;
|
|
1730
|
+
interaction?.onRenameChange(layer.id, true);
|
|
1731
|
+
setRenaming(true);
|
|
1732
|
+
return;
|
|
1733
|
+
}
|
|
1734
|
+
interaction?.onNameKeyDown(layer.id, e);
|
|
1735
|
+
}}
|
|
1736
|
+
onDoubleClick={() => {
|
|
1737
|
+
if (store.viewOnly()) return;
|
|
1738
|
+
interaction?.onRenameChange(layer.id, true);
|
|
1739
|
+
setRenaming(true);
|
|
1740
|
+
}}
|
|
513
1741
|
>
|
|
514
1742
|
{layer.label}
|
|
515
1743
|
</button>
|
|
516
1744
|
)}
|
|
1745
|
+
{/* The mask's mode, a sibling of the name for the kind icon's reason.
|
|
1746
|
+
In the windowed list a row is one fixed height, so the empty-run
|
|
1747
|
+
hint rides the badge's title there instead of its own line. */}
|
|
1748
|
+
{mask?.badge == null ? null : (
|
|
1749
|
+
<span
|
|
1750
|
+
className="mask-badge"
|
|
1751
|
+
title={mask.empty && interaction !== undefined ? NO_CONTENT_HINT : mask.badge}
|
|
1752
|
+
>
|
|
1753
|
+
{mask.badge}
|
|
1754
|
+
</span>
|
|
1755
|
+
)}
|
|
1756
|
+
{/* Who else holds this layer (Paper page 21), a dot each. */}
|
|
1757
|
+
<PeerDots at={(p) => p.sel.includes(layer.id)} />
|
|
1758
|
+
<LayerMarks layer={layer} />
|
|
517
1759
|
{/* The model's cost, for the layers that HAVE one. A sibling span for
|
|
518
1760
|
the kind icon's reason: `layer-name`'s textContent is the label and
|
|
519
1761
|
nothing else. Absent until the model has loaded, and absent forever
|
|
@@ -523,17 +1765,23 @@ function LayerRow({
|
|
|
523
1765
|
<span
|
|
524
1766
|
className={`layer-tris${overBudget ? " is-over-budget" : ""}`}
|
|
525
1767
|
aria-hidden="true"
|
|
526
|
-
title={
|
|
1768
|
+
title={
|
|
1769
|
+
overBudget
|
|
1770
|
+
? sceneBudgetWarning(sceneTriangles)
|
|
1771
|
+
: `${triangles.toLocaleString()} triangles`
|
|
1772
|
+
}
|
|
527
1773
|
>
|
|
528
1774
|
{formatTriangles(triangles)}
|
|
529
1775
|
</span>
|
|
530
1776
|
)}
|
|
531
|
-
{/* The
|
|
532
|
-
|
|
533
|
-
|
|
1777
|
+
{/* The eye and the lock, VISIBLE AT REST in the tertiary ink (Paper's
|
|
1778
|
+
layer rows), so a row's hidden and locked state reads without a
|
|
1779
|
+
hover. `aria-pressed` rather than a checkbox because they are
|
|
1780
|
+
toggles on a row, and a screen reader should hear the layer's name
|
|
1781
|
+
with them. */}
|
|
534
1782
|
<button
|
|
535
1783
|
type="button"
|
|
536
|
-
className="layer-flag"
|
|
1784
|
+
className="layer-flag is-eye"
|
|
537
1785
|
aria-pressed={layer.hidden === true}
|
|
538
1786
|
aria-label={`${layer.hidden === true ? "Show" : "Hide"} ${layer.label}`}
|
|
539
1787
|
onClick={() => {
|
|
@@ -545,7 +1793,7 @@ function LayerRow({
|
|
|
545
1793
|
</button>
|
|
546
1794
|
<button
|
|
547
1795
|
type="button"
|
|
548
|
-
className="layer-flag"
|
|
1796
|
+
className="layer-flag is-lock"
|
|
549
1797
|
aria-pressed={layer.locked === true}
|
|
550
1798
|
aria-label={`${layer.locked === true ? "Unlock" : "Lock"} ${layer.label}`}
|
|
551
1799
|
onClick={() => {
|
|
@@ -555,10 +1803,12 @@ function LayerRow({
|
|
|
555
1803
|
>
|
|
556
1804
|
{layer.locked === true ? LOCK_ICON : UNLOCK_ICON}
|
|
557
1805
|
</button>
|
|
1806
|
+
{trailing?.(layer)}
|
|
558
1807
|
</div>
|
|
1808
|
+
{mask?.empty === true && interaction === undefined ? (
|
|
1809
|
+
<div className="layer-mask-hint">{NO_CONTENT_HINT}</div>
|
|
1810
|
+
) : null}
|
|
559
1811
|
{children}
|
|
560
1812
|
</li>
|
|
561
1813
|
);
|
|
562
1814
|
}
|
|
563
|
-
|
|
564
|
-
|