@gramps-connect/story 0.4.0 → 0.6.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.
@@ -2,7 +2,8 @@
2
2
  // map, dragging the pin, typing coordinates, or a search by name (opt-in:
3
3
  // the one thing the editor sends anywhere). Picking one of the story's
4
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.
5
+ // A place's shapes and old maps are edited full screen (onEditMap), and
6
+ // always kept.
6
7
  import { useEffect, useMemo, useRef, useState } from "react";
7
8
  import { Autocomplete, Button, Group, Loader, Stack, Text, TextInput, Tooltip, UnstyledButton } from "@mantine/core";
8
9
  import type { Place } from "../types";
@@ -28,6 +29,9 @@ export interface PlaceFieldProps {
28
29
  strings: EditorStrings;
29
30
  /** A change, as a function of the place as it is by then. */
30
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;
31
35
  }
32
36
 
33
37
  type Found =
@@ -36,7 +40,7 @@ type Found =
36
40
  | { state: "results"; results: PlaceSearchResult[] }
37
41
  | { state: "message"; message: string };
38
42
 
39
- export function PlaceField({ place, choices, around, language, search, dark, strings, onChange }: PlaceFieldProps) {
43
+ export function PlaceField({ place, choices, around, language, search, dark, strings, onChange, onEditMap }: PlaceFieldProps) {
40
44
  const lat = place?.lat;
41
45
  const lon = place?.lon;
42
46
  const point = lat !== undefined && lon !== undefined ? ([lon, lat] as [number, number]) : null;
@@ -155,6 +159,12 @@ export function PlaceField({ place, choices, around, language, search, dark, str
155
159
  />
156
160
  <Text size="xs" c="dimmed">{strings.placeMapHint}</Text>
157
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
+ )}
158
168
  </Stack>
159
169
  );
160
170
  }
@@ -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
+ }
@@ -25,6 +25,8 @@ import {
25
25
  } from "./model";
26
26
  import { PhotosField } from "./PhotosField";
27
27
  import { PlaceField, type PlaceUpdate } from "./PlaceField";
28
+ import { PlaceMapDialog } from "./PlaceMapDialog";
29
+ import type { MapEditorStrings } from "./mapEditorStrings";
28
30
  import { WIKIDATA_PLACE_SEARCH, type PlaceSearch } from "./placeSearch";
29
31
  import { SlideList } from "./SlideList";
30
32
  import { StoryTextField } from "./StoryTextField";
@@ -60,6 +62,8 @@ export interface StoryEditorProps {
60
62
  /** Finding a place by name. By default Wikidata, and only when the
61
63
  * person asks; null leaves the search out. */
62
64
  placeSearch?: PlaceSearch | null;
65
+ /** The map editor's words (Edit map), over its English. */
66
+ mapStrings?: Partial<MapEditorStrings>;
63
67
  }
64
68
 
65
69
  /** The slide an issue's JSON Pointer is about, or STORY. */
