@gramps-connect/story 0.2.0 → 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.
@@ -0,0 +1,16 @@
1
+ import type { MediaItem, SlideMedia } from "../types";
2
+ import type { EditorStrings } from "./strings";
3
+ export interface PhotosFieldProps {
4
+ photos: SlideMedia[];
5
+ media: Record<string, MediaItem>;
6
+ /** A displayable URL for a media key. */
7
+ urlFor: (key: string) => Promise<string | null>;
8
+ strings: EditorStrings;
9
+ /** True while chosen files are being prepared. */
10
+ busy?: boolean;
11
+ onChange: (photos: SlideMedia[], key: string) => void;
12
+ /** Image files to add at the end, or to put in place of photo `replace`. */
13
+ onFiles: (files: File[], replace?: number) => void;
14
+ onDescription: (mediaKey: string, alt: string) => void;
15
+ }
16
+ export declare function PhotosField({ photos, media, urlFor, strings, busy, onChange, onFiles, onDescription }: PhotosFieldProps): import("react").JSX.Element;
@@ -1,20 +1,23 @@
1
1
  import { type ReactNode } from "react";
2
- import type { MediaItem, StoryDoc } from "../types";
2
+ import type { StoryDoc } from "../types";
3
3
  import type { PlayerStrings } from "../player/strings";
4
4
  import { type DateTextParser } from "./dates";
5
+ import { type NewImage } from "./model";
5
6
  import { type EditorStrings } from "./strings";
