@odori/cli 0.0.3 → 0.0.5
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-RXLB2CXH.js → chunk-RHG23EWW.js} +457 -241
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +45 -5
- package/dist/index.js +3 -3
- package/dist/registry-snapshot-JEVXYGS2.js +4868 -0
- package/package.json +3 -3
- package/src/assets.ts +90 -0
- package/src/brand-file.ts +16 -4
- package/src/cli.ts +38 -13
- package/src/commands/add.ts +37 -1
- package/src/commands/dev.ts +32 -2
- package/src/commands/doctor.ts +47 -2
- package/src/commands/exportVideo.ts +6 -0
- package/src/commands/{still.ts → frame.ts} +23 -9
- package/src/commands/update.ts +58 -7
- package/src/discovery.ts +63 -2
- package/src/index.ts +1 -1
- package/src/jobs.ts +1 -1
- package/src/registry-snapshot.json +1529 -327
- package/src/registry-source.ts +37 -2
- package/src/render.ts +8 -1
- package/src/server.ts +7 -1
- package/studio/src/Studio.tsx +6 -22
- package/studio/src/components/ExportPanel.tsx +90 -6
- package/studio/src/components/Inspector.tsx +101 -1
- package/studio/src/components/Navigator.tsx +149 -0
- package/studio/src/components/Settings.tsx +109 -0
- package/studio/src/components/Transport.tsx +98 -55
- package/studio/src/components/ui.tsx +16 -1
- package/studio/src/lib/highlight.ts +85 -0
- package/studio/src/settings.ts +87 -0
- package/studio/src/studio.css +350 -8
- package/studio/src/views/BrandsView.tsx +18 -1
- package/studio/src/views/ComponentsView.tsx +191 -26
- package/studio/src/views/HomeView.tsx +7 -4
- package/studio/src/views/VideosView.tsx +33 -6
- package/studio/src/virtual.d.ts +4 -1
- package/dist/registry-snapshot-BDP6PVYB.js +0 -3559
package/src/registry-source.ts
CHANGED
|
@@ -46,14 +46,28 @@ export type ComponentContract = {
|
|
|
46
46
|
loops?: boolean;
|
|
47
47
|
};
|
|
48
48
|
|
|
49
|
-
export type RegistryKind = "component" | "cue";
|
|
49
|
+
export type RegistryKind = "component" | "cue" | "asset";
|
|
50
|
+
|
|
51
|
+
/** A produced file the registry publishes: sound synthesis cannot reach. */
|
|
52
|
+
export type RegistryAsset = {
|
|
53
|
+
cue: string;
|
|
54
|
+
url: string;
|
|
55
|
+
target: string;
|
|
56
|
+
bytes: number;
|
|
57
|
+
integrity: string;
|
|
58
|
+
};
|
|
50
59
|
|
|
51
60
|
export type RegistryComponent = {
|
|
52
61
|
name: string;
|
|
53
|
-
/**
|
|
62
|
+
/**
|
|
63
|
+
* Components render pixels and cues render samples; both install as source.
|
|
64
|
+
* An asset installs as bytes into `public/` and is registered by URL.
|
|
65
|
+
*/
|
|
54
66
|
kind?: RegistryKind;
|
|
55
67
|
/** Present on cues: the brand name it registers, and the factory to call. */
|
|
56
68
|
cue?: {name: string; export: string};
|
|
69
|
+
/** Present on assets: the file to fetch and the cue name it answers to. */
|
|
70
|
+
asset?: RegistryAsset;
|
|
57
71
|
namespaced: string;
|
|
58
72
|
family: string;
|
|
59
73
|
description: string;
|
|
@@ -123,6 +137,7 @@ type RegistryItemDocument = {
|
|
|
123
137
|
namespaced?: string;
|
|
124
138
|
contract?: RegistryComponent["contract"];
|
|
125
139
|
cue?: {name: string; export: string};
|
|
140
|
+
asset?: RegistryAsset;
|
|
126
141
|
integrity?: string;
|
|
127
142
|
};
|
|
128
143
|
};
|
|
@@ -132,6 +147,25 @@ const DEFAULT_URL = "https://odori.dev/r/v1";
|
|
|
132
147
|
export const registryUrl = (config: ResolvedConfig): string =>
|
|
133
148
|
(config.registryUrl ?? process.env.ODORI_REGISTRY ?? DEFAULT_URL).replace(/\/$/, "");
|
|
134
149
|
|
|
150
|
+
/**
|
|
151
|
+
* The site the registry is served from, without its document path.
|
|
152
|
+
*
|
|
153
|
+
* Documents live under `/r/v1`; published media lives at the site root, the
|
|
154
|
+
* same absolute path a project will reference it by once it is installed. So
|
|
155
|
+
* an asset URL resolves against the origin rather than against the registry
|
|
156
|
+
* directory, and a self-hosted registry still finds its own files.
|
|
157
|
+
*/
|
|
158
|
+
export const registryOrigin = (config: ResolvedConfig): string => {
|
|
159
|
+
const url = registryUrl(config);
|
|
160
|
+
try {
|
|
161
|
+
return new URL(url).origin;
|
|
162
|
+
} catch {
|
|
163
|
+
// A relative or malformed registryUrl: keep everything before /r, which is
|
|
164
|
+
// where a mirror laid out like ours would put its media.
|
|
165
|
+
return url.replace(/\/r(\/v\d+)?$/, "");
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
|
|
135
169
|
/** One directory per origin URL, so two registries never share a cache. */
|
|
136
170
|
const cacheDir = (url: string): string =>
|
|
137
171
|
resolve(cacheRoot(), "registry", createHash("sha256").update(url).digest("hex").slice(0, 16));
|
|
@@ -149,6 +183,7 @@ const toComponent = (item: RegistryItemDocument): RegistryComponent => ({
|
|
|
149
183
|
namespaced: item.meta?.namespaced ?? `@odori/${item.name}`,
|
|
150
184
|
kind: (item.meta?.kind as RegistryComponent["kind"]) ?? "component",
|
|
151
185
|
...(item.meta?.cue ? {cue: item.meta.cue} : {}),
|
|
186
|
+
...(item.meta?.asset ? {asset: item.meta.asset} : {}),
|
|
152
187
|
family: item.meta?.family ?? "Uncategorized",
|
|
153
188
|
description: item.description ?? "",
|
|
154
189
|
files: item.files.map((file) => file.path.split("/").pop() ?? file.path),
|
package/src/render.ts
CHANGED
|
@@ -469,6 +469,13 @@ export type RenderOptions = {
|
|
|
469
469
|
/** Container and codec. Defaults to H.264 in MP4. */
|
|
470
470
|
format?: VideoFormat;
|
|
471
471
|
skipUnchangedFrames?: boolean;
|
|
472
|
+
/**
|
|
473
|
+
* Mix the composition's cues into the file. Defaults to true, because the
|
|
474
|
+
* score is part of the video. Set false for a silent cut: a loop for a
|
|
475
|
+
* landing page, a clip going into an editor that has its own audio, or a
|
|
476
|
+
* reviewer who just wants the picture.
|
|
477
|
+
*/
|
|
478
|
+
audio?: boolean;
|
|
472
479
|
/** Reuse encoded chunks whose frames still look identical. */
|
|
473
480
|
cache?: boolean;
|
|
474
481
|
signal?: AbortSignal;
|
|
@@ -571,7 +578,7 @@ export const renderMovie = async (
|
|
|
571
578
|
const captureMs = performance.now() - captureStart;
|
|
572
579
|
onProgress?.(1, "encoding");
|
|
573
580
|
|
|
574
|
-
const mixInputs: MixInput[] = (target.audio ?? [])
|
|
581
|
+
const mixInputs: MixInput[] = (options.audio === false ? [] : (target.audio ?? []))
|
|
575
582
|
.map((cue) => {
|
|
576
583
|
const file = resolveCueFile(config, cue.src);
|
|
577
584
|
if (!file) log.warn(`Skipping audio cue ${cue.src}: only project-local files can be encoded.`);
|
package/src/server.ts
CHANGED
|
@@ -93,6 +93,8 @@ const odoriProjectPlugin = (config: ResolvedConfig, getGraph: () => ProjectGraph
|
|
|
93
93
|
`export const project = ${JSON.stringify({
|
|
94
94
|
root: config.root,
|
|
95
95
|
videosDir: config.videosDir,
|
|
96
|
+
componentsDir: config.componentsDir,
|
|
97
|
+
categories: graph.categories,
|
|
96
98
|
exportDir: config.exportDir,
|
|
97
99
|
audioDir: config.audioDir,
|
|
98
100
|
docsUrl: config.docsUrl,
|
|
@@ -101,7 +103,11 @@ const odoriProjectPlugin = (config: ResolvedConfig, getGraph: () => ProjectGraph
|
|
|
101
103
|
assets: config.assets ?? [],
|
|
102
104
|
files: {
|
|
103
105
|
videos: graph.videos.map((video) => ({id: video.slug, file: video.relativeFile})),
|
|
104
|
-
previews: graph.previews.map((preview) => ({
|
|
106
|
+
previews: graph.previews.map((preview) => ({
|
|
107
|
+
id: preview.name,
|
|
108
|
+
file: preview.relativeFile,
|
|
109
|
+
usedBy: preview.usedBy ?? [],
|
|
110
|
+
})),
|
|
105
111
|
brands: graph.brands.map((brand) => ({id: brand.name, file: brand.relativeFile})),
|
|
106
112
|
},
|
|
107
113
|
})};`,
|
package/studio/src/Studio.tsx
CHANGED
|
@@ -8,15 +8,11 @@ import {HomeView} from "./views/HomeView";
|
|
|
8
8
|
import {CommandPalette} from "./components/CommandPalette";
|
|
9
9
|
import {Wordmark} from "./components/Wordmark";
|
|
10
10
|
import {Button, Icon, Kbd} from "./components/ui";
|
|
11
|
-
import {
|
|
11
|
+
import {Settings} from "./components/Settings";
|
|
12
|
+
import {useTheme} from "./theme";
|
|
12
13
|
|
|
13
14
|
const VIEWS = ["videos", "components", "brands", "assets"] as const;
|
|
14
15
|
|
|
15
|
-
const THEMES: Array<{id: Theme; label: string; icon: "display" | "sun" | "moon"}> = [
|
|
16
|
-
{id: "system", label: "System", icon: "display"},
|
|
17
|
-
{id: "light", label: "Light", icon: "sun"},
|
|
18
|
-
{id: "dark", label: "Dark", icon: "moon"},
|
|
19
|
-
];
|
|
20
16
|
export type StudioView = (typeof VIEWS)[number] | "home";
|
|
21
17
|
|
|
22
18
|
const ROUTES: readonly string[] = ["home", ...VIEWS];
|
|
@@ -48,7 +44,9 @@ export const Studio = () => {
|
|
|
48
44
|
const routeRef = useRef(route);
|
|
49
45
|
routeRef.current = route;
|
|
50
46
|
const search = useRef<HTMLInputElement>(null);
|
|
51
|
-
|
|
47
|
+
// Mounted here as well as in the menu so the stored theme is applied on
|
|
48
|
+
// load, whether or not anybody opens settings.
|
|
49
|
+
useTheme();
|
|
52
50
|
|
|
53
51
|
const navigate = (view: StudioView, selection?: string | null) => {
|
|
54
52
|
const next: Route = {view, selection: selection ?? null};
|
|
@@ -132,21 +130,7 @@ export const Studio = () => {
|
|
|
132
130
|
/>
|
|
133
131
|
<Kbd>⌘K</Kbd>
|
|
134
132
|
</div>
|
|
135
|
-
<
|
|
136
|
-
{THEMES.map((option) => (
|
|
137
|
-
<Button
|
|
138
|
-
key={option.id}
|
|
139
|
-
icon
|
|
140
|
-
active={theme === option.id}
|
|
141
|
-
aria-pressed={theme === option.id}
|
|
142
|
-
aria-label={option.label}
|
|
143
|
-
title={`${option.label} theme`}
|
|
144
|
-
onClick={() => setTheme(option.id)}
|
|
145
|
-
>
|
|
146
|
-
<Icon name={option.icon} />
|
|
147
|
-
</Button>
|
|
148
|
-
))}
|
|
149
|
-
</div>
|
|
133
|
+
<Settings />
|
|
150
134
|
</div>
|
|
151
135
|
</header>
|
|
152
136
|
|
|
@@ -65,24 +65,82 @@ export const ExportPanel = ({
|
|
|
65
65
|
}) => {
|
|
66
66
|
const [job, setJob] = useState<JobState | null>(null);
|
|
67
67
|
const [busy, setBusy] = useState(false);
|
|
68
|
+
/** Set when a job stops reporting progress, so a wedge reads as a wedge. */
|
|
69
|
+
const [stalled, setStalled] = useState(false);
|
|
70
|
+
/* Sound is part of the video, so it ships unless it is turned off. GIF has
|
|
71
|
+
no audio track at all and a still is one frame, so the choice only exists
|
|
72
|
+
where it means something. */
|
|
73
|
+
const [sound, setSound] = useState(true);
|
|
68
74
|
const [format, setFormat] = useState<Format>("mp4");
|
|
69
75
|
const [quality, setQuality] = useState<Quality>("studio");
|
|
70
76
|
const [scale, setScale] = useState(1);
|
|
71
77
|
const [notice, setNotice] = useState<string | null>(null);
|
|
72
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Watch a job until it finishes, and say so if it does not.
|
|
81
|
+
*
|
|
82
|
+
* The old loop treated every unreadable response as "try again" and, after
|
|
83
|
+
* fifteen minutes of that, returned without a word. A dev server restarted
|
|
84
|
+
* mid-export takes its job records with it, so what you saw was a bar frozen
|
|
85
|
+
* at whatever percentage it had reached, no error, and no end: the export
|
|
86
|
+
* looked like it was still going hours later. A watcher that cannot see the
|
|
87
|
+
* job has to say the job is unwatchable.
|
|
88
|
+
*/
|
|
73
89
|
const poll = async (id: string) => {
|
|
74
|
-
|
|
90
|
+
let unreachable = 0;
|
|
91
|
+
let last = -1;
|
|
92
|
+
let stalledFor = 0;
|
|
93
|
+
|
|
94
|
+
for (let attempt = 0; attempt < 1800; attempt += 1) {
|
|
75
95
|
await new Promise((wait) => setTimeout(wait, 1000));
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
96
|
+
let payload: JobState | null = null;
|
|
97
|
+
try {
|
|
98
|
+
const response = await fetch(`/__odori/jobs/${id}`);
|
|
99
|
+
if (response.ok) payload = (await response.json()) as JobState;
|
|
100
|
+
} catch {
|
|
101
|
+
// Network error and 404 are the same thing here: no news about the job.
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!payload) {
|
|
105
|
+
unreachable += 1;
|
|
106
|
+
// Ten seconds of silence is a server that went away, not a slow reply.
|
|
107
|
+
if (unreachable >= 10) {
|
|
108
|
+
setJob((current) => ({
|
|
109
|
+
...(current ?? {id, progress: 0}),
|
|
110
|
+
status: "failed",
|
|
111
|
+
error:
|
|
112
|
+
"Lost contact with the dev server, so this export can no longer be tracked. It may still have finished; check the out directory, or run odori jobs.",
|
|
113
|
+
}));
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
unreachable = 0;
|
|
79
120
|
setJob(payload);
|
|
80
121
|
if (payload.status === "ready" || payload.status === "failed") return;
|
|
122
|
+
|
|
123
|
+
/* A long render is normal; a silent one is the thing worth naming. The
|
|
124
|
+
encoder reports every five percent, so two minutes without a number
|
|
125
|
+
means something is wedged rather than slow. */
|
|
126
|
+
if (payload.progress === last) stalledFor += 1;
|
|
127
|
+
else {
|
|
128
|
+
stalledFor = 0;
|
|
129
|
+
last = payload.progress;
|
|
130
|
+
}
|
|
131
|
+
setStalled(stalledFor >= 120);
|
|
81
132
|
}
|
|
133
|
+
|
|
134
|
+
setJob((current) => ({
|
|
135
|
+
...(current ?? {id, progress: 0}),
|
|
136
|
+
status: "failed",
|
|
137
|
+
error: "Stopped watching after thirty minutes. Run odori jobs to see whether it is still going.",
|
|
138
|
+
}));
|
|
82
139
|
};
|
|
83
140
|
|
|
84
141
|
const post = async (path: string, body?: unknown) => {
|
|
85
142
|
setBusy(true);
|
|
143
|
+
setStalled(false);
|
|
86
144
|
setJob({id: "pending", status: "queued", progress: 0});
|
|
87
145
|
try {
|
|
88
146
|
const response = await fetch(path, {
|
|
@@ -135,7 +193,7 @@ export const ExportPanel = ({
|
|
|
135
193
|
const run = async () => {
|
|
136
194
|
setNotice(null);
|
|
137
195
|
if (format === "frame") return post("/__odori/still", {videoId, input, frame});
|
|
138
|
-
return post("/__odori/exports", {videoId, input, format, quality, scale});
|
|
196
|
+
return post("/__odori/exports", {videoId, input, format, quality, scale, audio: hasSound ? sound : false});
|
|
139
197
|
};
|
|
140
198
|
|
|
141
199
|
const file = outputName(videoId);
|
|
@@ -143,6 +201,8 @@ export const ExportPanel = ({
|
|
|
143
201
|
// video's layout and every frame number in the project depends on it.
|
|
144
202
|
const still = format === "frame";
|
|
145
203
|
const hasQuality = format === "mp4" || format === "webm";
|
|
204
|
+
/** GIF carries no audio track and a still is one frame. */
|
|
205
|
+
const hasSound = format !== "gif" && format !== "frame";
|
|
146
206
|
const inFlight = job && job.status !== "ready" && job.status !== "failed";
|
|
147
207
|
// The button names the file it will write - extension implied by the type
|
|
148
208
|
// above it - and reports its own progress while writing it.
|
|
@@ -220,6 +280,22 @@ export const ExportPanel = ({
|
|
|
220
280
|
</div>
|
|
221
281
|
) : null}
|
|
222
282
|
|
|
283
|
+
{hasSound ? (
|
|
284
|
+
<div className="export-row">
|
|
285
|
+
<span className="export-label">Sound</span>
|
|
286
|
+
<div className="export-options">
|
|
287
|
+
<Button variant="outline" active={sound} disabled={busy} onClick={() => setSound(true)}
|
|
288
|
+
title="Mix the brand's cues into the file">
|
|
289
|
+
On
|
|
290
|
+
</Button>
|
|
291
|
+
<Button variant="outline" active={!sound} disabled={busy} onClick={() => setSound(false)}
|
|
292
|
+
title="Write the picture with no audio track">
|
|
293
|
+
Off
|
|
294
|
+
</Button>
|
|
295
|
+
</div>
|
|
296
|
+
</div>
|
|
297
|
+
) : null}
|
|
298
|
+
|
|
223
299
|
<div className="export-actions">
|
|
224
300
|
<Button
|
|
225
301
|
variant="primary"
|
|
@@ -256,10 +332,18 @@ export const ExportPanel = ({
|
|
|
256
332
|
</span>
|
|
257
333
|
</div>
|
|
258
334
|
{job.status === "rendering" || job.status === "encoding" ? (
|
|
259
|
-
<div className="progress">
|
|
335
|
+
<div className="progress" data-stalled={stalled ? "true" : undefined}>
|
|
260
336
|
<span style={{width: `${Math.round((job.progress ?? 0) * 100)}%`}} />
|
|
261
337
|
</div>
|
|
262
338
|
) : null}
|
|
339
|
+
{/* A bar that has not moved in two minutes looks exactly like one
|
|
340
|
+
that is about to. Saying which is the whole point. */}
|
|
341
|
+
{stalled ? (
|
|
342
|
+
<p className="hint" style={{marginTop: 6}}>
|
|
343
|
+
No progress for two minutes. It may be a slow frame, or the render may be wedged. Run{" "}
|
|
344
|
+
<code>odori jobs</code> to see the log, and the dev server terminal for the error.
|
|
345
|
+
</p>
|
|
346
|
+
) : null}
|
|
263
347
|
</>
|
|
264
348
|
) : null}
|
|
265
349
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {useEffect, useState} from "react";
|
|
2
2
|
import type {ReactNode} from "react";
|
|
3
3
|
import {Icon} from "./ui";
|
|
4
|
+
import {tokenize} from "../lib/highlight";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* The right-hand pane, with its width and its presence under the reader's
|
|
@@ -29,14 +30,27 @@ const applyWidth = (value: number) => {
|
|
|
29
30
|
export const Inspector = ({
|
|
30
31
|
title,
|
|
31
32
|
hint,
|
|
33
|
+
source,
|
|
32
34
|
children,
|
|
33
35
|
}: {
|
|
34
36
|
/** What this pane is about, as a person names it. */
|
|
35
37
|
title?: string;
|
|
36
38
|
/** The longer form, one hover away - usually the source path. */
|
|
37
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[];
|
|
38
49
|
children: ReactNode;
|
|
39
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);
|
|
40
54
|
const [collapsed, setCollapsed] = useState(() => window.localStorage.getItem(COLLAPSED_KEY) === "true");
|
|
41
55
|
|
|
42
56
|
useEffect(() => {
|
|
@@ -104,6 +118,22 @@ export const Inspector = ({
|
|
|
104
118
|
<span className="inspector-title" title={hint ?? title}>
|
|
105
119
|
{title}
|
|
106
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}
|
|
107
137
|
<button
|
|
108
138
|
type="button"
|
|
109
139
|
className="inspector-toggle"
|
|
@@ -114,8 +144,78 @@ export const Inspector = ({
|
|
|
114
144
|
<Icon name="sidebar" />
|
|
115
145
|
</button>
|
|
116
146
|
</div>
|
|
117
|
-
{
|
|
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
|
+
)}
|
|
118
173
|
</div>
|
|
119
174
|
</aside>
|
|
120
175
|
);
|
|
121
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,149 @@
|
|
|
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
|
+
/* Keyed by the first item, not by the group. A run without a group
|
|
123
|
+
used to key on a constant, so a list that goes ungrouped, then
|
|
124
|
+
grouped, then ungrouped again handed React two siblings with the
|
|
125
|
+
same key - which it is free to drop or duplicate. */
|
|
126
|
+
<div key={run.items[0].id}>
|
|
127
|
+
{run.group ? <h3 className="navigator-group">{run.group}</h3> : null}
|
|
128
|
+
<ul className="list">
|
|
129
|
+
{run.items.map((item) => (
|
|
130
|
+
<li key={item.id}>
|
|
131
|
+
<button
|
|
132
|
+
type="button"
|
|
133
|
+
className="list-item"
|
|
134
|
+
data-active={item.id === selected ? "true" : undefined}
|
|
135
|
+
onClick={() => onSelect(item.id)}
|
|
136
|
+
>
|
|
137
|
+
<strong>{item.title}</strong>
|
|
138
|
+
{item.detail ? <span>{item.detail}</span> : null}
|
|
139
|
+
</button>
|
|
140
|
+
</li>
|
|
141
|
+
))}
|
|
142
|
+
</ul>
|
|
143
|
+
</div>
|
|
144
|
+
))
|
|
145
|
+
)}
|
|
146
|
+
</div>
|
|
147
|
+
</aside>
|
|
148
|
+
);
|
|
149
|
+
};
|