@gramps-connect/story 0.2.0 → 0.4.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 +25 -14
- package/dist/editor.js +851 -181
- 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/PlaceField.d.ts +21 -0
- package/dist/types/editor/PlaceMap.d.ts +12 -0
- package/dist/types/editor/StoryEditor.d.ts +21 -12
- package/dist/types/editor/index.d.ts +5 -2
- package/dist/types/editor/model.d.ts +42 -1
- package/dist/types/editor/placeSearch.d.ts +22 -0
- package/dist/types/editor/strings.d.ts +27 -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/PlaceField.tsx +160 -0
- package/src/editor/PlaceMap.tsx +88 -0
- package/src/editor/StoryEditor.tsx +143 -40
- package/src/editor/index.ts +8 -3
- package/src/editor/model.ts +100 -1
- package/src/editor/placeSearch.ts +61 -0
- package/src/editor/strings.ts +54 -0
- package/src/player/StoryPlayer.tsx +57 -5
- package/src/player/strings.ts +7 -0
|
@@ -6,21 +6,26 @@
|
|
|
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";
|
|
17
|
+
import { storyPlaces } from "../player/geo";
|
|
16
18
|
import { StoryPlayer } from "../player/StoryPlayer";
|
|
17
19
|
import type { PlayerStrings } from "../player/strings";
|
|
18
20
|
import { suggestEdtf, type DateTextParser } from "./dates";
|
|
19
21
|
import { DateField } from "./DateField";
|
|
20
22
|
import {
|
|
21
|
-
addSlide, deleteSlide, duplicateSlide, moveSlide, record, redo,
|
|
22
|
-
type History,
|
|
23
|
+
addImage, addSlide, deleteSlide, findMedia, duplicateSlide, moveSlide, record, redo, setSlidePhotos, startHistory, storyPlaceChoices,
|
|
24
|
+
undo, updateMedia, updateSlide, updateStory, type History, type NewImage,
|
|
23
25
|
} from "./model";
|
|
26
|
+
import { PhotosField } from "./PhotosField";
|
|
27
|
+
import { PlaceField, type PlaceUpdate } from "./PlaceField";
|
|
28
|
+
import { WIKIDATA_PLACE_SEARCH, type PlaceSearch } from "./placeSearch";
|
|
24
29
|
import { SlideList } from "./SlideList";
|
|
25
30
|
import { StoryTextField } from "./StoryTextField";
|
|
26
31
|
import { EDITOR_ENGLISH, type EditorStrings } from "./strings";
|
|
@@ -30,15 +35,19 @@ const BAR_HEIGHT = 56;
|
|
|
30
35
|
const STORY = -1;
|
|
31
36
|
|
|
32
37
|
export interface StoryEditorProps {
|
|
33
|
-
/** The story to start from
|
|
34
|
-
* (a new `key`) to edit
|
|
38
|
+
/** The story to start from, and its files (paths as in media `src`).
|
|
39
|
+
* Changing these later has no effect: remount (a new `key`) to edit
|
|
40
|
+
* another story. */
|
|
35
41
|
initialStory: StoryDoc;
|
|
36
|
-
|
|
37
|
-
files
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
+
initialFiles?: Map<string, Uint8Array>;
|
|
43
|
+
/** Called with the document and its files after every change, undo and
|
|
44
|
+
* redo included. The files only grow while editing (an undone photo's
|
|
45
|
+
* file stays); pruneMedia leaves out what nothing uses when saving. */
|
|
46
|
+
onChange?: (doc: StoryDoc, files: Map<string, Uint8Array>) => void;
|
|
47
|
+
/** Turns a chosen image file into what's stored. By default
|
|
48
|
+
* optimizeImage: 1600px WebP (JPEG where the browser can't), with the
|
|
49
|
+
* original's metadata left behind. */
|
|
50
|
+
prepareImage?: (file: Blob) => Promise<NewImage>;
|
|
42
51
|
/** The host's own buttons (Open, Save, ...), at the right of the top bar. */
|
|
43
52
|
toolbar?: ReactNode;
|
|
44
53
|
/** Shown first in the top bar, e.g. ViewerBrand. */
|
|
@@ -48,6 +57,9 @@ export interface StoryEditorProps {
|
|
|
48
57
|
playerStrings?: Partial<PlayerStrings>;
|
|
49
58
|
/** Date text -> EDTF. By default gramps-date, in the story's language. */
|
|
50
59
|
parseDateText?: DateTextParser;
|
|
60
|
+
/** Finding a place by name. By default Wikidata, and only when the
|
|
61
|
+
* person asks; null leaves the search out. */
|
|
62
|
+
placeSearch?: PlaceSearch | null;
|
|
51
63
|
}
|
|
52
64
|
|
|
53
65
|
/** The slide an issue's JSON Pointer is about, or STORY. */
|
|
@@ -56,7 +68,15 @@ function issueSlide(issue: StoryIssue): number {
|
|
|
56
68
|
return match ? Number(match[1]) : STORY;
|
|
57
69
|
}
|
|
58
70
|
|
|
59
|
-
|
|
71
|
+
type Resolve = (key: string, item: MediaItem) => Promise<string | null>;
|
|
72
|
+
|
|
73
|
+
/** The default prepareImage. */
|
|
74
|
+
export async function prepareImage(file: Blob): Promise<NewImage> {
|
|
75
|
+
const image = await optimizeImage(file);
|
|
76
|
+
return { data: new Uint8Array(await image.blob.arrayBuffer()), mime: image.mime, ext: image.ext, width: image.width, height: image.height };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function Thumb({ doc, slide, resolveMedia }: { doc: StoryDoc; slide: Slide; resolveMedia: Resolve }) {
|
|
60
80
|
const ref = slide.media?.[0]?.ref;
|
|
61
81
|
const item = ref && Object.prototype.hasOwnProperty.call(doc.media, ref) ? doc.media[ref] : undefined;
|
|
62
82
|
const [url, setUrl] = useState<string | null>(null);
|
|
@@ -77,8 +97,8 @@ function Thumb({ doc, slide, resolveMedia }: { doc: StoryDoc; slide: Slide; reso
|
|
|
77
97
|
}
|
|
78
98
|
|
|
79
99
|
export function StoryEditor({
|
|
80
|
-
initialStory,
|
|
81
|
-
parseDateText = suggestEdtf,
|
|
100
|
+
initialStory, initialFiles, onChange, prepareImage: prepare = prepareImage, toolbar, brand, dark = false,
|
|
101
|
+
strings: stringOverrides, playerStrings, parseDateText = suggestEdtf, placeSearch = WIKIDATA_PLACE_SEARCH,
|
|
82
102
|
}: StoryEditorProps) {
|
|
83
103
|
const strings = useMemo(() => ({ ...EDITOR_ENGLISH, ...stringOverrides }), [stringOverrides]);
|
|
84
104
|
const [history, setHistory] = useState<History>(() => startHistory(initialStory));
|
|
@@ -87,12 +107,39 @@ export function StoryEditor({
|
|
|
87
107
|
const [problemsOpen, setProblemsOpen] = useState(false);
|
|
88
108
|
const wide = useMediaQuery("(min-width: 1000px)", true);
|
|
89
109
|
const doc = history.present;
|
|
110
|
+
const [files, setFiles] = useState(() => new Map(initialFiles ?? []));
|
|
111
|
+
const [busy, setBusy] = useState(false);
|
|
112
|
+
const [photoError, setPhotoError] = useState<string | null>(null);
|
|
113
|
+
// The latest of each, for work that finishes after a render (photos).
|
|
114
|
+
const docRef = useRef(doc);
|
|
115
|
+
docRef.current = doc;
|
|
116
|
+
const filesRef = useRef(files);
|
|
117
|
+
filesRef.current = files;
|
|
118
|
+
|
|
119
|
+
// A blob: URL per stored file, made once and released with the editor.
|
|
120
|
+
const urls = useRef(new Map<string, string>());
|
|
121
|
+
useEffect(() => () => {
|
|
122
|
+
for (const url of urls.current.values()) URL.revokeObjectURL(url);
|
|
123
|
+
}, []);
|
|
124
|
+
const resolveMedia = useCallback<Resolve>(async (_key, item) => {
|
|
125
|
+
const cached = urls.current.get(item.src);
|
|
126
|
+
if (cached) return cached;
|
|
127
|
+
const data = filesRef.current.get(item.src);
|
|
128
|
+
if (!data) return null;
|
|
129
|
+
const url = URL.createObjectURL(new Blob([data as Uint8Array<ArrayBuffer>], { type: item.mime }));
|
|
130
|
+
urls.current.set(item.src, url);
|
|
131
|
+
return url;
|
|
132
|
+
}, []);
|
|
133
|
+
const urlFor = useCallback((key: string) => {
|
|
134
|
+
const item = Object.prototype.hasOwnProperty.call(docRef.current.media, key) ? docRef.current.media[key] : undefined;
|
|
135
|
+
return item ? resolveMedia(key, item) : Promise.resolve(null);
|
|
136
|
+
}, [resolveMedia]);
|
|
90
137
|
|
|
91
138
|
// Every change goes through here (and so into history and to the host).
|
|
92
139
|
const apply = useCallback((next: StoryDoc, key?: string) => {
|
|
93
140
|
setHistory((h) => record(h, next, key));
|
|
94
141
|
}, []);
|
|
95
|
-
useEffect(() => { onChange?.(doc); }, [doc, onChange]);
|
|
142
|
+
useEffect(() => { onChange?.(doc, files); }, [doc, files, onChange]);
|
|
96
143
|
|
|
97
144
|
const index = selected === STORY ? STORY : Math.min(selected, doc.slides.length - 1);
|
|
98
145
|
const slide = index === STORY ? undefined : doc.slides[index];
|
|
@@ -100,8 +147,77 @@ export function StoryEditor({
|
|
|
100
147
|
if (index !== STORY) apply(updateSlide(history.present, index, patch), `${slide!.id}:${key}`);
|
|
101
148
|
};
|
|
102
149
|
|
|
150
|
+
// A place change applies to the slide as it is by then: a name typed and
|
|
151
|
+
// a place picked in the same moment both land.
|
|
152
|
+
const setPlace = (slideId: string, update: PlaceUpdate, key: string) => {
|
|
153
|
+
setHistory((h) => {
|
|
154
|
+
const at = h.present.slides.findIndex((s) => s.id === slideId);
|
|
155
|
+
if (at < 0) return h;
|
|
156
|
+
const before = h.present.slides[at].place;
|
|
157
|
+
const after = update(before);
|
|
158
|
+
return after === before ? h : record(h, updateSlide(h.present, at, { place: after }), `${slideId}:${key}`);
|
|
159
|
+
});
|
|
160
|
+
};
|
|
161
|
+
const placeChoices = useMemo(() => storyPlaceChoices(doc, index), [doc, index]);
|
|
162
|
+
const around = useMemo(() => storyPlaces(doc.slides.map((s) => s.place))?.bounds ?? null, [doc.slides]);
|
|
163
|
+
|
|
103
164
|
const issues = useMemo(() => validate(doc, files).errors, [doc, files]);
|
|
104
165
|
|
|
166
|
+
/** Prepares image files and puts them on a slide: added at the end, or
|
|
167
|
+
* in place of photo `replace` (keeping its caption and credit). */
|
|
168
|
+
const addPhotos = useCallback(async (chosen: File[], slideId: string, replace?: number) => {
|
|
169
|
+
setBusy(true);
|
|
170
|
+
setPhotoError(null);
|
|
171
|
+
// A file already in the story (chosen again) is used as it is;
|
|
172
|
+
// anything else is prepared first.
|
|
173
|
+
const chosenImages: (NewImage | string)[] = [];
|
|
174
|
+
for (const file of chosen) {
|
|
175
|
+
try {
|
|
176
|
+
const known = findMedia(docRef.current, filesRef.current, new Uint8Array(await file.arrayBuffer()));
|
|
177
|
+
chosenImages.push(known ?? await prepare(file));
|
|
178
|
+
} catch {
|
|
179
|
+
setPhotoError(strings.photoFailed(file.name || "the image"));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
setBusy(false);
|
|
183
|
+
if (!chosenImages.length) return;
|
|
184
|
+
let next = docRef.current;
|
|
185
|
+
let nextFiles = filesRef.current;
|
|
186
|
+
const keys: string[] = [];
|
|
187
|
+
for (const image of chosenImages) {
|
|
188
|
+
if (typeof image === "string") {
|
|
189
|
+
keys.push(image);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const added = addImage(next, nextFiles, image);
|
|
193
|
+
next = added.doc;
|
|
194
|
+
nextFiles = added.files;
|
|
195
|
+
keys.push(added.key);
|
|
196
|
+
}
|
|
197
|
+
const at = next.slides.findIndex((s) => s.id === slideId);
|
|
198
|
+
if (at < 0) return;
|
|
199
|
+
const photos: SlideMedia[] = [...(next.slides[at].media ?? [])];
|
|
200
|
+
if (replace !== undefined && photos[replace]) photos[replace] = { ...photos[replace], ref: keys[0] };
|
|
201
|
+
else photos.push(...keys.map((ref) => ({ ref })));
|
|
202
|
+
setFiles(nextFiles);
|
|
203
|
+
apply(setSlidePhotos(next, at, photos));
|
|
204
|
+
}, [apply, prepare, strings]);
|
|
205
|
+
|
|
206
|
+
// Pasting an image (outside a text field) adds it to the slide.
|
|
207
|
+
const slideId = slide?.id;
|
|
208
|
+
useEffect(() => {
|
|
209
|
+
if (!slideId) return;
|
|
210
|
+
const onPaste = (e: ClipboardEvent) => {
|
|
211
|
+
if ((e.target as HTMLElement | null)?.closest("input, textarea, [contenteditable=true]")) return;
|
|
212
|
+
const images = [...(e.clipboardData?.files ?? [])].filter((f) => f.type.startsWith("image/"));
|
|
213
|
+
if (!images.length) return;
|
|
214
|
+
e.preventDefault();
|
|
215
|
+
void addPhotos(images, slideId);
|
|
216
|
+
};
|
|
217
|
+
window.addEventListener("paste", onPaste);
|
|
218
|
+
return () => window.removeEventListener("paste", onPaste);
|
|
219
|
+
}, [slideId, addPhotos]);
|
|
220
|
+
|
|
105
221
|
// Undo/redo from the keyboard, except where a text field has its own.
|
|
106
222
|
useEffect(() => {
|
|
107
223
|
const onKey = (e: KeyboardEvent) => {
|
|
@@ -181,30 +297,17 @@ export function StoryEditor({
|
|
|
181
297
|
label={strings.text} value={slide.text ?? ""} boldLabel={strings.bold} italicLabel={strings.italic} minRows={5}
|
|
182
298
|
onChange={(text) => setSlide({ text: text || undefined }, "text")}
|
|
183
299
|
/>
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
)}
|
|
300
|
+
<PlaceField
|
|
301
|
+
place={slide.place} choices={placeChoices} around={around} language={doc.language} search={placeSearch} dark={dark} strings={strings}
|
|
302
|
+
onChange={(update, key) => setPlace(slide.id, update, key)}
|
|
303
|
+
/>
|
|
304
|
+
<PhotosField
|
|
305
|
+
photos={slide.media ?? []} media={doc.media} urlFor={urlFor} strings={strings} busy={busy}
|
|
306
|
+
onChange={(photos, key) => apply(setSlidePhotos(doc, index, photos), `${slide.id}:${key}`)}
|
|
307
|
+
onFiles={(chosen, replace) => void addPhotos(chosen, slide.id, replace)}
|
|
308
|
+
onDescription={(key, alt) => apply(updateMedia(doc, key, { alt: alt || undefined }), `media:${key}:alt`)}
|
|
309
|
+
/>
|
|
310
|
+
{photoError && <Text size="xs" c="red" role="alert">{photoError}</Text>}
|
|
208
311
|
</Stack>
|
|
209
312
|
);
|
|
210
313
|
|
package/src/editor/index.ts
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
|
-
export { StoryEditor, type StoryEditorProps } from "./StoryEditor";
|
|
1
|
+
export { prepareImage, StoryEditor, type StoryEditorProps } from "./StoryEditor";
|
|
2
|
+
export { PhotosField, type PhotosFieldProps } from "./PhotosField";
|
|
3
|
+
export { PlaceField, type PlaceFieldProps, type PlaceUpdate } from "./PlaceField";
|
|
4
|
+
export { searchWikidataPlaces, wikidataPoint, WIKIDATA_PLACE_SEARCH, type PlaceSearch, type PlaceSearchResult } from "./placeSearch";
|
|
2
5
|
export { StoryTextField, type StoryTextFieldProps } from "./StoryTextField";
|
|
3
6
|
export { SlideList, type SlideListProps } from "./SlideList";
|
|
4
7
|
export { DateField, type DateFieldProps } from "./DateField";
|
|
5
8
|
export { suggestEdtf, type DateTextParser } from "./dates";
|
|
6
9
|
export { EDITOR_ENGLISH, type EditorStrings } from "./strings";
|
|
7
10
|
export {
|
|
8
|
-
addSlide, blankStory, COALESCE_MS, deleteSlide, duplicateSlide,
|
|
9
|
-
|
|
11
|
+
addImage, addSlide, blankStory, COALESCE_MS, deleteSlide, duplicateSlide, findMedia, formatLatLon, moveSlide, nextSlideId, parseLatLon,
|
|
12
|
+
patchPlace, pruneMedia, record, redo, roundDegrees, setSlidePhotos, slideDate, startHistory, storyFileName, storyPlaceChoices, undo,
|
|
13
|
+
updateMedia, updateSlide, updateStory, usedMedia,
|
|
14
|
+
type History, type NewImage,
|
|
10
15
|
} 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, Place, 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,105 @@ export function slideDate(previous: StoryDate | undefined, text: string, edtf: s
|
|
|
100
100
|
return next;
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
/** A place with some fields set or (undefined) removed; one left with
|
|
104
|
+
* nothing in it is no place at all. Its shape and overlays, and any field
|
|
105
|
+
* this version doesn't know, stay as they were. */
|
|
106
|
+
export function patchPlace(place: Place | undefined, patch: Partial<Place>): Place | undefined {
|
|
107
|
+
if (place && Object.entries(patch).every(([key, value]) => place[key as keyof Place] === value)) return place;
|
|
108
|
+
const next = patched(place ?? {}, patch);
|
|
109
|
+
return Object.keys(next).length ? next : undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Degrees to 5 decimals: about a metre, and no float noise. */
|
|
113
|
+
export const roundDegrees = (value: number) => Math.round(value * 1e5) / 1e5;
|
|
114
|
+
|
|
115
|
+
/** "47.3769, 8.5417" (or with a space or semicolon between) -> a point;
|
|
116
|
+
* null when that's not two numbers in range. */
|
|
117
|
+
export function parseLatLon(text: string): { lat: number; lon: number } | null {
|
|
118
|
+
const parts = text.trim().split(/\s*[,;\s]\s*/);
|
|
119
|
+
if (parts.length !== 2 || parts.some((p) => !/^[-+]?(\d+\.?\d*|\.\d+)$/.test(p))) return null;
|
|
120
|
+
const [lat, lon] = parts.map(Number);
|
|
121
|
+
if (Math.abs(lat) > 90 || Math.abs(lon) > 180) return null;
|
|
122
|
+
return { lat, lon };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function formatLatLon(lat: number, lon: number): string {
|
|
126
|
+
return `${roundDegrees(lat)}, ${roundDegrees(lon)}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The places of the story's other slides, one per name (the first slide
|
|
130
|
+
* to use it), for giving another slide the very same place. */
|
|
131
|
+
export function storyPlaceChoices(doc: StoryDoc, exceptIndex?: number): Place[] {
|
|
132
|
+
const byName = new Map<string, Place>();
|
|
133
|
+
doc.slides.forEach((slide, i) => {
|
|
134
|
+
const name = slide.place?.name?.trim();
|
|
135
|
+
if (i !== exceptIndex && name && !byName.has(name)) byName.set(name, slide.place!);
|
|
136
|
+
});
|
|
137
|
+
return [...byName.values()];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** A prepared image to add to a story (see optimizeImage). */
|
|
141
|
+
export interface NewImage {
|
|
142
|
+
data: Uint8Array;
|
|
143
|
+
mime: string;
|
|
144
|
+
/** File extension, without the dot. */
|
|
145
|
+
ext: string;
|
|
146
|
+
width?: number;
|
|
147
|
+
height?: number;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function sameBytes(a: Uint8Array | undefined, b: Uint8Array): boolean {
|
|
151
|
+
if (!a || a.length !== b.length) return false;
|
|
152
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The media key whose file is exactly these bytes, if any: a photo
|
|
157
|
+
* that's already in the story, chosen again. */
|
|
158
|
+
export function findMedia(doc: StoryDoc, files: Map<string, Uint8Array>, data: Uint8Array): string | undefined {
|
|
159
|
+
for (const [key, item] of Object.entries(doc.media)) {
|
|
160
|
+
if (sameBytes(files.get(item.src), data)) return key;
|
|
161
|
+
}
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Adds an image to the story's media (not to any slide), or finds the
|
|
166
|
+
* identical one already there, so the same photo is stored once. Returns
|
|
167
|
+
* its media key. */
|
|
168
|
+
export function addImage(
|
|
169
|
+
doc: StoryDoc, files: Map<string, Uint8Array>, image: NewImage, stem = "photo",
|
|
170
|
+
): { doc: StoryDoc; files: Map<string, Uint8Array>; key: string } {
|
|
171
|
+
const existing = findMedia(doc, files, image.data);
|
|
172
|
+
if (existing) return { doc, files, key: existing };
|
|
173
|
+
let n = 1;
|
|
174
|
+
const taken = (key: string) => Object.prototype.hasOwnProperty.call(doc.media, key) || files.has(`media/${key}.${image.ext}`);
|
|
175
|
+
while (taken(`${stem}-${n}`)) n += 1;
|
|
176
|
+
const key = `${stem}-${n}`;
|
|
177
|
+
const src = `media/${key}.${image.ext}`;
|
|
178
|
+
const item: MediaItem = {
|
|
179
|
+
src, mime: image.mime,
|
|
180
|
+
...(image.width ? { width: image.width } : {}),
|
|
181
|
+
...(image.height ? { height: image.height } : {}),
|
|
182
|
+
};
|
|
183
|
+
return {
|
|
184
|
+
doc: { ...doc, media: { ...doc.media, [key]: item } },
|
|
185
|
+
files: new Map(files).set(src, image.data),
|
|
186
|
+
key,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** A slide's photo list; an empty one removes the field. */
|
|
191
|
+
export function setSlidePhotos(doc: StoryDoc, index: number, media: SlideMedia[]): StoryDoc {
|
|
192
|
+
return updateSlide(doc, index, { media: media.length ? media : undefined });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Changes a media item itself (its description, shared by every slide
|
|
196
|
+
* that shows it). */
|
|
197
|
+
export function updateMedia(doc: StoryDoc, key: string, patch: Partial<MediaItem>): StoryDoc {
|
|
198
|
+
if (!Object.prototype.hasOwnProperty.call(doc.media, key)) return doc;
|
|
199
|
+
return { ...doc, media: { ...doc.media, [key]: patched(doc.media[key], patch) } };
|
|
200
|
+
}
|
|
201
|
+
|
|
103
202
|
/** Every media key a slide or overlay refers to. */
|
|
104
203
|
export function usedMedia(doc: StoryDoc): Set<string> {
|
|
105
204
|
const used = new Set<string>();
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Finding a place by name, for the place field's opt-in search: Wikidata's
|
|
2
|
+
// own search, then the chosen item's coordinates (P625). Both endpoints
|
|
3
|
+
// allow any origin and need no key. This is the only thing the editor
|
|
4
|
+
// sends anywhere, and only when asked to: it sends the name typed.
|
|
5
|
+
// (Gramps Connect's place lookup walks the same API further, up the
|
|
6
|
+
// "located in" chain, to build a place hierarchy; a story place is just a
|
|
7
|
+
// name and a point.)
|
|
8
|
+
|
|
9
|
+
const WIKIDATA_API = "https://www.wikidata.org/w/api.php";
|
|
10
|
+
const WIKIDATA_ENTITY_DATA = "https://www.wikidata.org/wiki/Special:EntityData";
|
|
11
|
+
|
|
12
|
+
export interface PlaceSearchResult {
|
|
13
|
+
/** The Wikidata item, e.g. "Q72". */
|
|
14
|
+
id: string;
|
|
15
|
+
label: string;
|
|
16
|
+
/** Usually names the region and country, which tells same-named places
|
|
17
|
+
* apart. */
|
|
18
|
+
description?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface PlaceSearch {
|
|
22
|
+
search: (term: string, language: string | undefined, signal?: AbortSignal) => Promise<PlaceSearchResult[]>;
|
|
23
|
+
/** The result's position, or null when it has none. */
|
|
24
|
+
point: (result: PlaceSearchResult, signal?: AbortSignal) => Promise<{ lat: number; lon: number } | null>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** "fr_CA" or "fr-CA" -> "fr-ca", Wikidata's form. */
|
|
28
|
+
function wikidataLanguage(language: string | undefined): string {
|
|
29
|
+
return (language ?? "").trim().toLowerCase().replace(/_/g, "-") || "en";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function searchWikidataPlaces(term: string, language?: string, signal?: AbortSignal): Promise<PlaceSearchResult[]> {
|
|
33
|
+
const code = wikidataLanguage(language);
|
|
34
|
+
const params = new URLSearchParams({
|
|
35
|
+
action: "wbsearchentities", search: term, language: code, uselang: code, format: "json", limit: "7",
|
|
36
|
+
origin: "*", // what makes an anonymous api.php GET answer cross-origin
|
|
37
|
+
});
|
|
38
|
+
const res = await fetch(`${WIKIDATA_API}?${params}`, { signal });
|
|
39
|
+
if (!res.ok) throw new Error(`Wikidata search failed (${res.status})`);
|
|
40
|
+
const data = (await res.json()) as { search?: { id: string; label?: string; description?: string }[] };
|
|
41
|
+
return (data.search ?? []).map((hit) => ({
|
|
42
|
+
id: hit.id,
|
|
43
|
+
label: hit.label ?? hit.id,
|
|
44
|
+
...(hit.description ? { description: hit.description } : {}),
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function wikidataPoint(result: PlaceSearchResult, signal?: AbortSignal): Promise<{ lat: number; lon: number } | null> {
|
|
49
|
+
const res = await fetch(`${WIKIDATA_ENTITY_DATA}/${encodeURIComponent(result.id)}.json`, { signal });
|
|
50
|
+
if (!res.ok) throw new Error(`Wikidata fetch failed for ${result.id} (${res.status})`);
|
|
51
|
+
const data = (await res.json()) as {
|
|
52
|
+
entities?: Record<string, { claims?: Record<string, { mainsnak?: { datavalue?: { value?: unknown } } }[]> }>;
|
|
53
|
+
};
|
|
54
|
+
// A redirected item comes back under its new id: take whichever is there.
|
|
55
|
+
const entity = data.entities?.[result.id] ?? Object.values(data.entities ?? {})[0];
|
|
56
|
+
const value = entity?.claims?.P625?.[0]?.mainsnak?.datavalue?.value as { latitude?: number; longitude?: number } | undefined;
|
|
57
|
+
if (typeof value?.latitude !== "number" || typeof value.longitude !== "number") return null;
|
|
58
|
+
return { lat: value.latitude, lon: value.longitude };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const WIKIDATA_PLACE_SEARCH: PlaceSearch = { search: searchWikidataPlaces, point: wikidataPoint };
|
package/src/editor/strings.ts
CHANGED
|
@@ -32,7 +32,34 @@ export interface EditorStrings {
|
|
|
32
32
|
matchDateAsWritten: string;
|
|
33
33
|
noPreciseDate: string;
|
|
34
34
|
place: string;
|
|
35
|
+
placeName: string;
|
|
36
|
+
removePlace: string;
|
|
37
|
+
findPlace: string;
|
|
38
|
+
findPlaceHint: string;
|
|
39
|
+
findPlaceNone: string;
|
|
40
|
+
findPlaceFailed: string;
|
|
41
|
+
findPlaceNoPoint: (name: string) => string;
|
|
42
|
+
coordinates: string;
|
|
43
|
+
coordinatesHint: string;
|
|
44
|
+
coordinatesInvalid: string;
|
|
45
|
+
placeMap: string;
|
|
46
|
+
placeMapHint: string;
|
|
47
|
+
placeShape: string;
|
|
48
|
+
placeOverlays: (count: number) => string;
|
|
49
|
+
placeKeeps: (what: string) => string;
|
|
35
50
|
photos: string;
|
|
51
|
+
photosHint: string;
|
|
52
|
+
addPhoto: string;
|
|
53
|
+
replacePhoto: string;
|
|
54
|
+
movePhotoUp: string;
|
|
55
|
+
movePhotoDown: string;
|
|
56
|
+
removePhoto: string;
|
|
57
|
+
caption: string;
|
|
58
|
+
credit: string;
|
|
59
|
+
creditHint: string;
|
|
60
|
+
description: string;
|
|
61
|
+
descriptionHint: string;
|
|
62
|
+
photoFailed: (name: string) => string;
|
|
36
63
|
undo: string;
|
|
37
64
|
redo: string;
|
|
38
65
|
present: string;
|
|
@@ -72,7 +99,34 @@ export const EDITOR_ENGLISH: EditorStrings = {
|
|
|
72
99
|
matchDateAsWritten: "Work it out from the date",
|
|
73
100
|
noPreciseDate: "None: this slide stays off the timeline.",
|
|
74
101
|
place: "Place",
|
|
102
|
+
placeName: "Name",
|
|
103
|
+
removePlace: "Remove place",
|
|
104
|
+
findPlace: "Find on Wikidata",
|
|
105
|
+
findPlaceHint: "Looks the name up on Wikidata, to put the pin there. The name is all that's sent, and only when you ask.",
|
|
106
|
+
findPlaceNone: "Wikidata has no place by that name.",
|
|
107
|
+
findPlaceFailed: "Couldn't reach Wikidata.",
|
|
108
|
+
findPlaceNoPoint: (name) => `Wikidata has no map position for ${name}.`,
|
|
109
|
+
coordinates: "Latitude, longitude",
|
|
110
|
+
coordinatesHint: "e.g. 47.3769, 8.5417",
|
|
111
|
+
coordinatesInvalid: "Two numbers: latitude (-90 to 90), then longitude (-180 to 180).",
|
|
112
|
+
placeMap: "Map: click to put the pin",
|
|
113
|
+
placeMapHint: "Click the map to put the pin there, or drag the pin.",
|
|
114
|
+
placeShape: "a shape",
|
|
115
|
+
placeOverlays: (count) => (count === 1 ? "a map overlay" : `${count} map overlays`),
|
|
116
|
+
placeKeeps: (what) => `This place also has ${what}, kept as they are.`,
|
|
75
117
|
photos: "Photos",
|
|
118
|
+
photosHint: "Add, drop or paste a photo. It's resized for the screen, and hidden details like camera and location are left out.",
|
|
119
|
+
addPhoto: "Add photo",
|
|
120
|
+
replacePhoto: "Replace",
|
|
121
|
+
movePhotoUp: "Move photo up",
|
|
122
|
+
movePhotoDown: "Move photo down",
|
|
123
|
+
removePhoto: "Remove",
|
|
124
|
+
caption: "Caption",
|
|
125
|
+
credit: "Credit",
|
|
126
|
+
creditHint: "Who took it, or where it's from",
|
|
127
|
+
description: "Description",
|
|
128
|
+
descriptionHint: "What the photo shows, for anyone who can't see it.",
|
|
129
|
+
photoFailed: (name) => `Couldn't use ${name}: this browser can't read it as an image.`,
|
|
76
130
|
undo: "Undo",
|
|
77
131
|
redo: "Redo",
|
|
78
132
|
present: "Present",
|
|
@@ -114,15 +114,49 @@ function SlidePhoto({ mediaKey, item, caption, credit, resolveMedia, colors }: {
|
|
|
114
114
|
);
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
|
|
117
|
+
/** A small button showing one of a slide's photos. */
|
|
118
|
+
function PhotoThumb({ mediaKey, item, label, selected, onSelect, resolveMedia, colors }: {
|
|
119
|
+
mediaKey: string;
|
|
120
|
+
item: MediaItem;
|
|
121
|
+
label: string;
|
|
122
|
+
selected: boolean;
|
|
123
|
+
onSelect: () => void;
|
|
124
|
+
resolveMedia: StoryPlayerProps["resolveMedia"];
|
|
125
|
+
colors: PlayerColors;
|
|
126
|
+
}) {
|
|
127
|
+
const [src, setSrc] = useState<string | null>(null);
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
let cancelled = false;
|
|
130
|
+
resolveMedia(mediaKey, item).then((url) => { if (!cancelled) setSrc(url); }, () => {});
|
|
131
|
+
return () => { cancelled = true; };
|
|
132
|
+
}, [mediaKey, item, resolveMedia]);
|
|
133
|
+
return (
|
|
134
|
+
<button
|
|
135
|
+
type="button" aria-label={label} title={label} aria-pressed={selected} onClick={onSelect}
|
|
136
|
+
style={{
|
|
137
|
+
width: 44, height: 44, padding: 0, borderRadius: 4, overflow: "hidden", cursor: "pointer",
|
|
138
|
+
border: `2px solid ${selected ? colors.mark : "transparent"}`, background: colors.border,
|
|
139
|
+
opacity: selected ? 1 : 0.7,
|
|
140
|
+
}}
|
|
141
|
+
>
|
|
142
|
+
{src && <img src={src} alt="" style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }} />}
|
|
143
|
+
</button>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function SlideContent({ slide, story, resolveMedia, colors, strings }: {
|
|
118
148
|
slide: Slide | undefined;
|
|
119
149
|
story: StoryDoc;
|
|
120
150
|
resolveMedia: StoryPlayerProps["resolveMedia"];
|
|
121
151
|
colors: PlayerColors;
|
|
152
|
+
strings: PlayerStrings;
|
|
122
153
|
}) {
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
|
|
154
|
+
// Every photo whose media exists; with several, thumbnails pick which
|
|
155
|
+
// one is shown. Keyed by slide in the caller, so each slide starts on
|
|
156
|
+
// its first photo.
|
|
157
|
+
const photos = (slide?.media ?? []).filter((m) => Object.prototype.hasOwnProperty.call(story.media, m.ref));
|
|
158
|
+
const [shown, setShown] = useState(0);
|
|
159
|
+
const photo = photos[Math.min(shown, photos.length - 1)];
|
|
126
160
|
const eyebrow = [slide?.date?.text, slide?.place?.name].filter(Boolean).join(" · ");
|
|
127
161
|
return (
|
|
128
162
|
<>
|
|
@@ -136,6 +170,22 @@ function SlideContent({ slide, story, resolveMedia, colors }: {
|
|
|
136
170
|
colors={colors}
|
|
137
171
|
/>
|
|
138
172
|
)}
|
|
173
|
+
{photos.length > 1 && (
|
|
174
|
+
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", justifyContent: "center" }}>
|
|
175
|
+
{photos.map((m, i) => (
|
|
176
|
+
<PhotoThumb
|
|
177
|
+
key={`${i}:${m.ref}`}
|
|
178
|
+
mediaKey={m.ref}
|
|
179
|
+
item={story.media[m.ref]}
|
|
180
|
+
label={strings.photoOf(i + 1, photos.length)}
|
|
181
|
+
selected={photo === m}
|
|
182
|
+
onSelect={() => setShown(i)}
|
|
183
|
+
resolveMedia={resolveMedia}
|
|
184
|
+
colors={colors}
|
|
185
|
+
/>
|
|
186
|
+
))}
|
|
187
|
+
</div>
|
|
188
|
+
)}
|
|
139
189
|
<div style={{ display: "flex", flexDirection: "column", gap: 4, alignItems: "center", maxWidth: 640, textAlign: "center" }}>
|
|
140
190
|
{eyebrow && <div dir="auto" style={{ fontSize: 14, color: colors.muted }}>{eyebrow}</div>}
|
|
141
191
|
{slide?.title && <div dir="auto" style={{ fontSize: 20, fontWeight: 700, color: colors.text }}>{slide.title}</div>}
|
|
@@ -326,7 +376,9 @@ export function StoryPlayer({
|
|
|
326
376
|
return item ? resolveMedia(key, item) : Promise.resolve(null);
|
|
327
377
|
}, [story, resolveMedia]);
|
|
328
378
|
|
|
329
|
-
const content =
|
|
379
|
+
const content = (
|
|
380
|
+
<SlideContent key={slide?.id ?? index} slide={slide} story={story} resolveMedia={resolveMedia} colors={colors} strings={strings} />
|
|
381
|
+
);
|
|
330
382
|
const timeline = showTimeline && (
|
|
331
383
|
<TimelineStrip points={datedSlides} currentIndex={index} onSelect={setIndex} colors={colors} />
|
|
332
384
|
);
|
package/src/player/strings.ts
CHANGED
|
@@ -15,6 +15,8 @@ export interface PlayerStrings {
|
|
|
15
15
|
historicalMapYear: (year: number) => string;
|
|
16
16
|
noDateStandardMap: string;
|
|
17
17
|
historicalYear: string;
|
|
18
|
+
/** A photo's thumbnail button, on a slide with several. */
|
|
19
|
+
photoOf: (n: number, total: number) => string;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
export const ENGLISH: PlayerStrings = {
|
|
@@ -28,6 +30,7 @@ export const ENGLISH: PlayerStrings = {
|
|
|
28
30
|
historicalMapYear: (year) => `Historical map, ${year}`,
|
|
29
31
|
noDateStandardMap: "No date here — standard map",
|
|
30
32
|
historicalYear: "Historical map year",
|
|
33
|
+
photoOf: (n, total) => `Photo ${n} of ${total}`,
|
|
31
34
|
};
|
|
32
35
|
|
|
33
36
|
const TABLE: Record<string, PlayerStrings> = {
|
|
@@ -43,6 +46,7 @@ const TABLE: Record<string, PlayerStrings> = {
|
|
|
43
46
|
historicalMapYear: (year) => `Historische Karte, ${year}`,
|
|
44
47
|
noDateStandardMap: "Kein Datum hier — Standardkarte",
|
|
45
48
|
historicalYear: "Jahr der historischen Karte",
|
|
49
|
+
photoOf: (n, total) => `Foto ${n} von ${total}`,
|
|
46
50
|
},
|
|
47
51
|
es: {
|
|
48
52
|
previous: "Anterior",
|
|
@@ -55,6 +59,7 @@ const TABLE: Record<string, PlayerStrings> = {
|
|
|
55
59
|
historicalMapYear: (year) => `Mapa histórico, ${year}`,
|
|
56
60
|
noDateStandardMap: "Sin fecha aquí — mapa estándar",
|
|
57
61
|
historicalYear: "Año del mapa histórico",
|
|
62
|
+
photoOf: (n, total) => `Foto ${n} de ${total}`,
|
|
58
63
|
},
|
|
59
64
|
fr: {
|
|
60
65
|
previous: "Précédent",
|
|
@@ -67,6 +72,7 @@ const TABLE: Record<string, PlayerStrings> = {
|
|
|
67
72
|
historicalMapYear: (year) => `Carte historique, ${year}`,
|
|
68
73
|
noDateStandardMap: "Pas de date ici — carte standard",
|
|
69
74
|
historicalYear: "Année de la carte historique",
|
|
75
|
+
photoOf: (n, total) => `Photo ${n} sur ${total}`,
|
|
70
76
|
},
|
|
71
77
|
nl: {
|
|
72
78
|
previous: "Vorige",
|
|
@@ -79,6 +85,7 @@ const TABLE: Record<string, PlayerStrings> = {
|
|
|
79
85
|
historicalMapYear: (year) => `Historische kaart, ${year}`,
|
|
80
86
|
noDateStandardMap: "Geen datum hier — standaardkaart",
|
|
81
87
|
historicalYear: "Jaar van de historische kaart",
|
|
88
|
+
photoOf: (n, total) => `Foto ${n} van ${total}`,
|
|
82
89
|
},
|
|
83
90
|
};
|
|
84
91
|
|