@@ -98,7 +102,7 @@ function Thumb({ doc, slide, resolveMedia }: { doc: StoryDoc; slide: Slide; reso
98
102
 
99
103
  export function StoryEditor({
100
104
  initialStory, initialFiles, onChange, prepareImage: prepare = prepareImage, toolbar, brand, dark = false,
101
- strings: stringOverrides, playerStrings, parseDateText = suggestEdtf, placeSearch = WIKIDATA_PLACE_SEARCH,
105
+ strings: stringOverrides, playerStrings, parseDateText = suggestEdtf, placeSearch = WIKIDATA_PLACE_SEARCH, mapStrings,
102
106
  }: StoryEditorProps) {
103
107
  const strings = useMemo(() => ({ ...EDITOR_ENGLISH, ...stringOverrides }), [stringOverrides]);
104
108
  const [history, setHistory] = useState<History>(() => startHistory(initialStory));
@@ -158,6 +162,9 @@ export function StoryEditor({
158
162
  return after === before ? h : record(h, updateSlide(h.present, at, { place: after }), `${slideId}:${key}`);
159
163
  });
160
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;
161
168
  const placeChoices = useMemo(() => storyPlaceChoices(doc, index), [doc, index]);
162
169
  const around = useMemo(() => storyPlaces(doc.slides.map((s) => s.place))?.bounds ?? null, [doc.slides]);
163
170
 
@@ -300,6 +307,7 @@ export function StoryEditor({
300
307
  <PlaceField
301
308
  place={slide.place} choices={placeChoices} around={around} language={doc.language} search={placeSearch} dark={dark} strings={strings}
302
309
  onChange={(update, key) => setPlace(slide.id, update, key)}
310
+ onEditMap={() => setMapSlideId(slide.id)}
303
311
  />
304
312
  <PhotosField
305
313
  photos={slide.media ?? []} media={doc.media} urlFor={urlFor} strings={strings} busy={busy}
@@ -423,6 +431,21 @@ export function StoryEditor({
423
431
  {column(index === STORY ? storyForm : slideForm, wide ? "min(520px, 36%)" : undefined)}
424
432
  {column(preview)}
425
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
+ )}
426
449
  <Modal opened={presenting} onClose={() => setPresenting(false)} fullScreen withCloseButton={false} padding={0}>
427
450
  <Box style={{ position: "fixed", inset: 0 }}>
428
451
  <StoryPlayer
@@ -1,6 +1,12 @@
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";
3
7
  export { PlaceField, type PlaceFieldProps, type PlaceUpdate } from "./PlaceField";
8
+ export { PlaceMapDialog, type PlaceMapDialogProps } from "./PlaceMapDialog";
9
+ export { editorToPlace, placeFeatures, placeToEditor } from "./placeShapes";
4
10
  export { searchWikidataPlaces, wikidataPoint, WIKIDATA_PLACE_SEARCH, type PlaceSearch, type PlaceSearchResult } from "./placeSearch";
5
11
  export { StoryTextField, type StoryTextFieldProps } from "./StoryTextField";
6
12
  export { SlideList, type SlideListProps } from "./SlideList";
@@ -0,0 +1,109 @@
1
+ // The map editor's words. English here; Gramps Connect passes its own
2
+ // translations (its t() is keyed by these same English texts).
3
+
4
+ export interface MapEditorStrings {
5
+ point: string;
6
+ line: string;
7
+ polygon: string;
8
+ rectangle: string;
9
+ select: string;
10
+ image: string;
11
+ editOptions: string;
12
+ labelText: string;
13
+ regionName: string;
14
+ fillOpacity: string;
15
+ scale: string;
16
+ transform: string;
17
+ overlayName: string;
18
+ opacity: string;
19
+ stackingOrder: string;
20
+ removeImage: string;
21
+ resizeImage: string;
22
+ rotateImage: string;
23
+ warpCorner: string;
24
+ basemapFailed: string;
25
+ imageFailed: string;
26
+ lockedOne: string;
27
+ lockedMany: (count: number) => string;
28
+ lockedExplain: string;
29
+ // The help (MapEditorHelp), in the pieces it's put together from.
30
+ helpTitle: string;
31
+ helpDrawing: string;
32
+ helpPoint: string;
33
+ helpLine: string;
34
+ helpEnterFinishes: string;
35
+ helpEscCancels: string;
36
+ helpRectangle: string;
37
+ helpImage: string;
38
+ helpImages: string;
39
+ helpBelow: string;
40
+ helpEditing: string;
41
+ helpSelect: string;
42
+ helpVertex: string;
43
+ helpColor: string;
44
+ helpDelete: string;
45
+ helpOr: string;
46
+ helpRotate: string;
47
+ helpRotateHandle: string;
48
+ helpImageMove: string;
49
+ helpImageResize: string;
50
+ helpImageRotate: string;
51
+ helpImageTransform: string;
52
+ helpImageRemove: string;
53
+ }
54
+
55
+ export const MAP_EDITOR_ENGLISH: MapEditorStrings = {
56
+ point: "Point",
57
+ line: "Line",
58
+ polygon: "Polygon",
59
+ rectangle: "Rectangle",
60
+ select: "Select",
61
+ image: "Image",
62
+ editOptions: "Edit Options",
63
+ labelText: "Label text",
64
+ regionName: "Region name (optional)",
65
+ fillOpacity: "Fill opacity",
66
+ scale: "Scale",
67
+ transform: "Transform",
68
+ overlayName: "Overlay name (optional)",
69
+ opacity: "Opacity",
70
+ stackingOrder: "Stacking order",
71
+ removeImage: "Remove this image",
72
+ resizeImage: "Drag to resize (keeps its exact shape, warped or not)",
73
+ rotateImage: "Drag to rotate",
74
+ warpCorner: "Drag to warp this corner",
75
+ basemapFailed: "The basemap could not be loaded.",
76
+ imageFailed: "The image failed to load.",
77
+ lockedOne: "1 shape can't be edited here",
78
+ lockedMany: (count) => `${count} shapes can't be edited here`,
79
+ lockedExplain: "For example, a region with a hole in it, or one whose outline crosses itself. It's drawn dashed, and saved exactly as it was.",
80
+ helpTitle: "How to use this editor",
81
+ helpDrawing: "Drawing",
82
+ helpPoint: "Point: click once to place it.",
83
+ helpLine: "Line, Polygon: click to place each point, then click that first point's own marker again to "
84
+ + "finish -- it's styled differently once there's enough points to close the shape. Pressing",
85
+ helpEnterFinishes: "finishes it too;",
86
+ helpEscCancels: "cancels it.",
87
+ helpRectangle: "Rectangle: click and drag from one corner to the opposite corner.",
88
+ helpImage: "Image: pick a media object to overlay -- see",
89
+ helpImages: "Images",
90
+ helpBelow: "below.",
91
+ helpEditing: "Editing a shape",
92
+ helpSelect: "Select: click a shape to select it, then drag it or one of its handles.",
93
+ helpVertex: "Drag a vertex to move it. To add a new point on a Line, Polygon, or Rectangle, drag the small "
94
+ + "midpoint marker between two existing vertices outward.",
95
+ helpColor: "The color swatch sets the next shape's color -- or, with a shape selected, changes its color.",
96
+ helpDelete: "Delete a selected shape or image with",
97
+ helpOr: "or",
98
+ helpRotate: "Rotate a selected Polygon, Rectangle, or Line: hold",
99
+ helpRotateHandle: "while dragging one of its handles.",
100
+ helpImageMove: "Drag anywhere on an image to move it.",
101
+ helpImageResize: "Drag its ↘ handle to resize it -- grows or shrinks from its own center, "
102
+ + "keeping its exact shape (works the same on a warped image, too).",
103
+ helpImageRotate: "Drag its ↻ handle to rotate it -- handy for aligning a scanned old map.",
104
+ helpImageTransform: "Switch to \"Transform\" to also show 4 corner handles you can drag independently "
105
+ + "(rubber-sheeting) -- for a scanned map that isn't printed at a uniform scale/orientation. "
106
+ + "Switching back to \"Scale\" just hides those 4 handles again; move/resize/rotate keep "
107
+ + "working on the image's exact current shape either way.",
108
+ helpImageRemove: "Click its × handle to remove it.",
109
+ };
@@ -228,13 +228,14 @@ export function storyFileName(title: string): string {
228
228
  return `${safe || "story"}.story.zip`;
229
229
  }
230
230
 
231
- /** Undo and redo over whole documents. Typing in one field makes one step,
232
- * not one per keystroke: a change with the same `key` within COALESCE_MS
233
- * of the last replaces it. */
234
- export interface History {
235
- past: StoryDoc[];
236
- present: StoryDoc;
237
- future: StoryDoc[];
231
+ /** Undo and redo over whole documents (a StoryDoc here; any value that's
232
+ * replaced, never changed in place, works). Typing in one field makes one
233
+ * step, not one per keystroke: a change with the same `key` within
234
+ * COALESCE_MS of the last replaces it. */
235
+ export interface History<T = StoryDoc> {
236
+ past: T[];
237
+ present: T;
238
+ future: T[];
238
239
  lastKey?: string;
239
240
  lastAt?: number;
240
241
  }
@@ -242,24 +243,24 @@ export interface History {
242
243
  export const COALESCE_MS = 1500;
243
244
  const MAX_STEPS = 200;
244
245
 
245
- export function startHistory(doc: StoryDoc): History {
246
+ export function startHistory<T = StoryDoc>(doc: T): History<T> {
246
247
  return { past: [], present: doc, future: [] };
247
248
  }
248
249
 
249
- export function record(history: History, next: StoryDoc, key?: string, now = Date.now()): History {
250
+ export function record<T>(history: History<T>, next: T, key?: string, now = Date.now()): History<T> {
250
251
  if (next === history.present) return history;
251
252
  const coalesce = key !== undefined && key === history.lastKey && history.lastAt !== undefined && now - history.lastAt < COALESCE_MS;
252
253
  const past = coalesce ? history.past : [...history.past, history.present].slice(-MAX_STEPS);
253
254
  return { past, present: next, future: [], lastKey: key, lastAt: now };
254
255
  }
255
256
 
256
- export function undo(history: History): History {
257
+ export function undo<T>(history: History<T>): History<T> {
257
258
  const previous = history.past[history.past.length - 1];
258
259
  if (!previous) return history;
259
260
  return { past: history.past.slice(0, -1), present: previous, future: [history.present, ...history.future] };
260
261
  }
261
262
 
262
- export function redo(history: History): History {
263
+ export function redo<T>(history: History<T>): History<T> {
263
264
  const [next, ...future] = history.future;
264
265
  if (!next) return history;
265
266
  return { past: [...history.past, history.present], present: next, future };
@@ -0,0 +1,63 @@
1
+ // A slide's place as the map editor sees it, and back: the place's
2
+ // `geometry` (a FeatureCollection, a Feature or a bare geometry) as
3
+ // features, and its `overlays` as image overlays keyed by media. Whatever
4
+ // the editor doesn't edit -- an overlay's date, fields this version
5
+ // doesn't know, a FeatureCollection's own members -- is carried over from
6
+ // the place as it was.
7
+ import type { Feature, FeatureCollection, Geometry } from "geojson";
8
+ import type { Overlay, Place, StoryDate } from "../types";
9
+ import type { MapEditorOverlay, MapEditorResult } from "./MapEditor";
10
+ import { patchPlace } from "./model";
11
+
12
+ /** The features in a place's `geometry`, whichever form it takes. */
13
+ export function placeFeatures(place: Place | undefined): Feature[] {
14
+ const geometry = place?.geometry as { type: string; features?: unknown[] } | undefined;
15
+ if (!geometry) return [];
16
+ if (geometry.type === "FeatureCollection") {
17
+ return ((geometry.features ?? []) as Feature[]).filter((f) => f && f.geometry);
18
+ }
19
+ if (geometry.type === "Feature") return (geometry as unknown as Feature).geometry ? [geometry as unknown as Feature] : [];
20
+ return [{ type: "Feature", properties: {}, geometry: geometry as unknown as Geometry }];
21
+ }
22
+
23
+ /** What the map editor opens with for a place. */
24
+ export function placeToEditor(place: Place | undefined): { features: Feature[]; overlays: MapEditorOverlay[] } {
25
+ return {
26
+ features: placeFeatures(place),
27
+ overlays: (place?.overlays ?? []).map((o) => ({
28
+ image: o.media, corners: o.corners,
29
+ ...(o.opacity !== undefined ? { opacity: o.opacity } : {}),
30
+ ...(o.order !== undefined ? { order: o.order } : {}),
31
+ })),
32
+ };
33
+ }
34
+
35
+ /** The place with what the map editor ended with. `dates` holds each
36
+ * overlay's date by media key, as edited beside the editor. */
37
+ export function editorToPlace(
38
+ place: Place | undefined, result: MapEditorResult, dates: Map<string, StoryDate | undefined>,
39
+ ): Place | undefined {
40
+ const before = place?.geometry as (FeatureCollection & Record<string, unknown>) | undefined;
41
+ const geometry = result.features.length === 0
42
+ ? undefined
43
+ // A FeatureCollection keeps its own other members; anything else
44
+ // becomes one.
45
+ : { ...(before?.type === "FeatureCollection" ? before : {}), type: "FeatureCollection", features: result.features };
46
+ const previous = new Map((place?.overlays ?? []).map((o) => [o.media, o]));
47
+ const overlays = result.overlays.map((o): Overlay => {
48
+ const { opacity: _o, order: _r, date: _d, ...kept } = previous.get(o.image) ?? ({} as Partial<Overlay>);
49
+ const date = dates.has(o.image) ? dates.get(o.image) : previous.get(o.image)?.date;
50
+ return {
51
+ ...kept,
52
+ media: o.image,
53
+ corners: o.corners,
54
+ ...(o.opacity !== undefined && o.opacity !== 1 ? { opacity: o.opacity } : {}),
55
+ ...(o.order !== undefined ? { order: Math.round(o.order) } : {}),
56
+ ...(date ? { date } : {}),
57
+ };
58
+ });
59
+ return patchPlace(place, {
60
+ geometry: geometry as Place["geometry"],
61
+ overlays: overlays.length ? overlays : undefined,
62
+ });
63
+ }
@@ -47,6 +47,19 @@ export interface EditorStrings {
47
47
  placeShape: string;
48
48
  placeOverlays: (count: number) => string;
49
49
  placeKeeps: (what: string) => string;
50
+ editMap: string;
51
+ editMapHint: string;
52
+ mapDialogTitle: (placeName: string | undefined) => string;
53
+ mapHelp: string;
54
+ importShapes: string;
55
+ importShapesHint: string;
56
+ importFailed: (fileName: string) => string;
57
+ importNothing: (fileName: string) => string;
58
+ overlayAlready: string;
59
+ overlayDate: string;
60
+ overlayDateHint: string;
61
+ cancel: string;
62
+ done: string;
50
63
  photos: string;
51
64
  photosHint: string;
52
65
  addPhoto: string;
@@ -113,7 +126,20 @@ export const EDITOR_ENGLISH: EditorStrings = {
113
126
  placeMapHint: "Click the map to put the pin there, or drag the pin.",
114
127
  placeShape: "a shape",
115
128
  placeOverlays: (count) => (count === 1 ? "a map overlay" : `${count} map overlays`),
116
- placeKeeps: (what) => `This place also has ${what}, kept as they are.`,
129
+ placeKeeps: (what) => `This place also has ${what}.`,
130
+ editMap: "Edit map",
131
+ editMapHint: "Shapes under the pin (a parish, a route) and scans of old maps laid over it.",
132
+ mapDialogTitle: (placeName) => (placeName ? `Map: ${placeName}` : "Map"),
133
+ mapHelp: "How to use this editor",
134
+ importShapes: "Import shapes",
135
+ importShapesHint: "Adds the points, lines and areas in a KML, GPX or GeoJSON file to this map.",
136
+ importFailed: (fileName) => `Couldn't read ${fileName} as a KML, GPX or GeoJSON file.`,
137
+ importNothing: (fileName) => `${fileName} has no shapes in it.`,
138
+ overlayAlready: "That image is already on this map.",
139
+ overlayDate: "Years it shows",
140
+ overlayDateHint: "e.g. 1850, or 1850 to 1880. With historical maps, it's hidden on slides outside them.",
141
+ cancel: "Cancel",
142
+ done: "Done",
117
143
  photos: "Photos",
118
144
  photosHint: "Add, drop or paste a photo. It's resized for the screen, and hidden details like camera and location are left out.",
119
145
  addPhoto: "Add photo",