@dailies-studio/shot-editor 0.1.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/assets/index-B7notge7.css +1 -0
- package/dist/assets/index-BBE6Ta9L.js +52 -0
- package/dist/index.html +13 -0
- package/package.json +45 -0
- package/src/api.ts +289 -0
- package/src/app/App.tsx +394 -0
- package/src/audio/index.tsx +74 -0
- package/src/audio/preview.ts +364 -0
- package/src/bridge.ts +45 -0
- package/src/bridgeTypes.ts +582 -0
- package/src/channels.ts +40 -0
- package/src/main.tsx +15 -0
- package/src/panels/ResizeHandle.tsx +113 -0
- package/src/panels/agent/AgentPanel.tsx +478 -0
- package/src/panels/agent/agent.css +322 -0
- package/src/panels/export/ExportDialog.tsx +284 -0
- package/src/panels/files/FilePanel.tsx +564 -0
- package/src/panels/files/McpSection.tsx +66 -0
- package/src/panels/files/icons.tsx +118 -0
- package/src/panels/header/DocSwitcher.tsx +85 -0
- package/src/panels/header/Header.tsx +132 -0
- package/src/panels/header/HistoryControls.tsx +20 -0
- package/src/panels/header/StatusBadges.tsx +49 -0
- package/src/panels/header/TransportBar.tsx +86 -0
- package/src/panels/header/ZoomControls.tsx +38 -0
- package/src/panels/help/HelpSection.tsx +190 -0
- package/src/panels/inspector/FramingRows.tsx +69 -0
- package/src/panels/inspector/InspectorPanel.tsx +178 -0
- package/src/panels/inspector/MarkerSection.tsx +63 -0
- package/src/panels/inspector/ParamRow.tsx +71 -0
- package/src/panels/inspector/animate/BlockTiming.tsx +341 -0
- package/src/panels/inspector/animate/DuringBlocks.tsx +85 -0
- package/src/panels/inspector/animate/InOutPresets.tsx +57 -0
- package/src/panels/inspector/animate/ResolvedChannels.tsx +150 -0
- package/src/panels/inspector/animate/ShotSection.tsx +126 -0
- package/src/panels/inspector/animate/useBlocks.ts +43 -0
- package/src/panels/inspector/design/AlignRow.tsx +63 -0
- package/src/panels/inspector/design/AppearanceSection.tsx +49 -0
- package/src/panels/inspector/design/ClonerSection.tsx +142 -0
- package/src/panels/inspector/design/ComponentSection.tsx +94 -0
- package/src/panels/inspector/design/CornerSection.tsx +52 -0
- package/src/panels/inspector/design/FillSection.tsx +89 -0
- package/src/panels/inspector/design/HtmlSection.tsx +50 -0
- package/src/panels/inspector/design/InstanceSection.tsx +302 -0
- package/src/panels/inspector/design/MediaSection.tsx +251 -0
- package/src/panels/inspector/design/MeshSection.tsx +172 -0
- package/src/panels/inspector/design/ShadowSection.tsx +73 -0
- package/src/panels/inspector/design/StrokeSection.tsx +85 -0
- package/src/panels/inspector/design/TransformSection.tsx +91 -0
- package/src/panels/inspector/design/TypographySection.tsx +170 -0
- package/src/panels/inspector/scene/CameraSection.tsx +59 -0
- package/src/panels/inspector/scene/EffectsSection.tsx +123 -0
- package/src/panels/inspector/scene/SceneSection.tsx +71 -0
- package/src/panels/inspector/scene/TransitionSection.tsx +136 -0
- package/src/panels/inspector/useDocWrite.ts +268 -0
- package/src/panels/material/MaterialPicker.tsx +172 -0
- package/src/panels/stage/StageMenu.tsx +38 -0
- package/src/panels/stage/StagePanel.tsx +131 -0
- package/src/panels/timeline/AudioLane.tsx +340 -0
- package/src/panels/timeline/BlockRow.tsx +242 -0
- package/src/panels/timeline/ChannelRow.tsx +266 -0
- package/src/panels/timeline/LifespanRow.tsx +165 -0
- package/src/panels/timeline/MarkerLane.tsx +146 -0
- package/src/panels/timeline/SceneStrip.tsx +145 -0
- package/src/panels/timeline/TimelineDock.tsx +488 -0
- package/src/panels/timeline/model.ts +641 -0
- package/src/panels/timeline/snap.ts +191 -0
- package/src/panels/toolbar/AddMenu.tsx +230 -0
- package/src/panels/toolbar/CameraViewBar.tsx +70 -0
- package/src/panels/toolbar/ShadingBar.tsx +42 -0
- package/src/panels/toolbar/ToolDockBar.tsx +110 -0
- package/src/panels/toolbar/ViewModeBar.tsx +49 -0
- package/src/shotTokens.css +131 -0
- package/src/stage/FrustumGizmo.tsx +184 -0
- package/src/stage/GridPlane.tsx +274 -0
- package/src/stage/MoveGizmo.tsx +411 -0
- package/src/stage/PerfHud.tsx +206 -0
- package/src/stage/SplitRenderer.tsx +511 -0
- package/src/stage/StageApp.tsx +3423 -0
- package/src/stage/StageTools.tsx +581 -0
- package/src/stage/bridgeClient.ts +278 -0
- package/src/stage/feed.ts +243 -0
- package/src/stage/frustumGeometry.ts +172 -0
- package/src/stage/gizmoMath.ts +494 -0
- package/src/stage/handles.ts +151 -0
- package/src/stage/instanceModules.ts +152 -0
- package/src/stage/interactions/move.ts +172 -0
- package/src/stage/interactions/panZoom.ts +60 -0
- package/src/stage/isolation.ts +155 -0
- package/src/stage/main.tsx +110 -0
- package/src/stage/modalState.ts +427 -0
- package/src/stage/overlays/Overlays.tsx +1046 -0
- package/src/stage/panes.ts +121 -0
- package/src/stage/penState.ts +112 -0
- package/src/stage/project.ts +402 -0
- package/src/stage/shading.ts +55 -0
- package/src/stage/tokenColor.ts +26 -0
- package/src/stage/view.ts +182 -0
- package/src/stage/workingView.ts +636 -0
- package/src/state/agentActions.ts +203 -0
- package/src/state/clientId.ts +25 -0
- package/src/state/context.tsx +33 -0
- package/src/state/insert.ts +507 -0
- package/src/state/keymap.ts +560 -0
- package/src/state/menu.ts +141 -0
- package/src/state/panelSize.ts +26 -0
- package/src/state/renderEvents.ts +100 -0
- package/src/state/store.ts +4094 -0
- package/src/state/triangles.ts +70 -0
- package/src/state/types.ts +384 -0
- package/src/styles.css +2948 -0
- package/src/tools.ts +181 -0
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
// The left sidebar's File tab: scenes, the layer tree inside the open one, and
|
|
2
|
+
// how to connect an agent (McpSection.tsx).
|
|
3
|
+
//
|
|
4
|
+
// TWO LEVELS, ONE BIT. `state.sceneId` decides which scene's layers are listed,
|
|
5
|
+
// and it is the same bit the timeline's macro/micro split reads and the URL
|
|
6
|
+
// mirrors. There is no second "which scene am I looking at" anywhere.
|
|
7
|
+
//
|
|
8
|
+
// THE TREE IS RENDERED FROM `scene.layers` AND `children`, never from
|
|
9
|
+
// `layersById` — the outline is the forest, and `order` is a separate axis that
|
|
10
|
+
// decides PAINT. Listing by `order` would put a group's children next to
|
|
11
|
+
// strangers, and listing by `layersById` would have no order at all.
|
|
12
|
+
import { useEffect, useState } from "react";
|
|
13
|
+
import { IconChevron, useContextMenu } from "@dailies-studio/ui";
|
|
14
|
+
import { componentDefsOf, homeOf, instancesOf, layerChildren, parentOf } from "@dailies-studio/shot/doc";
|
|
15
|
+
import type { Layer3D } from "@dailies-studio/shot/doc";
|
|
16
|
+
import { useShotEditor, useStore } from "../../state/context.js";
|
|
17
|
+
import { layerMenuEntries, menuTargetsFor } from "../../state/menu.js";
|
|
18
|
+
import {
|
|
19
|
+
TRIANGLE_SOFT_BUDGET,
|
|
20
|
+
formatTriangles,
|
|
21
|
+
sceneBudgetWarning,
|
|
22
|
+
sumTriangles,
|
|
23
|
+
} from "../../state/triangles.js";
|
|
24
|
+
import { McpSection } from "./McpSection.js";
|
|
25
|
+
import {
|
|
26
|
+
COMPONENT_ICON,
|
|
27
|
+
EYE_ICON,
|
|
28
|
+
EYE_OFF_ICON,
|
|
29
|
+
LOCK_ICON,
|
|
30
|
+
SCENE_ICON,
|
|
31
|
+
UNLOCK_ICON,
|
|
32
|
+
kindIcon,
|
|
33
|
+
} from "./icons.js";
|
|
34
|
+
|
|
35
|
+
export function FilePanel() {
|
|
36
|
+
const store = useStore();
|
|
37
|
+
const doc = useShotEditor((s) => s.doc);
|
|
38
|
+
const sceneId = useShotEditor((s) => s.sceneId);
|
|
39
|
+
const isolation = useShotEditor((s) => s.isolation);
|
|
40
|
+
const editingDef = useShotEditor((s) => s.editingDef);
|
|
41
|
+
const [query, setQuery] = useState("");
|
|
42
|
+
/** Collapsed subtree roots. PER-SESSION on purpose, never a workspace pref:
|
|
43
|
+
* prefs survive document switches and layer ids are document-scoped, so a
|
|
44
|
+
* persisted set would carry stale ids into every other document. */
|
|
45
|
+
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set());
|
|
46
|
+
/** The last plainly-clicked row — shift-click ranges from it over the
|
|
47
|
+
* VISIBLE flattened order, the Figma/Blender behaviour. */
|
|
48
|
+
const [anchor, setAnchor] = useState<string | null>(null);
|
|
49
|
+
/**
|
|
50
|
+
* BOTH ARE DROPPED WHEN THE DOCUMENT CHANGES, and that is not tidiness.
|
|
51
|
+
* This panel is mounted unconditionally and never keyed (App.tsx), and a
|
|
52
|
+
* document switch swaps `state.doc` underneath it rather than remounting
|
|
53
|
+
* it — while ids are deterministic slugs, so `l3_group` exists in nearly
|
|
54
|
+
* every shot. Kept, the set folds a group in the NEW document that nobody
|
|
55
|
+
* folded, and the anchor makes the author's first shift-click there a range
|
|
56
|
+
* measured from a click they made in a different document.
|
|
57
|
+
*/
|
|
58
|
+
const docId = doc?.id ?? null;
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
setCollapsed(new Set());
|
|
61
|
+
setAnchor(null);
|
|
62
|
+
}, [docId]);
|
|
63
|
+
|
|
64
|
+
if (doc === null) return null;
|
|
65
|
+
const scene = sceneId === null ? undefined : doc.scenesById[sceneId];
|
|
66
|
+
const defs = componentDefsOf(doc);
|
|
67
|
+
// The forest the outline shows: a definition's while one is being edited,
|
|
68
|
+
// the open shot's otherwise. ONE lookup, so the rows below never branch.
|
|
69
|
+
const home = homeOf(doc, editingDef ?? sceneId ?? "");
|
|
70
|
+
const trimmedQuery = query.trim().toLowerCase();
|
|
71
|
+
const rootIds =
|
|
72
|
+
home === null
|
|
73
|
+
? []
|
|
74
|
+
: isolation === null || editingDef !== null
|
|
75
|
+
? home.layers
|
|
76
|
+
: layerChildren(home.layersById[isolation] ?? ({} as Layer3D));
|
|
77
|
+
|
|
78
|
+
const toggleCollapse = (id: string): void => {
|
|
79
|
+
setCollapsed((prev) => {
|
|
80
|
+
const next = new Set(prev);
|
|
81
|
+
if (next.has(id)) next.delete(id);
|
|
82
|
+
else next.add(id);
|
|
83
|
+
return next;
|
|
84
|
+
});
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const rowClick = (id: string, e: { shiftKey: boolean; ctrlKey: boolean; metaKey: boolean }): void => {
|
|
88
|
+
const selection = store.getState().selection;
|
|
89
|
+
if (e.shiftKey && anchor !== null && home !== null) {
|
|
90
|
+
// The range is over WHAT IS ON SCREEN — the same flattening the rows
|
|
91
|
+
// render from, so a collapsed group's invisible children are never
|
|
92
|
+
// swept into a selection the author cannot see.
|
|
93
|
+
const flat = flattenVisible(home, rootIds, collapsed, trimmedQuery);
|
|
94
|
+
const a = flat.indexOf(anchor);
|
|
95
|
+
const b = flat.indexOf(id);
|
|
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
|
+
};
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<div className="file-panel" data-testid="file-panel">
|
|
113
|
+
<label className="file-search">
|
|
114
|
+
<input
|
|
115
|
+
type="search"
|
|
116
|
+
value={query}
|
|
117
|
+
placeholder="Search layers"
|
|
118
|
+
aria-label="Search layers"
|
|
119
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
120
|
+
/>
|
|
121
|
+
</label>
|
|
122
|
+
|
|
123
|
+
<div className="file-section-label">Shots</div>
|
|
124
|
+
<ul className="scene-list">
|
|
125
|
+
{doc.scenes.map((id) => {
|
|
126
|
+
const s = doc.scenesById[id];
|
|
127
|
+
if (s === undefined) return null;
|
|
128
|
+
return (
|
|
129
|
+
<li key={id}>
|
|
130
|
+
<button
|
|
131
|
+
type="button"
|
|
132
|
+
className={`scene-row${id === sceneId ? " is-current" : ""}`}
|
|
133
|
+
aria-current={id === sceneId}
|
|
134
|
+
// `focusScene`, not `setScene`: a bare pin leaves the playhead
|
|
135
|
+
// in whatever scene it was already in, and every store call
|
|
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)}
|
|
142
|
+
>
|
|
143
|
+
<span className="layer-kind" aria-hidden="true">
|
|
144
|
+
{SCENE_ICON}
|
|
145
|
+
</span>
|
|
146
|
+
<span className="row-label">{s.name}</span>
|
|
147
|
+
</button>
|
|
148
|
+
</li>
|
|
149
|
+
);
|
|
150
|
+
})}
|
|
151
|
+
</ul>
|
|
152
|
+
|
|
153
|
+
{/* COMPONENTS, above the layers and below the shots — a definition is
|
|
154
|
+
not in any shot, and the list is what makes a zero-instance one
|
|
155
|
+
reachable at all. Absent entirely when the document has none, so a
|
|
156
|
+
shot that never made one looks exactly as it did. */}
|
|
157
|
+
{Object.keys(defs).length === 0 ? null : (
|
|
158
|
+
<>
|
|
159
|
+
<div className="file-section-label">Components</div>
|
|
160
|
+
<ul className="component-list" data-testid="component-list">
|
|
161
|
+
{Object.values(defs).map((d) => (
|
|
162
|
+
<li key={d.id} className={`component-row${d.id === editingDef ? " is-current" : ""}`}>
|
|
163
|
+
<span className="layer-kind" aria-hidden="true">
|
|
164
|
+
{COMPONENT_ICON}
|
|
165
|
+
</span>
|
|
166
|
+
<button
|
|
167
|
+
type="button"
|
|
168
|
+
className="layer-name"
|
|
169
|
+
aria-current={d.id === editingDef}
|
|
170
|
+
// Scoping the OUTLINE, not the stage: a definition's layers
|
|
171
|
+
// are in no scene and have no camera to point at them.
|
|
172
|
+
onClick={() => store.editComponent(d.id === editingDef ? null : d.id)}
|
|
173
|
+
>
|
|
174
|
+
{d.name}
|
|
175
|
+
</button>
|
|
176
|
+
<span className="component-count" aria-hidden="true">
|
|
177
|
+
{instancesOf(doc, d.id).length}
|
|
178
|
+
</span>
|
|
179
|
+
<button
|
|
180
|
+
type="button"
|
|
181
|
+
className="layer-flag"
|
|
182
|
+
aria-label={`Place ${d.name}`}
|
|
183
|
+
title="Place another one"
|
|
184
|
+
onClick={() => store.placeComponent(d.id)}
|
|
185
|
+
>
|
|
186
|
+
+
|
|
187
|
+
</button>
|
|
188
|
+
</li>
|
|
189
|
+
))}
|
|
190
|
+
</ul>
|
|
191
|
+
</>
|
|
192
|
+
)}
|
|
193
|
+
|
|
194
|
+
<div className="file-section-label">{editingDef === null ? "Layers" : "Inside"}</div>
|
|
195
|
+
{/* THE WAY BACK OUT. Isolation scopes the outline to one subtree, and a
|
|
196
|
+
scoped list with no path above it reads as "the rest of my layers are
|
|
197
|
+
gone" — which is exactly what an author reports. Each crumb is a
|
|
198
|
+
button, so leaving is one click from any depth rather than N presses
|
|
199
|
+
of Escape. */}
|
|
200
|
+
{scene !== undefined && isolation !== null ? (
|
|
201
|
+
<nav className="isolation-crumbs" data-testid="isolation-crumbs">
|
|
202
|
+
<button type="button" className="crumb" onClick={() => store.exitIsolationAll()}>
|
|
203
|
+
{scene.name}
|
|
204
|
+
</button>
|
|
205
|
+
{crumbsFor(scene, isolation).map((id, i, all) => (
|
|
206
|
+
<button
|
|
207
|
+
key={id}
|
|
208
|
+
type="button"
|
|
209
|
+
className={`crumb${i === all.length - 1 ? " is-current" : ""}`}
|
|
210
|
+
aria-current={i === all.length - 1}
|
|
211
|
+
onClick={() => store.enterIsolation(id)}
|
|
212
|
+
>
|
|
213
|
+
{scene.layersById[id]?.label ?? id}
|
|
214
|
+
</button>
|
|
215
|
+
))}
|
|
216
|
+
</nav>
|
|
217
|
+
) : null}
|
|
218
|
+
{home === null ? null : (
|
|
219
|
+
<ul className="layer-list" data-testid="layer-list">
|
|
220
|
+
<LayerRows
|
|
221
|
+
// The CONTAINER, which is a definition while one is being edited.
|
|
222
|
+
// Every op the rows drive takes a container id, so the drag, the
|
|
223
|
+
// rename and the flags all work inside a definition unchanged.
|
|
224
|
+
scene={home}
|
|
225
|
+
containerId={editingDef ?? sceneId ?? ""}
|
|
226
|
+
// SCOPED to the isolated subtree's own children, so the outline
|
|
227
|
+
// says the same thing the stage does about what is being edited.
|
|
228
|
+
ids={rootIds}
|
|
229
|
+
depth={0}
|
|
230
|
+
parent={editingDef === null ? isolation : null}
|
|
231
|
+
query={trimmedQuery}
|
|
232
|
+
collapsed={collapsed}
|
|
233
|
+
onToggleCollapse={toggleCollapse}
|
|
234
|
+
onRowClick={rowClick}
|
|
235
|
+
/>
|
|
236
|
+
</ul>
|
|
237
|
+
)}
|
|
238
|
+
|
|
239
|
+
{/* Last, and below the layer tree: connecting an agent is about the
|
|
240
|
+
workspace, not the document being edited right now. */}
|
|
241
|
+
<McpSection />
|
|
242
|
+
</div>
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function LayerRows({
|
|
247
|
+
scene,
|
|
248
|
+
containerId,
|
|
249
|
+
ids,
|
|
250
|
+
depth,
|
|
251
|
+
parent,
|
|
252
|
+
query,
|
|
253
|
+
collapsed,
|
|
254
|
+
onToggleCollapse,
|
|
255
|
+
onRowClick,
|
|
256
|
+
}: {
|
|
257
|
+
scene: { layersById: Record<string, Layer3D> };
|
|
258
|
+
/** The scene OR component definition these layers live in. Every op the rows
|
|
259
|
+
* drive takes one, so a definition's interior is editable through exactly
|
|
260
|
+
* the same drag, rename and flag handlers. */
|
|
261
|
+
containerId: string;
|
|
262
|
+
ids: readonly string[];
|
|
263
|
+
depth: number;
|
|
264
|
+
/** The layer these rows are children of, or null at the scene root. A drop
|
|
265
|
+
* BETWEEN rows lands here; a drop ON one lands inside that row. */
|
|
266
|
+
parent: string | null;
|
|
267
|
+
query: string;
|
|
268
|
+
collapsed: ReadonlySet<string>;
|
|
269
|
+
onToggleCollapse: (id: string) => void;
|
|
270
|
+
onRowClick: (id: string, e: { shiftKey: boolean; ctrlKey: boolean; metaKey: boolean }) => void;
|
|
271
|
+
}) {
|
|
272
|
+
return (
|
|
273
|
+
<>
|
|
274
|
+
{ids.map((id, index) => {
|
|
275
|
+
const layer = scene.layersById[id];
|
|
276
|
+
if (layer === undefined) return null;
|
|
277
|
+
const children = layerChildren(layer);
|
|
278
|
+
// A search hides a row only when NEITHER it nor anything below it
|
|
279
|
+
// matches — hiding a matching layer because its group did not match
|
|
280
|
+
// would make the search unable to find anything nested.
|
|
281
|
+
if (query.length > 0 && !subtreeMatches(scene, layer, query)) return null;
|
|
282
|
+
// A SEARCH OVERRIDES A COLLAPSE: a match under a collapsed group must
|
|
283
|
+
// still be findable, the same rule the subtree gate above states. The
|
|
284
|
+
// one flattening in `flattenVisible` mirrors both, so a range select
|
|
285
|
+
// and the rendered rows can never disagree about what is visible.
|
|
286
|
+
const open = !collapsed.has(id) || query.length > 0;
|
|
287
|
+
return (
|
|
288
|
+
<LayerRow
|
|
289
|
+
key={id}
|
|
290
|
+
layer={layer}
|
|
291
|
+
depth={depth}
|
|
292
|
+
index={index}
|
|
293
|
+
parent={parent}
|
|
294
|
+
containerId={containerId}
|
|
295
|
+
// NO DISCLOSURE WHILE FILTERING. `open` is forced true by the
|
|
296
|
+
// search, so a chevron here would be a control that visibly does
|
|
297
|
+
// nothing on click — and then folds the group the moment the
|
|
298
|
+
// search is cleared, from a press the author had already seen
|
|
299
|
+
// fail. The fold is not a meaningful state while a filter decides
|
|
300
|
+
// what is on screen, so the row simply does not offer one.
|
|
301
|
+
hasChildren={children.length > 0 && query.length === 0}
|
|
302
|
+
open={open}
|
|
303
|
+
onToggleCollapse={onToggleCollapse}
|
|
304
|
+
onRowClick={onRowClick}
|
|
305
|
+
>
|
|
306
|
+
{children.length > 0 && open ? (
|
|
307
|
+
<ul className="layer-children">
|
|
308
|
+
<LayerRows
|
|
309
|
+
scene={scene}
|
|
310
|
+
containerId={containerId}
|
|
311
|
+
ids={children}
|
|
312
|
+
depth={depth + 1}
|
|
313
|
+
parent={layer.id}
|
|
314
|
+
query={query}
|
|
315
|
+
collapsed={collapsed}
|
|
316
|
+
onToggleCollapse={onToggleCollapse}
|
|
317
|
+
onRowClick={onRowClick}
|
|
318
|
+
/>
|
|
319
|
+
</ul>
|
|
320
|
+
) : null}
|
|
321
|
+
</LayerRow>
|
|
322
|
+
);
|
|
323
|
+
})}
|
|
324
|
+
</>
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* The row order AS RENDERED — same recursion, same predicates as `LayerRows`
|
|
330
|
+
* (subtree match, collapse-with-search-override) — for the shift range.
|
|
331
|
+
* Kept beside the renderer so a predicate change is one edit away from both.
|
|
332
|
+
*/
|
|
333
|
+
function flattenVisible(
|
|
334
|
+
scene: { layersById: Record<string, Layer3D> },
|
|
335
|
+
ids: readonly string[],
|
|
336
|
+
collapsed: ReadonlySet<string>,
|
|
337
|
+
query: string,
|
|
338
|
+
): string[] {
|
|
339
|
+
const out: string[] = [];
|
|
340
|
+
for (const id of ids) {
|
|
341
|
+
const layer = scene.layersById[id];
|
|
342
|
+
if (layer === undefined) continue;
|
|
343
|
+
if (query.length > 0 && !subtreeMatches(scene, layer, query)) continue;
|
|
344
|
+
out.push(id);
|
|
345
|
+
const children = layerChildren(layer);
|
|
346
|
+
const open = !collapsed.has(id) || query.length > 0;
|
|
347
|
+
if (children.length > 0 && open) out.push(...flattenVisible(scene, children, collapsed, query));
|
|
348
|
+
}
|
|
349
|
+
return out;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** The isolated layer and every ancestor above it, outermost first. The path
|
|
353
|
+
* the breadcrumb walks, and the reason entering a nested subtree is reversible
|
|
354
|
+
* one level at a time. */
|
|
355
|
+
function crumbsFor(scene: { layersById: Record<string, Layer3D> }, id: string): string[] {
|
|
356
|
+
const out: string[] = [];
|
|
357
|
+
let at: string | null = id;
|
|
358
|
+
for (let i = 0; at !== null && i <= Object.keys(scene.layersById).length; i += 1) {
|
|
359
|
+
out.push(at);
|
|
360
|
+
at = parentOf(scene as never, at);
|
|
361
|
+
}
|
|
362
|
+
return out.reverse();
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Which third of a row the pointer is over. The middle is the biggest, so
|
|
366
|
+
* "drop it in there" is the easy gesture and reordering is the deliberate
|
|
367
|
+
* one — Figma's proportions, and for the same reason. */
|
|
368
|
+
function dropZone(e: { clientY: number }, row: HTMLElement): "before" | "into" | "after" {
|
|
369
|
+
const rect = row.getBoundingClientRect();
|
|
370
|
+
const t = (e.clientY - rect.top) / Math.max(1, rect.height);
|
|
371
|
+
if (t < 0.28) return "before";
|
|
372
|
+
if (t > 0.72) return "after";
|
|
373
|
+
return "into";
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function subtreeMatches(
|
|
377
|
+
scene: { layersById: Record<string, Layer3D> },
|
|
378
|
+
layer: Layer3D,
|
|
379
|
+
query: string,
|
|
380
|
+
): boolean {
|
|
381
|
+
if (layer.label.toLowerCase().includes(query)) return true;
|
|
382
|
+
return layerChildren(layer).some((id) => {
|
|
383
|
+
const child = scene.layersById[id];
|
|
384
|
+
return child !== undefined && subtreeMatches(scene, child, query);
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function LayerRow({
|
|
389
|
+
layer,
|
|
390
|
+
depth,
|
|
391
|
+
index,
|
|
392
|
+
parent,
|
|
393
|
+
containerId,
|
|
394
|
+
hasChildren,
|
|
395
|
+
open,
|
|
396
|
+
onToggleCollapse,
|
|
397
|
+
onRowClick,
|
|
398
|
+
children,
|
|
399
|
+
}: {
|
|
400
|
+
layer: Layer3D;
|
|
401
|
+
depth: number;
|
|
402
|
+
index: number;
|
|
403
|
+
parent: string | null;
|
|
404
|
+
/** Scene or definition — see `LayerRows`. */
|
|
405
|
+
containerId: string;
|
|
406
|
+
hasChildren: boolean;
|
|
407
|
+
open: boolean;
|
|
408
|
+
onToggleCollapse: (id: string) => void;
|
|
409
|
+
onRowClick: (id: string, e: { shiftKey: boolean; ctrlKey: boolean; metaKey: boolean }) => void;
|
|
410
|
+
children: React.ReactNode;
|
|
411
|
+
}) {
|
|
412
|
+
const store = useStore();
|
|
413
|
+
const selected = useShotEditor((s) => s.selection.includes(layer.id));
|
|
414
|
+
// Two primitives, never the record: `useShotEditor` compares with `Object.is`
|
|
415
|
+
// per notification, so returning a slice of the map would re-render every row
|
|
416
|
+
// on every unrelated change.
|
|
417
|
+
const triangles = useShotEditor((s) => s.meshTriangles[layer.id]);
|
|
418
|
+
const sceneTriangles = useShotEditor((s) => sumTriangles(s.meshTriangles));
|
|
419
|
+
const overBudget = triangles !== undefined && sceneTriangles > TRIANGLE_SOFT_BUDGET;
|
|
420
|
+
const [renaming, setRenaming] = useState(false);
|
|
421
|
+
const [over, setOver] = useState<"before" | "into" | "after" | null>(null);
|
|
422
|
+
const menu = useContextMenu();
|
|
423
|
+
|
|
424
|
+
return (
|
|
425
|
+
<li className="layer-item">
|
|
426
|
+
{menu.menu}
|
|
427
|
+
<div
|
|
428
|
+
className={`layer-row${selected ? " is-selected" : ""}${over === null ? "" : ` is-drop-${over}`}`}
|
|
429
|
+
style={{ paddingLeft: 8 + depth * 12 }}
|
|
430
|
+
data-layer={layer.id}
|
|
431
|
+
// The SAME menu the stage opens, from the same builder. A row and a
|
|
432
|
+
// click on the canvas are two ways of pointing at one layer, and a
|
|
433
|
+
// Delete that meant different things in the two would be worse than
|
|
434
|
+
// having only one of them.
|
|
435
|
+
onContextMenu={(e) => {
|
|
436
|
+
e.preventDefault();
|
|
437
|
+
// Select first, unless the row is already part of the selection — in
|
|
438
|
+
// which case the whole selection stands and the menu acts on all of
|
|
439
|
+
// it. The stage's `onContextMenu` policy, deliberately identical.
|
|
440
|
+
const state = store.getState();
|
|
441
|
+
const ids = state.selection.includes(layer.id) ? state.selection : [layer.id];
|
|
442
|
+
if (!state.selection.includes(layer.id)) store.select([layer.id]);
|
|
443
|
+
const next = store.getState();
|
|
444
|
+
menu.open({ x: e.clientX, y: e.clientY }, layerMenuEntries(store, next, menuTargetsFor(next, ids)));
|
|
445
|
+
}}
|
|
446
|
+
// THE OUTLINE DRAG. Dropping on the MIDDLE of a row puts the layer
|
|
447
|
+
// inside it; dropping near an edge puts it beside it, among that row's
|
|
448
|
+
// own siblings. Both go through `moveLayer`, which re-bases the
|
|
449
|
+
// transform so the tree changes and the picture does not.
|
|
450
|
+
draggable
|
|
451
|
+
onDragStart={(e) => e.dataTransfer.setData("text/plain", layer.id)}
|
|
452
|
+
onDragOver={(e) => {
|
|
453
|
+
e.preventDefault();
|
|
454
|
+
setOver(dropZone(e, e.currentTarget));
|
|
455
|
+
}}
|
|
456
|
+
onDragLeave={() => setOver(null)}
|
|
457
|
+
onDrop={(e) => {
|
|
458
|
+
e.preventDefault();
|
|
459
|
+
const zone = dropZone(e, e.currentTarget);
|
|
460
|
+
setOver(null);
|
|
461
|
+
const from = e.dataTransfer.getData("text/plain");
|
|
462
|
+
if (from.length === 0 || from === layer.id) return;
|
|
463
|
+
if (zone === "into") store.moveLayerTo(from, { parent: layer.id });
|
|
464
|
+
else store.moveLayerTo(from, { parent, index: zone === "before" ? index : index + 1 });
|
|
465
|
+
}}
|
|
466
|
+
>
|
|
467
|
+
{/* The disclosure chevron, a SEPARATE button so the name button's
|
|
468
|
+
textContent stays the label alone. Leaf rows get a spacer, so the
|
|
469
|
+
kind icons line up whether or not a row can fold. */}
|
|
470
|
+
{hasChildren ? (
|
|
471
|
+
<button
|
|
472
|
+
type="button"
|
|
473
|
+
className={`layer-toggle${open ? " is-open" : ""}`}
|
|
474
|
+
aria-expanded={open}
|
|
475
|
+
aria-label={`${open ? "Collapse" : "Expand"} ${layer.label}`}
|
|
476
|
+
onClick={() => onToggleCollapse(layer.id)}
|
|
477
|
+
>
|
|
478
|
+
<IconChevron />
|
|
479
|
+
</button>
|
|
480
|
+
) : (
|
|
481
|
+
<span className="layer-toggle-spacer" aria-hidden="true" />
|
|
482
|
+
)}
|
|
483
|
+
{/* OUTSIDE the name button, never inside it: the button's textContent is
|
|
484
|
+
the layer's label and nothing else — filePanel.test.tsx compares it
|
|
485
|
+
by equality, and the label is also what a screen reader announces. */}
|
|
486
|
+
<span className="layer-kind" aria-hidden="true">
|
|
487
|
+
{kindIcon(layer.kind)}
|
|
488
|
+
</span>
|
|
489
|
+
{renaming ? (
|
|
490
|
+
<input
|
|
491
|
+
className="layer-rename"
|
|
492
|
+
autoFocus
|
|
493
|
+
defaultValue={layer.label}
|
|
494
|
+
aria-label={`Rename ${layer.label}`}
|
|
495
|
+
onBlur={(e) => {
|
|
496
|
+
store.renameLayer(layer.id, e.target.value);
|
|
497
|
+
setRenaming(false);
|
|
498
|
+
}}
|
|
499
|
+
onKeyDown={(e) => {
|
|
500
|
+
if (e.key === "Enter") e.currentTarget.blur();
|
|
501
|
+
if (e.key === "Escape") setRenaming(false);
|
|
502
|
+
}}
|
|
503
|
+
/>
|
|
504
|
+
) : (
|
|
505
|
+
<button
|
|
506
|
+
type="button"
|
|
507
|
+
className="layer-name"
|
|
508
|
+
// The panel owns the click policy (shift = range over the visible
|
|
509
|
+
// order, ctrl/meta = toggle, plain = select + anchor) because only
|
|
510
|
+
// it knows the flattened row order a range is measured over.
|
|
511
|
+
onClick={(e) => onRowClick(layer.id, e)}
|
|
512
|
+
onDoubleClick={() => setRenaming(true)}
|
|
513
|
+
>
|
|
514
|
+
{layer.label}
|
|
515
|
+
</button>
|
|
516
|
+
)}
|
|
517
|
+
{/* The model's cost, for the layers that HAVE one. A sibling span for
|
|
518
|
+
the kind icon's reason: `layer-name`'s textContent is the label and
|
|
519
|
+
nothing else. Absent until the model has loaded, and absent forever
|
|
520
|
+
for a kind that carries no geometry — a badge reading 0 would be a
|
|
521
|
+
claim, and "not measured" is the honest state. */}
|
|
522
|
+
{triangles === undefined ? null : (
|
|
523
|
+
<span
|
|
524
|
+
className={`layer-tris${overBudget ? " is-over-budget" : ""}`}
|
|
525
|
+
aria-hidden="true"
|
|
526
|
+
title={overBudget ? sceneBudgetWarning(sceneTriangles) : `${triangles.toLocaleString()} triangles`}
|
|
527
|
+
>
|
|
528
|
+
{formatTriangles(triangles)}
|
|
529
|
+
</span>
|
|
530
|
+
)}
|
|
531
|
+
{/* The hover-eye and the lock. `aria-pressed` rather than a checkbox
|
|
532
|
+
because they are toggles on a row, and a screen reader should hear
|
|
533
|
+
the layer's name with them. */}
|
|
534
|
+
<button
|
|
535
|
+
type="button"
|
|
536
|
+
className="layer-flag"
|
|
537
|
+
aria-pressed={layer.hidden === true}
|
|
538
|
+
aria-label={`${layer.hidden === true ? "Show" : "Hide"} ${layer.label}`}
|
|
539
|
+
onClick={() => {
|
|
540
|
+
store.select([layer.id]);
|
|
541
|
+
store.toggleFlag("hidden");
|
|
542
|
+
}}
|
|
543
|
+
>
|
|
544
|
+
{layer.hidden === true ? EYE_OFF_ICON : EYE_ICON}
|
|
545
|
+
</button>
|
|
546
|
+
<button
|
|
547
|
+
type="button"
|
|
548
|
+
className="layer-flag"
|
|
549
|
+
aria-pressed={layer.locked === true}
|
|
550
|
+
aria-label={`${layer.locked === true ? "Unlock" : "Lock"} ${layer.label}`}
|
|
551
|
+
onClick={() => {
|
|
552
|
+
store.select([layer.id]);
|
|
553
|
+
store.toggleFlag("locked");
|
|
554
|
+
}}
|
|
555
|
+
>
|
|
556
|
+
{layer.locked === true ? LOCK_ICON : UNLOCK_ICON}
|
|
557
|
+
</button>
|
|
558
|
+
</div>
|
|
559
|
+
{children}
|
|
560
|
+
</li>
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// The File tab's MCP section: how to attach a coding agent to this workspace.
|
|
2
|
+
// Lives where History used to — connecting an agent is a thing an author does
|
|
3
|
+
// every session, and buried at the bottom of the collapsed Manual nobody
|
|
4
|
+
// found it.
|
|
5
|
+
//
|
|
6
|
+
// On a CLOUD workspace the Worker serves `GET /auth/mcp-token` (cookie-authed)
|
|
7
|
+
// with a ready-to-paste `claude mcp add` command scoped to this user's own
|
|
8
|
+
// /mcp endpoint — render that with a Copy button. A local server has no such
|
|
9
|
+
// route, so any non-JSON answer, non-200, or missing field means "local": show
|
|
10
|
+
// the `connect` instructions. The fallback needs no cloud-detection meta — the
|
|
11
|
+
// route's absence IS the signal.
|
|
12
|
+
import { useEffect, useState } from "react";
|
|
13
|
+
|
|
14
|
+
export function McpSection() {
|
|
15
|
+
const [command, setCommand] = useState<string | null>(null);
|
|
16
|
+
const [copied, setCopied] = useState(false);
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
let alive = true;
|
|
20
|
+
void (async () => {
|
|
21
|
+
try {
|
|
22
|
+
const res = await fetch("/auth/mcp-token", { headers: { accept: "application/json" } });
|
|
23
|
+
if (!res.ok) return;
|
|
24
|
+
const body = (await res.json()) as { command?: unknown };
|
|
25
|
+
if (alive && typeof body.command === "string") setCommand(body.command);
|
|
26
|
+
} catch {
|
|
27
|
+
// Local server, offline, or an HTML answer — the static text below
|
|
28
|
+
// is already the right instruction set.
|
|
29
|
+
}
|
|
30
|
+
})();
|
|
31
|
+
return () => {
|
|
32
|
+
alive = false;
|
|
33
|
+
};
|
|
34
|
+
}, []);
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div className="mcp-section" data-testid="mcp-section">
|
|
38
|
+
<div className="file-section-label">MCP</div>
|
|
39
|
+
{command === null ? (
|
|
40
|
+
<p className="help-note">
|
|
41
|
+
Run <code>npx dailies-studio connect</code> in this project, then start Claude Code in the
|
|
42
|
+
same folder — it picks up <code>.mcp.json</code> and can edit this scene.
|
|
43
|
+
</p>
|
|
44
|
+
) : (
|
|
45
|
+
<>
|
|
46
|
+
<p className="help-note">
|
|
47
|
+
Paste into your terminal — Claude Code connects to this workspace over MCP:
|
|
48
|
+
</p>
|
|
49
|
+
<div className="help-command">
|
|
50
|
+
<code>{command}</code>
|
|
51
|
+
<button
|
|
52
|
+
type="button"
|
|
53
|
+
onClick={() => {
|
|
54
|
+
void navigator.clipboard?.writeText(command);
|
|
55
|
+
setCopied(true);
|
|
56
|
+
window.setTimeout(() => setCopied(false), 1500);
|
|
57
|
+
}}
|
|
58
|
+
>
|
|
59
|
+
{copied ? "Copied" : "Copy"}
|
|
60
|
+
</button>
|
|
61
|
+
</div>
|
|
62
|
+
</>
|
|
63
|
+
)}
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|