@gramps-connect/story 0.3.0 → 0.5.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.
Files changed (40) hide show
  1. package/README.md +23 -5
  2. package/dist/editor.js +2065 -177
  3. package/dist/editor.js.map +4 -4
  4. package/dist/embed.js +1 -1
  5. package/dist/map-edit.js +361 -0
  6. package/dist/map-edit.js.map +7 -0
  7. package/dist/player.js +3 -1
  8. package/dist/player.js.map +2 -2
  9. package/dist/types/editor/MapEditor.d.ts +89 -0
  10. package/dist/types/editor/PlaceField.d.ts +24 -0
  11. package/dist/types/editor/PlaceMap.d.ts +12 -0
  12. package/dist/types/editor/PlaceMapDialog.d.ts +18 -0
  13. package/dist/types/editor/StoryEditor.d.ts +8 -1
  14. package/dist/types/editor/index.d.ts +7 -1
  15. package/dist/types/editor/mapEditorStrings.d.ts +50 -0
  16. package/dist/types/editor/model.d.ts +17 -1
  17. package/dist/types/editor/placeSearch.d.ts +22 -0
  18. package/dist/types/editor/placeShapes.d.ts +13 -0
  19. package/dist/types/editor/strings.d.ts +28 -0
  20. package/dist/types/mapEdit/geometry.d.ts +91 -0
  21. package/dist/types/mapEdit/index.d.ts +3 -0
  22. package/dist/types/mapEdit/mapFile.d.ts +77 -0
  23. package/dist/types/mapEdit/model.d.ts +55 -0
  24. package/package.json +18 -2
  25. package/src/editor/MapEditor.tsx +1545 -0
  26. package/src/editor/PlaceField.tsx +170 -0
  27. package/src/editor/PlaceMap.tsx +88 -0
  28. package/src/editor/PlaceMapDialog.tsx +228 -0
  29. package/src/editor/StoryEditor.tsx +50 -14
  30. package/src/editor/index.ts +11 -2
  31. package/src/editor/mapEditorStrings.ts +109 -0
  32. package/src/editor/model.ts +38 -1
  33. package/src/editor/placeSearch.ts +61 -0
  34. package/src/editor/placeShapes.ts +63 -0
  35. package/src/editor/strings.ts +56 -0
  36. package/src/mapEdit/geometry.ts +211 -0
  37. package/src/mapEdit/index.ts +15 -0
  38. package/src/mapEdit/mapFile.ts +199 -0
  39. package/src/mapEdit/model.ts +250 -0
  40. package/src/player/MapBackground.tsx +3 -1