6
7
  export interface StoryEditorProps {
7
- /** The story to start from. Changing it later has no effect: remount
8
- * (a new `key`) to edit another. */
8
+ /** The story to start from, and its files (paths as in media `src`).
9
+ * Changing these later has no effect: remount (a new `key`) to edit
10
+ * another story. */
9
11
  initialStory: StoryDoc;
10
- /** The story's files, for checking that every media file is there. */
11
- files?: {
12
- has(path: string): boolean;
13
- };
14
- /** As for StoryPlayer. */
15
- resolveMedia: (key: string, item: MediaItem) => Promise<string | null>;
16
- /** Called with the document after every change, undo and redo included. */
17
- onChange?: (doc: StoryDoc) => void;
12
+ initialFiles?: Map<string, Uint8Array>;
13
+ /** Called with the document and its files after every change, undo and
14
+ * redo included. The files only grow while editing (an undone photo's
15
+ * file stays); pruneMedia leaves out what nothing uses when saving. */
16
+ onChange?: (doc: StoryDoc, files: Map<string, Uint8Array>) => void;
17
+ /** Turns a chosen image file into what's stored. By default
18
+ * optimizeImage: 1600px WebP (JPEG where the browser can't), with the
19
+ * original's metadata left behind. */
20
+ prepareImage?: (file: Blob) => Promise<NewImage>;
18
21
  /** The host's own buttons (Open, Save, ...), at the right of the top bar. */
19
22
  toolbar?: ReactNode;
20
23
  /** Shown first in the top bar, e.g. ViewerBrand. */
@@ -25,4 +28,6 @@ export interface StoryEditorProps {
25
28
  /** Date text -> EDTF. By default gramps-date, in the story's language. */
26
29
  parseDateText?: DateTextParser;
27
30
  }
28
- export declare function StoryEditor({ initialStory, files, resolveMedia, onChange, toolbar, brand, dark, strings: stringOverrides, playerStrings, parseDateText, }: StoryEditorProps): import("react").JSX.Element;
31
+ /** The default prepareImage. */
32
+ export declare function prepareImage(file: Blob): Promise<NewImage>;
33
+ export declare function StoryEditor({ initialStory, initialFiles, onChange, prepareImage: prepare, toolbar, brand, dark, strings: stringOverrides, playerStrings, parseDateText, }: StoryEditorProps): import("react").JSX.Element;
@@ -1,7 +1,8 @@
1
- export { StoryEditor, type StoryEditorProps } from "./StoryEditor";
1
+ export { prepareImage, StoryEditor, type StoryEditorProps } from "./StoryEditor";
2
+ export { PhotosField, type PhotosFieldProps } from "./PhotosField";
2
3
  export { StoryTextField, type StoryTextFieldProps } from "./StoryTextField";
3
4
  export { SlideList, type SlideListProps } from "./SlideList";
4
5
  export { DateField, type DateFieldProps } from "./DateField";
5
6
  export { suggestEdtf, type DateTextParser } from "./dates";
6
7
  export { EDITOR_ENGLISH, type EditorStrings } from "./strings";
7
- export { addSlide, blankStory, COALESCE_MS, deleteSlide, duplicateSlide, moveSlide, nextSlideId, pruneMedia, record, redo, slideDate, startHistory, storyFileName, undo, updateSlide, updateStory, usedMedia, type History, } from "./model";
8
+ export { addImage, addSlide, blankStory, COALESCE_MS, deleteSlide, duplicateSlide, findMedia, moveSlide, nextSlideId, pruneMedia, record, redo, setSlidePhotos, slideDate, startHistory, storyFileName, undo, updateMedia, updateSlide, updateStory, usedMedia, type History, type NewImage, } from "./model";
@@ -1,4 +1,4 @@
1
- import type { Slide, StoryDate, StoryDoc } from "../types";
1
+ import type { MediaItem, Slide, SlideMedia, StoryDate, StoryDoc } from "../types";
2
2
  /** A story with one empty slide: the smallest valid one. */
3
3
  export declare function blankStory(title: string, language?: string): StoryDoc;
4
4
  /** "s<n>" past every id already of that form, so it's unique however the
@@ -27,6 +27,31 @@ export declare function updateStory(doc: StoryDoc, patch: Partial<Omit<StoryDoc,
27
27
  * date at all. A new precise form drops a `sortYear` it would contradict
28
28
  * (the timeline reads sortYear first); the date's other fields stay. */
29
29
  export declare function slideDate(previous: StoryDate | undefined, text: string, edtf: string | undefined): StoryDate | undefined;
30
+ /** A prepared image to add to a story (see optimizeImage). */
31
+ export interface NewImage {
32
+ data: Uint8Array;
33
+ mime: string;
34
+ /** File extension, without the dot. */
35
+ ext: string;
36
+ width?: number;
37
+ height?: number;
38
+ }
39
+ /** The media key whose file is exactly these bytes, if any: a photo
40
+ * that's already in the story, chosen again. */
41
+ export declare function findMedia(doc: StoryDoc, files: Map<string, Uint8Array>, data: Uint8Array): string | undefined;
42
+ /** Adds an image to the story's media (not to any slide), or finds the
43
+ * identical one already there, so the same photo is stored once. Returns
44
+ * its media key. */
45
+ export declare function addImage(doc: StoryDoc, files: Map<string, Uint8Array>, image: NewImage, stem?: string): {
46
+ doc: StoryDoc;
47
+ files: Map<string, Uint8Array>;
48
+ key: string;
49
+ };
50
+ /** A slide's photo list; an empty one removes the field. */
51
+ export declare function setSlidePhotos(doc: StoryDoc, index: number, media: SlideMedia[]): StoryDoc;
52
+ /** Changes a media item itself (its description, shared by every slide
53
+ * that shows it). */
54
+ export declare function updateMedia(doc: StoryDoc, key: string, patch: Partial<MediaItem>): StoryDoc;
30
55
  /** Every media key a slide or overlay refers to. */
31
56
  export declare function usedMedia(doc: StoryDoc): Set<string>;
32
57
  /** The story as it should be saved: media nothing refers to any more left
@@ -30,6 +30,18 @@ export interface EditorStrings {
30
30
  noPreciseDate: string;
31
31
  place: string;
32
32
  photos: string;
33
+ photosHint: string;
34
+ addPhoto: string;
35
+ replacePhoto: string;
36
+ movePhotoUp: string;
37
+ movePhotoDown: string;
38
+ removePhoto: string;
39
+ caption: string;
40
+ credit: string;
41
+ creditHint: string;
42
+ description: string;
43
+ descriptionHint: string;
44
+ photoFailed: (name: string) => string;
33
45
  undo: string;
34
46
  redo: string;
35
47
  present: string;
@@ -10,6 +10,8 @@ export interface PlayerStrings {
10
10
  historicalMapYear: (year: number) => string;
11
11
  noDateStandardMap: string;
12
12
  historicalYear: string;
13
+ /** A photo's thumbnail button, on a slide with several. */
14
+ photoOf: (n: number, total: number) => string;
13
15
  }
