@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.
- package/README.md +23 -5
- package/dist/editor.js +2065 -177
- package/dist/editor.js.map +4 -4
- package/dist/embed.js +1 -1
- package/dist/map-edit.js +361 -0
- package/dist/map-edit.js.map +7 -0
- package/dist/player.js +3 -1
- package/dist/player.js.map +2 -2
- package/dist/types/editor/MapEditor.d.ts +89 -0
- package/dist/types/editor/PlaceField.d.ts +24 -0
- package/dist/types/editor/PlaceMap.d.ts +12 -0
- package/dist/types/editor/PlaceMapDialog.d.ts +18 -0
- package/dist/types/editor/StoryEditor.d.ts +8 -1
- package/dist/types/editor/index.d.ts +7 -1
- package/dist/types/editor/mapEditorStrings.d.ts +50 -0
- package/dist/types/editor/model.d.ts +17 -1
- package/dist/types/editor/placeSearch.d.ts +22 -0
- package/dist/types/editor/placeShapes.d.ts +13 -0
- package/dist/types/editor/strings.d.ts +28 -0
- package/dist/types/mapEdit/geometry.d.ts +91 -0
- package/dist/types/mapEdit/index.d.ts +3 -0
- package/dist/types/mapEdit/mapFile.d.ts +77 -0
- package/dist/types/mapEdit/model.d.ts +55 -0
- package/package.json +18 -2
- package/src/editor/MapEditor.tsx +1545 -0
- package/src/editor/PlaceField.tsx +170 -0
- package/src/editor/PlaceMap.tsx +88 -0
- package/src/editor/PlaceMapDialog.tsx +228 -0
- package/src/editor/StoryEditor.tsx +50 -14
- package/src/editor/index.ts +11 -2
- package/src/editor/mapEditorStrings.ts +109 -0
- package/src/editor/model.ts +38 -1
- package/src/editor/placeSearch.ts +61 -0
- package/src/editor/placeShapes.ts +63 -0
- package/src/editor/strings.ts +56 -0
- package/src/mapEdit/geometry.ts +211 -0
- package/src/mapEdit/index.ts +15 -0
- package/src/mapEdit/mapFile.ts +199 -0
- package/src/mapEdit/model.ts +250 -0
- 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,
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
package/src/editor/index.ts
CHANGED
|
@@ -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,
|
|
10
|
-
|
|
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";
|