@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.
- package/README.md +15 -13
- package/dist/editor.js +490 -119
- package/dist/editor.js.map +4 -4
- package/dist/embed.js +10 -10
- package/dist/player.js +63 -8
- package/dist/player.js.map +2 -2
- package/dist/types/editor/PhotosField.d.ts +16 -0
- package/dist/types/editor/StoryEditor.d.ts +17 -12
- package/dist/types/editor/index.d.ts +3 -2
- package/dist/types/editor/model.d.ts +26 -1
- package/dist/types/editor/strings.d.ts +12 -0
- package/dist/types/player/strings.d.ts +2 -0
- package/package.json +1 -1
- package/src/editor/PhotosField.tsx +134 -0
- package/src/editor/StoryEditor.tsx +119 -29
- package/src/editor/index.ts +5 -3
- package/src/editor/model.ts +63 -1
- package/src/editor/strings.ts +24 -0
- package/src/player/StoryPlayer.tsx +57 -5
- package/src/player/strings.ts +7 -0
|
@@ -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 {
|
|
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
|
|
8
|
-
* (a new `key`) to edit
|
|
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
|
-
|
|
11
|
-
files
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
@@ -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,
|
|
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
|
|
34
|
-
* (a new `key`) to edit
|
|
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
|
-
|
|
37
|
-
files
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
|
package/src/editor/index.ts
CHANGED
|
@@ -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,
|
|
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";
|
package/src/editor/model.ts
CHANGED
|
@@ -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>();
|
package/src/editor/strings.ts
CHANGED
|
@@ -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",
|