@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
|
@@ -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;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Place } from "../types";
|
|
2
|
+
import type { Bounds } from "../player/geo";
|
|
3
|
+
import type { PlaceSearch } from "./placeSearch";
|
|
4
|
+
import type { EditorStrings } from "./strings";
|
|
5
|
+
export type PlaceUpdate = (place: Place | undefined) => Place | undefined;
|
|
6
|
+
export interface PlaceFieldProps {
|
|
7
|
+
place: Place | undefined;
|
|
8
|
+
/** The story's other places, to reuse. */
|
|
9
|
+
choices: Place[];
|
|
10
|
+
/** Where the map looks while this place has no point. */
|
|
11
|
+
around?: Bounds | null;
|
|
12
|
+
/** The story's language: search results are named in it. */
|
|
13
|
+
language?: string;
|
|
14
|
+
/** Null hides the search. */
|
|
15
|
+
search: PlaceSearch | null;
|
|
16
|
+
dark?: boolean;
|
|
17
|
+
strings: EditorStrings;
|
|
18
|
+
/** A change, as a function of the place as it is by then. */
|
|
19
|
+
onChange: (update: PlaceUpdate, key: string) => void;
|
|
20
|
+
}
|
|
21
|
+
export declare function PlaceField({ place, choices, around, language, search, dark, strings, onChange }: PlaceFieldProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import "maplibre-gl/dist/maplibre-gl.css";
|
|
2
|
+
import type { LonLat } from "../types";
|
|
3
|
+
import type { Bounds } from "../player/geo";
|
|
4
|
+
export interface PlaceMapProps {
|
|
5
|
+
point: LonLat | null;
|
|
6
|
+
/** Where to look when there's no point yet. */
|
|
7
|
+
around?: Bounds | null;
|
|
8
|
+
dark?: boolean;
|
|
9
|
+
label: string;
|
|
10
|
+
onPick: (point: LonLat) => void;
|
|
11
|
+
}
|
|
12
|
+
export declare function PlaceMap({ point, around, dark, label, onPick }: PlaceMapProps): import("react").JSX.Element;
|
|
@@ -1,20 +1,24 @@
|
|
|
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";
|
|
6
|
+
import { type PlaceSearch } from "./placeSearch";
|
|
5
7
|
import { type EditorStrings } from "./strings";
|
|
6
8
|
export interface StoryEditorProps {
|
|
7
|
-
/** The story to start from
|
|
8
|
-
* (a new `key`) to edit
|
|
9
|
+
/** The story to start from, and its files (paths as in media `src`).
|
|
10
|
+
* Changing these later has no effect: remount (a new `key`) to edit
|
|
11
|
+
* another story. */
|
|
9
12
|
initialStory: StoryDoc;
|
|
10
|
-
|
|
11
|
-
files
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
initialFiles?: Map<string, Uint8Array>;
|
|
14
|
+
/** Called with the document and its files after every change, undo and
|
|
15
|
+
* redo included. The files only grow while editing (an undone photo's
|
|
16
|
+
* file stays); pruneMedia leaves out what nothing uses when saving. */
|
|
17
|
+
onChange?: (doc: StoryDoc, files: Map<string, Uint8Array>) => void;
|
|
18
|
+
/** Turns a chosen image file into what's stored. By default
|
|
19
|
+
* optimizeImage: 1600px WebP (JPEG where the browser can't), with the
|
|
20
|
+
* original's metadata left behind. */
|
|
21
|
+
prepareImage?: (file: Blob) => Promise<NewImage>;
|
|
18
22
|
/** The host's own buttons (Open, Save, ...), at the right of the top bar. */
|
|
19
23
|
toolbar?: ReactNode;
|
|
20
24
|
/** Shown first in the top bar, e.g. ViewerBrand. */
|
|
@@ -24,5 +28,10 @@ export interface StoryEditorProps {
|
|
|
24
28
|
playerStrings?: Partial<PlayerStrings>;
|
|
25
29
|
/** Date text -> EDTF. By default gramps-date, in the story's language. */
|
|
26
30
|
parseDateText?: DateTextParser;
|
|
31
|
+
/** Finding a place by name. By default Wikidata, and only when the
|
|
32
|
+
* person asks; null leaves the search out. */
|
|
33
|
+
placeSearch?: PlaceSearch | null;
|
|
27
34
|
}
|
|
28
|
-
|
|
35
|
+
/** The default prepareImage. */
|
|
36
|
+
export declare function prepareImage(file: Blob): Promise<NewImage>;
|
|
37
|
+
export declare function StoryEditor({ initialStory, initialFiles, onChange, prepareImage: prepare, toolbar, brand, dark, strings: stringOverrides, playerStrings, parseDateText, placeSearch, }: StoryEditorProps): import("react").JSX.Element;
|
|
@@ -1,7 +1,10 @@
|
|
|
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
|
-
export { addSlide, blankStory, COALESCE_MS, deleteSlide, duplicateSlide, moveSlide, nextSlideId, pruneMedia, record, redo, slideDate, startHistory, storyFileName, undo, updateSlide, updateStory, usedMedia, type History, } from "./model";
|
|
10
|
+
export { addImage, addSlide, blankStory, COALESCE_MS, deleteSlide, duplicateSlide, findMedia, formatLatLon, moveSlide, nextSlideId, parseLatLon, patchPlace, pruneMedia, record, redo, roundDegrees, setSlidePhotos, slideDate, startHistory, storyFileName, storyPlaceChoices, 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, Place, 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,47 @@ 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 place with some fields set or (undefined) removed; one left with
|
|
31
|
+
* nothing in it is no place at all. Its shape and overlays, and any field
|
|
32
|
+
* this version doesn't know, stay as they were. */
|
|
33
|
+
export declare function patchPlace(place: Place | undefined, patch: Partial<Place>): Place | undefined;
|
|
34
|
+
/** Degrees to 5 decimals: about a metre, and no float noise. */
|
|
35
|
+
export declare const roundDegrees: (value: number) => number;
|
|
36
|
+
/** "47.3769, 8.5417" (or with a space or semicolon between) -> a point;
|
|
37
|
+
* null when that's not two numbers in range. */
|
|
38
|
+
export declare function parseLatLon(text: string): {
|
|
39
|
+
lat: number;
|
|
40
|
+
lon: number;
|
|
41
|
+
} | null;
|
|
42
|
+
export declare function formatLatLon(lat: number, lon: number): string;
|
|
43
|
+
/** The places of the story's other slides, one per name (the first slide
|
|
44
|
+
* to use it), for giving another slide the very same place. */
|
|
45
|
+
export declare function storyPlaceChoices(doc: StoryDoc, exceptIndex?: number): Place[];
|
|
46
|
+
/** A prepared image to add to a story (see optimizeImage). */
|
|
47
|
+
export interface NewImage {
|
|
48
|
+
data: Uint8Array;
|
|
49
|
+
mime: string;
|
|
50
|
+
/** File extension, without the dot. */
|
|
51
|
+
ext: string;
|
|
52
|
+
width?: number;
|
|
53
|
+
height?: number;
|
|
54
|
+
}
|
|
55
|
+
/** The media key whose file is exactly these bytes, if any: a photo
|
|
56
|
+
* that's already in the story, chosen again. */
|
|
57
|
+
export declare function findMedia(doc: StoryDoc, files: Map<string, Uint8Array>, data: Uint8Array): string | undefined;
|
|
58
|
+
/** Adds an image to the story's media (not to any slide), or finds the
|
|
59
|
+
* identical one already there, so the same photo is stored once. Returns
|
|
60
|
+
* its media key. */
|
|
61
|
+
export declare function addImage(doc: StoryDoc, files: Map<string, Uint8Array>, image: NewImage, stem?: string): {
|
|
62
|
+
doc: StoryDoc;
|
|
63
|
+
files: Map<string, Uint8Array>;
|
|
64
|
+
key: string;
|
|
65
|
+
};
|
|
66
|
+
/** A slide's photo list; an empty one removes the field. */
|
|
67
|
+
export declare function setSlidePhotos(doc: StoryDoc, index: number, media: SlideMedia[]): StoryDoc;
|
|
68
|
+
/** Changes a media item itself (its description, shared by every slide
|
|
69
|
+
* that shows it). */
|
|
70
|
+
export declare function updateMedia(doc: StoryDoc, key: string, patch: Partial<MediaItem>): StoryDoc;
|
|
30
71
|
/** Every media key a slide or overlay refers to. */
|
|
31
72
|
export declare function usedMedia(doc: StoryDoc): Set<string>;
|
|
32
73
|
/** The story as it should be saved: media nothing refers to any more left
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface PlaceSearchResult {
|
|
2
|
+
/** The Wikidata item, e.g. "Q72". */
|
|
3
|
+
id: string;
|
|
4
|
+
label: string;
|
|
5
|
+
/** Usually names the region and country, which tells same-named places
|
|
6
|
+
* apart. */
|
|
7
|
+
description?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface PlaceSearch {
|
|
10
|
+
search: (term: string, language: string | undefined, signal?: AbortSignal) => Promise<PlaceSearchResult[]>;
|
|
11
|
+
/** The result's position, or null when it has none. */
|
|
12
|
+
point: (result: PlaceSearchResult, signal?: AbortSignal) => Promise<{
|
|
13
|
+
lat: number;
|
|
14
|
+
lon: number;
|
|
15
|
+
} | null>;
|
|
16
|
+
}
|
|
17
|
+
export declare function searchWikidataPlaces(term: string, language?: string, signal?: AbortSignal): Promise<PlaceSearchResult[]>;
|
|
18
|
+
export declare function wikidataPoint(result: PlaceSearchResult, signal?: AbortSignal): Promise<{
|
|
19
|
+
lat: number;
|
|
20
|
+
lon: number;
|
|
21
|
+
} | null>;
|
|
22
|
+
export declare const WIKIDATA_PLACE_SEARCH: PlaceSearch;
|
|
@@ -29,7 +29,34 @@ export interface EditorStrings {
|
|
|
29
29
|
matchDateAsWritten: string;
|
|
30
30
|
noPreciseDate: string;
|
|
31
31
|
place: string;
|
|
32
|
+
placeName: string;
|
|
33
|
+
removePlace: string;
|
|
34
|
+
findPlace: string;
|
|
35
|
+
findPlaceHint: string;
|
|
36
|
+
findPlaceNone: string;
|
|
37
|
+
findPlaceFailed: string;
|
|
38
|
+
findPlaceNoPoint: (name: string) => string;
|
|
39
|
+
coordinates: string;
|
|
40
|
+
coordinatesHint: string;
|
|
41
|
+
coordinatesInvalid: string;
|
|
42
|
+
placeMap: string;
|
|
43
|
+
placeMapHint: string;
|
|
44
|
+
placeShape: string;
|
|
45
|
+
placeOverlays: (count: number) => string;
|
|
46
|
+
placeKeeps: (what: string) => string;
|
|
32
47
|
photos: string;
|
|
48
|
+
photosHint: string;
|
|
49
|
+
addPhoto: string;
|
|
50
|
+
replacePhoto: string;
|
|
51
|
+
movePhotoUp: string;
|
|
52
|
+
movePhotoDown: string;
|
|
53
|
+
removePhoto: string;
|
|
54
|
+
caption: string;
|
|
55
|
+
credit: string;
|
|
56
|
+
creditHint: string;
|
|
57
|
+
description: string;
|
|
58
|
+
descriptionHint: string;
|
|
59
|
+
photoFailed: (name: string) => string;
|
|
33
60
|
undo: string;
|
|
34
61
|
redo: string;
|
|
35
62
|
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
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// A slide's place: a name and a point. The point comes from clicking the
|
|
2
|
+
// map, dragging the pin, typing coordinates, or a search by name (opt-in:
|
|
3
|
+
// the one thing the editor sends anywhere). Picking one of the story's
|
|
4
|
+
// other places gives this slide the very same place, overlays and all.
|
|
5
|
+
// A place's shape and overlays aren't edited here, but are always kept.
|
|
6
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
7
|
+
import { Autocomplete, Button, Group, Loader, Stack, Text, TextInput, Tooltip, UnstyledButton } from "@mantine/core";
|
|
8
|
+
import type { Place } from "../types";
|
|
9
|
+
import type { Bounds } from "../player/geo";
|
|
10
|
+
import { formatLatLon, parseLatLon, patchPlace, roundDegrees } from "./model";
|
|
11
|
+
import { PlaceMap } from "./PlaceMap";
|
|
12
|
+
import type { PlaceSearch, PlaceSearchResult } from "./placeSearch";
|
|
13
|
+
import type { EditorStrings } from "./strings";
|
|
14
|
+
|
|
15
|
+
export type PlaceUpdate = (place: Place | undefined) => Place | undefined;
|
|
16
|
+
|
|
17
|
+
export interface PlaceFieldProps {
|
|
18
|
+
place: Place | undefined;
|
|
19
|
+
/** The story's other places, to reuse. */
|
|
20
|
+
choices: Place[];
|
|
21
|
+
/** Where the map looks while this place has no point. */
|
|
22
|
+
around?: Bounds | null;
|
|
23
|
+
/** The story's language: search results are named in it. */
|
|
24
|
+
language?: string;
|
|
25
|
+
/** Null hides the search. */
|
|
26
|
+
search: PlaceSearch | null;
|
|
27
|
+
dark?: boolean;
|
|
28
|
+
strings: EditorStrings;
|
|
29
|
+
/** A change, as a function of the place as it is by then. */
|
|
30
|
+
onChange: (update: PlaceUpdate, key: string) => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type Found =
|
|
34
|
+
| { state: "idle" }
|
|
35
|
+
| { state: "searching" }
|
|
36
|
+
| { state: "results"; results: PlaceSearchResult[] }
|
|
37
|
+
| { state: "message"; message: string };
|
|
38
|
+
|
|
39
|
+
export function PlaceField({ place, choices, around, language, search, dark, strings, onChange }: PlaceFieldProps) {
|
|
40
|
+
const lat = place?.lat;
|
|
41
|
+
const lon = place?.lon;
|
|
42
|
+
const point = lat !== undefined && lon !== undefined ? ([lon, lat] as [number, number]) : null;
|
|
43
|
+
const name = place?.name ?? "";
|
|
44
|
+
|
|
45
|
+
// The coordinates as typed, until they're a point; then as the point is.
|
|
46
|
+
const [coords, setCoords] = useState(point ? formatLatLon(point[1], point[0]) : "");
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
setCoords((typed) => {
|
|
49
|
+
const parsed = parseLatLon(typed);
|
|
50
|
+
if (lat === undefined || lon === undefined) return parsed ? "" : typed;
|
|
51
|
+
return parsed && parsed.lat === lat && parsed.lon === lon ? typed : formatLatLon(lat, lon);
|
|
52
|
+
});
|
|
53
|
+
}, [lat, lon]);
|
|
54
|
+
const coordsInvalid = coords.trim() !== "" && !parseLatLon(coords);
|
|
55
|
+
|
|
56
|
+
const [found, setFound] = useState<Found>({ state: "idle" });
|
|
57
|
+
const searching = useRef<AbortController | null>(null);
|
|
58
|
+
useEffect(() => () => searching.current?.abort(), []);
|
|
59
|
+
const find = async () => {
|
|
60
|
+
if (!search || !name.trim()) return;
|
|
61
|
+
searching.current?.abort();
|
|
62
|
+
const controller = new AbortController();
|
|
63
|
+
searching.current = controller;
|
|
64
|
+
setFound({ state: "searching" });
|
|
65
|
+
try {
|
|
66
|
+
const results = await search.search(name.trim(), language, controller.signal);
|
|
67
|
+
setFound(results.length ? { state: "results", results } : { state: "message", message: strings.findPlaceNone });
|
|
68
|
+
} catch {
|
|
69
|
+
if (!controller.signal.aborted) setFound({ state: "message", message: strings.findPlaceFailed });
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
const choose = async (result: PlaceSearchResult) => {
|
|
73
|
+
if (!search) return;
|
|
74
|
+
searching.current?.abort();
|
|
75
|
+
const controller = new AbortController();
|
|
76
|
+
searching.current = controller;
|
|
77
|
+
setFound({ state: "searching" });
|
|
78
|
+
try {
|
|
79
|
+
const at = await search.point(result, controller.signal);
|
|
80
|
+
if (!at) {
|
|
81
|
+
setFound({ state: "message", message: strings.findPlaceNoPoint(result.label) });
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
setFound({ state: "idle" });
|
|
85
|
+
onChange((p) => patchPlace(p, { name: result.label, lat: roundDegrees(at.lat), lon: roundDegrees(at.lon) }), "place");
|
|
86
|
+
} catch {
|
|
87
|
+
if (!controller.signal.aborted) setFound({ state: "message", message: strings.findPlaceFailed });
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const byName = useMemo(() => new Map(choices.map((c) => [c.name!.trim(), c])), [choices]);
|
|
92
|
+
const keeps = [
|
|
93
|
+
place?.geometry ? strings.placeShape : null,
|
|
94
|
+
place?.overlays?.length ? strings.placeOverlays(place.overlays.length) : null,
|
|
95
|
+
].filter(Boolean);
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<Stack gap="xs">
|
|
99
|
+
<Group justify="space-between">
|
|
100
|
+
<Text size="sm" fw={500}>{strings.place}</Text>
|
|
101
|
+
{place && (
|
|
102
|
+
<Button variant="subtle" size="compact-xs" color="red" onClick={() => { setFound({ state: "idle" }); onChange(() => undefined, "place"); }}>
|
|
103
|
+
{strings.removePlace}
|
|
104
|
+
</Button>
|
|
105
|
+
)}
|
|
106
|
+
</Group>
|
|
107
|
+
<Group gap="xs" align="flex-end" wrap="nowrap">
|
|
108
|
+
<Autocomplete
|
|
109
|
+
size="xs" label={strings.placeName} style={{ flex: 1 }} value={name}
|
|
110
|
+
data={[...byName.keys()]}
|
|
111
|
+
comboboxProps={{ withinPortal: true }}
|
|
112
|
+
onChange={(value) => onChange((p) => patchPlace(p, { name: value || undefined }), "place:name")}
|
|
113
|
+
onOptionSubmit={(value) => {
|
|
114
|
+
const chosen = byName.get(value);
|
|
115
|
+
if (chosen) onChange(() => structuredClone(chosen), "place");
|
|
116
|
+
}}
|
|
117
|
+
/>
|
|
118
|
+
{search && (
|
|
119
|
+
<Tooltip label={strings.findPlaceHint} multiline w={260}>
|
|
120
|
+
<Button variant="default" size="xs" disabled={!name.trim() || found.state === "searching"} onClick={() => void find()}>
|
|
121
|
+
{strings.findPlace}
|
|
122
|
+
</Button>
|
|
123
|
+
</Tooltip>
|
|
124
|
+
)}
|
|
125
|
+
</Group>
|
|
126
|
+
{found.state === "searching" && <Loader size="xs" />}
|
|
127
|
+
{found.state === "message" && <Text size="xs" c="dimmed" role="status">{found.message}</Text>}
|
|
128
|
+
{found.state === "results" && (
|
|
129
|
+
<Stack gap={2} role="list" aria-label={strings.findPlace}>
|
|
130
|
+
{found.results.map((result) => (
|
|
131
|
+
<UnstyledButton
|
|
132
|
+
key={result.id} role="listitem" onClick={() => void choose(result)}
|
|
133
|
+
style={{ padding: "4px 8px", borderRadius: 4, border: "1px solid var(--mantine-color-default-border)" }}
|
|
134
|
+
>
|
|
135
|
+
<Text size="sm">{result.label}</Text>
|
|
136
|
+
{result.description && <Text size="xs" c="dimmed">{result.description}</Text>}
|
|
137
|
+
</UnstyledButton>
|
|
138
|
+
))}
|
|
139
|
+
</Stack>
|
|
140
|
+
)}
|
|
141
|
+
<TextInput
|
|
142
|
+
size="xs" label={strings.coordinates} placeholder={strings.coordinatesHint} w={260} value={coords}
|
|
143
|
+
error={coordsInvalid ? strings.coordinatesInvalid : undefined}
|
|
144
|
+
onChange={(e) => {
|
|
145
|
+
const text = e.currentTarget.value;
|
|
146
|
+
setCoords(text);
|
|
147
|
+
const parsed = parseLatLon(text);
|
|
148
|
+
if (!text.trim()) onChange((p) => patchPlace(p, { lat: undefined, lon: undefined }), "place:point");
|
|
149
|
+
else if (parsed) onChange((p) => patchPlace(p, parsed), "place:point");
|
|
150
|
+
}}
|
|
151
|
+
/>
|
|
152
|
+
<PlaceMap
|
|
153
|
+
point={point} around={around} dark={dark} label={strings.placeMap}
|
|
154
|
+
onPick={([pickedLon, pickedLat]) => onChange((p) => patchPlace(p, { lat: pickedLat, lon: pickedLon }), "place:point")}
|
|
155
|
+
/>
|
|
156
|
+
<Text size="xs" c="dimmed">{strings.placeMapHint}</Text>
|
|
157
|
+
{keeps.length > 0 && <Text size="xs" c="dimmed">{strings.placeKeeps(keeps.join(", "))}</Text>}
|
|
158
|
+
</Stack>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// A small map for setting a place's point: click to put the pin there,
|
|
2
|
+
// drag the pin to move it. It starts at the point, or (with none yet) at
|
|
3
|
+
// the story's other places, or the whole world.
|
|
4
|
+
import { useEffect, useRef } from "react";
|
|
5
|
+
import * as maplibregl from "maplibre-gl";
|
|
6
|
+
import "maplibre-gl/dist/maplibre-gl.css";
|
|
7
|
+
import type { Map as MapLibreMap, Marker as MapLibreMarker } from "maplibre-gl";
|
|
8
|
+
import type { LonLat } from "../types";
|
|
9
|
+
import type { Bounds } from "../player/geo";
|
|
10
|
+
import { mapStyleUrl } from "../player/mapStyles";
|
|
11
|
+
import { defaultColors, PIN_PATH_D } from "../player/theme";
|
|
12
|
+
import { roundDegrees } from "./model";
|
|
13
|
+
|
|
14
|
+
export interface PlaceMapProps {
|
|
15
|
+
point: LonLat | null;
|
|
16
|
+
/** Where to look when there's no point yet. */
|
|
17
|
+
around?: Bounds | null;
|
|
18
|
+
dark?: boolean;
|
|
19
|
+
label: string;
|
|
20
|
+
onPick: (point: LonLat) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function PlaceMap({ point, around, dark = false, label, onPick }: PlaceMapProps) {
|
|
24
|
+
const container = useRef<HTMLDivElement>(null);
|
|
25
|
+
const mapRef = useRef<MapLibreMap | null>(null);
|
|
26
|
+
const markerRef = useRef<MapLibreMarker | null>(null);
|
|
27
|
+
const onPickRef = useRef(onPick);
|
|
28
|
+
onPickRef.current = onPick;
|
|
29
|
+
const pick = (lngLat: { lng: number; lat: number }) => onPickRef.current([roundDegrees(lngLat.lng), roundDegrees(lngLat.lat)]);
|
|
30
|
+
// The first point and surroundings only: moving is the effect below.
|
|
31
|
+
const start = useRef({ point, around });
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (!container.current) return;
|
|
35
|
+
const { point: first, around: box } = start.current;
|
|
36
|
+
const map = new maplibregl.Map({
|
|
37
|
+
container: container.current,
|
|
38
|
+
style: mapStyleUrl(dark, null),
|
|
39
|
+
...(first ? { center: first, zoom: 9 } : box ? { bounds: box, fitBoundsOptions: { padding: 30, maxZoom: 9 } } : { center: [10, 30], zoom: 0.5 }),
|
|
40
|
+
attributionControl: { compact: true },
|
|
41
|
+
// Scrolling the form past the map shouldn't zoom it.
|
|
42
|
+
cooperativeGestures: true,
|
|
43
|
+
});
|
|
44
|
+
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-right");
|
|
45
|
+
map.getCanvas().style.cursor = "crosshair";
|
|
46
|
+
map.on("click", (e) => pick(e.lngLat));
|
|
47
|
+
mapRef.current = map;
|
|
48
|
+
return () => {
|
|
49
|
+
markerRef.current?.remove();
|
|
50
|
+
markerRef.current = null;
|
|
51
|
+
map.remove();
|
|
52
|
+
mapRef.current = null;
|
|
53
|
+
};
|
|
54
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
55
|
+
}, [dark]);
|
|
56
|
+
|
|
57
|
+
// The pin follows the point, however it changed; the view follows only
|
|
58
|
+
// when the pin would be out of sight.
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
const map = mapRef.current;
|
|
61
|
+
if (!map) return;
|
|
62
|
+
if (!point) {
|
|
63
|
+
markerRef.current?.remove();
|
|
64
|
+
markerRef.current = null;
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (!markerRef.current) {
|
|
68
|
+
const el = document.createElement("div");
|
|
69
|
+
const c = defaultColors(dark);
|
|
70
|
+
el.innerHTML = `<svg viewBox="0 0 24 24" width="28" height="28" style="display:block;cursor:grab">`
|
|
71
|
+
+ `<path d="${PIN_PATH_D}" fill="${c.mark}" stroke="${c.surface}" stroke-width="1.5" /></svg>`;
|
|
72
|
+
const marker = new maplibregl.Marker({ element: el, anchor: "bottom", draggable: true }).setLngLat(point).addTo(map);
|
|
73
|
+
marker.on("dragend", () => pick(marker.getLngLat()));
|
|
74
|
+
markerRef.current = marker;
|
|
75
|
+
} else {
|
|
76
|
+
markerRef.current.setLngLat(point);
|
|
77
|
+
}
|
|
78
|
+
if (!map.getBounds().contains(point)) map.easeTo({ center: point, zoom: Math.max(map.getZoom(), 8) });
|
|
79
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
80
|
+
}, [point?.[0], point?.[1], dark]);
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div
|
|
84
|
+
ref={container} role="application" aria-label={label}
|
|
85
|
+
style={{ height: 240, borderRadius: 6, overflow: "hidden", border: "1px solid var(--mantine-color-default-border)" }}
|
|
86
|
+
/>
|
|
87
|
+
);
|
|
88
|
+
}
|