@gramps-connect/story 0.3.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.
@@ -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;
@@ -3,6 +3,7 @@ import type { StoryDoc } from "../types";
3
3
  import type { PlayerStrings } from "../player/strings";
4
4
  import { type DateTextParser } from "./dates";
5
5
  import { type NewImage } from "./model";
6
+ import { type PlaceSearch } from "./placeSearch";
6
7
  import { type EditorStrings } from "./strings";
7
8
  export interface StoryEditorProps {
8
9
  /** The story to start from, and its files (paths as in media `src`).
@@ -27,7 +28,10 @@ export interface StoryEditorProps {
27
28
  playerStrings?: Partial<PlayerStrings>;
28
29
  /** Date text -> EDTF. By default gramps-date, in the story's language. */
29
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;
30
34
  }
31
35
  /** The default prepareImage. */
32
36
  export declare function prepareImage(file: Blob): Promise<NewImage>;
33
- export declare function StoryEditor({ initialStory, initialFiles, onChange, prepareImage: prepare, toolbar, brand, dark, strings: stringOverrides, playerStrings, parseDateText, }: StoryEditorProps): import("react").JSX.Element;
37
+ export declare function StoryEditor({ initialStory, initialFiles, onChange, prepareImage: prepare, toolbar, brand, dark, strings: stringOverrides, playerStrings, parseDateText, placeSearch, }: StoryEditorProps): import("react").JSX.Element;
@@ -1,8 +1,10 @@
1
1
  export { prepareImage, StoryEditor, type StoryEditorProps } from "./StoryEditor";
2
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";
3
5
  export { StoryTextField, type StoryTextFieldProps } from "./StoryTextField";
4
6
  export { SlideList, type SlideListProps } from "./SlideList";
5
7
  export { DateField, type DateFieldProps } from "./DateField";
6
8
  export { suggestEdtf, type DateTextParser } from "./dates";
7
9
  export { EDITOR_ENGLISH, type EditorStrings } from "./strings";
8
- export { addImage, addSlide, blankStory, COALESCE_MS, deleteSlide, duplicateSlide, findMedia, moveSlide, nextSlideId, pruneMedia, record, redo, setSlidePhotos, slideDate, startHistory, storyFileName, undo, updateMedia, updateSlide, updateStory, usedMedia, type History, type NewImage, } from "./model";
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 { MediaItem, Slide, SlideMedia, 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,22 @@ 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[];
30
46
  /** A prepared image to add to a story (see optimizeImage). */
31
47
  export interface NewImage {
32
48
  data: Uint8Array;
@@ -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,6 +29,21 @@ 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;
33
48
  photosHint: string;
34
49
  addPhoto: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gramps-connect/story",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Read, write, validate and present .story.zip files: portable, versioned family-history stories.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "type": "module",
@@ -0,0 +1,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
+ }
@@ -14,15 +14,18 @@ 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 { WIKIDATA_PLACE_SEARCH, type PlaceSearch } from "./placeSearch";
26
29
  import { SlideList } from "./SlideList";
27
30
  import { StoryTextField } from "./StoryTextField";
28
31
  import { EDITOR_ENGLISH, type EditorStrings } from "./strings";
@@ -54,6 +57,9 @@ export interface StoryEditorProps {
54
57
  playerStrings?: Partial<PlayerStrings>;
55
58
  /** Date text -> EDTF. By default gramps-date, in the story's language. */
56
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;
57
63
  }
58
64
 
59
65
  /** The slide an issue's JSON Pointer is about, or STORY. */
@@ -92,7 +98,7 @@ function Thumb({ doc, slide, resolveMedia }: { doc: StoryDoc; slide: Slide; reso
92
98
 
93
99
  export function StoryEditor({
94
100
  initialStory, initialFiles, onChange, prepareImage: prepare = prepareImage, toolbar, brand, dark = false,
95
- strings: stringOverrides, playerStrings, parseDateText = suggestEdtf,
101
+ strings: stringOverrides, playerStrings, parseDateText = suggestEdtf, placeSearch = WIKIDATA_PLACE_SEARCH,
96
102
  }: StoryEditorProps) {
97
103
  const strings = useMemo(() => ({ ...EDITOR_ENGLISH, ...stringOverrides }), [stringOverrides]);
98
104
  const [history, setHistory] = useState<History>(() => startHistory(initialStory));
@@ -141,6 +147,20 @@ export function StoryEditor({
141
147
  if (index !== STORY) apply(updateSlide(history.present, index, patch), `${slide!.id}:${key}`);
142
148
  };
143
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
+
144
164
  const issues = useMemo(() => validate(doc, files).errors, [doc, files]);
145
165
 
146
166
  /** Prepares image files and puts them on a slide: added at the end, or
@@ -277,17 +297,10 @@ export function StoryEditor({
277
297
  label={strings.text} value={slide.text ?? ""} boldLabel={strings.bold} italicLabel={strings.italic} minRows={5}
278
298
  onChange={(text) => setSlide({ text: text || undefined }, "text")}
279
299
  />
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
- )}
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
+ />
291
304
  <PhotosField
292
305
  photos={slide.media ?? []} media={doc.media} urlFor={urlFor} strings={strings} busy={busy}
293
306
  onChange={(photos, key) => apply(setSlidePhotos(doc, index, photos), `${slide.id}:${key}`)}
@@ -1,12 +1,15 @@
1
1
  export { prepareImage, StoryEditor, type StoryEditorProps } from "./StoryEditor";
2
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";
3
5
  export { StoryTextField, type StoryTextFieldProps } from "./StoryTextField";
4
6
  export { SlideList, type SlideListProps } from "./SlideList";
5
7
  export { DateField, type DateFieldProps } from "./DateField";
6
8
  export { suggestEdtf, type DateTextParser } from "./dates";
7
9
  export { EDITOR_ENGLISH, type EditorStrings } from "./strings";
8
10
  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,
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,
11
14
  type History, type NewImage,
12
15
  } from "./model";
@@ -4,7 +4,7 @@
4
4
  // know (a newer minor version's, an extension's) ride along untouched:
5
5
  // opening and saving a story must never lose anything.
6
6
  import { FORMAT_ID, FORMAT_VERSION } from "../version";
7
- import type { MediaItem, Slide, SlideMedia, 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,43 @@ 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
+
103
140
  /** A prepared image to add to a story (see optimizeImage). */
104
141
  export interface NewImage {
105
142
  data: Uint8Array;
@@ -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 };