14
16
  export declare const ENGLISH: PlayerStrings;
15
17
  /** The best table for a list of BCP 47 tags, most preferred first:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gramps-connect/story",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Read, write, validate and present .story.zip files: portable, versioned family-history stories.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "type": "module",
@@ -0,0 +1,134 @@
1
+ // A slide's photos: add (pick, drop or paste -- pasting is the editor's),
2
+ // replace, reorder and remove them, and write each one's caption, credit
3
+ // and description. The files themselves are the editor's to prepare and
4
+ // store; this only says what was chosen.
5
+ import { useEffect, useRef, useState, type DragEvent } from "react";
6
+ import { ActionIcon, Box, Button, Group, Loader, Stack, Text, TextInput, Tooltip } from "@mantine/core";
7
+ import type { MediaItem, SlideMedia } from "../types";
8
+ import type { EditorStrings } from "./strings";
9
+
10
+ export interface PhotosFieldProps {
11
+ photos: SlideMedia[];
12
+ media: Record<string, MediaItem>;
13
+ /** A displayable URL for a media key. */
14
+ urlFor: (key: string) => Promise<string | null>;
15
+ strings: EditorStrings;
16
+ /** True while chosen files are being prepared. */
17
+ busy?: boolean;
18
+ onChange: (photos: SlideMedia[], key: string) => void;
19
+ /** Image files to add at the end, or to put in place of photo `replace`. */
20
+ onFiles: (files: File[], replace?: number) => void;
21
+ onDescription: (mediaKey: string, alt: string) => void;
22
+ }
23
+
24
+ const imagesIn = (list: FileList | null | undefined) => [...(list ?? [])].filter((f) => f.type.startsWith("image/"));
25
+
26
+ function Preview({ mediaKey, urlFor }: { mediaKey: string; urlFor: PhotosFieldProps["urlFor"] }) {
27
+ const [url, setUrl] = useState<string | null>(null);
28
+ useEffect(() => {
29
+ let live = true;
30
+ void urlFor(mediaKey).then((u) => { if (live) setUrl(u); });
31
+ return () => { live = false; };
32
+ }, [mediaKey, urlFor]);
33
+ return (
34
+ <Box w={96} h={96} style={{ flex: "none", borderRadius: 6, overflow: "hidden", background: "var(--mantine-color-default-hover)" }}>
35
+ {url && <img src={url} alt="" style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }} />}
36
+ </Box>
37
+ );
38
+ }
39
+
40
+ export function PhotosField({ photos, media, urlFor, strings, busy, onChange, onFiles, onDescription }: PhotosFieldProps) {
41
+ const input = useRef<HTMLInputElement>(null);
42
+ const replacing = useRef<number | undefined>(undefined);
43
+ const [dropping, setDropping] = useState(false);
44
+
45
+ const pick = (replace?: number) => {
46
+ replacing.current = replace;
47
+ input.current?.click();
48
+ };
49
+ const set = (i: number, patch: Partial<SlideMedia>, field: string) => {
50
+ const next = photos.map((p, j) => {
51
+ if (j !== i) return p;
52
+ const changed: Record<string, unknown> = { ...p, ...patch };
53
+ for (const [k, v] of Object.entries(patch)) if (!v) delete changed[k];
54
+ return changed as unknown as SlideMedia;
55
+ });
56
+ onChange(next, `photo:${i}:${field}`);
57
+ };
58
+ const move = (from: number, to: number) => {
59
+ const next = [...photos];
60
+ const [p] = next.splice(from, 1);
61
+ next.splice(to, 0, p);
62
+ onChange(next, "photos");
63
+ };
64
+ const onDrop = (e: DragEvent) => {
65
+ e.preventDefault();
66
+ setDropping(false);
67
+ const files = imagesIn(e.dataTransfer.files);
68
+ if (files.length) onFiles(files);
69
+ };
70
+
71
+ return (
72
+ <Stack
73
+ gap="sm"
74
+ onDragOver={(e) => { if ([...e.dataTransfer.types].includes("Files")) { e.preventDefault(); setDropping(true); } }}
75
+ onDragLeave={(e) => { if (e.currentTarget === e.target) setDropping(false); }}
76
+ onDrop={onDrop}
77
+ style={{
78
+ borderRadius: 8, padding: dropping ? 8 : 0, margin: dropping ? -8 : 0,
79
+ outline: dropping ? "2px dashed var(--mantine-primary-color-filled)" : undefined,
80
+ }}
81
+ >
82
+ <Group justify="space-between">
83
+ <Text size="sm" fw={500}>{strings.photos}</Text>
84
+ <Group gap="xs">
85
+ {busy && <Loader size="xs" />}
86
+ <Button variant="default" size="xs" onClick={() => pick()} disabled={busy}>+ {strings.addPhoto}</Button>
87
+ </Group>
88
+ </Group>
89
+ <input
90
+ ref={input} type="file" accept="image/*" multiple hidden
91
+ onChange={(e) => {
92
+ const files = imagesIn(e.currentTarget.files);
93
+ e.currentTarget.value = "";
94
+ if (files.length) onFiles(replacing.current === undefined ? files : files.slice(0, 1), replacing.current);
95
+ }}
96
+ />
97
+ {photos.length === 0 && <Text size="xs" c="dimmed">{strings.photosHint}</Text>}
98
+ {photos.map((photo, i) => {
99
+ const item = Object.prototype.hasOwnProperty.call(media, photo.ref) ? media[photo.ref] : undefined;
100
+ return (
101
+ <Group key={i} gap="sm" align="flex-start" wrap="nowrap">
102
+ <Stack gap={4} align="center">
103
+ <Preview mediaKey={photo.ref} urlFor={urlFor} />
104
+ <Group gap={4}>
105
+ <Tooltip label={strings.movePhotoUp}>
106
+ <ActionIcon size="sm" variant="default" aria-label={strings.movePhotoUp} disabled={i === 0} onClick={() => move(i, i - 1)}>↑</ActionIcon>
107
+ </Tooltip>
108
+ <Tooltip label={strings.movePhotoDown}>
109
+ <ActionIcon size="sm" variant="default" aria-label={strings.movePhotoDown} disabled={i === photos.length - 1} onClick={() => move(i, i + 1)}>↓</ActionIcon>
110
+ </Tooltip>
111
+ </Group>
112
+ </Stack>
113
+ <Stack gap={6} style={{ flex: 1, minWidth: 0 }}>
114
+ <TextInput size="xs" label={strings.caption} value={photo.caption ?? ""} onChange={(e) => set(i, { caption: e.currentTarget.value }, "caption")} />
115
+ <TextInput size="xs" label={strings.credit} placeholder={strings.creditHint} value={photo.credit ?? ""} onChange={(e) => set(i, { credit: e.currentTarget.value }, "credit")} />
116
+ {item && (
117
+ <TextInput
118
+ size="xs" label={strings.description} description={strings.descriptionHint} value={item.alt ?? ""}
119
+ onChange={(e) => onDescription(photo.ref, e.currentTarget.value)}
120
+ />
121
+ )}
122
+ <Group gap="xs">
123
+ <Button variant="subtle" size="compact-xs" onClick={() => pick(i)} disabled={busy}>{strings.replacePhoto}</Button>
124
+ <Button variant="subtle" size="compact-xs" color="red" onClick={() => onChange(photos.filter((_, j) => j !== i), "photos")}>
125
+ {strings.removePhoto}
126
+ </Button>
127
+ </Group>
128
+ </Stack>
129
+ </Group>
130
+ );
131
+ })}
132
+ </Stack>
133
+ );
134
+ }
@@ -6,21 +6,23 @@
6
6
  //