@@ -0,0 +1,170 @@
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 shapes and old maps are edited full screen (onEditMap), and
6
+ // always kept.
7
+ import { useEffect, useMemo, useRef, useState } from "react";
8
+ import { Autocomplete, Button, Group, Loader, Stack, Text, TextInput, Tooltip, UnstyledButton } from "@mantine/core";
9
+ import type { Place } from "../types";
10
+ import type { Bounds } from "../player/geo";
11
+ import { formatLatLon, parseLatLon, patchPlace, roundDegrees } from "./model";
12
+ import { PlaceMap } from "./PlaceMap";
13
+ import type { PlaceSearch, PlaceSearchResult } from "./placeSearch";
14
+ import type { EditorStrings } from "./strings";
15
+
16
+ export type PlaceUpdate = (place: Place | undefined) => Place | undefined;
17
+
18
+ export interface PlaceFieldProps {
19
+ place: Place | undefined;
20
+ /** The story's other places, to reuse. */
21
+ choices: Place[];
22
+ /** Where the map looks while this place has no point. */
23
+ around?: Bounds | null;
24
+ /** The story's language: search results are named in it. */
25
+ language?: string;
26
+ /** Null hides the search. */
27
+ search: PlaceSearch | null;
28
+ dark?: boolean;
29
+ strings: EditorStrings;
30
+ /** A change, as a function of the place as it is by then. */
31
+ onChange: (update: PlaceUpdate, key: string) => void;
32
+ /** Opens the shapes-and-old-maps editor; without it, those are only
33
+ * kept. */
34
+ onEditMap?: () => void;
35
+ }
36
+
37
+ type Found =
38
+ | { state: "idle" }
39
+ | { state: "searching" }
40
+ | { state: "results"; results: PlaceSearchResult[] }
41
+ | { state: "message"; message: string };
42
+
43
+ export function PlaceField({ place, choices, around, language, search, dark, strings, onChange, onEditMap }: PlaceFieldProps) {
44
+ const lat = place?.lat;
45
+ const lon = place?.lon;
46
+ const point = lat !== undefined && lon !== undefined ? ([lon, lat] as [number, number]) : null;
47
+ const name = place?.name ?? "";
48
+
49
+ // The coordinates as typed, until they're a point; then as the point is.
50
+ const [coords, setCoords] = useState(point ? formatLatLon(point[1], point[0]) : "");
51
+ useEffect(() => {
52
+ setCoords((typed) => {
53
+ const parsed = parseLatLon(typed);
54
+ if (lat === undefined || lon === undefined) return parsed ? "" : typed;
55
+ return parsed && parsed.lat === lat && parsed.lon === lon ? typed : formatLatLon(lat, lon);
56
+ });
57
+ }, [lat, lon]);
58
+ const coordsInvalid = coords.trim() !== "" && !parseLatLon(coords);
59
+
60
+ const [found, setFound] = useState<Found>({ state: "idle" });
61
+ const searching = useRef<AbortController | null>(null);
62
+ useEffect(() => () => searching.current?.abort(), []);
63
+ const find = async () => {
64
+ if (!search || !name.trim()) return;
65
+ searching.current?.abort();
66
+ const controller = new AbortController();
67
+ searching.current = controller;
68
+ setFound({ state: "searching" });
69
+ try {
70
+ const results = await search.search(name.trim(), language, controller.signal);
71
+ setFound(results.length ? { state: "results", results } : { state: "message", message: strings.findPlaceNone });
72
+ } catch {
73
+ if (!controller.signal.aborted) setFound({ state: "message", message: strings.findPlaceFailed });
74
+ }
75
+ };
76
+ const choose = async (result: PlaceSearchResult) => {
77
+ if (!search) return;
78
+ searching.current?.abort();
79
+ const controller = new AbortController();
80
+ searching.current = controller;
81
+ setFound({ state: "searching" });
82
+ try {
83
+ const at = await search.point(result, controller.signal);
84
+ if (!at) {
85
+ setFound({ state: "message", message: strings.findPlaceNoPoint(result.label) });
86
+ return;
87
+ }
88
+ setFound({ state: "idle" });
89
+ onChange((p) => patchPlace(p, { name: result.label, lat: roundDegrees(at.lat), lon: roundDegrees(at.lon) }), "place");
90
+ } catch {
91
+ if (!controller.signal.aborted) setFound({ state: "message", message: strings.findPlaceFailed });
92
+ }
93
+ };
94
+
95
+ const byName = useMemo(() => new Map(choices.map((c) => [c.name!.trim(), c])), [choices]);
96
+ const keeps = [
97
+ place?.geometry ? strings.placeShape : null,
98
+ place?.overlays?.length ? strings.placeOverlays(place.overlays.length) : null,
99
+ ].filter(Boolean);
100
+
101
+ return (
102
+ <Stack gap="xs">
103
+ <Group justify="space-between">
104
+ <Text size="sm" fw={500}>{strings.place}</Text>
105
+ {place && (
106
+ <Button variant="subtle" size="compact-xs" color="red" onClick={() => { setFound({ state: "idle" }); onChange(() => undefined, "place"); }}>
107
+ {strings.removePlace}
108
+ </Button>
109
+ )}
110
+ </Group>
111
+ <Group gap="xs" align="flex-end" wrap="nowrap">
112
+ <Autocomplete
113
+ size="xs" label={strings.placeName} style={{ flex: 1 }} value={name}
114
+ data={[...byName.keys()]}
115
+ comboboxProps={{ withinPortal: true }}
116
+ onChange={(value) => onChange((p) => patchPlace(p, { name: value || undefined }), "place:name")}
117
+ onOptionSubmit={(value) => {
118
+ const chosen = byName.get(value);
119
+ if (chosen) onChange(() => structuredClone(chosen), "place");
120
+ }}
121
+ />
122
+ {search && (
123
+ <Tooltip label={strings.findPlaceHint} multiline w={260}>
124
+ <Button variant="default" size="xs" disabled={!name.trim() || found.state === "searching"} onClick={() => void find()}>
125
+ {strings.findPlace}
126
+ </Button>
127
+ </Tooltip>
128
+ )}
129
+ </Group>
130
+ {found.state === "searching" && <Loader size="xs" />}
131
+ {found.state === "message" && <Text size="xs" c="dimmed" role="status">{found.message}</Text>}
132
+ {found.state === "results" && (
133
+ <Stack gap={2} role="list" aria-label={strings.findPlace}>
134
+ {found.results.map((result) => (
135
+ <UnstyledButton
136
+ key={result.id} role="listitem" onClick={() => void choose(result)}
137
+ style={{ padding: "4px 8px", borderRadius: 4, border: "1px solid var(--mantine-color-default-border)" }}
138
+ >
139
+ <Text size="sm">{result.label}</Text>
140
+ {result.description && <Text size="xs" c="dimmed">{result.description}</Text>}
141
+ </UnstyledButton>
142
+ ))}
143
+ </Stack>
144
+ )}
145
+ <TextInput
146
+ size="xs" label={strings.coordinates} placeholder={strings.coordinatesHint} w={260} value={coords}
147
+ error={coordsInvalid ? strings.coordinatesInvalid : undefined}
148
+ onChange={(e) => {
149
+ const text = e.currentTarget.value;
150
+ setCoords(text);
151
+ const parsed = parseLatLon(text);
152
+ if (!text.trim()) onChange((p) => patchPlace(p, { lat: undefined, lon: undefined }), "place:point");
153
+ else if (parsed) onChange((p) => patchPlace(p, parsed), "place:point");
154
+ }}
155
+ />
156
+ <PlaceMap
157
+ point={point} around={around} dark={dark} label={strings.placeMap}
158
+ onPick={([pickedLon, pickedLat]) => onChange((p) => patchPlace(p, { lat: pickedLat, lon: pickedLon }), "place:point")}
159
+ />
160
+ <Text size="xs" c="dimmed">{strings.placeMapHint}</Text>
161
+ {keeps.length > 0 && <Text size="xs" c="dimmed">{strings.placeKeeps(keeps.join(", "))}</Text>}
162
+ {onEditMap && (
163
+ <Group gap="sm" wrap="nowrap" align="center">
164
+ <Button variant="default" size="xs" style={{ flex: "none" }} onClick={onEditMap}>{strings.editMap}</Button>
165
+ <Text size="xs" c="dimmed">{strings.editMapHint}</Text>
166
+ </Group>
167
+ )}
168
+ </Stack>
169
+ );
170
+ }
@@ -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
+ }
@@ -0,0 +1,228 @@
1
+ // A slide's map, full screen: the shapes under its pin (drawn, or imported
2
+ // from a KML, GPX or GeoJSON file) and scanned old maps laid over it, each
3
+ // with the years it shows. Works on a copy: Done hands the editor the
4
+ // place and any images added, as one change; Cancel drops them.
5
+ import { useMemo, useRef, useState } from "react";
6
+ import { ActionIcon, Box, Button, Group, Modal, Stack, Text, TextInput, Tooltip } from "@mantine/core";
7
+ import type { MediaItem, Place, StoryDate, StoryDoc } from "../types";
8
+ import { optimizeImage } from "../image";
9
+ import { parseMapFile } from "../mapEdit";
10
+ import { storyPlaces } from "../player/geo";
11
+ import { edtfYearRange } from "../player/dates";
12
+ import type { DateTextParser } from "./dates";
13
+ import { MapEditor, MapEditorHelp, type MapEditorHandle } from "./MapEditor";
14
+ import type { MapEditorStrings } from "./mapEditorStrings";
15
+ import { addImage, slideDate, type NewImage } from "./model";
16
+ import { editorToPlace, placeToEditor } from "./placeShapes";
17
+ import type { EditorStrings } from "./strings";
18
+
19
+ const BAR_HEIGHT = 56;
20
+
21
+ /** Where the map editor's English is Gramps Connect's (images come from
22
+ * its media there), the story editor's own. */
23
+ const STORY_MAP_WORDS: Partial<MapEditorStrings> = {
24
+ helpImage: "Image: add a picture file, such as a scan of an old map -- see",
25
+ };
26
+
27
+ export interface PlaceMapDialogProps {
28
+ place: Place | undefined;
29
+ doc: StoryDoc;
30
+ files: Map<string, Uint8Array>;
31
+ dark?: boolean;
32
+ parseDateText: DateTextParser;
33
+ strings: EditorStrings;
34
+ mapStrings?: Partial<MapEditorStrings>;
35
+ onCancel: () => void;
36
+ /** The place as edited, and the story's media and files with any images
37
+ * added here. */
38
+ onDone: (place: Place | undefined, media: Record<string, MediaItem>, files: Map<string, Uint8Array>) => void;
39
+ }
40
+
41
+ /** The default way an old map's scan is stored: 2048px, PNG when it has
42
+ * transparency (see optimizeImage's "overlay" purpose). */
43
+ async function prepareOverlay(file: Blob): Promise<NewImage> {
44
+ const image = await optimizeImage(file, { purpose: "overlay" });
45
+ return { data: new Uint8Array(await image.blob.arrayBuffer()), mime: image.mime, ext: image.ext, width: image.width, height: image.height };
46
+ }
47
+
48
+ /** An overlay's years, as readers would write them; the precise form
49
+ * follows. Narrow, for the map editor's side panel. */
50
+ function OverlayDateField({ value, language, parse, strings, onChange }: {
51
+ value: StoryDate | undefined;
52
+ language: string | undefined;
53
+ parse: DateTextParser;
54
+ strings: EditorStrings;
55
+ onChange: (date: StoryDate | undefined) => void;
56
+ }) {
57
+ const [text, setText] = useState(value?.text ?? "");
58
+ const request = useRef(0);
59
+ const edtf = value?.edtf;
60
+ return (
61
+ <Stack gap={2} maw={220}>
62
+ <TextInput
63
+ size="xs" label={strings.overlayDate} description={strings.overlayDateHint} value={text}
64
+ onChange={(e) => {
65
+ const typed = e.currentTarget.value;
66
+ setText(typed);
67
+ const id = ++request.current;
68
+ if (!typed.trim()) { onChange(undefined); return; }
69
+ void parse(typed, language).then((parsed) => {
70
+ if (id === request.current) onChange(slideDate(value, typed, parsed));
71
+ });
72
+ }}
73
+ />
74
+ {text.trim() && (
75
+ <Text size="xs" c={edtf && edtfYearRange(edtf) ? "dimmed" : "red"}>
76
+ {edtf && edtfYearRange(edtf) ? `EDTF ${edtf}` : strings.preciseDateNotUnderstood}
77
+ </Text>
78
+ )}
79
+ </Stack>
80
+ );
81
+ }
82
+
83
+ export function PlaceMapDialog({
84
+ place, doc, files, dark, parseDateText, strings, mapStrings: mapStringOverrides, onCancel, onDone,
85
+ }: PlaceMapDialogProps) {
86
+ const mapStrings = { ...STORY_MAP_WORDS, ...mapStringOverrides };
87
+ const editorRef = useRef<MapEditorHandle>(null);
88
+ const initial = useMemo(() => placeToEditor(place), [place]);
89
+ // The story's media and files with what's been added here.
90
+ const [work, setWork] = useState({ doc, files });
91
+ const workRef = useRef(work);
92
+ workRef.current = work;
93
+ // Each overlay's date by media key, once changed here.
94
+ const [dates, setDates] = useState(() => new Map<string, StoryDate | undefined>());
95
+ const [message, setMessage] = useState<string | null>(null);
96
+ const [helpOpen, setHelpOpen] = useState(false);
97
+ const imageInput = useRef<HTMLInputElement>(null);
98
+ const shapesInput = useRef<HTMLInputElement>(null);
99
+
100
+ // Where to look with nothing to frame: the pin, or the story's places.
101
+ const initialView = useMemo(() => {
102
+ if (place?.lat !== undefined && place.lon !== undefined) return { center: [place.lon, place.lat] as [number, number], zoom: 12 };
103
+ const all = storyPlaces(doc.slides.map((s) => s.place));
104
+ if (!all) return undefined;
105
+ const [w, s, e, n] = all.bounds;
106
+ return { center: [(w + e) / 2, (s + n) / 2] as [number, number], zoom: 5 };
107
+ }, [place, doc]);
108
+
109
+ const resolveImage = async (key: string) => {
110
+ const item = Object.prototype.hasOwnProperty.call(workRef.current.doc.media, key) ? workRef.current.doc.media[key] : undefined;
111
+ const data = item && workRef.current.files.get(item.src);
112
+ if (!item || !data) throw new Error(strings.photoFailed(key));
113
+ return URL.createObjectURL(new Blob([data as Uint8Array<ArrayBuffer>], { type: item.mime }));
114
+ };
115
+
116
+ const addImages = async (chosen: File[]) => {
117
+ setMessage(null);
118
+ for (const file of chosen) {
119
+ let key: string;
120
+ try {
121
+ const added = addImage(workRef.current.doc, workRef.current.files, await prepareOverlay(file), "map");
122
+ workRef.current = { doc: added.doc, files: added.files };
123
+ setWork(workRef.current);
124
+ key = added.key;
125
+ } catch {
126
+ setMessage(strings.photoFailed(file.name || "the image"));
127
+ continue;
128
+ }
129
+ try {
130
+ if (!(await editorRef.current?.addImage(key))) setMessage(strings.overlayAlready);
131
+ } catch (err) {
132
+ setMessage(err instanceof Error ? err.message : String(err));
133
+ }
134
+ }
135
+ };
136
+
137
+ const importShapes = async (chosen: File[]) => {
138
+ setMessage(null);
139
+ for (const file of chosen) {
140
+ try {
141
+ // A KML GroundOverlay's picture lives outside the file; only shapes
142
+ // come in.
143
+ const features = parseMapFile(await file.text()).filter((f) => f.properties?.["@geometry-type"] !== "groundoverlay");
144
+ if (features.length === 0) setMessage(strings.importNothing(file.name));
145
+ else editorRef.current?.addFeatures(features);
146
+ } catch {
147
+ setMessage(strings.importFailed(file.name));
148
+ }
149
+ }
150
+ };
151
+
152
+ const done = () => {
153
+ const editor = editorRef.current;
154
+ if (!editor) return;
155
+ onDone(editorToPlace(place, editor.getResult(), dates), workRef.current.doc.media, workRef.current.files);
156
+ };
157
+
158
+ const bar = dark ? "rgba(20,20,20,0.85)" : "rgba(255,255,255,0.9)";
159
+ return (
160
+ <Modal opened onClose={onCancel} fullScreen withCloseButton={false} closeOnEscape={false} padding={0}>
161
+ <Box style={{ position: "fixed", inset: 0, overflow: "hidden" }}>
162
+ <MapEditor
163
+ ref={editorRef}
164
+ initial={initial}
165
+ initialView={initialView}
166
+ resolveImage={resolveImage}
167
+ releaseImage={(url) => URL.revokeObjectURL(url)}
168
+ onAddImage={() => imageInput.current?.click()}
169
+ onError={setMessage}
170
+ dark={dark}
171
+ toolbarTop={BAR_HEIGHT + 12}
172
+ overlayNames={false}
173
+ strings={mapStrings}
174
+ renderOverlayOptions={(key) => (
175
+ <OverlayDateField
176
+ key={key}
177
+ value={dates.has(key) ? dates.get(key) : place?.overlays?.find((o) => o.media === key)?.date}
178
+ language={doc.language}
179
+ parse={parseDateText}
180
+ strings={strings}
181
+ onChange={(date) => setDates((prev) => new Map(prev).set(key, date))}
182
+ />
183
+ )}
184
+ />
185
+ <Group
186
+ justify="space-between" wrap="nowrap" px="md" gap="sm"
187
+ style={{ position: "absolute", top: 0, left: 0, right: 0, height: BAR_HEIGHT, zIndex: 3, background: bar }}
188
+ >
189
+ <Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
190
+ <Text fw={600} truncate dir="auto">{strings.mapDialogTitle(place?.name)}</Text>
191
+ <Tooltip label={strings.mapHelp}>
192
+ <ActionIcon variant="subtle" radius="xl" aria-label={strings.mapHelp} onClick={() => setHelpOpen(true)}>ⓘ</ActionIcon>
193
+ </Tooltip>
194
+ </Group>
195
+ <Group gap="xs" wrap="nowrap">
196
+ <Tooltip label={strings.importShapesHint} multiline w={280}>
197
+ <Button variant="default" size="sm" onClick={() => shapesInput.current?.click()}>{strings.importShapes}</Button>
198
+ </Tooltip>
199
+ <Button variant="default" size="sm" onClick={onCancel}>{strings.cancel}</Button>
200
+ <Button size="sm" onClick={done}>{strings.done}</Button>
201
+ </Group>
202
+ </Group>
203
+ {message && (
204
+ <Text
205
+ size="sm" role="alert"
206
+ style={{
207
+ position: "absolute", bottom: 12, left: 12, zIndex: 3, maxWidth: 480, padding: "6px 10px", borderRadius: 6,
208
+ background: bar, color: "var(--mantine-color-red-filled)",
209
+ }}
210
+ >
211
+ {message}
212
+ </Text>
213
+ )}
214
+ <input
215
+ ref={imageInput} type="file" accept="image/*" multiple hidden
216
+ onChange={(e) => { const chosen = [...(e.currentTarget.files ?? [])]; e.currentTarget.value = ""; void addImages(chosen); }}
217
+ />
218
+ <input
219
+ ref={shapesInput} type="file" accept=".kml,.gpx,.geojson,.json,application/vnd.google-earth.kml+xml,application/gpx+xml,application/geo+json" multiple hidden
220
+ onChange={(e) => { const chosen = [...(e.currentTarget.files ?? [])]; e.currentTarget.value = ""; void importShapes(chosen); }}
221
+ />
222
+ </Box>
223
+ <Modal opened={helpOpen} onClose={() => setHelpOpen(false)} title={strings.mapHelp} size="md" zIndex={1000}>
224
+ <MapEditorHelp strings={mapStrings} />
225
+ </Modal>
226
+ </Modal>
227
+ );
228
+ }
@@ -14,15 +14,20 @@ import { useMediaQuery } from "@mantine/hooks";
14
14
  import type { MediaItem, Slide, SlideMedia, StoryDoc } from "../types";
