@gramps-connect/story 0.1.3 → 0.3.0
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/README.md +51 -2
- package/dist/editor.js +3910 -0
- package/dist/editor.js.map +7 -0
- package/dist/embed.js +10 -10
- package/dist/player.js +80 -8
- package/dist/player.js.map +2 -2
- package/dist/types/editor/DateField.d.ts +12 -0
- package/dist/types/editor/PhotosField.d.ts +16 -0
- package/dist/types/editor/SlideList.d.ts +12 -0
- package/dist/types/editor/StoryEditor.d.ts +33 -0
- package/dist/types/editor/StoryTextField.d.ts +9 -0
- package/dist/types/editor/dates.d.ts +6 -0
- package/dist/types/editor/index.d.ts +8 -0
- package/dist/types/editor/model.d.ts +80 -0
- package/dist/types/editor/strings.d.ts +52 -0
- package/dist/types/player/StoryPlayer.d.ts +4 -1
- package/dist/types/player/strings.d.ts +2 -0
- package/package.json +21 -5
- package/src/editor/DateField.tsx +95 -0
- package/src/editor/PhotosField.tsx +134 -0
- package/src/editor/SlideList.tsx +54 -0
- package/src/editor/StoryEditor.tsx +423 -0
- package/src/editor/StoryTextField.tsx +61 -0
- package/src/editor/dates.ts +21 -0
- package/src/editor/index.ts +12 -0
- package/src/editor/model.ts +229 -0
- package/src/editor/strings.ts +106 -0
- package/src/player/StoryPlayer.tsx +71 -6
- package/src/player/strings.ts +7 -0
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
// Edits a story document: a list of slides on the left, the selected
|
|
2
|
+
// slide's (or the story's own) fields in the middle, and the real player
|
|
3
|
+
// on the right showing that slide as it's changed. Works on the document
|
|
4
|
+
// only -- opening and saving files is the host's (the viewer site's editor
|
|
5
|
+
// page does it with the File System Access API or a download).
|
|
6
|
+
//
|
|
7
|
+
// Needs Mantine: wrap it in a MantineProvider and load @mantine/core's
|
|
8
|
+
// stylesheet, as with any Mantine component.
|
|
9
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
10
|
+
import {
|
|
11
|
+
ActionIcon, Badge, Box, Button, Divider, Group, Modal, Popover, Select, Stack, Text, TextInput, Tooltip, UnstyledButton,
|
|
12
|
+
} from "@mantine/core";
|
|
13
|
+
import { useMediaQuery } from "@mantine/hooks";
|
|
14
|
+
import type { MediaItem, Slide, SlideMedia, StoryDoc } from "../types";
|
|
15
|
+
import { optimizeImage } from "../image";
|
|
16
|
+
import { validate, type StoryIssue } from "../validate";
|
|
17
|
+
import { StoryPlayer } from "../player/StoryPlayer";
|
|
18
|
+
import type { PlayerStrings } from "../player/strings";
|
|
19
|
+
import { suggestEdtf, type DateTextParser } from "./dates";
|
|
20
|
+
import { DateField } from "./DateField";
|
|
21
|
+
import {
|
|
22
|
+
addImage, addSlide, deleteSlide, findMedia, duplicateSlide, moveSlide, record, redo, setSlidePhotos, startHistory, undo, updateMedia,
|
|
23
|
+
updateSlide, updateStory, type History, type NewImage,
|
|
24
|
+
} from "./model";
|
|
25
|
+
import { PhotosField } from "./PhotosField";
|
|
26
|
+
import { SlideList } from "./SlideList";
|
|
27
|
+
import { StoryTextField } from "./StoryTextField";
|
|
28
|
+
import { EDITOR_ENGLISH, type EditorStrings } from "./strings";
|
|
29
|
+
|
|
30
|
+
const BAR_HEIGHT = 56;
|
|
31
|
+
/** The story's own settings, in place of a slide index. */
|
|
32
|
+
const STORY = -1;
|
|
33
|
+
|
|
34
|
+
export interface StoryEditorProps {
|
|
35
|
+
/** The story to start from, and its files (paths as in media `src`).
|
|
36
|
+
* Changing these later has no effect: remount (a new `key`) to edit
|
|
37
|
+
* another story. */
|
|
38
|
+
initialStory: StoryDoc;
|
|
39
|
+
initialFiles?: Map<string, Uint8Array>;
|
|
40
|
+
/** Called with the document and its files after every change, undo and
|
|
41
|
+
* redo included. The files only grow while editing (an undone photo's
|
|
42
|
+
* file stays); pruneMedia leaves out what nothing uses when saving. */
|
|
43
|
+
onChange?: (doc: StoryDoc, files: Map<string, Uint8Array>) => void;
|
|
44
|
+
/** Turns a chosen image file into what's stored. By default
|
|
45
|
+
* optimizeImage: 1600px WebP (JPEG where the browser can't), with the
|
|
46
|
+
* original's metadata left behind. */
|
|
47
|
+
prepareImage?: (file: Blob) => Promise<NewImage>;
|
|
48
|
+
/** The host's own buttons (Open, Save, ...), at the right of the top bar. */
|
|
49
|
+
toolbar?: ReactNode;
|
|
50
|
+
/** Shown first in the top bar, e.g. ViewerBrand. */
|
|
51
|
+
brand?: ReactNode;
|
|
52
|
+
dark?: boolean;
|
|
53
|
+
strings?: Partial<EditorStrings>;
|
|
54
|
+
playerStrings?: Partial<PlayerStrings>;
|
|
55
|
+
/** Date text -> EDTF. By default gramps-date, in the story's language. */
|
|
56
|
+
parseDateText?: DateTextParser;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The slide an issue's JSON Pointer is about, or STORY. */
|
|
60
|
+
function issueSlide(issue: StoryIssue): number {
|
|
61
|
+
const match = /^\/slides\/(\d+)/.exec(issue.path);
|
|
62
|
+
return match ? Number(match[1]) : STORY;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
type Resolve = (key: string, item: MediaItem) => Promise<string | null>;
|
|
66
|
+
|
|
67
|
+
/** The default prepareImage. */
|
|
68
|
+
export async function prepareImage(file: Blob): Promise<NewImage> {
|
|
69
|
+
const image = await optimizeImage(file);
|
|
70
|
+
return { data: new Uint8Array(await image.blob.arrayBuffer()), mime: image.mime, ext: image.ext, width: image.width, height: image.height };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function Thumb({ doc, slide, resolveMedia }: { doc: StoryDoc; slide: Slide; resolveMedia: Resolve }) {
|
|
74
|
+
const ref = slide.media?.[0]?.ref;
|
|
75
|
+
const item = ref && Object.prototype.hasOwnProperty.call(doc.media, ref) ? doc.media[ref] : undefined;
|
|
76
|
+
const [url, setUrl] = useState<string | null>(null);
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
let live = true;
|
|
79
|
+
setUrl(null);
|
|
80
|
+
if (ref && item) void resolveMedia(ref, item).then((u) => { if (live) setUrl(u); });
|
|
81
|
+
return () => { live = false; };
|
|
82
|
+
}, [ref, item, resolveMedia]);
|
|
83
|
+
return (
|
|
84
|
+
<Box
|
|
85
|
+
w={40} h={40}
|
|
86
|
+
style={{ flex: "none", borderRadius: 4, background: "var(--mantine-color-default-hover)", overflow: "hidden" }}
|
|
87
|
+
>
|
|
88
|
+
{url && <img src={url} alt="" style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }} />}
|
|
89
|
+
</Box>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function StoryEditor({
|
|
94
|
+
initialStory, initialFiles, onChange, prepareImage: prepare = prepareImage, toolbar, brand, dark = false,
|
|
95
|
+
strings: stringOverrides, playerStrings, parseDateText = suggestEdtf,
|
|
96
|
+
}: StoryEditorProps) {
|
|
97
|
+
const strings = useMemo(() => ({ ...EDITOR_ENGLISH, ...stringOverrides }), [stringOverrides]);
|
|
98
|
+
const [history, setHistory] = useState<History>(() => startHistory(initialStory));
|
|
99
|
+
const [selected, setSelected] = useState(0);
|
|
100
|
+
const [presenting, setPresenting] = useState(false);
|
|
101
|
+
const [problemsOpen, setProblemsOpen] = useState(false);
|
|
102
|
+
const wide = useMediaQuery("(min-width: 1000px)", true);
|
|
103
|
+
const doc = history.present;
|
|
104
|
+
const [files, setFiles] = useState(() => new Map(initialFiles ?? []));
|
|
105
|
+
const [busy, setBusy] = useState(false);
|
|
106
|
+
const [photoError, setPhotoError] = useState<string | null>(null);
|
|
107
|
+
// The latest of each, for work that finishes after a render (photos).
|
|
108
|
+
const docRef = useRef(doc);
|
|
109
|
+
docRef.current = doc;
|
|
110
|
+
const filesRef = useRef(files);
|
|
111
|
+
filesRef.current = files;
|
|
112
|
+
|
|
113
|
+
// A blob: URL per stored file, made once and released with the editor.
|
|
114
|
+
const urls = useRef(new Map<string, string>());
|
|
115
|
+
useEffect(() => () => {
|
|
116
|
+
for (const url of urls.current.values()) URL.revokeObjectURL(url);
|
|
117
|
+
}, []);
|
|
118
|
+
const resolveMedia = useCallback<Resolve>(async (_key, item) => {
|
|
119
|
+
const cached = urls.current.get(item.src);
|
|
120
|
+
if (cached) return cached;
|
|
121
|
+
const data = filesRef.current.get(item.src);
|
|
122
|
+
if (!data) return null;
|
|
123
|
+
const url = URL.createObjectURL(new Blob([data as Uint8Array<ArrayBuffer>], { type: item.mime }));
|
|
124
|
+
urls.current.set(item.src, url);
|
|
125
|
+
return url;
|
|
126
|
+
}, []);
|
|
127
|
+
const urlFor = useCallback((key: string) => {
|
|
128
|
+
const item = Object.prototype.hasOwnProperty.call(docRef.current.media, key) ? docRef.current.media[key] : undefined;
|
|
129
|
+
return item ? resolveMedia(key, item) : Promise.resolve(null);
|
|
130
|
+
}, [resolveMedia]);
|
|
131
|
+
|
|
132
|
+
// Every change goes through here (and so into history and to the host).
|
|
133
|
+
const apply = useCallback((next: StoryDoc, key?: string) => {
|
|
134
|
+
setHistory((h) => record(h, next, key));
|
|
135
|
+
}, []);
|
|
136
|
+
useEffect(() => { onChange?.(doc, files); }, [doc, files, onChange]);
|
|
137
|
+
|
|
138
|
+
const index = selected === STORY ? STORY : Math.min(selected, doc.slides.length - 1);
|
|
139
|
+
const slide = index === STORY ? undefined : doc.slides[index];
|
|
140
|
+
const setSlide = (patch: Partial<Slide>, key: string) => {
|
|
141
|
+
if (index !== STORY) apply(updateSlide(history.present, index, patch), `${slide!.id}:${key}`);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const issues = useMemo(() => validate(doc, files).errors, [doc, files]);
|
|
145
|
+
|
|
146
|
+
/** Prepares image files and puts them on a slide: added at the end, or
|
|
147
|
+
* in place of photo `replace` (keeping its caption and credit). */
|
|
148
|
+
const addPhotos = useCallback(async (chosen: File[], slideId: string, replace?: number) => {
|
|
149
|
+
setBusy(true);
|
|
150
|
+
setPhotoError(null);
|
|
151
|
+
// A file already in the story (chosen again) is used as it is;
|
|
152
|
+
// anything else is prepared first.
|
|
153
|
+
const chosenImages: (NewImage | string)[] = [];
|
|
154
|
+
for (const file of chosen) {
|
|
155
|
+
try {
|
|
156
|
+
const known = findMedia(docRef.current, filesRef.current, new Uint8Array(await file.arrayBuffer()));
|
|
157
|
+
chosenImages.push(known ?? await prepare(file));
|
|
158
|
+
} catch {
|
|
159
|
+
setPhotoError(strings.photoFailed(file.name || "the image"));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
setBusy(false);
|
|
163
|
+
if (!chosenImages.length) return;
|
|
164
|
+
let next = docRef.current;
|
|
165
|
+
let nextFiles = filesRef.current;
|
|
166
|
+
const keys: string[] = [];
|
|
167
|
+
for (const image of chosenImages) {
|
|
168
|
+
if (typeof image === "string") {
|
|
169
|
+
keys.push(image);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const added = addImage(next, nextFiles, image);
|
|
173
|
+
next = added.doc;
|
|
174
|
+
nextFiles = added.files;
|
|
175
|
+
keys.push(added.key);
|
|
176
|
+
}
|
|
177
|
+
const at = next.slides.findIndex((s) => s.id === slideId);
|
|
178
|
+
if (at < 0) return;
|
|
179
|
+
const photos: SlideMedia[] = [...(next.slides[at].media ?? [])];
|
|
180
|
+
if (replace !== undefined && photos[replace]) photos[replace] = { ...photos[replace], ref: keys[0] };
|
|
181
|
+
else photos.push(...keys.map((ref) => ({ ref })));
|
|
182
|
+
setFiles(nextFiles);
|
|
183
|
+
apply(setSlidePhotos(next, at, photos));
|
|
184
|
+
}, [apply, prepare, strings]);
|
|
185
|
+
|
|
186
|
+
// Pasting an image (outside a text field) adds it to the slide.
|
|
187
|
+
const slideId = slide?.id;
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
if (!slideId) return;
|
|
190
|
+
const onPaste = (e: ClipboardEvent) => {
|
|
191
|
+
if ((e.target as HTMLElement | null)?.closest("input, textarea, [contenteditable=true]")) return;
|
|
192
|
+
const images = [...(e.clipboardData?.files ?? [])].filter((f) => f.type.startsWith("image/"));
|
|
193
|
+
if (!images.length) return;
|
|
194
|
+
e.preventDefault();
|
|
195
|
+
void addPhotos(images, slideId);
|
|
196
|
+
};
|
|
197
|
+
window.addEventListener("paste", onPaste);
|
|
198
|
+
return () => window.removeEventListener("paste", onPaste);
|
|
199
|
+
}, [slideId, addPhotos]);
|
|
200
|
+
|
|
201
|
+
// Undo/redo from the keyboard, except where a text field has its own.
|
|
202
|
+
useEffect(() => {
|
|
203
|
+
const onKey = (e: KeyboardEvent) => {
|
|
204
|
+
if (!(e.ctrlKey || e.metaKey) || e.altKey) return;
|
|
205
|
+
const target = e.target as HTMLElement | null;
|
|
206
|
+
if (target?.closest("input, textarea, [contenteditable=true]")) return;
|
|
207
|
+
const key = e.key.toLowerCase();
|
|
208
|
+
if (key === "z" && !e.shiftKey) { e.preventDefault(); setHistory(undo); }
|
|
209
|
+
else if ((key === "z" && e.shiftKey) || key === "y") { e.preventDefault(); setHistory(redo); }
|
|
210
|
+
};
|
|
211
|
+
window.addEventListener("keydown", onKey);
|
|
212
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
213
|
+
}, []);
|
|
214
|
+
|
|
215
|
+
const slideActions = slide && (
|
|
216
|
+
<Group gap={6}>
|
|
217
|
+
<Tooltip label={strings.moveUp}>
|
|
218
|
+
<ActionIcon variant="default" aria-label={strings.moveUp} disabled={index === 0}
|
|
219
|
+
onClick={() => { apply(moveSlide(doc, index, index - 1)); setSelected(index - 1); }}>↑</ActionIcon>
|
|
220
|
+
</Tooltip>
|
|
221
|
+
<Tooltip label={strings.moveDown}>
|
|
222
|
+
<ActionIcon variant="default" aria-label={strings.moveDown} disabled={index === doc.slides.length - 1}
|
|
223
|
+
onClick={() => { apply(moveSlide(doc, index, index + 1)); setSelected(index + 1); }}>↓</ActionIcon>
|
|
224
|
+
</Tooltip>
|
|
225
|
+
<Button variant="default" size="xs" onClick={() => { const r = duplicateSlide(doc, index); apply(r.doc); setSelected(r.index); }}>
|
|
226
|
+
{strings.duplicateSlide}
|
|
227
|
+
</Button>
|
|
228
|
+
<Button variant="default" size="xs" color="red" disabled={doc.slides.length <= 1}
|
|
229
|
+
onClick={() => { const r = deleteSlide(doc, index); apply(r.doc); setSelected(r.index); }}>
|
|
230
|
+
{strings.deleteSlide}
|
|
231
|
+
</Button>
|
|
232
|
+
</Group>
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
const storyForm = (
|
|
236
|
+
<Stack gap="md">
|
|
237
|
+
<Text fw={600} size="lg">{strings.story}</Text>
|
|
238
|
+
<TextInput
|
|
239
|
+
label={strings.title} required value={doc.title}
|
|
240
|
+
onChange={(e) => apply(updateStory(doc, { title: e.currentTarget.value }), "story:title")}
|
|
241
|
+
/>
|
|
242
|
+
<TextInput
|
|
243
|
+
label={strings.language} description={strings.languageHint} w={260} value={doc.language ?? ""}
|
|
244
|
+
onChange={(e) => apply(updateStory(doc, { language: e.currentTarget.value.trim() || undefined }), "story:language")}
|
|
245
|
+
/>
|
|
246
|
+
<Select
|
|
247
|
+
label={strings.map} w={320} allowDeselect={false}
|
|
248
|
+
data={[
|
|
249
|
+
{ value: "auto", label: strings.mapAuto },
|
|
250
|
+
{ value: "standard", label: strings.mapStandard },
|
|
251
|
+
{ value: "historical", label: strings.mapHistorical },
|
|
252
|
+
{ value: "none", label: strings.mapNone },
|
|
253
|
+
]}
|
|
254
|
+
value={doc.presentation?.basemap ?? "auto"}
|
|
255
|
+
onChange={(value) => value && apply(updateStory(doc, {
|
|
256
|
+
presentation: { ...doc.presentation, basemap: value as NonNullable<StoryDoc["presentation"]>["basemap"] },
|
|
257
|
+
}))}
|
|
258
|
+
/>
|
|
259
|
+
</Stack>
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
const slideForm = slide && (
|
|
263
|
+
<Stack gap="md" key={slide.id}>
|
|
264
|
+
<Group justify="space-between" wrap="wrap" gap="xs">
|
|
265
|
+
<Text fw={600} size="lg">{index === 0 ? strings.openingSlide : `${index + 1} / ${doc.slides.length}`}</Text>
|
|
266
|
+
{slideActions}
|
|
267
|
+
</Group>
|
|
268
|
+
<TextInput
|
|
269
|
+
label={strings.slideTitle} value={slide.title ?? ""}
|
|
270
|
+
onChange={(e) => setSlide({ title: e.currentTarget.value || undefined }, "title")}
|
|
271
|
+
/>
|
|
272
|
+
<DateField
|
|
273
|
+
value={slide.date} language={doc.language} parse={parseDateText} strings={strings}
|
|
274
|
+
onChange={(date) => setSlide({ date }, "date")}
|
|
275
|
+
/>
|
|
276
|
+
<StoryTextField
|
|
277
|
+
label={strings.text} value={slide.text ?? ""} boldLabel={strings.bold} italicLabel={strings.italic} minRows={5}
|
|
278
|
+
onChange={(text) => setSlide({ text: text || undefined }, "text")}
|
|
279
|
+
/>
|
|
280
|
+
{slide.place && (
|
|
281
|
+
<Stack gap={2}>
|
|
282
|
+
<Text size="sm" fw={500}>{strings.place}</Text>
|
|
283
|
+
<Text size="sm">
|
|
284
|
+
{slide.place.name ?? ""}
|
|
285
|
+
{slide.place.lat !== undefined && slide.place.lon !== undefined && (
|
|
286
|
+
<Text span c="dimmed" size="sm"> {slide.place.lat.toFixed(4)}, {slide.place.lon.toFixed(4)}</Text>
|
|
287
|
+
)}
|
|
288
|
+
</Text>
|
|
289
|
+
</Stack>
|
|
290
|
+
)}
|
|
291
|
+
<PhotosField
|
|
292
|
+
photos={slide.media ?? []} media={doc.media} urlFor={urlFor} strings={strings} busy={busy}
|
|
293
|
+
onChange={(photos, key) => apply(setSlidePhotos(doc, index, photos), `${slide.id}:${key}`)}
|
|
294
|
+
onFiles={(chosen, replace) => void addPhotos(chosen, slide.id, replace)}
|
|
295
|
+
onDescription={(key, alt) => apply(updateMedia(doc, key, { alt: alt || undefined }), `media:${key}:alt`)}
|
|
296
|
+
/>
|
|
297
|
+
{photoError && <Text size="xs" c="red" role="alert">{photoError}</Text>}
|
|
298
|
+
</Stack>
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
const list = (
|
|
302
|
+
<Stack gap="xs">
|
|
303
|
+
<UnstyledButton
|
|
304
|
+
onClick={() => setSelected(STORY)}
|
|
305
|
+
style={{ padding: "8px", borderRadius: 6, background: index === STORY ? "var(--mantine-primary-color-light)" : undefined }}
|
|
306
|
+
>
|
|
307
|
+
<Text fw={600} size="sm">{strings.story}</Text>
|
|
308
|
+
<Text size="xs" c="dimmed">{strings.storySettings}</Text>
|
|
309
|
+
</UnstyledButton>
|
|
310
|
+
<Divider label={strings.slides} labelPosition="left" />
|
|
311
|
+
<SlideList
|
|
312
|
+
label={strings.slides}
|
|
313
|
+
items={doc.slides}
|
|
314
|
+
selected={index}
|
|
315
|
+
onSelect={setSelected}
|
|
316
|
+
onMove={(from, to) => { apply(moveSlide(doc, from, to)); setSelected(to); }}
|
|
317
|
+
getKey={(s) => s.id}
|
|
318
|
+
renderItem={(s, i) => (
|
|
319
|
+
<Group gap="sm" wrap="nowrap">
|
|
320
|
+
<Thumb doc={doc} slide={s} resolveMedia={resolveMedia} />
|
|
321
|
+
<Box style={{ minWidth: 0 }}>
|
|
322
|
+
<Text size="sm" fw={500} truncate dir="auto" c={s.title ? undefined : "dimmed"}>
|
|
323
|
+
{s.title || strings.untitledSlide}
|
|
324
|
+
</Text>
|
|
325
|
+
<Text size="xs" c="dimmed" truncate>{i === 0 ? strings.openingSlide : s.date?.text ?? ""}</Text>
|
|
326
|
+
</Box>
|
|
327
|
+
</Group>
|
|
328
|
+
)}
|
|
329
|
+
/>
|
|
330
|
+
<Button
|
|
331
|
+
variant="light" size="xs" mt={4}
|
|
332
|
+
onClick={() => { const r = addSlide(doc, index === STORY ? doc.slides.length - 1 : index); apply(r.doc); setSelected(r.index); }}
|
|
333
|
+
>
|
|
334
|
+
+ {strings.addSlide}
|
|
335
|
+
</Button>
|
|
336
|
+
</Stack>
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
const preview = (
|
|
340
|
+
<Box style={{ position: "relative", height: wide ? "100%" : 460, borderRadius: 8, overflow: "hidden", border: "1px solid var(--mantine-color-default-border)" }}>
|
|
341
|
+
<StoryPlayer
|
|
342
|
+
story={doc} resolveMedia={resolveMedia} dark={dark} strings={playerStrings}
|
|
343
|
+
keyboard={false} initialSlide={index === STORY ? 0 : index}
|
|
344
|
+
onSlideChange={(i) => { if (i !== index && index !== STORY) setSelected(i); }}
|
|
345
|
+
/>
|
|
346
|
+
</Box>
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
const column = (children: ReactNode, width?: number | string) => (
|
|
350
|
+
<Box
|
|
351
|
+
style={{
|
|
352
|
+
width, flex: width ? "none" : 1, minWidth: 0, padding: 16,
|
|
353
|
+
overflowY: wide ? "auto" : undefined, height: wide ? "100%" : undefined,
|
|
354
|
+
}}
|
|
355
|
+
>
|
|
356
|
+
{children}
|
|
357
|
+
</Box>
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
return (
|
|
361
|
+
<Box style={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
|
362
|
+
<Group
|
|
363
|
+
h={BAR_HEIGHT} px="md" gap="sm" wrap="nowrap" justify="space-between"
|
|
364
|
+
style={{ flex: "none", borderBottom: "1px solid var(--mantine-color-default-border)" }}
|
|
365
|
+
>
|
|
366
|
+
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
|
367
|
+
{brand}
|
|
368
|
+
<Text fw={600} truncate dir="auto">{doc.title}</Text>
|
|
369
|
+
</Group>
|
|
370
|
+
<Group gap="xs" wrap="nowrap">
|
|
371
|
+
{issues.length > 0 && (
|
|
372
|
+
<Popover opened={problemsOpen} onChange={setProblemsOpen} width={360} position="bottom-end" withArrow>
|
|
373
|
+
<Popover.Target>
|
|
374
|
+
<Badge color="red" variant="light" style={{ cursor: "pointer" }} onClick={() => setProblemsOpen((o) => !o)}>
|
|
375
|
+
{strings.problems(issues.length)}
|
|
376
|
+
</Badge>
|
|
377
|
+
</Popover.Target>
|
|
378
|
+
<Popover.Dropdown>
|
|
379
|
+
<Stack gap={6}>
|
|
380
|
+
{issues.map((issue, i) => {
|
|
381
|
+
const at = issueSlide(issue);
|
|
382
|
+
return (
|
|
383
|
+
<UnstyledButton key={i} onClick={() => { setSelected(at); setProblemsOpen(false); }}>
|
|
384
|
+
<Text size="sm" fw={500}>{at === STORY ? strings.story : (doc.slides[at]?.title || `${strings.untitledSlide} ${at + 1}`)}</Text>
|
|
385
|
+
<Text size="xs" c="dimmed">{issue.path} {issue.message}</Text>
|
|
386
|
+
</UnstyledButton>
|
|
387
|
+
);
|
|
388
|
+
})}
|
|
389
|
+
</Stack>
|
|
390
|
+
</Popover.Dropdown>
|
|
391
|
+
</Popover>
|
|
392
|
+
)}
|
|
393
|
+
<Tooltip label={strings.undo}>
|
|
394
|
+
<ActionIcon variant="default" size="lg" aria-label={strings.undo} disabled={history.past.length === 0} onClick={() => setHistory(undo)}>↶</ActionIcon>
|
|
395
|
+
</Tooltip>
|
|
396
|
+
<Tooltip label={strings.redo}>
|
|
397
|
+
<ActionIcon variant="default" size="lg" aria-label={strings.redo} disabled={history.future.length === 0} onClick={() => setHistory(redo)}>↷</ActionIcon>
|
|
398
|
+
</Tooltip>
|
|
399
|
+
<Button variant="default" onClick={() => setPresenting(true)}>{strings.present}</Button>
|
|
400
|
+
{toolbar}
|
|
401
|
+
</Group>
|
|
402
|
+
</Group>
|
|
403
|
+
<Box
|
|
404
|
+
style={{
|
|
405
|
+
flex: 1, minHeight: 0, display: "flex", flexDirection: wide ? "row" : "column",
|
|
406
|
+
overflowY: wide ? undefined : "auto",
|
|
407
|
+
}}
|
|
408
|
+
>
|
|
409
|
+
{column(list, wide ? 280 : undefined)}
|
|
410
|
+
{column(index === STORY ? storyForm : slideForm, wide ? "min(520px, 36%)" : undefined)}
|
|
411
|
+
{column(preview)}
|
|
412
|
+
</Box>
|
|
413
|
+
<Modal opened={presenting} onClose={() => setPresenting(false)} fullScreen withCloseButton={false} padding={0}>
|
|
414
|
+
<Box style={{ position: "fixed", inset: 0 }}>
|
|
415
|
+
<StoryPlayer
|
|
416
|
+
story={doc} resolveMedia={resolveMedia} dark={dark} strings={playerStrings}
|
|
417
|
+
initialSlide={index === STORY ? 0 : index} onClose={() => setPresenting(false)}
|
|
418
|
+
/>
|
|
419
|
+
</Box>
|
|
420
|
+
</Modal>
|
|
421
|
+
</Box>
|
|
422
|
+
);
|
|
423
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// A plain Textarea plus Bold/Italic buttons that wrap the selection in the
|
|
2
|
+
// story format's `**`/`*` markers -- the only formatting a slide's text has
|
|
3
|
+
// (parseStoryText is the matching reader), so no rich-text editor.
|
|
4
|
+
import { useRef } from "react";
|
|
5
|
+
import { Button, Group, Stack, Text, Textarea } from "@mantine/core";
|
|
6
|
+
|
|
7
|
+
/** Wraps the textarea's selection in `marker` (or unwraps it, if the
|
|
8
|
+
* selection is already exactly `marker…marker`); with nothing selected,
|
|
9
|
+
* inserts an empty pair and leaves the cursor between them. */
|
|
10
|
+
function applyMarker(el: HTMLTextAreaElement, marker: string, value: string, onChange: (v: string) => void) {
|
|
11
|
+
const start = el.selectionStart ?? value.length;
|
|
12
|
+
const end = el.selectionEnd ?? value.length;
|
|
13
|
+
if (start === end) {
|
|
14
|
+
onChange(value.slice(0, start) + marker + marker + value.slice(end));
|
|
15
|
+
requestAnimationFrame(() => {
|
|
16
|
+
el.focus();
|
|
17
|
+
el.setSelectionRange(start + marker.length, start + marker.length);
|
|
18
|
+
});
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const selected = value.slice(start, end);
|
|
22
|
+
const alreadyWrapped = selected.length > marker.length * 2 && selected.startsWith(marker) && selected.endsWith(marker);
|
|
23
|
+
const replacement = alreadyWrapped ? selected.slice(marker.length, selected.length - marker.length) : `${marker}${selected}${marker}`;
|
|
24
|
+
onChange(value.slice(0, start) + replacement + value.slice(end));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface StoryTextFieldProps {
|
|
28
|
+
label: string;
|
|
29
|
+
value: string;
|
|
30
|
+
onChange: (value: string) => void;
|
|
31
|
+
boldLabel?: string;
|
|
32
|
+
italicLabel?: string;
|
|
33
|
+
minRows?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function StoryTextField({ label, value, onChange, boldLabel = "Bold", italicLabel = "Italic", minRows = 3 }: StoryTextFieldProps) {
|
|
37
|
+
const ref = useRef<HTMLTextAreaElement>(null);
|
|
38
|
+
return (
|
|
39
|
+
<Stack gap={4}>
|
|
40
|
+
<Group gap="xs">
|
|
41
|
+
<Text size="sm" fw={500}>{label}</Text>
|
|
42
|
+
<Button
|
|
43
|
+
variant="default" size="compact-xs" fw={700} aria-label={boldLabel} title={boldLabel}
|
|
44
|
+
onClick={() => ref.current && applyMarker(ref.current, "**", value, onChange)}
|
|
45
|
+
>
|
|
46
|
+
B
|
|
47
|
+
</Button>
|
|
48
|
+
<Button
|
|
49
|
+
variant="default" size="compact-xs" fs="italic" aria-label={italicLabel} title={italicLabel}
|
|
50
|
+
onClick={() => ref.current && applyMarker(ref.current, "*", value, onChange)}
|
|
51
|
+
>
|
|
52
|
+
i
|
|
53
|
+
</Button>
|
|
54
|
+
</Group>
|
|
55
|
+
<Textarea
|
|
56
|
+
ref={ref} aria-label={label} autosize minRows={minRows} maxRows={14} value={value}
|
|
57
|
+
onChange={(e) => onChange(e.currentTarget.value)}
|
|
58
|
+
/>
|
|
59
|
+
</Stack>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// A date as typed ("about 1850", "um 1850") to its precise form (EDTF),
|
|
2
|
+
// read the way Gramps reads dates, in the story's language: the player
|
|
3
|
+
// needs the precise form for the timeline and for picking historical maps.
|
|
4
|
+
import { loadLocale, parseDate, resolveLocaleCode, toEdtf } from "@gramps-connect/gramps-date";
|
|
5
|
+
|
|
6
|
+
/** Turns date text into EDTF, or undefined when the text isn't a date
|
|
7
|
+
* that has one ("spring 1850", a non-Gregorian date). */
|
|
8
|
+
export type DateTextParser = (text: string, language: string | undefined) => Promise<string | undefined>;
|
|
9
|
+
|
|
10
|
+
/** The default parser: gramps-date in the story's language (loaded on first
|
|
11
|
+
* use), English when the story has none or gramps-date doesn't know it. */
|
|
12
|
+
export const suggestEdtf: DateTextParser = async (text, language) => {
|
|
13
|
+
if (!text.trim()) return undefined;
|
|
14
|
+
const code = (language && resolveLocaleCode(language.replace(/-/g, "_"))) || "en";
|
|
15
|
+
if (code !== "en" && !(await loadLocale(code))) return undefined;
|
|
16
|
+
try {
|
|
17
|
+
return toEdtf(parseDate(text, { locale: code }));
|
|
18
|
+
} catch {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { prepareImage, StoryEditor, type StoryEditorProps } from "./StoryEditor";
|
|
2
|
+
export { PhotosField, type PhotosFieldProps } from "./PhotosField";
|
|
3
|
+
export { StoryTextField, type StoryTextFieldProps } from "./StoryTextField";
|
|
4
|
+
export { SlideList, type SlideListProps } from "./SlideList";
|
|
5
|
+
export { DateField, type DateFieldProps } from "./DateField";
|
|
6
|
+
export { suggestEdtf, type DateTextParser } from "./dates";
|
|
7
|
+
export { EDITOR_ENGLISH, type EditorStrings } from "./strings";
|
|
8
|
+
export {
|
|
9
|
+
addImage, addSlide, blankStory, COALESCE_MS, deleteSlide, duplicateSlide, findMedia, moveSlide, nextSlideId, pruneMedia, record, redo,
|
|
10
|
+
setSlidePhotos, slideDate, startHistory, storyFileName, undo, updateMedia, updateSlide, updateStory, usedMedia,
|
|
11
|
+
type History, type NewImage,
|
|
12
|
+
} from "./model";
|