@odori/cli 0.0.2 → 0.0.4
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/{chunk-7XJL2BYO.js → chunk-NYXWEZU2.js} +717 -348
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +63 -8
- package/dist/index.js +3 -3
- package/dist/registry-snapshot-MSH2EA36.js +4867 -0
- package/package.json +3 -3
- package/src/assets.ts +90 -0
- package/src/brand-file.ts +16 -4
- package/src/chunk-cache.ts +6 -0
- package/src/cli.ts +22 -12
- package/src/commands/add.ts +33 -8
- package/src/commands/dev.ts +114 -12
- package/src/commands/doctor.ts +47 -2
- package/src/commands/exportVideo.ts +39 -5
- package/src/commands/{still.ts → frame.ts} +23 -9
- package/src/commands/init.ts +1 -1
- package/src/commands/new.ts +1 -1
- package/src/cues.ts +34 -21
- package/src/discovery.ts +85 -5
- package/src/formats.ts +67 -8
- package/src/index.ts +1 -1
- package/src/jobs.ts +6 -2
- package/src/registry-snapshot.json +1530 -328
- package/src/registry-source.ts +87 -5
- package/src/render.ts +48 -13
- package/src/server.ts +55 -13
- package/studio/src/Studio.tsx +19 -22
- package/studio/src/components/ExportPanel.tsx +124 -90
- package/studio/src/components/Inspector.tsx +221 -0
- package/studio/src/components/Navigator.tsx +145 -0
- package/studio/src/components/Thumbnail.tsx +65 -23
- package/studio/src/components/ui.tsx +9 -2
- package/studio/src/lib/highlight.ts +85 -0
- package/studio/src/studio.css +435 -20
- package/studio/src/views/AssetsView.tsx +14 -1
- package/studio/src/views/BrandsView.tsx +74 -26
- package/studio/src/views/ComponentsView.tsx +206 -55
- package/studio/src/views/HomeView.tsx +44 -19
- package/studio/src/views/VideosView.tsx +53 -48
- package/studio/src/virtual.d.ts +4 -1
- package/dist/registry-snapshot-NIH2JMQ6.js +0 -3559
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {Badge, Button,
|
|
1
|
+
import {useState} from "react";
|
|
2
|
+
import {Badge, Button, SectionTitle} from "./ui";
|
|
3
3
|
|
|
4
4
|
type JobState = {
|
|
5
5
|
id: string;
|
|
@@ -17,13 +17,30 @@ const tone = (status: string) => (status === "failed" ? "danger" : status === "r
|
|
|
17
17
|
/** Jobs are identified by what they wrote, not by their manifest hash. */
|
|
18
18
|
const fileName = (path?: string) => path?.split(/[\\/]/).pop();
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
};
|
|
20
|
+
/** What the file is: a codec choice, or one still frame. */
|
|
21
|
+
type Format = "mp4" | "webm" | "prores" | "gif" | "frame";
|
|
22
|
+
type Quality = "studio" | "social" | "web";
|
|
23
|
+
|
|
24
|
+
const EXTENSIONS: Record<Format, string> = {mp4: ".mp4", webm: ".webm", prores: ".mov", gif: ".gif", frame: ".png"};
|
|
25
|
+
|
|
26
|
+
const FORMAT_CHOICES: Array<{id: Format; label: string; hint: string}> = [
|
|
27
|
+
{id: "mp4", label: "MP4", hint: "H.264. Plays everywhere; the default."},
|
|
28
|
+
{id: "webm", label: "WebM", hint: "VP9 with alpha, for the web and overlays."},
|
|
29
|
+
{id: "prores", label: "ProRes", hint: "ProRes 4444 with alpha, for an editor."},
|
|
30
|
+
{id: "gif", label: "GIF", hint: "Palette-optimised animation. Silent, by the format."},
|
|
31
|
+
{id: "frame", label: "Frame", hint: "The current frame as a PNG."},
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const SCALES = [0.5, 1, 2];
|
|
35
|
+
|
|
36
|
+
const QUALITY_CHOICES: Array<{id: Quality; label: string; hint: string}> = [
|
|
37
|
+
{id: "studio", label: "Studio", hint: "Highest quality, for further editing."},
|
|
38
|
+
{id: "social", label: "Social", hint: "Tuned for upload pipelines that re-encode anyway."},
|
|
39
|
+
{id: "web", label: "Web", hint: "Smallest file that still looks intentional."},
|
|
40
|
+
];
|
|
25
41
|
|
|
26
|
-
|
|
42
|
+
/** Even dimensions, the way the encoder's scale filter lands them. */
|
|
43
|
+
const scaled = (value: number, scale: number) => Math.trunc((value * scale) / 2) * 2;
|
|
27
44
|
|
|
28
45
|
/** Ids are paths under videos/, output files are flat. */
|
|
29
46
|
const outputName = (videoId: string) => videoId.split("/").join("-");
|
|
@@ -37,36 +54,21 @@ export const ExportPanel = ({
|
|
|
37
54
|
videoId,
|
|
38
55
|
input,
|
|
39
56
|
frame,
|
|
40
|
-
|
|
57
|
+
width,
|
|
58
|
+
height,
|
|
41
59
|
}: {
|
|
42
60
|
videoId: string;
|
|
43
61
|
input: Record<string, unknown>;
|
|
44
62
|
frame: number;
|
|
45
|
-
|
|
63
|
+
width: number;
|
|
64
|
+
height: number;
|
|
46
65
|
}) => {
|
|
47
66
|
const [job, setJob] = useState<JobState | null>(null);
|
|
48
67
|
const [busy, setBusy] = useState(false);
|
|
49
|
-
const [
|
|
50
|
-
const [
|
|
51
|
-
const [
|
|
68
|
+
const [format, setFormat] = useState<Format>("mp4");
|
|
69
|
+
const [quality, setQuality] = useState<Quality>("studio");
|
|
70
|
+
const [scale, setScale] = useState(1);
|
|
52
71
|
const [notice, setNotice] = useState<string | null>(null);
|
|
53
|
-
const menuRef = useRef<HTMLDivElement | null>(null);
|
|
54
|
-
|
|
55
|
-
useEffect(() => {
|
|
56
|
-
if (!menuOpen) return undefined;
|
|
57
|
-
const dismiss = (event: MouseEvent) => {
|
|
58
|
-
if (!menuRef.current?.contains(event.target as Node)) setMenuOpen(false);
|
|
59
|
-
};
|
|
60
|
-
const onKey = (event: KeyboardEvent) => {
|
|
61
|
-
if (event.key === "Escape") setMenuOpen(false);
|
|
62
|
-
};
|
|
63
|
-
document.addEventListener("mousedown", dismiss);
|
|
64
|
-
document.addEventListener("keydown", onKey);
|
|
65
|
-
return () => {
|
|
66
|
-
document.removeEventListener("mousedown", dismiss);
|
|
67
|
-
document.removeEventListener("keydown", onKey);
|
|
68
|
-
};
|
|
69
|
-
}, [menuOpen]);
|
|
70
72
|
|
|
71
73
|
const poll = async (id: string) => {
|
|
72
74
|
for (let attempt = 0; attempt < 900; attempt += 1) {
|
|
@@ -130,74 +132,110 @@ export const ExportPanel = ({
|
|
|
130
132
|
}
|
|
131
133
|
};
|
|
132
134
|
|
|
133
|
-
const run = async (
|
|
134
|
-
setMenuOpen(false);
|
|
135
|
-
setMode(next);
|
|
135
|
+
const run = async () => {
|
|
136
136
|
setNotice(null);
|
|
137
|
-
if (
|
|
138
|
-
|
|
139
|
-
return post("/__odori/exports", {videoId, input});
|
|
137
|
+
if (format === "frame") return post("/__odori/still", {videoId, input, frame});
|
|
138
|
+
return post("/__odori/exports", {videoId, input, format, quality, scale});
|
|
140
139
|
};
|
|
141
140
|
|
|
142
141
|
const file = outputName(videoId);
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
142
|
+
// Frame rate is deliberately not an option here: fps is authored in the
|
|
143
|
+
// video's layout and every frame number in the project depends on it.
|
|
144
|
+
const still = format === "frame";
|
|
145
|
+
const hasQuality = format === "mp4" || format === "webm";
|
|
146
|
+
const inFlight = job && job.status !== "ready" && job.status !== "failed";
|
|
147
|
+
// The button names the file it will write - extension implied by the type
|
|
148
|
+
// above it - and reports its own progress while writing it.
|
|
149
|
+
const actionLabel = inFlight
|
|
150
|
+
? `${job.status === "queued" ? "Queued" : job.status === "encoding" ? "Encoding" : "Rendering"} ${Math.round((job.progress ?? 0) * 100)}%`
|
|
151
|
+
: busy
|
|
152
|
+
? "Working"
|
|
153
|
+
: still
|
|
154
|
+
? `Export ${file}-${frame}`
|
|
155
|
+
: `Export ${file}`;
|
|
153
156
|
|
|
154
157
|
return (
|
|
155
|
-
<div>
|
|
158
|
+
<div className="export-panel">
|
|
156
159
|
<SectionTitle>Export</SectionTitle>
|
|
157
|
-
|
|
160
|
+
|
|
161
|
+
<div className="export-row">
|
|
162
|
+
<span className="export-label">Type</span>
|
|
163
|
+
<div className="export-options">
|
|
164
|
+
{FORMAT_CHOICES.map((item) => (
|
|
165
|
+
<Button
|
|
166
|
+
key={item.id}
|
|
167
|
+
variant="outline"
|
|
168
|
+
active={item.id === format}
|
|
169
|
+
disabled={busy}
|
|
170
|
+
title={item.hint}
|
|
171
|
+
onClick={() => setFormat(item.id)}
|
|
172
|
+
>
|
|
173
|
+
{item.label}
|
|
174
|
+
</Button>
|
|
175
|
+
))}
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
{/* A still is a browser screenshot at the composition's own size, so
|
|
180
|
+
the encoder options do not apply to it. */}
|
|
181
|
+
{still ? null : (
|
|
182
|
+
<div className="export-row">
|
|
183
|
+
<span className="export-label">Size</span>
|
|
184
|
+
<div className="export-options">
|
|
185
|
+
{SCALES.map((option) => (
|
|
186
|
+
<Button
|
|
187
|
+
key={option}
|
|
188
|
+
variant="outline"
|
|
189
|
+
active={option === scale}
|
|
190
|
+
disabled={busy}
|
|
191
|
+
onClick={() => setScale(option)}
|
|
192
|
+
>
|
|
193
|
+
{option}x
|
|
194
|
+
</Button>
|
|
195
|
+
))}
|
|
196
|
+
</div>
|
|
197
|
+
<span className="export-dimensions">
|
|
198
|
+
{scaled(width, scale)} × {scaled(height, scale)}
|
|
199
|
+
</span>
|
|
200
|
+
</div>
|
|
201
|
+
)}
|
|
202
|
+
|
|
203
|
+
{hasQuality ? (
|
|
204
|
+
<div className="export-row">
|
|
205
|
+
<span className="export-label">Quality</span>
|
|
206
|
+
<div className="export-options">
|
|
207
|
+
{QUALITY_CHOICES.map((item) => (
|
|
208
|
+
<Button
|
|
209
|
+
key={item.id}
|
|
210
|
+
variant="outline"
|
|
211
|
+
active={item.id === quality}
|
|
212
|
+
disabled={busy}
|
|
213
|
+
title={item.hint}
|
|
214
|
+
onClick={() => setQuality(item.id)}
|
|
215
|
+
>
|
|
216
|
+
{item.label}
|
|
217
|
+
</Button>
|
|
218
|
+
))}
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
) : null}
|
|
222
|
+
|
|
223
|
+
<div className="export-actions">
|
|
158
224
|
<Button
|
|
159
225
|
variant="primary"
|
|
160
226
|
disabled={busy}
|
|
161
|
-
onClick={() => void run(
|
|
162
|
-
title={
|
|
227
|
+
onClick={() => void run()}
|
|
228
|
+
title={
|
|
229
|
+
still
|
|
230
|
+
? `Write ${file}-${frame}.png to your Downloads folder`
|
|
231
|
+
: `Encode the timeline to ${file}${EXTENSIONS[format]} in your Downloads folder`
|
|
232
|
+
}
|
|
163
233
|
>
|
|
164
|
-
{actionLabel}
|
|
234
|
+
<span>{actionLabel}</span>
|
|
165
235
|
</Button>
|
|
166
|
-
<Button
|
|
167
|
-
|
|
168
|
-
disabled={busy}
|
|
169
|
-
icon
|
|
170
|
-
aria-haspopup="menu"
|
|
171
|
-
aria-expanded={menuOpen}
|
|
172
|
-
aria-label="Choose what to export"
|
|
173
|
-
onClick={() => {
|
|
174
|
-
// The panel sits low in the sidebar, so the menu opens toward
|
|
175
|
-
// whichever edge has room for it.
|
|
176
|
-
const bounds = menuRef.current?.getBoundingClientRect();
|
|
177
|
-
if (bounds) setDropUp(window.innerHeight - bounds.bottom < 180);
|
|
178
|
-
setMenuOpen((open) => !open);
|
|
179
|
-
}}
|
|
180
|
-
>
|
|
181
|
-
<Icon name="caret" />
|
|
236
|
+
<Button variant="outline" disabled={busy} onClick={() => void copyFrame()} title="Copy the current frame as an image, writing no file">
|
|
237
|
+
<span>Copy frame</span>
|
|
182
238
|
</Button>
|
|
183
|
-
{menuOpen ? (
|
|
184
|
-
<div className="menu" role="menu" data-placement={dropUp ? "up" : "down"}>
|
|
185
|
-
{modes.map((item) => (
|
|
186
|
-
<button
|
|
187
|
-
key={item.id}
|
|
188
|
-
type="button"
|
|
189
|
-
role="menuitemradio"
|
|
190
|
-
aria-checked={item.id === mode}
|
|
191
|
-
className="menu-item"
|
|
192
|
-
data-active={item.id === mode ? "true" : undefined}
|
|
193
|
-
onClick={() => void run(item.id)}
|
|
194
|
-
>
|
|
195
|
-
<span>{item.label}</span>
|
|
196
|
-
<span className="hint">{item.hint}</span>
|
|
197
|
-
</button>
|
|
198
|
-
))}
|
|
199
|
-
</div>
|
|
200
|
-
) : null}
|
|
201
239
|
</div>
|
|
202
240
|
|
|
203
241
|
{notice ? (
|
|
@@ -223,11 +261,7 @@ export const ExportPanel = ({
|
|
|
223
261
|
</div>
|
|
224
262
|
) : null}
|
|
225
263
|
</>
|
|
226
|
-
) :
|
|
227
|
-
<p className="hint" style={{marginTop: 8}}>
|
|
228
|
-
Preview needs no encode. Export writes {exportDir}/{file}.mp4 from the frozen manifest.
|
|
229
|
-
</p>
|
|
230
|
-
)}
|
|
264
|
+
) : null}
|
|
231
265
|
|
|
232
266
|
</div>
|
|
233
267
|
);
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import {useEffect, useState} from "react";
|
|
2
|
+
import type {ReactNode} from "react";
|
|
3
|
+
import {Icon} from "./ui";
|
|
4
|
+
import {tokenize} from "../lib/highlight";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The right-hand pane, with its width and its presence under the reader's
|
|
8
|
+
* control.
|
|
9
|
+
*
|
|
10
|
+
* Studio lives beside an editor or an agent pane at least as often as it owns
|
|
11
|
+
* the window, and at those widths a fixed inspector is what makes the whole
|
|
12
|
+
* app feel unusable: the stage keeps the leftovers. The width is a CSS
|
|
13
|
+
* variable on the root so the grid in `.main` can read it, and both the width
|
|
14
|
+
* and the collapsed state persist per browser, because a pane that snaps back
|
|
15
|
+
* on reload was never adjustable.
|
|
16
|
+
*/
|
|
17
|
+
const WIDTH_VAR = "--inspector-width";
|
|
18
|
+
const STORAGE_KEY = "odori-inspector-width";
|
|
19
|
+
const COLLAPSED_KEY = "odori-inspector-collapsed";
|
|
20
|
+
const MIN = 240;
|
|
21
|
+
const MAX = 560;
|
|
22
|
+
|
|
23
|
+
const clampWidth = (value: number): number =>
|
|
24
|
+
Math.round(Math.min(Math.max(value, MIN), Math.min(MAX, window.innerWidth * 0.5)));
|
|
25
|
+
|
|
26
|
+
const applyWidth = (value: number) => {
|
|
27
|
+
document.documentElement.style.setProperty(WIDTH_VAR, `${clampWidth(value)}px`);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const Inspector = ({
|
|
31
|
+
title,
|
|
32
|
+
hint,
|
|
33
|
+
source,
|
|
34
|
+
children,
|
|
35
|
+
}: {
|
|
36
|
+
/** What this pane is about, as a person names it. */
|
|
37
|
+
title?: string;
|
|
38
|
+
/** The longer form, one hover away - usually the source path. */
|
|
39
|
+
hint?: string;
|
|
40
|
+
/**
|
|
41
|
+
* The files that produced what is on the stage, relative to the project.
|
|
42
|
+
* Given any, the pane offers to show them: the source is the truth here, so
|
|
43
|
+
* it should be one click from the frame rather than a path to go and find.
|
|
44
|
+
* More than one because a component is two files - what it draws and the
|
|
45
|
+
* fixture that plays it - and reading only the fixture answers the wrong
|
|
46
|
+
* question.
|
|
47
|
+
*/
|
|
48
|
+
source?: string | string[];
|
|
49
|
+
children: ReactNode;
|
|
50
|
+
}) => {
|
|
51
|
+
const files = source === undefined ? [] : Array.isArray(source) ? source : [source];
|
|
52
|
+
const [showing, setShowing] = useState<"inspect" | "code">("inspect");
|
|
53
|
+
const [reading, setReading] = useState(0);
|
|
54
|
+
const [collapsed, setCollapsed] = useState(() => window.localStorage.getItem(COLLAPSED_KEY) === "true");
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
const stored = Number(window.localStorage.getItem(STORAGE_KEY));
|
|
58
|
+
if (Number.isFinite(stored) && stored > 0) applyWidth(stored);
|
|
59
|
+
}, []);
|
|
60
|
+
|
|
61
|
+
// The grid in .main reads this, so collapsing costs no React re-layout.
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
document.documentElement.dataset.inspector = collapsed ? "collapsed" : "open";
|
|
64
|
+
window.localStorage.setItem(COLLAPSED_KEY, String(collapsed));
|
|
65
|
+
return () => {
|
|
66
|
+
delete document.documentElement.dataset.inspector;
|
|
67
|
+
};
|
|
68
|
+
}, [collapsed]);
|
|
69
|
+
|
|
70
|
+
if (collapsed) {
|
|
71
|
+
return (
|
|
72
|
+
<button
|
|
73
|
+
type="button"
|
|
74
|
+
className="inspector-reopen"
|
|
75
|
+
aria-label="Show inspector"
|
|
76
|
+
title="Show inspector"
|
|
77
|
+
onClick={() => setCollapsed(false)}
|
|
78
|
+
>
|
|
79
|
+
<Icon name="sidebar" />
|
|
80
|
+
</button>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<aside className="inspector">
|
|
86
|
+
<div
|
|
87
|
+
className="inspector-resize"
|
|
88
|
+
role="separator"
|
|
89
|
+
aria-orientation="vertical"
|
|
90
|
+
aria-label="Resize inspector"
|
|
91
|
+
onPointerDown={(event) => {
|
|
92
|
+
// The handle is eight pixels wide; a fast drag leaves it between
|
|
93
|
+
// events. The window sees every move, so the listeners go there
|
|
94
|
+
// for the duration of the drag rather than relying on capture.
|
|
95
|
+
event.preventDefault();
|
|
96
|
+
document.body.style.userSelect = "none";
|
|
97
|
+
document.body.style.cursor = "col-resize";
|
|
98
|
+
const onMove = (move: PointerEvent) => applyWidth(window.innerWidth - move.clientX);
|
|
99
|
+
const onUp = () => {
|
|
100
|
+
window.removeEventListener("pointermove", onMove);
|
|
101
|
+
window.removeEventListener("pointerup", onUp);
|
|
102
|
+
document.body.style.userSelect = "";
|
|
103
|
+
document.body.style.cursor = "";
|
|
104
|
+
const width = getComputedStyle(document.documentElement).getPropertyValue(WIDTH_VAR).trim();
|
|
105
|
+
if (width) window.localStorage.setItem(STORAGE_KEY, String(Number.parseInt(width, 10)));
|
|
106
|
+
};
|
|
107
|
+
window.addEventListener("pointermove", onMove);
|
|
108
|
+
window.addEventListener("pointerup", onUp);
|
|
109
|
+
}}
|
|
110
|
+
onDoubleClick={() => {
|
|
111
|
+
// Back to the default, the way a draggable divider usually resets.
|
|
112
|
+
document.documentElement.style.removeProperty(WIDTH_VAR);
|
|
113
|
+
window.localStorage.removeItem(STORAGE_KEY);
|
|
114
|
+
}}
|
|
115
|
+
/>
|
|
116
|
+
<div className="inspector-scroll">
|
|
117
|
+
<div className="inspector-head">
|
|
118
|
+
<span className="inspector-title" title={hint ?? title}>
|
|
119
|
+
{title}
|
|
120
|
+
</span>
|
|
121
|
+
{files.length > 0 ? (
|
|
122
|
+
<div className="inspector-views" role="tablist">
|
|
123
|
+
{(["inspect", "code"] as const).map((view) => (
|
|
124
|
+
<button
|
|
125
|
+
key={view}
|
|
126
|
+
type="button"
|
|
127
|
+
role="tab"
|
|
128
|
+
aria-selected={showing === view}
|
|
129
|
+
data-active={showing === view ? "true" : undefined}
|
|
130
|
+
onClick={() => setShowing(view)}
|
|
131
|
+
>
|
|
132
|
+
{view === "inspect" ? "Inspect" : "Code"}
|
|
133
|
+
</button>
|
|
134
|
+
))}
|
|
135
|
+
</div>
|
|
136
|
+
) : null}
|
|
137
|
+
<button
|
|
138
|
+
type="button"
|
|
139
|
+
className="inspector-toggle"
|
|
140
|
+
aria-label="Hide inspector"
|
|
141
|
+
title="Hide inspector"
|
|
142
|
+
onClick={() => setCollapsed(true)}
|
|
143
|
+
>
|
|
144
|
+
<Icon name="sidebar" />
|
|
145
|
+
</button>
|
|
146
|
+
</div>
|
|
147
|
+
{files.length > 0 && showing === "code" ? (
|
|
148
|
+
<>
|
|
149
|
+
{/* One file is the file; several want naming, so the pane says
|
|
150
|
+
which of a component's two halves is on screen. */}
|
|
151
|
+
{files.length > 1 ? (
|
|
152
|
+
<div className="source-files" role="tablist">
|
|
153
|
+
{files.map((file, index) => (
|
|
154
|
+
<button
|
|
155
|
+
key={file}
|
|
156
|
+
type="button"
|
|
157
|
+
role="tab"
|
|
158
|
+
aria-selected={index === reading}
|
|
159
|
+
data-active={index === reading ? "true" : undefined}
|
|
160
|
+
title={file}
|
|
161
|
+
onClick={() => setReading(index)}
|
|
162
|
+
>
|
|
163
|
+
{file.split("/").pop()}
|
|
164
|
+
</button>
|
|
165
|
+
))}
|
|
166
|
+
</div>
|
|
167
|
+
) : null}
|
|
168
|
+
<SourceView file={files[Math.min(reading, files.length - 1)]} />
|
|
169
|
+
</>
|
|
170
|
+
) : (
|
|
171
|
+
children
|
|
172
|
+
)}
|
|
173
|
+
</div>
|
|
174
|
+
</aside>
|
|
175
|
+
);
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The file, as it is on disk. Read only on purpose: the editor is the editor,
|
|
180
|
+
* and what this answers is "what wrote this frame", which is a reading
|
|
181
|
+
* question. It refetches when the path changes, and the dev server reloads the
|
|
182
|
+
* page on every source edit, so what is shown is never stale.
|
|
183
|
+
*/
|
|
184
|
+
const SourceView = ({file}: {file: string}) => {
|
|
185
|
+
const [text, setText] = useState<string | null>(null);
|
|
186
|
+
const [failed, setFailed] = useState<string | null>(null);
|
|
187
|
+
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
let live = true;
|
|
190
|
+
setText(null);
|
|
191
|
+
setFailed(null);
|
|
192
|
+
void fetch(`/__odori/source?file=${encodeURIComponent(file)}`)
|
|
193
|
+
.then(async (response) => {
|
|
194
|
+
if (!response.ok) throw new Error(await response.text());
|
|
195
|
+
return response.text();
|
|
196
|
+
})
|
|
197
|
+
.then((body) => live && setText(body))
|
|
198
|
+
.catch((error) => live && setFailed(error instanceof Error ? error.message : String(error)));
|
|
199
|
+
return () => {
|
|
200
|
+
live = false;
|
|
201
|
+
};
|
|
202
|
+
}, [file]);
|
|
203
|
+
|
|
204
|
+
if (failed) return <p className="hint">{failed}</p>;
|
|
205
|
+
if (text === null) return <p className="hint">Reading {file}</p>;
|
|
206
|
+
return (
|
|
207
|
+
<pre className="source">
|
|
208
|
+
<code>
|
|
209
|
+
{tokenize(text).map((token, index) =>
|
|
210
|
+
token.kind === "plain" ? (
|
|
211
|
+
token.text
|
|
212
|
+
) : (
|
|
213
|
+
<span key={index} data-token={token.kind}>
|
|
214
|
+
{token.text}
|
|
215
|
+
</span>
|
|
216
|
+
),
|
|
217
|
+
)}
|
|
218
|
+
</code>
|
|
219
|
+
</pre>
|
|
220
|
+
);
|
|
221
|
+
};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import {useEffect, useMemo, useRef, useState} from "react";
|
|
2
|
+
import {Icon} from "./ui";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The list of everything in a view, beside the thing you are looking at.
|
|
6
|
+
*
|
|
7
|
+
* Studio moved to gallery-then-detail so a narrow pane could give the stage
|
|
8
|
+
* its whole width, which is right when Studio sits beside an agent and wrong
|
|
9
|
+
* when you are working through a library: reaching the next component meant
|
|
10
|
+
* going back to the gallery, finding it, and clicking in. A session of that
|
|
11
|
+
* is a lot of round trips.
|
|
12
|
+
*
|
|
13
|
+
* So the list comes back as something you can put away. It collapses the way
|
|
14
|
+
* the inspector does, remembers that choice, and filters as you type, which
|
|
15
|
+
* is the part a gallery cannot do at all.
|
|
16
|
+
*/
|
|
17
|
+
const COLLAPSED_KEY = "odori-navigator-collapsed";
|
|
18
|
+
|
|
19
|
+
export type NavigatorItem = {
|
|
20
|
+
id: string;
|
|
21
|
+
title: string;
|
|
22
|
+
/** The quieter second line: an id, a duration, a file. */
|
|
23
|
+
detail?: string;
|
|
24
|
+
/** A heading to sit under. Items with none come first, ungrouped. */
|
|
25
|
+
group?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const Navigator = ({
|
|
29
|
+
label,
|
|
30
|
+
items,
|
|
31
|
+
selected,
|
|
32
|
+
onSelect,
|
|
33
|
+
}: {
|
|
34
|
+
/** What this list is of, for the header and the screen reader. */
|
|
35
|
+
label: string;
|
|
36
|
+
items: NavigatorItem[];
|
|
37
|
+
selected: string | null;
|
|
38
|
+
onSelect: (id: string) => void;
|
|
39
|
+
}) => {
|
|
40
|
+
const [collapsed, setCollapsed] = useState(() => window.localStorage.getItem(COLLAPSED_KEY) === "true");
|
|
41
|
+
const [query, setQuery] = useState("");
|
|
42
|
+
const field = useRef<HTMLInputElement>(null);
|
|
43
|
+
|
|
44
|
+
// The grid in .main reads this, so opening the list costs no React layout.
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
document.documentElement.dataset.navigator = collapsed ? "collapsed" : "open";
|
|
47
|
+
window.localStorage.setItem(COLLAPSED_KEY, String(collapsed));
|
|
48
|
+
return () => {
|
|
49
|
+
delete document.documentElement.dataset.navigator;
|
|
50
|
+
};
|
|
51
|
+
}, [collapsed]);
|
|
52
|
+
|
|
53
|
+
const matches = useMemo(() => {
|
|
54
|
+
const needle = query.trim().toLowerCase();
|
|
55
|
+
if (!needle) return items;
|
|
56
|
+
return items.filter((item) =>
|
|
57
|
+
[item.title, item.id, item.group].some((value) => value?.toLowerCase().includes(needle)),
|
|
58
|
+
);
|
|
59
|
+
}, [items, query]);
|
|
60
|
+
|
|
61
|
+
/* Headings come from the order the caller sorted, so a group is a run of
|
|
62
|
+
items rather than a bucket this has to invent an order for. */
|
|
63
|
+
const runs = useMemo(() => {
|
|
64
|
+
const out: Array<{group?: string; items: NavigatorItem[]}> = [];
|
|
65
|
+
for (const item of matches) {
|
|
66
|
+
const last = out.at(-1);
|
|
67
|
+
if (last && last.group === item.group) last.items.push(item);
|
|
68
|
+
else out.push({group: item.group, items: [item]});
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}, [matches]);
|
|
72
|
+
|
|
73
|
+
if (collapsed) {
|
|
74
|
+
return (
|
|
75
|
+
<button
|
|
76
|
+
type="button"
|
|
77
|
+
className="navigator-reopen"
|
|
78
|
+
aria-label={`Show ${label.toLowerCase()}`}
|
|
79
|
+
title={`Show ${label.toLowerCase()}`}
|
|
80
|
+
onClick={() => setCollapsed(false)}
|
|
81
|
+
>
|
|
82
|
+
<Icon name="sidebar" />
|
|
83
|
+
</button>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<aside className="navigator" aria-label={label}>
|
|
89
|
+
<div className="navigator-head">
|
|
90
|
+
<button
|
|
91
|
+
type="button"
|
|
92
|
+
className="navigator-toggle"
|
|
93
|
+
aria-label={`Hide ${label.toLowerCase()}`}
|
|
94
|
+
title={`Hide ${label.toLowerCase()}`}
|
|
95
|
+
onClick={() => setCollapsed(true)}
|
|
96
|
+
>
|
|
97
|
+
<Icon name="sidebar" />
|
|
98
|
+
</button>
|
|
99
|
+
<input
|
|
100
|
+
ref={field}
|
|
101
|
+
className="input navigator-search"
|
|
102
|
+
type="search"
|
|
103
|
+
placeholder={`Filter ${label.toLowerCase()}`}
|
|
104
|
+
value={query}
|
|
105
|
+
onChange={(event) => setQuery(event.currentTarget.value)}
|
|
106
|
+
onKeyDown={(event) => {
|
|
107
|
+
if (event.key === "Escape") {
|
|
108
|
+
// The first press clears, the second gives the keys back to the
|
|
109
|
+
// transport: typing in here must not eat the space bar.
|
|
110
|
+
if (query) setQuery("");
|
|
111
|
+
else event.currentTarget.blur();
|
|
112
|
+
}
|
|
113
|
+
}}
|
|
114
|
+
/>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
<div className="navigator-scroll">
|
|
118
|
+
{matches.length === 0 ? (
|
|
119
|
+
<p className="hint navigator-empty">Nothing matches {query}.</p>
|
|
120
|
+
) : (
|
|
121
|
+
runs.map((run) => (
|
|
122
|
+
<div key={run.group ?? "."}>
|
|
123
|
+
{run.group ? <h3 className="navigator-group">{run.group}</h3> : null}
|
|
124
|
+
<ul className="list">
|
|
125
|
+
{run.items.map((item) => (
|
|
126
|
+
<li key={item.id}>
|
|
127
|
+
<button
|
|
128
|
+
type="button"
|
|
129
|
+
className="list-item"
|
|
130
|
+
data-active={item.id === selected ? "true" : undefined}
|
|
131
|
+
onClick={() => onSelect(item.id)}
|
|
132
|
+
>
|
|
133
|
+
<strong>{item.title}</strong>
|
|
134
|
+
{item.detail ? <span>{item.detail}</span> : null}
|
|
135
|
+
</button>
|
|
136
|
+
</li>
|
|
137
|
+
))}
|
|
138
|
+
</ul>
|
|
139
|
+
</div>
|
|
140
|
+
))
|
|
141
|
+
)}
|
|
142
|
+
</div>
|
|
143
|
+
</aside>
|
|
144
|
+
);
|
|
145
|
+
};
|