@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.
- package/README.md +14 -5
- package/dist/editor.js +464 -165
- package/dist/editor.js.map +4 -4
- 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 +5 -1
- package/dist/types/editor/index.d.ts +3 -1
- package/dist/types/editor/model.d.ts +17 -1
- package/dist/types/editor/placeSearch.d.ts +22 -0
- package/dist/types/editor/strings.d.ts +15 -0
- package/package.json +1 -1
- package/src/editor/PlaceField.tsx +160 -0
- package/src/editor/PlaceMap.tsx +88 -0
- package/src/editor/StoryEditor.tsx +27 -14
- package/src/editor/index.ts +5 -2
- package/src/editor/model.ts +38 -1
- package/src/editor/placeSearch.ts +61 -0
- package/src/editor/strings.ts +30 -0
|
@@ -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
|
@@ -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,
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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}`)}
|
package/src/editor/index.ts
CHANGED
|
@@ -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,
|
|
10
|
-
|
|
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";
|
package/src/editor/model.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// know (a newer minor version's, an extension's) ride along untouched:
|
|
5
5
|
// opening and saving a story must never lose anything.
|
|
6
6
|
import { FORMAT_ID, FORMAT_VERSION } from "../version";
|
|
7
|
-
import type { 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 };
|