@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.
- package/README.md +14 -5
- package/dist/editor.js +1710 -121
- 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 +4 -1
- package/dist/types/editor/PlaceMapDialog.d.ts +18 -0
- package/dist/types/editor/StoryEditor.d.ts +4 -1
- package/dist/types/editor/index.d.ts +4 -0
- package/dist/types/editor/mapEditorStrings.d.ts +50 -0
- package/dist/types/editor/model.d.ts +12 -11
- package/dist/types/editor/placeShapes.d.ts +13 -0
- package/dist/types/editor/strings.d.ts +13 -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 +12 -2
- package/src/editor/PlaceMapDialog.tsx +228 -0
- package/src/editor/StoryEditor.tsx +24 -1
- package/src/editor/index.ts +6 -0
- package/src/editor/mapEditorStrings.ts +109 -0
- package/src/editor/model.ts +12 -11
- package/src/editor/placeShapes.ts +63 -0
- package/src/editor/strings.ts +27 -1
- 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
|
@@ -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
|
|
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
|
package/src/editor/index.ts
CHANGED
|
@@ -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
|
+
};
|
package/src/editor/model.ts
CHANGED
|
@@ -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
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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:
|
|
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
|
|
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
|
+
}
|
package/src/editor/strings.ts
CHANGED
|
@@ -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}
|
|
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",
|