@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
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import {useEffect, useRef, useState} from "react";
|
|
2
|
+
import {Button, Icon} from "./ui";
|
|
3
|
+
import {useStartSound} from "../settings";
|
|
4
|
+
import {useTheme, type Theme} from "../theme";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The workspace preferences, behind one control in the header.
|
|
8
|
+
*
|
|
9
|
+
* This replaced a three-button theme switcher. Theme was the only preference
|
|
10
|
+
* Studio had, so spending a permanent row of chrome on it was fine right up
|
|
11
|
+
* until there was a second one; three more buttons for sound would have made
|
|
12
|
+
* the header a settings panel that never closes. A gear says "there are
|
|
13
|
+
* choices here" in the space one of the old buttons used.
|
|
14
|
+
*/
|
|
15
|
+
const THEMES: Array<{id: Theme; label: string; hint: string}> = [
|
|
16
|
+
{id: "system", label: "System", hint: "Follow the operating system"},
|
|
17
|
+
{id: "light", label: "Light", hint: "Always light"},
|
|
18
|
+
{id: "dark", label: "Dark", hint: "Always dark"},
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const SOUNDS = [
|
|
22
|
+
{id: "on" as const, label: "On", hint: "Play the score with the video"},
|
|
23
|
+
{id: "off" as const, label: "Off", hint: "Open every video muted"},
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
export const Settings = () => {
|
|
27
|
+
const [open, setOpen] = useState(false);
|
|
28
|
+
const {theme, setTheme} = useTheme();
|
|
29
|
+
const [sound, setSound] = useStartSound();
|
|
30
|
+
const wrapper = useRef<HTMLDivElement>(null);
|
|
31
|
+
const trigger = useRef<HTMLButtonElement>(null);
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (!open) return undefined;
|
|
35
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
36
|
+
if (!wrapper.current?.contains(event.target as Node)) setOpen(false);
|
|
37
|
+
};
|
|
38
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
39
|
+
if (event.key !== "Escape") return;
|
|
40
|
+
setOpen(false);
|
|
41
|
+
// Closing with the keyboard has to put the focus somewhere, and the
|
|
42
|
+
// control that opened the menu is the only place that is not a surprise.
|
|
43
|
+
trigger.current?.focus();
|
|
44
|
+
};
|
|
45
|
+
// Capture, so a click that also does something else still closes the menu.
|
|
46
|
+
document.addEventListener("pointerdown", onPointerDown, true);
|
|
47
|
+
document.addEventListener("keydown", onKeyDown);
|
|
48
|
+
return () => {
|
|
49
|
+
document.removeEventListener("pointerdown", onPointerDown, true);
|
|
50
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
51
|
+
};
|
|
52
|
+
}, [open]);
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div className="settings" ref={wrapper}>
|
|
56
|
+
<Button
|
|
57
|
+
ref={trigger}
|
|
58
|
+
icon
|
|
59
|
+
active={open}
|
|
60
|
+
aria-label="Settings"
|
|
61
|
+
aria-expanded={open}
|
|
62
|
+
aria-haspopup="menu"
|
|
63
|
+
title="Settings"
|
|
64
|
+
onClick={() => setOpen((value) => !value)}
|
|
65
|
+
>
|
|
66
|
+
<Icon name="settings" />
|
|
67
|
+
</Button>
|
|
68
|
+
|
|
69
|
+
{open ? (
|
|
70
|
+
<div className="menu settings-menu" role="menu" aria-label="Settings">
|
|
71
|
+
<p className="menu-heading">Theme</p>
|
|
72
|
+
{THEMES.map((option) => (
|
|
73
|
+
<button
|
|
74
|
+
key={option.id}
|
|
75
|
+
type="button"
|
|
76
|
+
role="menuitemradio"
|
|
77
|
+
aria-checked={theme === option.id}
|
|
78
|
+
className="menu-item"
|
|
79
|
+
data-selected={theme === option.id ? "true" : undefined}
|
|
80
|
+
onClick={() => setTheme(option.id)}
|
|
81
|
+
>
|
|
82
|
+
<span>{option.label}</span>
|
|
83
|
+
<span className="menu-hint">{option.hint}</span>
|
|
84
|
+
</button>
|
|
85
|
+
))}
|
|
86
|
+
|
|
87
|
+
<p className="menu-heading">Sound</p>
|
|
88
|
+
{SOUNDS.map((option) => (
|
|
89
|
+
<button
|
|
90
|
+
key={option.id}
|
|
91
|
+
type="button"
|
|
92
|
+
role="menuitemradio"
|
|
93
|
+
aria-checked={sound === option.id}
|
|
94
|
+
className="menu-item"
|
|
95
|
+
data-selected={sound === option.id ? "true" : undefined}
|
|
96
|
+
onClick={() => setSound(option.id)}
|
|
97
|
+
>
|
|
98
|
+
<span>{option.label}</span>
|
|
99
|
+
<span className="menu-hint">{option.hint}</span>
|
|
100
|
+
</button>
|
|
101
|
+
))}
|
|
102
|
+
{/* Changing it now would mute or unmute the video being watched,
|
|
103
|
+
which is not what a default is. */}
|
|
104
|
+
<p className="menu-note">Applies to the next video you open.</p>
|
|
105
|
+
</div>
|
|
106
|
+
) : null}
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
};
|
|
@@ -1,10 +1,20 @@
|
|
|
1
|
-
import {useEffect, useRef} from "react";
|
|
1
|
+
import {useEffect, useRef, useState} from "react";
|
|
2
2
|
import {formatTimecode, type AudioTrack, type CompiledTimeline, type Playback} from "odori";
|
|
3
3
|
import {Button, Icon} from "./ui";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Transport plus a scene-aware timeline. The track is the scene structure the
|
|
7
7
|
* runtime compiled, not a decorative bar.
|
|
8
|
+
*
|
|
9
|
+
* The playhead is not draggable. Dragging one is a commitment: the pointer is
|
|
10
|
+
* captured, so a release outside the window can be missed and leave the
|
|
11
|
+
* transport stuck mid-scrub, and every intermediate frame is a real seek, which
|
|
12
|
+
* is what made scrubbing sound like a stuck record. Hovering costs nothing and
|
|
13
|
+
* answers the same question - what is at this moment - so the track previews
|
|
14
|
+
* under the cursor and a click is what actually moves the playhead.
|
|
15
|
+
*
|
|
16
|
+
* Preview is a paused-only affordance. A second marker chasing the cursor while
|
|
17
|
+
* the video plays is two playheads disagreeing about where you are.
|
|
8
18
|
*/
|
|
9
19
|
export const RATES = [0.25, 0.5, 1, 1.5, 2, 4];
|
|
10
20
|
|
|
@@ -22,7 +32,7 @@ export const Transport = ({
|
|
|
22
32
|
onToggleLoop,
|
|
23
33
|
onToggleMuted,
|
|
24
34
|
onSoloCue,
|
|
25
|
-
|
|
35
|
+
onPreview,
|
|
26
36
|
rate = 1,
|
|
27
37
|
onRate,
|
|
28
38
|
}: {
|
|
@@ -41,65 +51,77 @@ export const Transport = ({
|
|
|
41
51
|
onToggleLoop: () => void;
|
|
42
52
|
onToggleMuted?: () => void;
|
|
43
53
|
onSoloCue?: (id: string | null) => void;
|
|
44
|
-
|
|
54
|
+
/**
|
|
55
|
+
* The frame under the cursor, or null when the cursor is off the track. The
|
|
56
|
+
* stage renders this instead of the playhead frame, so hovering shows the
|
|
57
|
+
* moment without committing to it.
|
|
58
|
+
*/
|
|
59
|
+
onPreview?: (frame: number | null) => void;
|
|
45
60
|
rate?: number;
|
|
46
61
|
onRate?: (rate: number) => void;
|
|
47
62
|
}) => {
|
|
48
63
|
const trackRef = useRef<HTMLDivElement>(null);
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
// A click picks a frame; only a drag auditions. Audio waits for movement so
|
|
55
|
-
// choosing a frame while paused stays silent.
|
|
56
|
-
const dragOrigin = useRef(0);
|
|
57
|
-
const auditioning = useRef(false);
|
|
64
|
+
const [preview, setPreview] = useState<number | null>(null);
|
|
65
|
+
/* How much room the track has, so the ruler can be drawn for it rather than
|
|
66
|
+
for an assumed window. Studio is used docked beside an agent as often as
|
|
67
|
+
it is used full width. */
|
|
68
|
+
const [width, setWidth] = useState(0);
|
|
58
69
|
const {frame} = playback;
|
|
59
70
|
|
|
60
|
-
|
|
71
|
+
/** The frame under a given client x, or null if the track has no box yet. */
|
|
72
|
+
const frameAt = (clientX: number): number | null => {
|
|
61
73
|
const element = trackRef.current;
|
|
62
|
-
if (!element) return;
|
|
74
|
+
if (!element) return null;
|
|
63
75
|
const bounds = element.getBoundingClientRect();
|
|
76
|
+
if (bounds.width === 0) return null;
|
|
64
77
|
const ratio = Math.max(0, Math.min(1, (clientX - bounds.left) / bounds.width));
|
|
65
|
-
|
|
78
|
+
return Math.round(ratio * (durationInFrames - 1));
|
|
66
79
|
};
|
|
67
80
|
|
|
68
81
|
useEffect(() => {
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
window.removeEventListener("pointerup", up);
|
|
94
|
-
};
|
|
95
|
-
});
|
|
82
|
+
const element = trackRef.current;
|
|
83
|
+
if (!element) return undefined;
|
|
84
|
+
const observer = new ResizeObserver(([entry]) => setWidth(entry?.contentRect.width ?? 0));
|
|
85
|
+
observer.observe(element);
|
|
86
|
+
return () => observer.disconnect();
|
|
87
|
+
}, []);
|
|
88
|
+
|
|
89
|
+
const showPreview = (next: number | null) => {
|
|
90
|
+
setPreview(next);
|
|
91
|
+
onPreview?.(next);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/* Playing is the one state with no preview, so starting playback while the
|
|
95
|
+
cursor rests on the track clears the marker it left behind. In an effect,
|
|
96
|
+
not in render: the parent owns the previewed frame and telling it during
|
|
97
|
+
our own render would be a write into somebody else's component. */
|
|
98
|
+
const playing = playback.playing;
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
if (playing) {
|
|
101
|
+
setPreview(null);
|
|
102
|
+
onPreview?.(null);
|
|
103
|
+
}
|
|
104
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
105
|
+
}, [playing]);
|
|
96
106
|
|
|
107
|
+
/** What the stage is showing: the previewed frame wins while hovering. */
|
|
108
|
+
const shown = preview ?? frame;
|
|
97
109
|
const scenes = timeline?.scenes ?? [];
|
|
98
|
-
const active = scenes.find((scene) =>
|
|
110
|
+
const active = scenes.find((scene) => shown >= scene.start && shown < scene.start + scene.durationInFrames);
|
|
111
|
+
|
|
112
|
+
/*
|
|
113
|
+
* The ruler is drawn to the width it actually has. A fixed twelve marks is
|
|
114
|
+
* fine in a wide window and unreadable in a narrow one, where "10.0s" next
|
|
115
|
+
* to "11.0s" in eighteen pixels is a smear rather than a scale - and where
|
|
116
|
+
* two marks could round to the same frame, which React counted as a
|
|
117
|
+
* duplicate key. Roughly fifty pixels is what one label needs to stand
|
|
118
|
+
* alone.
|
|
119
|
+
*/
|
|
99
120
|
const seconds = Math.max(1, Math.round(durationInFrames / fps));
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
121
|
+
const divisions = Math.max(1, Math.min(seconds, Math.floor(width / 52) || 1, 12));
|
|
122
|
+
const ticks = [...new Set(
|
|
123
|
+
Array.from({length: divisions + 1}, (_, index) => Math.round((index * durationInFrames) / divisions)),
|
|
124
|
+
)];
|
|
103
125
|
|
|
104
126
|
return (
|
|
105
127
|
<div className="transport">
|
|
@@ -114,12 +136,22 @@ export const Transport = ({
|
|
|
114
136
|
<div
|
|
115
137
|
className="track"
|
|
116
138
|
ref={trackRef}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
139
|
+
data-preview={preview !== null ? "true" : undefined}
|
|
140
|
+
onPointerMove={(event) => {
|
|
141
|
+
// Touch reports a move before its tap, and a marker that appears
|
|
142
|
+
// under a finger and stays there is worse than none.
|
|
143
|
+
if (playing || event.pointerType === "touch") return;
|
|
144
|
+
showPreview(frameAt(event.clientX));
|
|
145
|
+
}}
|
|
146
|
+
onPointerLeave={() => showPreview(null)}
|
|
147
|
+
onClick={(event) => {
|
|
148
|
+
const next = frameAt(event.clientX);
|
|
149
|
+
if (next === null) return;
|
|
150
|
+
// Picking a frame is not a request to stop, but it is a request to
|
|
151
|
+
// look at that frame, and the clock would move off it immediately.
|
|
121
152
|
playback.pause();
|
|
122
|
-
|
|
153
|
+
playback.seek(next);
|
|
154
|
+
showPreview(null);
|
|
123
155
|
}}
|
|
124
156
|
>
|
|
125
157
|
{scenes.length > 0 ? (
|
|
@@ -139,6 +171,14 @@ export const Transport = ({
|
|
|
139
171
|
continuous timeline
|
|
140
172
|
</div>
|
|
141
173
|
)}
|
|
174
|
+
{preview !== null && preview !== frame ? (
|
|
175
|
+
<div
|
|
176
|
+
className="playhead"
|
|
177
|
+
data-preview="true"
|
|
178
|
+
aria-hidden="true"
|
|
179
|
+
style={{left: `${(preview / Math.max(1, durationInFrames - 1)) * 100}%`}}
|
|
180
|
+
/>
|
|
181
|
+
) : null}
|
|
142
182
|
<div className="playhead" style={{left: `${(frame / Math.max(1, durationInFrames - 1)) * 100}%`}} />
|
|
143
183
|
</div>
|
|
144
184
|
|
|
@@ -224,11 +264,14 @@ export const Transport = ({
|
|
|
224
264
|
</Button>
|
|
225
265
|
) : null}
|
|
226
266
|
|
|
227
|
-
|
|
228
|
-
|
|
267
|
+
{/* The readout follows the picture, not the playhead: while hovering,
|
|
268
|
+
the stage is showing the previewed frame and a counter reporting
|
|
269
|
+
some other number is just wrong. */}
|
|
270
|
+
<span className="timecode" style={{marginLeft: 8}} data-preview={preview !== null ? "true" : undefined}>
|
|
271
|
+
<b>{formatTimecode(shown, fps)}</b> / {formatTimecode(durationInFrames - 1, fps)}
|
|
229
272
|
</span>
|
|
230
|
-
<span className="timecode">
|
|
231
|
-
frame <b>{
|
|
273
|
+
<span className="timecode" data-preview={preview !== null ? "true" : undefined}>
|
|
274
|
+
frame <b>{shown}</b> of {durationInFrames - 1}
|
|
232
275
|
</span>
|
|
233
276
|
{active ? <span className="timecode">scene <b>{active.name ?? active.id}</b></span> : null}
|
|
234
277
|
</div>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {ButtonHTMLAttributes, ReactNode} from "react";
|
|
1
|
+
import type {ButtonHTMLAttributes, ReactNode, Ref} from "react";
|
|
2
2
|
|
|
3
3
|
export const Button = ({
|
|
4
4
|
variant = "ghost",
|
|
@@ -10,6 +10,8 @@ export const Button = ({
|
|
|
10
10
|
variant?: "ghost" | "outline" | "primary";
|
|
11
11
|
active?: boolean;
|
|
12
12
|
icon?: boolean;
|
|
13
|
+
/** React 19 passes ref as an ordinary prop; the type has to say so. */
|
|
14
|
+
ref?: Ref<HTMLButtonElement>;
|
|
13
15
|
}) => (
|
|
14
16
|
<button
|
|
15
17
|
type="button"
|
|
@@ -69,10 +71,23 @@ export const Icon = ({
|
|
|
69
71
|
| "display"
|
|
70
72
|
| "external"
|
|
71
73
|
| "sidebar"
|
|
74
|
+
| "settings"
|
|
72
75
|
| "search";
|
|
73
76
|
}) => {
|
|
74
77
|
const paths: Record<string, ReactNode> = {
|
|
75
78
|
play: <path d="M4.5 2.8v8.4l7-4.2z" fill="currentColor" />,
|
|
79
|
+
settings: (
|
|
80
|
+
<>
|
|
81
|
+
<circle cx="7" cy="7" r="2.1" fill="none" stroke="currentColor" strokeWidth="1.2" />
|
|
82
|
+
<path
|
|
83
|
+
d="M7 1.3l.85 1.5a4.7 4.7 0 0 1 1.2.5l1.7-.3.85 1.47-1 1.36c.08.2.14.4.18.62l1.53.72v1.7l-1.53.72c-.04.21-.1.42-.18.62l1 1.36-.85 1.47-1.7-.3a4.7 4.7 0 0 1-1.2.5L7 12.7l-.85-1.5a4.7 4.7 0 0 1-1.2-.5l-1.7.3-.85-1.47 1-1.36a4.7 4.7 0 0 1-.18-.62L1.7 6.83v-1.7l1.52-.72c.05-.21.11-.42.19-.62l-1-1.36.85-1.47 1.7.3c.37-.21.78-.38 1.2-.5z"
|
|
84
|
+
fill="none"
|
|
85
|
+
stroke="currentColor"
|
|
86
|
+
strokeWidth="1.1"
|
|
87
|
+
strokeLinejoin="round"
|
|
88
|
+
/>
|
|
89
|
+
</>
|
|
90
|
+
),
|
|
76
91
|
sun: (
|
|
77
92
|
<>
|
|
78
93
|
<circle cx="7" cy="7" r="2.5" fill="none" stroke="currentColor" strokeWidth="1.2" />
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small TypeScript and TSX tokenizer, for reading source in the inspector.
|
|
3
|
+
*
|
|
4
|
+
* Studio ships inside `@odori/cli`, so anything it imports is something every
|
|
5
|
+
* consumer downloads. A real highlighter is megabytes of grammars to colour a
|
|
6
|
+
* read-only panel, which is a bad trade for a dev tool; this is one pass of
|
|
7
|
+
* one regex over files we already know the shape of. It is deliberately not a
|
|
8
|
+
* parser: it does not resolve types, it will not know a word is a variable
|
|
9
|
+
* rather than a call, and it does not need to. What it has to get right is
|
|
10
|
+
* that a keyword inside a string stays a string, and a slash that opens a
|
|
11
|
+
* comment is not division, which ordering the alternation handles.
|
|
12
|
+
*/
|
|
13
|
+
export type Token = {text: string; kind: TokenKind};
|
|
14
|
+
|
|
15
|
+
export type TokenKind =
|
|
16
|
+
| "plain"
|
|
17
|
+
| "comment"
|
|
18
|
+
| "string"
|
|
19
|
+
| "keyword"
|
|
20
|
+
| "number"
|
|
21
|
+
| "tag"
|
|
22
|
+
| "attr"
|
|
23
|
+
| "fn"
|
|
24
|
+
| "punct";
|
|
25
|
+
|
|
26
|
+
const KEYWORDS = new Set([
|
|
27
|
+
"as", "async", "await", "break", "case", "catch", "class", "const", "continue", "declare", "default", "delete",
|
|
28
|
+
"do", "else", "enum", "export", "extends", "false", "finally", "for", "from", "function", "if", "implements",
|
|
29
|
+
"import", "in", "instanceof", "interface", "keyof", "let", "new", "null", "of", "readonly", "return", "satisfies",
|
|
30
|
+
"static", "super", "switch", "this", "throw", "true", "try", "type", "typeof", "undefined", "var", "void", "while",
|
|
31
|
+
"yield",
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
/*
|
|
35
|
+
* Order is the whole design. Comments and strings come first so their contents
|
|
36
|
+
* are never read as code; the JSX tag rule follows, because `<Scene` is a tag
|
|
37
|
+
* and `a < b` is not; words and numbers come last.
|
|
38
|
+
*/
|
|
39
|
+
const PATTERN = new RegExp(
|
|
40
|
+
[
|
|
41
|
+
"(?<comment>//[^\\n]*|/\\*[\\s\\S]*?\\*/)",
|
|
42
|
+
"(?<string>`(?:\\\\.|[^`\\\\])*`|\"(?:\\\\.|[^\"\\\\\\n])*\"|'(?:\\\\.|[^'\\\\\\n])*')",
|
|
43
|
+
"(?<tag></?[A-Za-z][\\w.]*(?=[\\s/>]))",
|
|
44
|
+
"(?<number>\\b\\d[\\w.]*\\b)",
|
|
45
|
+
"(?<word>[A-Za-z_$][\\w$]*)",
|
|
46
|
+
"(?<punct>[{}()[\\].,;:=+\\-*/<>!?&|%^~]+)",
|
|
47
|
+
].join("|"),
|
|
48
|
+
"g",
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
export const tokenize = (source: string): Token[] => {
|
|
52
|
+
const tokens: Token[] = [];
|
|
53
|
+
let last = 0;
|
|
54
|
+
|
|
55
|
+
const push = (text: string, kind: TokenKind) => {
|
|
56
|
+
if (text) tokens.push({text, kind});
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
for (const match of source.matchAll(PATTERN)) {
|
|
60
|
+
const groups = match.groups ?? {};
|
|
61
|
+
const index = match.index ?? 0;
|
|
62
|
+
push(source.slice(last, index), "plain");
|
|
63
|
+
last = index + match[0].length;
|
|
64
|
+
|
|
65
|
+
if (groups.comment) push(match[0], "comment");
|
|
66
|
+
else if (groups.string) push(match[0], "string");
|
|
67
|
+
else if (groups.tag) push(match[0], "tag");
|
|
68
|
+
else if (groups.number) push(match[0], "number");
|
|
69
|
+
else if (groups.punct) push(match[0], "punct");
|
|
70
|
+
else if (groups.word) {
|
|
71
|
+
const word = match[0];
|
|
72
|
+
// A word followed by `(` is being called; one followed by `=` inside a
|
|
73
|
+
// tag is an attribute. Both are guesses from the next character, which
|
|
74
|
+
// is as far as a tokenizer can honestly go.
|
|
75
|
+
const next = source[last];
|
|
76
|
+
if (KEYWORDS.has(word)) push(word, "keyword");
|
|
77
|
+
else if (next === "(") push(word, "fn");
|
|
78
|
+
else if (next === "=" && source[last + 1] !== "=") push(word, "attr");
|
|
79
|
+
else push(word, "plain");
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
push(source.slice(last), "plain");
|
|
84
|
+
return tokens;
|
|
85
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import {useCallback, useEffect, useState} from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Workspace preferences: the choices that belong to the person using Studio
|
|
5
|
+
* rather than to the project.
|
|
6
|
+
*
|
|
7
|
+
* They are deliberately not in `odori.config.ts`. A config file is checked in
|
|
8
|
+
* and shared, and "start muted" is a fact about someone's desk and how loud
|
|
9
|
+
* the room is, not about the video. Everything here is per browser, best
|
|
10
|
+
* effort, and safe to lose.
|
|
11
|
+
*/
|
|
12
|
+
export const STORAGE_PREFIX = "odori-studio-";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Whether the transport starts with sound.
|
|
16
|
+
*
|
|
17
|
+
* Sound belongs on by default: a video with a score is not the same video
|
|
18
|
+
* without one, and a preview that silently drops half the work teaches you
|
|
19
|
+
* the wrong thing about it. But somebody working in an open office, or with
|
|
20
|
+
* Studio parked beside an agent all day, wants the opposite, and that is a
|
|
21
|
+
* setting rather than an argument.
|
|
22
|
+
*/
|
|
23
|
+
export type StartSound = "on" | "off";
|
|
24
|
+
|
|
25
|
+
export const SETTINGS = {
|
|
26
|
+
sound: {key: "sound", values: ["on", "off"] as const, fallback: "on" as StartSound},
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
const read = <T extends string>(key: string, values: readonly T[], fallback: T): T => {
|
|
30
|
+
try {
|
|
31
|
+
const stored = window.localStorage.getItem(`${STORAGE_PREFIX}${key}`);
|
|
32
|
+
return values.includes(stored as T) ? (stored as T) : fallback;
|
|
33
|
+
} catch {
|
|
34
|
+
// Private browsing and hardened profiles refuse storage. A preference is
|
|
35
|
+
// not worth failing a render over.
|
|
36
|
+
return fallback;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** A single preference, persisted per browser. */
|
|
41
|
+
export const usePreference = <T extends string>(
|
|
42
|
+
key: string,
|
|
43
|
+
values: readonly T[],
|
|
44
|
+
fallback: T,
|
|
45
|
+
): [T, (next: T) => void] => {
|
|
46
|
+
const [value, setValue] = useState<T>(() =>
|
|
47
|
+
typeof window === "undefined" ? fallback : read(key, values, fallback),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const update = useCallback(
|
|
51
|
+
(next: T) => {
|
|
52
|
+
setValue(next);
|
|
53
|
+
try {
|
|
54
|
+
window.localStorage.setItem(`${STORAGE_PREFIX}${key}`, next);
|
|
55
|
+
} catch {
|
|
56
|
+
// Applied for this session either way.
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
[key],
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
return [value, update];
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** Read once, outside React, for state that has to be right on first render. */
|
|
66
|
+
export const readStartSound = (): StartSound =>
|
|
67
|
+
typeof window === "undefined"
|
|
68
|
+
? SETTINGS.sound.fallback
|
|
69
|
+
: read(SETTINGS.sound.key, SETTINGS.sound.values, SETTINGS.sound.fallback);
|
|
70
|
+
|
|
71
|
+
export const useStartSound = () =>
|
|
72
|
+
usePreference<StartSound>(SETTINGS.sound.key, SETTINGS.sound.values, SETTINGS.sound.fallback);
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Other tabs of the same Studio should not disagree about a preference. The
|
|
76
|
+
* storage event fires only in the tabs that did not make the change, which is
|
|
77
|
+
* exactly the set that needs telling.
|
|
78
|
+
*/
|
|
79
|
+
export const useSettingsSync = (onChange: () => void) => {
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
const listener = (event: StorageEvent) => {
|
|
82
|
+
if (event.key?.startsWith(STORAGE_PREFIX)) onChange();
|
|
83
|
+
};
|
|
84
|
+
window.addEventListener("storage", listener);
|
|
85
|
+
return () => window.removeEventListener("storage", listener);
|
|
86
|
+
}, [onChange]);
|
|
87
|
+
};
|