7
7
  // Needs Mantine: wrap it in a MantineProvider and load @mantine/core's
8
8
  // stylesheet, as with any Mantine component.
9
- import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
9
+ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
10
10
  import {
11
11
  ActionIcon, Badge, Box, Button, Divider, Group, Modal, Popover, Select, Stack, Text, TextInput, Tooltip, UnstyledButton,
12
12
  } from "@mantine/core";
13
13
  import { useMediaQuery } from "@mantine/hooks";
14
- import type { MediaItem, Slide, StoryDoc } from "../types";
14
+ import type { MediaItem, Slide, SlideMedia, StoryDoc } from "../types";
15
+ import { optimizeImage } from "../image";
15
16
  import { validate, type StoryIssue } from "../validate";
16
17
  import { StoryPlayer } from "../player/StoryPlayer";
17
18
  import type { PlayerStrings } from "../player/strings";
18
19
  import { suggestEdtf, type DateTextParser } from "./dates";
19
20
  import { DateField } from "./DateField";
20
21
  import {
21
- addSlide, deleteSlide, duplicateSlide, moveSlide, record, redo, startHistory, undo, updateSlide, updateStory,
22
- type History,
22
+ addImage, addSlide, deleteSlide, findMedia, duplicateSlide, moveSlide, record, redo, setSlidePhotos, startHistory, undo, updateMedia,
23
+ updateSlide, updateStory, type History, type NewImage,
23
24
  } from "./model";
