@backmatter/studio 0.0.17 → 0.0.18
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/chunks/{UiKitApp-D7vllRQE.js → UiKitApp-DkuQBiQZ.js} +2 -2
- package/dist/chunks/{WorkspaceRoutes-DH1LD-2Q.js → WorkspaceRoutes-DVvb3N-V.js} +21440 -21060
- package/dist/chunks/{dashboard-Dsaeud2x.js → dashboard-B4FITg6c.js} +2 -2
- package/dist/chunks/{index-CGMqw2NH.js → index-CegtXnEI.js} +1 -1
- package/dist/dashboard.js +2 -2
- package/dist/index.js +2 -2
- package/dist/studio.css +20 -14
- package/package.json +2 -2
- package/src/api.ts +3 -0
- package/src/{pages/code-session-routes.ts → content-routes.ts} +6 -6
- package/src/fields/FieldControl.tsx +25 -2
- package/src/fields/LinkFieldInput.tsx +315 -0
- package/src/fields/choose-field-input.ts +5 -0
- package/src/fields/link-targets.ts +60 -0
- package/src/host-path.ts +13 -0
- package/src/pages/CodeSessionPage.tsx +6 -241
- package/src/pages/DocEditorPage.tsx +81 -121
- package/src/pages/MediaFieldInput.tsx +609 -26
- package/src/pages/MediaGalleryPage.tsx +14 -3
- package/src/pages/PreviewAddress.tsx +263 -0
- package/src/pages/code-session-boot.ts +0 -12
- package/src/pages/media-selection.ts +79 -0
|
@@ -43,9 +43,13 @@ const CELL = 168;
|
|
|
43
43
|
export function MediaGalleryPage({
|
|
44
44
|
pick = false,
|
|
45
45
|
onPick,
|
|
46
|
+
pickedUrls,
|
|
47
|
+
onToggleUrl,
|
|
46
48
|
}: {
|
|
47
49
|
pick?: boolean;
|
|
48
50
|
onPick?: (url: string) => void;
|
|
51
|
+
pickedUrls?: string[];
|
|
52
|
+
onToggleUrl?: (url: string) => void;
|
|
49
53
|
}) {
|
|
50
54
|
const { workspace } = useOutletContext<WorkspaceOutletContext>();
|
|
51
55
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
@@ -168,7 +172,6 @@ export function MediaGalleryPage({
|
|
|
168
172
|
}, [uploadFiles]);
|
|
169
173
|
|
|
170
174
|
useEffect(() => {
|
|
171
|
-
if (pick) return;
|
|
172
175
|
const hasFiles = (event: DragEvent) =>
|
|
173
176
|
Array.from(event.dataTransfer?.types ?? []).includes("Files");
|
|
174
177
|
const onEnter = (event: DragEvent) => {
|
|
@@ -202,7 +205,7 @@ export function MediaGalleryPage({
|
|
|
202
205
|
window.removeEventListener("dragleave", onLeave);
|
|
203
206
|
window.removeEventListener("drop", onDrop);
|
|
204
207
|
};
|
|
205
|
-
}, [
|
|
208
|
+
}, [uploadFiles]);
|
|
206
209
|
|
|
207
210
|
const browsable = [
|
|
208
211
|
...state.pending.flatMap((item) => (item.media ? [item.media] : [])),
|
|
@@ -479,10 +482,18 @@ export function MediaGalleryPage({
|
|
|
479
482
|
<MediaHit
|
|
480
483
|
item={cell.item}
|
|
481
484
|
menu={!pick}
|
|
482
|
-
selected={
|
|
485
|
+
selected={
|
|
486
|
+
onToggleUrl
|
|
487
|
+
? (pickedUrls?.includes(cell.item.url) ?? false)
|
|
488
|
+
: !pick && !sharedId && cell.item.id === selectedId
|
|
489
|
+
}
|
|
483
490
|
canDelete={canDelete}
|
|
484
491
|
onOpen={() => {
|
|
485
492
|
setSelectedId(null);
|
|
493
|
+
if (onToggleUrl) {
|
|
494
|
+
onToggleUrl(cell.item.url);
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
486
497
|
if (pick) {
|
|
487
498
|
if (onPick) onPick(cell.item.url);
|
|
488
499
|
return;
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// packages/studio/src/pages/PreviewAddress.tsx
|
|
2
|
+
import { useEffect, useRef, useState, type FormEvent } from "react";
|
|
3
|
+
import { Combobox } from "@base-ui/react/combobox";
|
|
4
|
+
import {
|
|
5
|
+
IconArrowRotateClockwise,
|
|
6
|
+
IconArrowUpRight,
|
|
7
|
+
IconGlobe,
|
|
8
|
+
} from "@central-icons-react/round-outlined-radius-2-stroke-1.5";
|
|
9
|
+
import * as stylex from "@stylexjs/stylex";
|
|
10
|
+
import { colors, radius, space, type } from "../styles/tokens.stylex";
|
|
11
|
+
import { cx, popup, popupClassName } from "../ui/popup";
|
|
12
|
+
import { hostPath } from "../host-path";
|
|
13
|
+
import { routesMatching } from "../content-routes";
|
|
14
|
+
|
|
15
|
+
const hover = "@media (hover: hover)";
|
|
16
|
+
|
|
17
|
+
/** Reload, the page path, and open. The path opens the route list. */
|
|
18
|
+
export function PreviewAddress({
|
|
19
|
+
path,
|
|
20
|
+
enabled,
|
|
21
|
+
routes,
|
|
22
|
+
routesPending,
|
|
23
|
+
onOpenRoutes,
|
|
24
|
+
onPath,
|
|
25
|
+
onReload,
|
|
26
|
+
onOpen,
|
|
27
|
+
}: {
|
|
28
|
+
path: string;
|
|
29
|
+
enabled: boolean;
|
|
30
|
+
routes: readonly string[];
|
|
31
|
+
routesPending: boolean;
|
|
32
|
+
onOpenRoutes?: () => void;
|
|
33
|
+
onPath: (path: string) => void;
|
|
34
|
+
onReload: () => void;
|
|
35
|
+
onOpen: () => void;
|
|
36
|
+
}) {
|
|
37
|
+
const [query, setQuery] = useState<string | null>(null);
|
|
38
|
+
const [open, setOpen] = useState(false);
|
|
39
|
+
const picked = useRef(false);
|
|
40
|
+
const fieldRef = useRef<HTMLButtonElement>(null);
|
|
41
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
42
|
+
const shown = query ? routesMatching(routes, query) : [...routes];
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (!open) return;
|
|
46
|
+
const input = inputRef.current;
|
|
47
|
+
input?.focus();
|
|
48
|
+
input?.select();
|
|
49
|
+
}, [open]);
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div {...stylex.props(styles.address)}>
|
|
53
|
+
<button
|
|
54
|
+
type="button"
|
|
55
|
+
aria-label="Reload preview"
|
|
56
|
+
disabled={!enabled}
|
|
57
|
+
onClick={onReload}
|
|
58
|
+
{...stylex.props(styles.iconButton)}
|
|
59
|
+
>
|
|
60
|
+
<IconArrowRotateClockwise size={14} ariaHidden />
|
|
61
|
+
</button>
|
|
62
|
+
<Combobox.Root
|
|
63
|
+
items={shown}
|
|
64
|
+
filteredItems={shown}
|
|
65
|
+
inputValue={query ?? ""}
|
|
66
|
+
disabled={!enabled}
|
|
67
|
+
open={open}
|
|
68
|
+
value={routes.includes(path) ? path : null}
|
|
69
|
+
onOpenChange={(next) => {
|
|
70
|
+
if (!enabled && next) return;
|
|
71
|
+
setOpen(next);
|
|
72
|
+
if (next) onOpenRoutes?.();
|
|
73
|
+
else setQuery(null);
|
|
74
|
+
}}
|
|
75
|
+
onInputValueChange={(value, details) => {
|
|
76
|
+
if (details.reason === "input-change" || details.reason === "input-clear") {
|
|
77
|
+
setQuery(value);
|
|
78
|
+
}
|
|
79
|
+
}}
|
|
80
|
+
onValueChange={(value) => {
|
|
81
|
+
if (typeof value !== "string") return;
|
|
82
|
+
picked.current = true;
|
|
83
|
+
setQuery(null);
|
|
84
|
+
onPath(hostPath(value));
|
|
85
|
+
queueMicrotask(() => {
|
|
86
|
+
picked.current = false;
|
|
87
|
+
});
|
|
88
|
+
}}
|
|
89
|
+
>
|
|
90
|
+
<Combobox.Trigger
|
|
91
|
+
ref={fieldRef}
|
|
92
|
+
aria-label="Preview path"
|
|
93
|
+
disabled={!enabled}
|
|
94
|
+
data-no-ring
|
|
95
|
+
className={cx(styles.addressTrigger)}
|
|
96
|
+
>
|
|
97
|
+
<IconGlobe size={13} ariaHidden />
|
|
98
|
+
<span {...stylex.props(styles.addressPath)}>{path}</span>
|
|
99
|
+
</Combobox.Trigger>
|
|
100
|
+
<Combobox.Portal>
|
|
101
|
+
<Combobox.Positioner
|
|
102
|
+
anchor={fieldRef}
|
|
103
|
+
side="bottom"
|
|
104
|
+
align="start"
|
|
105
|
+
sideOffset={6}
|
|
106
|
+
className={cx(popup.positioner)}
|
|
107
|
+
>
|
|
108
|
+
<Combobox.Popup
|
|
109
|
+
aria-label="Pages"
|
|
110
|
+
className={(state) => popupClassName(state, styles.routePopup)}
|
|
111
|
+
>
|
|
112
|
+
<form
|
|
113
|
+
onSubmit={(event: FormEvent<HTMLFormElement>) => {
|
|
114
|
+
event.preventDefault();
|
|
115
|
+
const fromList = picked.current;
|
|
116
|
+
picked.current = false;
|
|
117
|
+
if (fromList) return;
|
|
118
|
+
const typed = event.currentTarget.querySelector("input")?.value;
|
|
119
|
+
if (typed?.trim()) onPath(hostPath(typed));
|
|
120
|
+
setOpen(false);
|
|
121
|
+
setQuery(null);
|
|
122
|
+
}}
|
|
123
|
+
>
|
|
124
|
+
<Combobox.Input
|
|
125
|
+
ref={inputRef}
|
|
126
|
+
placeholder="Find a page"
|
|
127
|
+
aria-label="Find a page"
|
|
128
|
+
spellCheck={false}
|
|
129
|
+
autoComplete="off"
|
|
130
|
+
data-no-ring
|
|
131
|
+
className={cx(popup.quietInput, styles.routeSearch)}
|
|
132
|
+
onKeyDown={(event) => {
|
|
133
|
+
if (event.key !== "Enter") return;
|
|
134
|
+
// A highlighted row is chosen by the combobox. A bare Enter goes to the typed path.
|
|
135
|
+
if (event.currentTarget.getAttribute("aria-activedescendant")) return;
|
|
136
|
+
const typed = event.currentTarget.value.trim();
|
|
137
|
+
if (!typed) return;
|
|
138
|
+
picked.current = true;
|
|
139
|
+
onPath(hostPath(typed));
|
|
140
|
+
}}
|
|
141
|
+
/>
|
|
142
|
+
</form>
|
|
143
|
+
<div {...stylex.props(popup.separator)} />
|
|
144
|
+
<Combobox.Empty className={cx(popup.empty)}>
|
|
145
|
+
{routesPending ? "Loading pages" : "No pages"}
|
|
146
|
+
</Combobox.Empty>
|
|
147
|
+
<Combobox.List className={cx(styles.routeList)}>
|
|
148
|
+
{(route: string) => (
|
|
149
|
+
<Combobox.Item
|
|
150
|
+
key={route}
|
|
151
|
+
value={route}
|
|
152
|
+
className={(state) =>
|
|
153
|
+
cx(
|
|
154
|
+
popup.item,
|
|
155
|
+
styles.routeItem,
|
|
156
|
+
state.highlighted && popup.itemHighlighted,
|
|
157
|
+
state.selected && popup.itemSelected,
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
>
|
|
161
|
+
{route}
|
|
162
|
+
</Combobox.Item>
|
|
163
|
+
)}
|
|
164
|
+
</Combobox.List>
|
|
165
|
+
</Combobox.Popup>
|
|
166
|
+
</Combobox.Positioner>
|
|
167
|
+
</Combobox.Portal>
|
|
168
|
+
</Combobox.Root>
|
|
169
|
+
<button
|
|
170
|
+
type="button"
|
|
171
|
+
aria-label="Open preview in a new tab"
|
|
172
|
+
disabled={!enabled}
|
|
173
|
+
onClick={onOpen}
|
|
174
|
+
{...stylex.props(styles.iconButton)}
|
|
175
|
+
>
|
|
176
|
+
<IconArrowUpRight size={14} ariaHidden />
|
|
177
|
+
</button>
|
|
178
|
+
</div>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const styles = stylex.create({
|
|
183
|
+
address: {
|
|
184
|
+
display: "flex",
|
|
185
|
+
alignItems: "center",
|
|
186
|
+
gap: space.xs,
|
|
187
|
+
minWidth: 0,
|
|
188
|
+
},
|
|
189
|
+
addressTrigger: {
|
|
190
|
+
display: "flex",
|
|
191
|
+
flex: 1,
|
|
192
|
+
alignItems: "center",
|
|
193
|
+
gap: space.xs,
|
|
194
|
+
minWidth: 0,
|
|
195
|
+
height: "28px",
|
|
196
|
+
paddingInline: space.sm,
|
|
197
|
+
borderWidth: 0,
|
|
198
|
+
borderRadius: "999px",
|
|
199
|
+
font: "inherit",
|
|
200
|
+
textAlign: "left",
|
|
201
|
+
cursor: { default: "pointer", ":disabled": "default" },
|
|
202
|
+
color: { default: colors.textMuted, ":disabled": colors.textMuted },
|
|
203
|
+
backgroundColor: colors.bgPanelInset,
|
|
204
|
+
":focus-visible": {
|
|
205
|
+
outlineWidth: 2,
|
|
206
|
+
outlineStyle: "solid",
|
|
207
|
+
outlineColor: colors.focus,
|
|
208
|
+
outlineOffset: 1,
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
addressPath: {
|
|
212
|
+
flex: 1,
|
|
213
|
+
minWidth: 0,
|
|
214
|
+
overflow: "hidden",
|
|
215
|
+
textOverflow: "ellipsis",
|
|
216
|
+
whiteSpace: "nowrap",
|
|
217
|
+
fontFamily: type.mono,
|
|
218
|
+
fontSize: type.xs,
|
|
219
|
+
color: colors.text,
|
|
220
|
+
},
|
|
221
|
+
routeSearch: {
|
|
222
|
+
fontFamily: type.mono,
|
|
223
|
+
fontSize: type.xs,
|
|
224
|
+
},
|
|
225
|
+
routePopup: {
|
|
226
|
+
width: "var(--anchor-width)",
|
|
227
|
+
minWidth: "220px",
|
|
228
|
+
maxHeight: "min(320px, var(--available-height))",
|
|
229
|
+
display: "flex",
|
|
230
|
+
flexDirection: "column",
|
|
231
|
+
overflowY: "hidden",
|
|
232
|
+
},
|
|
233
|
+
routeList: {
|
|
234
|
+
minHeight: 0,
|
|
235
|
+
overflowY: "auto",
|
|
236
|
+
},
|
|
237
|
+
routeItem: {
|
|
238
|
+
fontFamily: type.mono,
|
|
239
|
+
},
|
|
240
|
+
iconButton: {
|
|
241
|
+
display: "inline-flex",
|
|
242
|
+
alignItems: "center",
|
|
243
|
+
justifyContent: "center",
|
|
244
|
+
flexShrink: 0,
|
|
245
|
+
width: "28px",
|
|
246
|
+
height: "28px",
|
|
247
|
+
padding: 0,
|
|
248
|
+
borderWidth: 0,
|
|
249
|
+
borderRadius: radius.sm,
|
|
250
|
+
cursor: { default: "pointer", ":disabled": "default" },
|
|
251
|
+
color: { default: colors.textMuted, ":disabled": colors.border },
|
|
252
|
+
backgroundColor: {
|
|
253
|
+
default: "transparent",
|
|
254
|
+
[hover]: { default: null, ":hover:not(:disabled)": colors.bgHover },
|
|
255
|
+
},
|
|
256
|
+
":focus-visible": {
|
|
257
|
+
outlineWidth: 2,
|
|
258
|
+
outlineStyle: "solid",
|
|
259
|
+
outlineColor: colors.focus,
|
|
260
|
+
outlineOffset: 1,
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
});
|
|
@@ -29,15 +29,3 @@ export function bootSteps(input: {
|
|
|
29
29
|
if (input.phase === "stopped") return at(0, "failed");
|
|
30
30
|
return at(input.furthest === "starting" ? 2 : input.furthest === "installing" ? 1 : 0, "failed");
|
|
31
31
|
}
|
|
32
|
-
|
|
33
|
-
/** Normalizes what someone types in the address bar to a path inside the host. */
|
|
34
|
-
export function hostPath(input: string): string {
|
|
35
|
-
const trimmed = input.trim();
|
|
36
|
-
if (!trimmed) return "/";
|
|
37
|
-
try {
|
|
38
|
-
const url = new URL(trimmed);
|
|
39
|
-
return `${url.pathname}${url.search}${url.hash}` || "/";
|
|
40
|
-
} catch {
|
|
41
|
-
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// packages/studio/src/pages/media-selection.ts
|
|
2
|
+
|
|
3
|
+
export function toggleMediaUrl(urls: readonly string[], url: string): string[] {
|
|
4
|
+
const index = urls.indexOf(url);
|
|
5
|
+
if (index >= 0) {
|
|
6
|
+
return urls.filter((_, i) => i !== index);
|
|
7
|
+
}
|
|
8
|
+
return [...urls, url];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function appendMediaUrls(
|
|
12
|
+
existing: readonly string[],
|
|
13
|
+
incoming: readonly string[],
|
|
14
|
+
): string[] {
|
|
15
|
+
const next = [...existing];
|
|
16
|
+
for (const url of incoming) {
|
|
17
|
+
if (!next.includes(url)) {
|
|
18
|
+
next.push(url);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return next;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function moveMediaUrl(urls: readonly string[], from: number, to: number): string[] {
|
|
25
|
+
if (from < 0 || from >= urls.length || to < 0 || to >= urls.length) {
|
|
26
|
+
return [...urls];
|
|
27
|
+
}
|
|
28
|
+
const next = [...urls];
|
|
29
|
+
const [item] = next.splice(from, 1);
|
|
30
|
+
next.splice(to, 0, item!);
|
|
31
|
+
return next;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function acceptedDrop(
|
|
35
|
+
existing: readonly string[],
|
|
36
|
+
accepted: readonly string[],
|
|
37
|
+
multiple: boolean,
|
|
38
|
+
): { urls: string[]; notAdded: number } {
|
|
39
|
+
if (!multiple) {
|
|
40
|
+
if (accepted.length === 0) {
|
|
41
|
+
return { urls: [...existing], notAdded: 0 };
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
urls: [accepted[0]!],
|
|
45
|
+
notAdded: Math.max(0, accepted.length - 1),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
return { urls: appendMediaUrls(existing, accepted), notAdded: 0 };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function notAddedMessage(count: number): string | null {
|
|
52
|
+
if (count === 0) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
if (count === 1) {
|
|
56
|
+
return "1 image was not added";
|
|
57
|
+
}
|
|
58
|
+
return `${count} images were not added`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function editorUrls(
|
|
62
|
+
value: unknown,
|
|
63
|
+
multiple: boolean,
|
|
64
|
+
): { urls: string[]; raw: unknown } {
|
|
65
|
+
if (value === undefined || value === null || value === "") {
|
|
66
|
+
return { urls: [], raw: undefined };
|
|
67
|
+
}
|
|
68
|
+
if (typeof value === "string") {
|
|
69
|
+
return { urls: [value], raw: value };
|
|
70
|
+
}
|
|
71
|
+
if (Array.isArray(value)) {
|
|
72
|
+
if (multiple) {
|
|
73
|
+
const urls = value.filter((item): item is string => typeof item === "string");
|
|
74
|
+
return { urls, raw: value };
|
|
75
|
+
}
|
|
76
|
+
return { urls: [], raw: value };
|
|
77
|
+
}
|
|
78
|
+
return { urls: [], raw: undefined };
|
|
79
|
+
}
|