@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,191 @@
|
|
|
1
|
+
// Snapping on the master strip. PURE — no DOM, no store, no React.
|
|
2
|
+
//
|
|
3
|
+
// ── THE TWO RULES ────────────────────────────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// 1. IT IS ON BY DEFAULT, and a held modifier suppresses it by passing an EMPTY
|
|
6
|
+
// TARGET LIST rather than by branching around the call. One code path, so
|
|
7
|
+
// "what does a drag do" cannot have two answers, and the suppressed case is
|
|
8
|
+
// the same function with nothing to bite on.
|
|
9
|
+
//
|
|
10
|
+
// 2. THE RESULT IS ALWAYS AN INTEGER FRAME. Snapping to a sub-frame time would
|
|
11
|
+
// put a float back into a system whose whole invariant is integer frames over
|
|
12
|
+
// a rational fps — the same float that would then ride into a document, an
|
|
13
|
+
// export and a cache key.
|
|
14
|
+
//
|
|
15
|
+
// The threshold is a SCREEN distance divided by the live pixels-per-frame, so
|
|
16
|
+
// the pull feels the same size at every zoom. A threshold in frames would get
|
|
17
|
+
// stronger as the author zoomed out until a whole scene was one sticky point;
|
|
18
|
+
// the store's layout snapping is divided by the view zoom for the same reason.
|
|
19
|
+
import { sceneWindows } from "@dailies-studio/shot/doc";
|
|
20
|
+
import type { Doc3D } from "@dailies-studio/shot/doc";
|
|
21
|
+
import { lifespanOf } from "./model.js";
|
|
22
|
+
|
|
23
|
+
/** How close a moving edge has to come, in SCREEN px. */
|
|
24
|
+
export const TIMELINE_SNAP_PX = 6;
|
|
25
|
+
|
|
26
|
+
export type SnapKind = "playhead" | "marker" | "scene" | "block" | "lifespan" | "audio";
|
|
27
|
+
|
|
28
|
+
export interface SnapTarget {
|
|
29
|
+
/** MASTER frame. Every target in one space, because a gesture that crosses a
|
|
30
|
+
* scene boundary has to compare a scene-2 block edge with a scene-1 one. */
|
|
31
|
+
frame: number;
|
|
32
|
+
kind: SnapKind;
|
|
33
|
+
/** What the guide would say if the UI ever labelled it. */
|
|
34
|
+
label: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface SnapHit {
|
|
38
|
+
/** The snapped frame, or the rounded input when nothing was near. ALWAYS an
|
|
39
|
+
* integer. */
|
|
40
|
+
frame: number;
|
|
41
|
+
/** What it landed on, or null. Non-null is exactly the condition for drawing
|
|
42
|
+
* a guide: a drag that merely rounded has nothing to point at. */
|
|
43
|
+
target: SnapTarget | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The pull distance in FRAMES at this zoom. */
|
|
47
|
+
export function snapThresholdFrames(ppf: number): number {
|
|
48
|
+
return TIMELINE_SNAP_PX / Math.max(ppf, 1e-6);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface SnapScope {
|
|
52
|
+
/** The playhead, in master frames. */
|
|
53
|
+
playhead: number;
|
|
54
|
+
/**
|
|
55
|
+
* What the gesture is moving, whose own edges are NOT targets.
|
|
56
|
+
*
|
|
57
|
+
* Without it every drag is pinned at zero travel: the bar under the pointer
|
|
58
|
+
* is always exactly on its own edge, so it wins every comparison and the
|
|
59
|
+
* gesture never leaves the ground.
|
|
60
|
+
*
|
|
61
|
+
* `blockId` narrows that to the ONE bar. A lifespan drag moves the layer's bar
|
|
62
|
+
* and every block on it, so the whole layer really is in motion and none of its
|
|
63
|
+
* edges is a target. A block drag moves exactly one bar: its siblings and the
|
|
64
|
+
* layer's own lifespan stay put, and butting a During block against the In
|
|
65
|
+
* block's end — or onto the lifespan in, which is the pairing the lifespan row
|
|
66
|
+
* exists to make visible — is the most natural alignment in the lane. Excluding
|
|
67
|
+
* them buys nothing and disables it.
|
|
68
|
+
*/
|
|
69
|
+
exclude?: {
|
|
70
|
+
sceneId?: string;
|
|
71
|
+
layerId?: string;
|
|
72
|
+
blockId?: string;
|
|
73
|
+
/** The audio clip a gesture is moving. Document-level, so it carries no
|
|
74
|
+
* scene: `doc.audio` is timed on MASTER frames. */
|
|
75
|
+
audioClipId?: string;
|
|
76
|
+
/** The marker a gesture is moving. Same reason as `audioClipId`, and it is
|
|
77
|
+
* not optional politeness: a marker sits exactly on its own target, so
|
|
78
|
+
* without this a marker drag is pinned at zero travel. */
|
|
79
|
+
markerId?: string;
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Everything a drag on the master strip may land on, in master frames.
|
|
85
|
+
*
|
|
86
|
+
* Collected ONCE at the press and held for the whole gesture — like every other
|
|
87
|
+
* value in this lane — because the document changes under a live drag and a
|
|
88
|
+
* target list that re-derived per move would include the thing being dragged.
|
|
89
|
+
*
|
|
90
|
+
* Deduped by frame, first writer wins, which makes the push order below a
|
|
91
|
+
* PRIORITY order: the playhead and the markers are what the author put there on
|
|
92
|
+
* purpose, so a guide at a frame that is also a scene boundary names the marker.
|
|
93
|
+
*/
|
|
94
|
+
export function collectSnapTargets(doc: Doc3D, opts: SnapScope): SnapTarget[] {
|
|
95
|
+
const out: SnapTarget[] = [];
|
|
96
|
+
const seen = new Set<number>();
|
|
97
|
+
const push = (frame: number, kind: SnapKind, label: string): void => {
|
|
98
|
+
const at = Math.round(frame);
|
|
99
|
+
if (at < 0 || seen.has(at)) return;
|
|
100
|
+
seen.add(at);
|
|
101
|
+
out.push({ frame: at, kind, label });
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
push(opts.playhead, "playhead", "playhead");
|
|
105
|
+
for (const marker of doc.markers ?? []) {
|
|
106
|
+
if (marker.id === opts.exclude?.markerId) continue;
|
|
107
|
+
push(marker.frame, "marker", marker.label ?? "marker");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Audio edges are MASTER frames already, so they need no scene offset — and
|
|
111
|
+
// they come before the scene loop because landing a block on the start of the
|
|
112
|
+
// music is the alignment this lane is for.
|
|
113
|
+
for (const clip of Object.values(doc.audio)) {
|
|
114
|
+
if (clip.id === opts.exclude?.audioClipId) continue;
|
|
115
|
+
push(clip.frame, "audio", "audio in");
|
|
116
|
+
push(clip.frame + clip.durationFrames, "audio", "audio out");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
for (const window of sceneWindows(doc)) {
|
|
120
|
+
const scene = doc.scenesById[window.id];
|
|
121
|
+
if (scene === undefined) continue;
|
|
122
|
+
push(window.start, "scene", `${scene.name} start`);
|
|
123
|
+
push(window.end, "scene", `${scene.name} end`);
|
|
124
|
+
for (const layer of Object.values(scene.layersById)) {
|
|
125
|
+
const moving =
|
|
126
|
+
opts.exclude?.sceneId === window.id && opts.exclude.layerId === layer.id ? opts.exclude : undefined;
|
|
127
|
+
// The whole layer only when the whole layer moves — see SnapScope.exclude.
|
|
128
|
+
if (moving !== undefined && moving.blockId === undefined) continue;
|
|
129
|
+
const life = lifespanOf(layer, scene);
|
|
130
|
+
push(window.start + life.in, "lifespan", `${layer.label} in`);
|
|
131
|
+
push(window.start + life.out, "lifespan", `${layer.label} out`);
|
|
132
|
+
for (const block of layer.blocks) {
|
|
133
|
+
if (block.id === moving?.blockId) continue;
|
|
134
|
+
push(window.start + block.frame, "block", `${layer.label} ${block.preset}`);
|
|
135
|
+
push(window.start + block.frame + block.durationFrames, "block", `${layer.label} ${block.preset} end`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The nearest target within `thresholdFrames`, or the rounded input. Distance
|
|
143
|
+
* is measured against the RAW frame, so a candidate 0.4 frames from a target
|
|
144
|
+
* and 0.6 from another lands on the right one rather than on whichever the
|
|
145
|
+
* rounding happened to favour. */
|
|
146
|
+
export function snapFrame(
|
|
147
|
+
frame: number,
|
|
148
|
+
targets: readonly SnapTarget[],
|
|
149
|
+
thresholdFrames: number,
|
|
150
|
+
): SnapHit {
|
|
151
|
+
const rounded = Math.round(frame);
|
|
152
|
+
if (!(thresholdFrames > 0)) return { frame: rounded, target: null };
|
|
153
|
+
let best: SnapTarget | null = null;
|
|
154
|
+
let bestDistance = Infinity;
|
|
155
|
+
for (const target of targets) {
|
|
156
|
+
const distance = Math.abs(target.frame - frame);
|
|
157
|
+
if (distance <= thresholdFrames && distance < bestDistance) {
|
|
158
|
+
best = target;
|
|
159
|
+
bestDistance = distance;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return best === null ? { frame: rounded, target: null } : { frame: best.frame, target: best };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* What a row is handed at the press: give it the frame you want and it gives
|
|
167
|
+
* back the frame you get, having told the dock where to draw the guide.
|
|
168
|
+
*
|
|
169
|
+
* A closure rather than the three arguments, because the row has no business
|
|
170
|
+
* knowing the target list, the zoom or how a guide is drawn — it knows one
|
|
171
|
+
* candidate frame, which is the only part of snapping that differs per gesture.
|
|
172
|
+
*/
|
|
173
|
+
export type SnapPress = (
|
|
174
|
+
exclude: SnapScope["exclude"],
|
|
175
|
+
suppressed: boolean,
|
|
176
|
+
) => (candidateMasterFrame: number) => number;
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Is snapping held off for this press?
|
|
180
|
+
*
|
|
181
|
+
* ⌘/ctrl, read AT THE PRESS. Every NLE defaults snapping on and gives it a
|
|
182
|
+
* hold-to-suppress rather than a toggle, and for a lane whose whole job is
|
|
183
|
+
* landing on other people's edges that polarity is the right one — the author
|
|
184
|
+
* asks for the exception, not for the rule.
|
|
185
|
+
*
|
|
186
|
+
* Structural rather than `PointerEvent`, so the pure lane stays free of the DOM
|
|
187
|
+
* and a test can hand it a literal.
|
|
188
|
+
*/
|
|
189
|
+
export function isSnapSuppressed(event: { metaKey: boolean; ctrlKey: boolean }): boolean {
|
|
190
|
+
return event.metaKey || event.ctrlKey;
|
|
191
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// The ADD menu: the layer kinds a DRAG CANNOT EXPRESS.
|
|
2
|
+
//
|
|
3
|
+
// A rectangle is a box, so the rect tool is a drag. A model is a FILE, a device
|
|
4
|
+
// is a CHOICE FROM A REGISTRY, and a cloner is an operation on what is already
|
|
5
|
+
// selected — none of those is a rubber band, and giving each of them a tool
|
|
6
|
+
// would put three buttons in the dock that all do the same thing when dragged
|
|
7
|
+
// (nothing) and something else entirely when clicked.
|
|
8
|
+
//
|
|
9
|
+
// ── THE ROWS ARE GATED ON THE SCHEMA AT RUNTIME ─────────────────────────
|
|
10
|
+
//
|
|
11
|
+
// `hasLayerKind` reads `LAYER_KINDS`, so a kind removed from the document model
|
|
12
|
+
// removes its row here instead of leaving a button that mints a layer nothing
|
|
13
|
+
// can render. It has to be a runtime test and not `LAYER_KINDS.includes("mesh")`
|
|
14
|
+
// inline: against the literal tuple that call is a TYPE error the day the kind
|
|
15
|
+
// goes, which is a build nobody can ship — the menu could not degrade, only
|
|
16
|
+
// fail to compile.
|
|
17
|
+
//
|
|
18
|
+
// ── ONE FILE INPUT, TWO PICKERS ─────────────────────────────────────────
|
|
19
|
+
//
|
|
20
|
+
// The model row needs one, and so does the FRAME TOOL (`image`, labelled
|
|
21
|
+
// "Frame" because the file it takes may be an image or a video) — which draws
|
|
22
|
+
// its box on the canvas and only then needs a file, so its geometry parks in
|
|
23
|
+
// the store (`imagePick`) and is claimed here. A dialog can only be opened by a
|
|
24
|
+
// click on a real `<input>`, and the store is not one.
|
|
25
|
+
import { useEffect, useRef, useState } from "react";
|
|
26
|
+
import { DEVICE_PRESET_IDS } from "@dailies-studio/shot/doc";
|
|
27
|
+
import { useShotEditor, useStore } from "../../state/context.js";
|
|
28
|
+
import { hasLayerKind } from "../../state/insert.js";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* What the hidden input is being asked for, or null when idle.
|
|
32
|
+
*
|
|
33
|
+
* An OBJECT and not a bare string, so asking for the same kind twice is a new
|
|
34
|
+
* value: a dialog the author cancelled fires no event at all, and with a string
|
|
35
|
+
* the second "image" would be `===` the stale first one, the effect would not
|
|
36
|
+
* re-run, and the picker would silently never open again.
|
|
37
|
+
*/
|
|
38
|
+
type Pick = { kind: "image" | "model" };
|
|
39
|
+
|
|
40
|
+
export function AddMenu() {
|
|
41
|
+
const store = useStore();
|
|
42
|
+
const imagePick = useShotEditor((s) => s.imagePick);
|
|
43
|
+
const hasSelection = useShotEditor((s) => s.selection.length > 0);
|
|
44
|
+
const canGroup = useShotEditor((s) => s.selection.length >= 2);
|
|
45
|
+
const library = useShotEditor((s) => s.library);
|
|
46
|
+
const libraryLoaded = useShotEditor((s) => s.libraryLoaded);
|
|
47
|
+
const [open, setOpen] = useState(false);
|
|
48
|
+
const [busy, setBusy] = useState(false);
|
|
49
|
+
const [want, setWant] = useState<Pick | null>(null);
|
|
50
|
+
const fileRef = useRef<HTMLInputElement | null>(null);
|
|
51
|
+
|
|
52
|
+
// A drawn image box is waiting on a file: open the dialog for it. An EFFECT
|
|
53
|
+
// is the seam because the draw happened in the STAGE's document, so there is
|
|
54
|
+
// no click of ours to hang it off. User activation propagates from a
|
|
55
|
+
// same-origin iframe, so the dialog is still allowed to open; if a browser
|
|
56
|
+
// ever stops propagating it, the ceiling is that the image tool needs a
|
|
57
|
+
// second click here rather than anything breaking.
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (imagePick === null) return;
|
|
60
|
+
setWant({ kind: "image" });
|
|
61
|
+
}, [imagePick]);
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (want !== null) fileRef.current?.click();
|
|
65
|
+
}, [want]);
|
|
66
|
+
|
|
67
|
+
// The registry, once, when the menu first opens rather than at mount: it is
|
|
68
|
+
// an HTTP call, and a chrome that never adds a component should never make
|
|
69
|
+
// it. A failure leaves the instance rows absent (the error surfaces in the
|
|
70
|
+
// inspector, which is the surface that can say what it means for a layer).
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (open && !libraryLoaded) void store.refreshLibrary();
|
|
73
|
+
}, [open, libraryLoaded, store]);
|
|
74
|
+
|
|
75
|
+
const report = (e: unknown): void =>
|
|
76
|
+
store.reportError(e instanceof Error ? e.message : String(e));
|
|
77
|
+
|
|
78
|
+
const chose = (file: File | null): void => {
|
|
79
|
+
const asked = want;
|
|
80
|
+
setWant(null);
|
|
81
|
+
if (asked === null) return;
|
|
82
|
+
setBusy(true);
|
|
83
|
+
const done =
|
|
84
|
+
asked.kind === "image"
|
|
85
|
+
? store.completeImageDraw(file)
|
|
86
|
+
: file === null
|
|
87
|
+
? Promise.resolve()
|
|
88
|
+
: store.addMeshLayer(file);
|
|
89
|
+
void done.catch(report).finally(() => setBusy(false));
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// Every row is gated, so a menu with nothing left in it is not an empty
|
|
93
|
+
// popover — it is no button at all.
|
|
94
|
+
const rows: { key: string; label: string; disabled?: boolean; title?: string; run: () => void }[] = [];
|
|
95
|
+
if (hasLayerKind("mesh")) {
|
|
96
|
+
rows.push({ key: "mesh", label: "3D model…", run: () => setWant({ kind: "model" }) });
|
|
97
|
+
}
|
|
98
|
+
if (hasLayerKind("device")) {
|
|
99
|
+
// FROM THE REGISTRY, never a list written here: a hardcoded one is a second
|
|
100
|
+
// place that knows what a device is, and `validateShotDoc` refuses a preset
|
|
101
|
+
// the registry does not have — so the two disagreeing means a menu row that
|
|
102
|
+
// can only produce a document the author cannot save.
|
|
103
|
+
for (const preset of DEVICE_PRESET_IDS) {
|
|
104
|
+
rows.push({
|
|
105
|
+
key: `device:${preset}`,
|
|
106
|
+
label: preset.charAt(0).toUpperCase() + preset.slice(1),
|
|
107
|
+
run: () => store.addDeviceLayer(preset),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (hasLayerKind("html")) {
|
|
112
|
+
rows.push({ key: "html", label: "Html snippet", run: () => store.addHtmlLayer() });
|
|
113
|
+
}
|
|
114
|
+
if (hasLayerKind("instance")) {
|
|
115
|
+
// FROM THE LIVE REGISTRY, filtered to the only host that can mount inside
|
|
116
|
+
// the shot's 3D canvas. Same reason the device rows come from
|
|
117
|
+
// DEVICE_PRESET_IDS: a hardcoded list would be a second place that knows
|
|
118
|
+
// what a component is, and the save gate refuses anything else — so a row
|
|
119
|
+
// offering a "dom" component could only ever produce a document the author
|
|
120
|
+
// cannot save.
|
|
121
|
+
for (const row of library.filter((r) => r.host === "three")) {
|
|
122
|
+
rows.push({
|
|
123
|
+
key: `instance:${row.id}`,
|
|
124
|
+
label: row.name,
|
|
125
|
+
title: row.id,
|
|
126
|
+
run: () => void store.addInstanceLayer(row.id).catch(report),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (hasLayerKind("group")) {
|
|
131
|
+
rows.push({
|
|
132
|
+
key: "group",
|
|
133
|
+
label: "Group",
|
|
134
|
+
// The pointer twin of Ctrl/Cmd+G. Two members minimum, same floor
|
|
135
|
+
// `groupSelection` itself enforces: a one-layer group is just a layer
|
|
136
|
+
// with an extra transform nobody asked for.
|
|
137
|
+
disabled: !canGroup,
|
|
138
|
+
title: canGroup ? "Group the selection (Ctrl/Cmd+G)" : "Select two or more layers to group",
|
|
139
|
+
run: () => store.groupSelection(),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
if (hasLayerKind("cloner")) {
|
|
143
|
+
rows.push({
|
|
144
|
+
key: "cloner",
|
|
145
|
+
label: "Cloner",
|
|
146
|
+
// No empty cloner, ever: `validateShotDoc` refuses one with no children,
|
|
147
|
+
// so the row that inserted it would be a row that breaks saving.
|
|
148
|
+
disabled: !hasSelection,
|
|
149
|
+
title: hasSelection ? "Wrap the selection in a cloner" : "Select a layer to clone first",
|
|
150
|
+
run: () => store.addClonerLayer(),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
if (hasLayerKind("component")) {
|
|
154
|
+
rows.push({
|
|
155
|
+
key: "component",
|
|
156
|
+
label: "Component",
|
|
157
|
+
// A definition is MADE from something, exactly like a cloner: there is no
|
|
158
|
+
// empty one, because a definition with no roots is refused at the gate.
|
|
159
|
+
disabled: !hasSelection,
|
|
160
|
+
title: hasSelection
|
|
161
|
+
? "Make the selection reusable, and leave one placement here"
|
|
162
|
+
: "Select the layers to turn into a component first",
|
|
163
|
+
run: () => store.createComponentFromSelection(),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
if (rows.length === 0) return null;
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<div className="tool-flyout-anchor">
|
|
170
|
+
<button
|
|
171
|
+
type="button"
|
|
172
|
+
className="tool-button"
|
|
173
|
+
aria-haspopup="menu"
|
|
174
|
+
aria-expanded={open}
|
|
175
|
+
title="Add a layer at the playhead"
|
|
176
|
+
data-testid="add-menu-button"
|
|
177
|
+
disabled={busy}
|
|
178
|
+
onClick={() => setOpen((v) => !v)}
|
|
179
|
+
>
|
|
180
|
+
{busy ? "…" : "+"}
|
|
181
|
+
</button>
|
|
182
|
+
{open ? (
|
|
183
|
+
<div className="tool-flyout" role="menu" data-testid="add-menu">
|
|
184
|
+
{rows.map((row) => (
|
|
185
|
+
<button
|
|
186
|
+
key={row.key}
|
|
187
|
+
type="button"
|
|
188
|
+
role="menuitem"
|
|
189
|
+
className="tool-button"
|
|
190
|
+
disabled={row.disabled === true}
|
|
191
|
+
title={row.title ?? row.label}
|
|
192
|
+
onClick={() => {
|
|
193
|
+
setOpen(false);
|
|
194
|
+
row.run();
|
|
195
|
+
}}
|
|
196
|
+
>
|
|
197
|
+
{row.label}
|
|
198
|
+
</button>
|
|
199
|
+
))}
|
|
200
|
+
</div>
|
|
201
|
+
) : null}
|
|
202
|
+
<input
|
|
203
|
+
ref={fileRef}
|
|
204
|
+
className="audio-file-input"
|
|
205
|
+
type="file"
|
|
206
|
+
// EXTENSIONS, NOT `image/*` OR `video/*`. The "sniff" on the far end
|
|
207
|
+
// is a filename extension map (`KIND_BY_EXT` in cli/src/assetStore.ts),
|
|
208
|
+
// and `storeAsset` writes the bytes BEFORE the kind is consulted — so a
|
|
209
|
+
// file the picker offered and the map does not know (.mkv, .svg, .heic)
|
|
210
|
+
// is stored, then refused, leaving an orphan blob in the
|
|
211
|
+
// content-addressed store that nothing ever collects. The list is
|
|
212
|
+
// exactly what the map answers "image" or "video" for.
|
|
213
|
+
accept={
|
|
214
|
+
want?.kind === "model"
|
|
215
|
+
? ".glb,.gltf,model/gltf-binary"
|
|
216
|
+
: ".png,.jpg,.jpeg,.webp,.avif,.gif,.mp4,.webm,.mov,.m4v," +
|
|
217
|
+
"image/png,image/jpeg,image/webp,image/avif,image/gif," +
|
|
218
|
+
"video/mp4,video/webm,video/quicktime"
|
|
219
|
+
}
|
|
220
|
+
hidden
|
|
221
|
+
data-testid="add-file-input"
|
|
222
|
+
onChange={(e) => {
|
|
223
|
+
chose(e.currentTarget.files?.[0] ?? null);
|
|
224
|
+
// Cleared so re-picking the SAME file fires change again.
|
|
225
|
+
e.currentTarget.value = "";
|
|
226
|
+
}}
|
|
227
|
+
/>
|
|
228
|
+
</div>
|
|
229
|
+
);
|
|
230
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// The camera verbs that belong to the WORKING PANE, floating over it.
|
|
2
|
+
//
|
|
3
|
+
// ── WHY THESE THREE ARE TOGETHER, AND WHY THEY ARE HERE ─────────────────
|
|
4
|
+
//
|
|
5
|
+
// A frustum you can look at but not act on is decoration. What makes a free
|
|
6
|
+
// view worth having is the ROUND TRIP between it and the camera, and the round
|
|
7
|
+
// trip is exactly these: point the camera at what I am looking at, keep
|
|
8
|
+
// pointing it there while I move, and frame this selection. They sit over the
|
|
9
|
+
// pane they act on rather than in the inspector, because the pane is where the
|
|
10
|
+
// author's eyes and pointer already are.
|
|
11
|
+
//
|
|
12
|
+
// Mounted only while the working pane is on screen: in camera mode there is no
|
|
13
|
+
// "this view" to align to, and a control that is present but meaningless is
|
|
14
|
+
// worse than one that is absent.
|
|
15
|
+
//
|
|
16
|
+
// ── THE FOLLOW TOGGLE IS NOT CALLED A LOCK ──────────────────────────────
|
|
17
|
+
//
|
|
18
|
+
// Blender's long-running complaint about the equivalent toggle is that its name
|
|
19
|
+
// says the opposite of what it does — nothing is locked, the camera is being
|
|
20
|
+
// moved by every orbit — and that it is easy to forget it is on. So it is named
|
|
21
|
+
// for what happens, it is `aria-pressed`, and the affordance that stops it
|
|
22
|
+
// being forgotten is drawn INSIDE the pane (see Overlays' FollowBadge). A
|
|
23
|
+
// checkbox in a panel nobody is looking at while they orbit is how the Blender
|
|
24
|
+
// version gets forgotten.
|
|
25
|
+
//
|
|
26
|
+
// A FLOATING SIBLING of the stage, never a wrapper. See App.tsx's header.
|
|
27
|
+
import { useShotEditor, useStore } from "../../state/context.js";
|
|
28
|
+
|
|
29
|
+
export function CameraViewBar() {
|
|
30
|
+
const store = useStore();
|
|
31
|
+
const mode = useShotEditor((s) => s.stageLayout.mode);
|
|
32
|
+
const follows = useShotEditor((s) => s.cameraFollowsView);
|
|
33
|
+
const hasSelection = useShotEditor((s) => s.selection.length > 0);
|
|
34
|
+
const open = useShotEditor((s) => s.name !== null);
|
|
35
|
+
if (!open || mode === "camera") return null;
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div className="camera-view-bar" role="group" aria-label="Camera" data-testid="camera-view-bar">
|
|
39
|
+
<button
|
|
40
|
+
type="button"
|
|
41
|
+
className="btn"
|
|
42
|
+
title="Point the shot camera where this view is looking (⌥C)"
|
|
43
|
+
onClick={() => store.alignCameraToWorkingView()}
|
|
44
|
+
>
|
|
45
|
+
Align camera
|
|
46
|
+
</button>
|
|
47
|
+
<button
|
|
48
|
+
type="button"
|
|
49
|
+
className={`btn${follows ? " is-armed" : ""}`}
|
|
50
|
+
aria-pressed={follows}
|
|
51
|
+
title="Keep the shot camera pointed where this view looks, while you orbit"
|
|
52
|
+
onClick={() => store.setCameraFollowsView(!follows)}
|
|
53
|
+
>
|
|
54
|
+
Follow view
|
|
55
|
+
</button>
|
|
56
|
+
<button
|
|
57
|
+
type="button"
|
|
58
|
+
className="btn"
|
|
59
|
+
// Disabled rather than refusing, because "nothing is selected" is
|
|
60
|
+
// visible on screen — a refusal message is for the cases the author
|
|
61
|
+
// cannot see, like a shot painting over the value they just wrote.
|
|
62
|
+
disabled={!hasSelection}
|
|
63
|
+
title="Frame the selected layers"
|
|
64
|
+
onClick={() => store.fitCameraToSelection()}
|
|
65
|
+
>
|
|
66
|
+
Fit to selection
|
|
67
|
+
</button>
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// How the WORKING pane draws: solid clay, wireframe, real materials, or the
|
|
2
|
+
// graded render from the working camera.
|
|
3
|
+
//
|
|
4
|
+
// A floating sibling like ViewModeBar, and its structural twin: a table of
|
|
5
|
+
// modes so glyph, label and cycle order live beside each other. It renders
|
|
6
|
+
// NOTHING when the layout is camera-only — the camera pane is always the
|
|
7
|
+
// graded render, so with no working pane on screen there is nothing this bar
|
|
8
|
+
// addresses and an armed button over an invisible pane would be a lie.
|
|
9
|
+
import { useShotEditor, useStore } from "../../state/context.js";
|
|
10
|
+
import { SHADING_MODES } from "../../stage/shading.js";
|
|
11
|
+
import type { ShadingMode } from "../../bridgeTypes.js";
|
|
12
|
+
|
|
13
|
+
const SHADING_GLYPHS: Readonly<Record<ShadingMode, { glyph: string; label: string }>> = {
|
|
14
|
+
solid: { glyph: "◯", label: "Solid" },
|
|
15
|
+
wireframe: { glyph: "◇", label: "Wireframe" },
|
|
16
|
+
material: { glyph: "◑", label: "Material" },
|
|
17
|
+
rendered: { glyph: "✦", label: "Rendered" },
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function ShadingBar() {
|
|
21
|
+
const store = useStore();
|
|
22
|
+
const shading = useShotEditor((s) => s.shadingMode);
|
|
23
|
+
const layoutMode = useShotEditor((s) => s.stageLayout.mode);
|
|
24
|
+
const open = useShotEditor((s) => s.name !== null);
|
|
25
|
+
if (!open || layoutMode === "camera") return null;
|
|
26
|
+
return (
|
|
27
|
+
<div className="view-mode-bar" role="group" aria-label="Working-pane shading" data-testid="shading-bar">
|
|
28
|
+
{SHADING_MODES.map((mode) => (
|
|
29
|
+
<button
|
|
30
|
+
key={mode}
|
|
31
|
+
type="button"
|
|
32
|
+
className={`tool-button${shading === mode ? " is-armed" : ""}`}
|
|
33
|
+
aria-pressed={shading === mode}
|
|
34
|
+
title={`${SHADING_GLYPHS[mode].label} — working pane (Z cycles)`}
|
|
35
|
+
onClick={() => store.setShadingMode(mode)}
|
|
36
|
+
>
|
|
37
|
+
{SHADING_GLYPHS[mode].glyph}
|
|
38
|
+
</button>
|
|
39
|
+
))}
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// The tool dock: one button per DOCK_TOOLS row, plus the shape flyout.
|
|
2
|
+
//
|
|
3
|
+
// Everything here is derived from `TOOL_SPECS`. The icon map is keyed on
|
|
4
|
+
// `DockTool` and `FlyoutTool`, so a tool added to the table without a glyph is
|
|
5
|
+
// a COMPILE ERROR rather than an empty button — which is the whole reason the
|
|
6
|
+
// table's surfaces are types rather than strings.
|
|
7
|
+
import { useState } from "react";
|
|
8
|
+
import { DOCK_TOOLS, FLYOUT_TOOLS, specOf } from "../../tools.js";
|
|
9
|
+
import type { DockTool, FlyoutTool, ShotTool } from "../../tools.js";
|
|
10
|
+
import { useShotEditor, useStore } from "../../state/context.js";
|
|
11
|
+
import { AddMenu } from "./AddMenu.js";
|
|
12
|
+
|
|
13
|
+
/** Text glyphs rather than an icon set: one fewer dependency, and every one of
|
|
14
|
+
* these is a shape the font already draws. */
|
|
15
|
+
const DOCK_GLYPHS: Record<DockTool, string> = {
|
|
16
|
+
select: "▹",
|
|
17
|
+
move: "✥",
|
|
18
|
+
text: "T",
|
|
19
|
+
image: "▤",
|
|
20
|
+
rect: "▭",
|
|
21
|
+
path: "✎",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const FLYOUT_GLYPHS: Record<FlyoutTool, string> = {
|
|
25
|
+
ellipse: "◯",
|
|
26
|
+
line: "╱",
|
|
27
|
+
arrow: "↗",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function ToolDockBar() {
|
|
31
|
+
const store = useStore();
|
|
32
|
+
const tool = useShotEditor((s) => s.tool);
|
|
33
|
+
const animateMode = useShotEditor((s) => s.animateMode);
|
|
34
|
+
const open = useShotEditor((s) => s.name !== null);
|
|
35
|
+
const [flyoutOpen, setFlyoutOpen] = useState(false);
|
|
36
|
+
if (!open) return null;
|
|
37
|
+
|
|
38
|
+
const label = (t: ShotTool): string => {
|
|
39
|
+
const spec = specOf(t);
|
|
40
|
+
const key = spec.key === null ? "" : ` (${spec.shift === true ? "⇧" : ""}${spec.key.toUpperCase()})`;
|
|
41
|
+
return `${spec.label}${key}`;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div className="tool-dock" role="toolbar" aria-label="Tools" data-testid="tool-dock">
|
|
46
|
+
{DOCK_TOOLS.map((t) => (
|
|
47
|
+
<button
|
|
48
|
+
key={t}
|
|
49
|
+
type="button"
|
|
50
|
+
className={`tool-button${tool === t ? " is-armed" : ""}`}
|
|
51
|
+
aria-pressed={tool === t}
|
|
52
|
+
title={label(t)}
|
|
53
|
+
onClick={() => store.setTool(t)}
|
|
54
|
+
>
|
|
55
|
+
{DOCK_GLYPHS[t]}
|
|
56
|
+
</button>
|
|
57
|
+
))}
|
|
58
|
+
|
|
59
|
+
<div className="tool-flyout-anchor">
|
|
60
|
+
<button
|
|
61
|
+
type="button"
|
|
62
|
+
className={`tool-button${FLYOUT_TOOLS.includes(tool as FlyoutTool) ? " is-armed" : ""}`}
|
|
63
|
+
aria-haspopup="menu"
|
|
64
|
+
aria-expanded={flyoutOpen}
|
|
65
|
+
title="Shapes"
|
|
66
|
+
onClick={() => setFlyoutOpen((v) => !v)}
|
|
67
|
+
>
|
|
68
|
+
◇
|
|
69
|
+
</button>
|
|
70
|
+
{flyoutOpen ? (
|
|
71
|
+
<div className="tool-flyout" role="menu">
|
|
72
|
+
{FLYOUT_TOOLS.map((t) => (
|
|
73
|
+
<button
|
|
74
|
+
key={t}
|
|
75
|
+
type="button"
|
|
76
|
+
role="menuitem"
|
|
77
|
+
className={`tool-button${tool === t ? " is-armed" : ""}`}
|
|
78
|
+
title={label(t)}
|
|
79
|
+
onClick={() => {
|
|
80
|
+
store.setTool(t);
|
|
81
|
+
setFlyoutOpen(false);
|
|
82
|
+
}}
|
|
83
|
+
>
|
|
84
|
+
{FLYOUT_GLYPHS[t]} {specOf(t).label}
|
|
85
|
+
</button>
|
|
86
|
+
))}
|
|
87
|
+
</div>
|
|
88
|
+
) : null}
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
{/* NOT a tool either, and beside the shapes for the same reason: it is
|
|
92
|
+
the kinds a DRAG CANNOT EXPRESS — a file, a registry choice, an
|
|
93
|
+
operation on the selection. See AddMenu. */}
|
|
94
|
+
<AddMenu />
|
|
95
|
+
|
|
96
|
+
{/* NOT a tool, and the separator says so. Animate changes what an edit
|
|
97
|
+
MEANS while any tool is held — see tools.ts. */}
|
|
98
|
+
<span className="tool-separator" aria-hidden="true" />
|
|
99
|
+
<button
|
|
100
|
+
type="button"
|
|
101
|
+
className={`tool-button tool-animate${animateMode ? " is-armed" : ""}`}
|
|
102
|
+
aria-pressed={animateMode}
|
|
103
|
+
title="Animate (⇧A) — a canvas edit becomes a block ending at the playhead"
|
|
104
|
+
onClick={() => store.setAnimateMode(!animateMode)}
|
|
105
|
+
>
|
|
106
|
+
⏺
|
|
107
|
+
</button>
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|