25
+ import { PhotosField } from "./PhotosField";
24
26
  import { SlideList } from "./SlideList";
25
27
  import { StoryTextField } from "./StoryTextField";
26
28
  import { EDITOR_ENGLISH, type EditorStrings } from "./strings";
@@ -30,15 +32,19 @@ const BAR_HEIGHT = 56;
30
32
  const STORY = -1;
31
33
 
32
34
  export interface StoryEditorProps {
33
- /** The story to start from. Changing it later has no effect: remount
34
- * (a new `key`) to edit another. */
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. */
35
38
  initialStory: StoryDoc;
36
- /** The story's files, for checking that every media file is there. */
37
- files?: { has(path: string): boolean };
38
- /** As for StoryPlayer. */
39
- resolveMedia: (key: string, item: MediaItem) => Promise<string | null>;
40
- /** Called with the document after every change, undo and redo included. */
41
- onChange?: (doc: StoryDoc) => void;
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>;
42
48
  /** The host's own buttons (Open, Save, ...), at the right of the top bar. */
43
49
  toolbar?: ReactNode;
44
50
  /** Shown first in the top bar, e.g. ViewerBrand. */
@@ -56,7 +62,15 @@ function issueSlide(issue: StoryIssue): number {
56
62
  return match ? Number(match[1]) : STORY;
57
63
  }
58
64
 
59
- function Thumb({ doc, slide, resolveMedia }: { doc: StoryDoc; slide: Slide; resolveMedia: StoryEditorProps["resolveMedia"] }) {
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 }) {
60
74
  const ref = slide.media?.[0]?.ref;
61
75
  const item = ref && Object.prototype.hasOwnProperty.call(doc.media, ref) ? doc.media[ref] : undefined;
62
76
  const [url, setUrl] = useState<string | null>(null);
@@ -77,8 +91,8 @@ function Thumb({ doc, slide, resolveMedia }: { doc: StoryDoc; slide: Slide; reso
77
91
  }
78
92
 
79
93
  export function StoryEditor({
80
- initialStory, files, resolveMedia, onChange, toolbar, brand, dark = false, strings: stringOverrides, playerStrings,
81
- parseDateText = suggestEdtf,
94
+ initialStory, initialFiles, onChange, prepareImage: prepare = prepareImage, toolbar, brand, dark = false,
95
+ strings: stringOverrides, playerStrings, parseDateText = suggestEdtf,
82
96
  }: StoryEditorProps) {
83
97
  const strings = useMemo(() => ({ ...EDITOR_ENGLISH, ...stringOverrides }), [stringOverrides]);
84
98
  const [history, setHistory] = useState<History>(() => startHistory(initialStory));
@@ -87,12 +101,39 @@ export function StoryEditor({
87
101
  const [problemsOpen, setProblemsOpen] = useState(false);
88
102
  const wide = useMediaQuery("(min-width: 1000px)", true);
89
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]);
90
131
 
91
132
  // Every change goes through here (and so into history and to the host).
92
133
  const apply = useCallback((next: StoryDoc, key?: string) => {
93
134
  setHistory((h) => record(h, next, key));
94
135
  }, []);
95
- useEffect(() => { onChange?.(doc); }, [doc, onChange]);
136
+ useEffect(() => { onChange?.(doc, files); }, [doc, files, onChange]);
96
137
 
97
138
  const index = selected === STORY ? STORY : Math.min(selected, doc.slides.length - 1);
98
139
  const slide = index === STORY ? undefined : doc.slides[index];
@@ -102,6 +143,61 @@ export function StoryEditor({
102
143
 
103
144
  const issues = useMemo(() => validate(doc, files).errors, [doc, files]);
104
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
+
105
201
  // Undo/redo from the keyboard, except where a text field has its own.
106
202
  useEffect(() => {
107
203
  const onKey = (e: KeyboardEvent) => {
@@ -192,19 +288,13 @@ export function StoryEditor({
192
288
  </Text>
193
289
  </Stack>
194
290
  )}
195
- {slide.media && slide.media.length > 0 && (
196
- <Stack gap={4}>
197
- <Text size="sm" fw={500}>{strings.photos}</Text>
198
- <Group gap="sm">
199
- {slide.media.map((m, i) => (
200
- <Group key={i} gap="xs" wrap="nowrap">
201
- <Thumb doc={doc} slide={{ id: "", media: [m] }} resolveMedia={resolveMedia} />
202
- <Text size="xs" c="dimmed" maw={200} lineClamp={2}>{m.caption ?? ""}</Text>
203
- </Group>
204
- ))}
205
- </Group>
206
- </Stack>
207
- )}
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>}
208
298
  </Stack>
209
299
  );
210
300
 
@@ -1,10 +1,12 @@
1
- export { StoryEditor, type StoryEditorProps } from "./StoryEditor";
1
+ export { prepareImage, StoryEditor, type StoryEditorProps } from "./StoryEditor";
2
+ export { PhotosField, type PhotosFieldProps } from "./PhotosField";
2
3
  export { StoryTextField, type StoryTextFieldProps } from "./StoryTextField";
3
4
  export { SlideList, type SlideListProps } from "./SlideList";
4
5
  export { DateField, type DateFieldProps } from "./DateField";
5
6
  export { suggestEdtf, type DateTextParser } from "./dates";
6
7
  export { EDITOR_ENGLISH, type EditorStrings } from "./strings";
7
8
  export {
8
- addSlide, blankStory, COALESCE_MS, deleteSlide, duplicateSlide, moveSlide, nextSlideId, pruneMedia, record, redo,
9
- slideDate, startHistory, storyFileName, undo, updateSlide, updateStory, usedMedia, type History,
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,
10
12
  } from "./model";
@@ -4,7 +4,7 @@
4
4
  // know (a newer minor version's, an extension's) ride along untouched:
5
5
  // opening and saving a story must never lose anything.
6
6
  import { FORMAT_ID, FORMAT_VERSION } from "../version";
7
- import type { Slide, StoryDate, StoryDoc } from "../types";
7
+ import type { MediaItem, Slide, SlideMedia, StoryDate, StoryDoc } from "../types";
8
8
 
9
9
  /** A story with one empty slide: the smallest valid one. */
10
10
  export function blankStory(title: string, language?: string): StoryDoc {
@@ -100,6 +100,68 @@ export function slideDate(previous: StoryDate | undefined, text: string, edtf: s
100
100
  return next;
101
101
  }
102
102
 
103
+ /** A prepared image to add to a story (see optimizeImage). */
104
+ export interface NewImage {
105
+ data: Uint8Array;
106
+ mime: string;
107
+ /** File extension, without the dot. */
108
+ ext: string;
109
+ width?: number;
110
+ height?: number;
111
+ }
112
+
113
+ function sameBytes(a: Uint8Array | undefined, b: Uint8Array): boolean {
114
+ if (!a || a.length !== b.length) return false;
115
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
116
+ return true;
117
+ }
118
+
119
+ /** The media key whose file is exactly these bytes, if any: a photo
120
+ * that's already in the story, chosen again. */
121
+ export function findMedia(doc: StoryDoc, files: Map<string, Uint8Array>, data: Uint8Array): string | undefined {
122
+ for (const [key, item] of Object.entries(doc.media)) {
123
+ if (sameBytes(files.get(item.src), data)) return key;
124
+ }
125
+ return undefined;
126
+ }
127
+
128
+ /** Adds an image to the story's media (not to any slide), or finds the
129
+ * identical one already there, so the same photo is stored once. Returns
130
+ * its media key. */
131
+ export function addImage(
132
+ doc: StoryDoc, files: Map<string, Uint8Array>, image: NewImage, stem = "photo",
133
+ ): { doc: StoryDoc; files: Map<string, Uint8Array>; key: string } {
134
+ const existing = findMedia(doc, files, image.data);
135
+ if (existing) return { doc, files, key: existing };
136
+ let n = 1;
137
+ const taken = (key: string) => Object.prototype.hasOwnProperty.call(doc.media, key) || files.has(`media/${key}.${image.ext}`);
138
+ while (taken(`${stem}-${n}`)) n += 1;
139
+ const key = `${stem}-${n}`;
140
+ const src = `media/${key}.${image.ext}`;
141
+ const item: MediaItem = {
142
+ src, mime: image.mime,
143
+ ...(image.width ? { width: image.width } : {}),
144
+ ...(image.height ? { height: image.height } : {}),
145
+ };
146
+ return {
147
+ doc: { ...doc, media: { ...doc.media, [key]: item } },
148
+ files: new Map(files).set(src, image.data),
149
+ key,
150
+ };
151
+ }
152
+
153
+ /** A slide's photo list; an empty one removes the field. */
154
+ export function setSlidePhotos(doc: StoryDoc, index: number, media: SlideMedia[]): StoryDoc {
155
+ return updateSlide(doc, index, { media: media.length ? media : undefined });
156
+ }
157
+
158
+ /** Changes a media item itself (its description, shared by every slide
159
+ * that shows it). */
160
+ export function updateMedia(doc: StoryDoc, key: string, patch: Partial<MediaItem>): StoryDoc {
161
+ if (!Object.prototype.hasOwnProperty.call(doc.media, key)) return doc;
162
+ return { ...doc, media: { ...doc.media, [key]: patched(doc.media[key], patch) } };
163
+ }
164
+
103
165
  /** Every media key a slide or overlay refers to. */
104
166
  export function usedMedia(doc: StoryDoc): Set<string> {
105
167
  const used = new Set<string>();
@@ -33,6 +33,18 @@ export interface EditorStrings {
33
33
  noPreciseDate: string;
34
34
  place: string;
35
35
  photos: string;
36
+ photosHint: string;
37
+ addPhoto: string;
38
+ replacePhoto: string;
39
+ movePhotoUp: string;
40
+ movePhotoDown: string;
41
+ removePhoto: string;
42
+ caption: string;
43
+ credit: string;
44
+ creditHint: string;
45
+ description: string;
46
+ descriptionHint: string;
47
+ photoFailed: (name: string) => string;
36
48
  undo: string;
37
49
  redo: string;
38
50
  present: string;
@@ -73,6 +85,18 @@ export const EDITOR_ENGLISH: EditorStrings = {
73
85
  noPreciseDate: "None: this slide stays off the timeline.",
74
86
  place: "Place",
75
87
  photos: "Photos",
88
+ photosHint: "Add, drop or paste a photo. It's resized for the screen, and hidden details like camera and location are left out.",
89
+ addPhoto: "Add photo",
90
+ replacePhoto: "Replace",
91
+ movePhotoUp: "Move photo up",
92
+ movePhotoDown: "Move photo down",
93
+ removePhoto: "Remove",
94
+ caption: "Caption",
95
+ credit: "Credit",
96
+ creditHint: "Who took it, or where it's from",
97
+ description: "Description",
98
+ descriptionHint: "What the photo shows, for anyone who can't see it.",
99
+ photoFailed: (name) => `Couldn't use ${name}: this browser can't read it as an image.`,
76
100
  undo: "Undo",
77
101
  redo: "Redo",
78
102
  present: "Present",