@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.
@@ -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
- }, [pick, uploadFiles]);
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={!pick && !sharedId && cell.item.id === selectedId}
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
+ }