15
15
  import { optimizeImage } from "../image";
16
16
  import { validate, type StoryIssue } from "../validate";
17
+ import { storyPlaces } from "../player/geo";
17
18
  import { StoryPlayer } from "../player/StoryPlayer";
18
19
  import type { PlayerStrings } from "../player/strings";
19
20
  import { suggestEdtf, type DateTextParser } from "./dates";
20
21
  import { DateField } from "./DateField";
21
22
  import {
22
- addImage, addSlide, deleteSlide, findMedia, duplicateSlide, moveSlide, record, redo, setSlidePhotos, startHistory, undo, updateMedia,
23
- updateSlide, updateStory, type History, type NewImage,
23
+ addImage, addSlide, deleteSlide, findMedia, duplicateSlide, moveSlide, record, redo, setSlidePhotos, startHistory, storyPlaceChoices,
24
+ undo, updateMedia, updateSlide, updateStory, type History, type NewImage,
24
25
  } from "./model";
25
26
  import { PhotosField } from "./PhotosField";
27
+ import { PlaceField, type PlaceUpdate } from "./PlaceField";
28
+ import { PlaceMapDialog } from "./PlaceMapDialog";
29
+ import type { MapEditorStrings } from "./mapEditorStrings";
30
+ import { WIKIDATA_PLACE_SEARCH, type PlaceSearch } from "./placeSearch";
26
31
  import { SlideList } from "./SlideList";
27
32
  import { StoryTextField } from "./StoryTextField";
28
33
  import { EDITOR_ENGLISH, type EditorStrings } from "./strings";
@@ -54,6 +59,11 @@ export interface StoryEditorProps {
54
59
  playerStrings?: Partial<PlayerStrings>;
55
60
  /** Date text -> EDTF. By default gramps-date, in the story's language. */
56
61
  parseDateText?: DateTextParser;
62
+ /** Finding a place by name. By default Wikidata, and only when the
63
+ * person asks; null leaves the search out. */
64
+ placeSearch?: PlaceSearch | null;
65
+ /** The map editor's words (Edit map), over its English. */
66
+ mapStrings?: Partial<MapEditorStrings>;
57
67
  }
58
68
 
59
69
  /** The slide an issue's JSON Pointer is about, or STORY. */
@@ -92,7 +102,7 @@ function Thumb({ doc, slide, resolveMedia }: { doc: StoryDoc; slide: Slide; reso
92
102
 
93
103
  export function StoryEditor({
94
104
  initialStory, initialFiles, onChange, prepareImage: prepare = prepareImage, toolbar, brand, dark = false,
95
- strings: stringOverrides, playerStrings, parseDateText = suggestEdtf,
105
+ strings: stringOverrides, playerStrings, parseDateText = suggestEdtf, placeSearch = WIKIDATA_PLACE_SEARCH, mapStrings,
96
106
  }: StoryEditorProps) {
97
107
  const strings = useMemo(() => ({ ...EDITOR_ENGLISH, ...stringOverrides }), [stringOverrides]);
98
108
  const [history, setHistory] = useState<History>(() => startHistory(initialStory));
@@ -141,6 +151,23 @@ export function StoryEditor({
141
151
  if (index !== STORY) apply(updateSlide(history.present, index, patch), `${slide!.id}:${key}`);
142
152
  };
143
153
 
154
+ // A place change applies to the slide as it is by then: a name typed and
155
+ // a place picked in the same moment both land.
156
+ const setPlace = (slideId: string, update: PlaceUpdate, key: string) => {
157
+ setHistory((h) => {
158
+ const at = h.present.slides.findIndex((s) => s.id === slideId);
159
+ if (at < 0) return h;
160
+ const before = h.present.slides[at].place;
161
+ const after = update(before);
162
+ return after === before ? h : record(h, updateSlide(h.present, at, { place: after }), `${slideId}:${key}`);
163
+ });
164
+ };
165
+ // The slide whose map (shapes, old maps) is open full screen.
166
+ const [mapSlideId, setMapSlideId] = useState<string | null>(null);
167
+ const mapSlide = mapSlideId ? doc.slides.find((s) => s.id === mapSlideId) : undefined;
168
+ const placeChoices = useMemo(() => storyPlaceChoices(doc, index), [doc, index]);
169
+ const around = useMemo(() => storyPlaces(doc.slides.map((s) => s.place))?.bounds ?? null, [doc.slides]);
170
+
144
171
  const issues = useMemo(() => validate(doc, files).errors, [doc, files]);
145
172
 
146
173
  /** Prepares image files and puts them on a slide: added at the end, or
@@ -277,17 +304,11 @@ export function StoryEditor({
277
304
  label={strings.text} value={slide.text ?? ""} boldLabel={strings.bold} italicLabel={strings.italic} minRows={5}
278
305
  onChange={(text) => setSlide({ text: text || undefined }, "text")}
279
306
  />
280
- {slide.place && (
281
- <Stack gap={2}>
282
- <Text size="sm" fw={500}>{strings.place}</Text>
283
- <Text size="sm">
284
- {slide.place.name ?? ""}
285
- {slide.place.lat !== undefined && slide.place.lon !== undefined && (
286
- <Text span c="dimmed" size="sm"> {slide.place.lat.toFixed(4)}, {slide.place.lon.toFixed(4)}</Text>
287
- )}
288
- </Text>
289
- </Stack>
290
- )}
307
+ <PlaceField
308
+ place={slide.place} choices={placeChoices} around={around} language={doc.language} search={placeSearch} dark={dark} strings={strings}
309
+ onChange={(update, key) => setPlace(slide.id, update, key)}
310
+ onEditMap={() => setMapSlideId(slide.id)}
311
+ />
291
312
  <PhotosField
292
313
  photos={slide.media ?? []} media={doc.media} urlFor={urlFor} strings={strings} busy={busy}
293
314
  onChange={(photos, key) => apply(setSlidePhotos(doc, index, photos), `${slide.id}:${key}`)}
@@ -410,6 +431,21 @@ export function StoryEditor({
410
431
  {column(index === STORY ? storyForm : slideForm, wide ? "min(520px, 36%)" : undefined)}
411
432
  {column(preview)}
412
433
  </Box>
434
+ {mapSlide && (
435
+ <PlaceMapDialog
436
+ place={mapSlide.place} doc={doc} files={files} dark={dark} parseDateText={parseDateText}
437
+ strings={strings} mapStrings={mapStrings}
438
+ onCancel={() => setMapSlideId(null)}
439
+ onDone={(place, media, nextFiles) => {
440
+ const at = doc.slides.findIndex((s) => s.id === mapSlide.id);
441
+ setMapSlideId(null);
442
+ if (at < 0) return;
443
+ setFiles(nextFiles);
444
+ // One step for undo: the map's images and the place together.
445
+ apply(updateSlide({ ...doc, media }, at, { place }));
446
+ }}
447
+ />
448
+ )}
413
449
  <Modal opened={presenting} onClose={() => setPresenting(false)} fullScreen withCloseButton={false} padding={0}>
414
450
  <Box style={{ position: "fixed", inset: 0 }}>
415
451
  <StoryPlayer
@@ -1,12 +1,21 @@
1
1
  export { prepareImage, StoryEditor, type StoryEditorProps } from "./StoryEditor";
2
+ export {
3
+ MapEditor, MapEditorHelp, type MapEditorHandle, type MapEditorOverlay, type MapEditorProps, type MapEditorResult, type MapEditorStatus,
4
+ } from "./MapEditor";
5
+ export { MAP_EDITOR_ENGLISH, type MapEditorStrings } from "./mapEditorStrings";
2
6
  export { PhotosField, type PhotosFieldProps } from "./PhotosField";
7
+ export { PlaceField, type PlaceFieldProps, type PlaceUpdate } from "./PlaceField";
8
+ export { PlaceMapDialog, type PlaceMapDialogProps } from "./PlaceMapDialog";
9
+ export { editorToPlace, placeFeatures, placeToEditor } from "./placeShapes";
10
+ export { searchWikidataPlaces, wikidataPoint, WIKIDATA_PLACE_SEARCH, type PlaceSearch, type PlaceSearchResult } from "./placeSearch";
3
11
  export { StoryTextField, type StoryTextFieldProps } from "./StoryTextField";
4
12
  export { SlideList, type SlideListProps } from "./SlideList";
5
13
  export { DateField, type DateFieldProps } from "./DateField";
6
14
  export { suggestEdtf, type DateTextParser } from "./dates";
7
15
  export { EDITOR_ENGLISH, type EditorStrings } from "./strings";
8
16
  export {
9
- addImage, addSlide, blankStory, COALESCE_MS, deleteSlide, duplicateSlide, findMedia, moveSlide, nextSlideId, pruneMedia, record, redo,
10
- setSlidePhotos, slideDate, startHistory, storyFileName, undo, updateMedia, updateSlide, updateStory, usedMedia,
17
+ addImage, addSlide, blankStory, COALESCE_MS, deleteSlide, duplicateSlide, findMedia, formatLatLon, moveSlide, nextSlideId, parseLatLon,
18
+ patchPlace, pruneMedia, record, redo, roundDegrees, setSlidePhotos, slideDate, startHistory, storyFileName, storyPlaceChoices, undo,
19
+ updateMedia, updateSlide, updateStory, usedMedia,
11
20
  type History, type NewImage,
12
21
  } from "./model";