@gramps-connect/story 0.1.2 → 0.2.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 +49 -2
- package/dist/editor.js +3539 -0
- package/dist/editor.js.map +7 -0
- package/dist/embed.js +43 -43
- package/dist/player.js +74 -13
- package/dist/player.js.map +2 -2
- package/dist/types/editor/DateField.d.ts +12 -0
- package/dist/types/editor/SlideList.d.ts +12 -0
- package/dist/types/editor/StoryEditor.d.ts +28 -0
- package/dist/types/editor/StoryTextField.d.ts +9 -0
- package/dist/types/editor/dates.d.ts +6 -0
- package/dist/types/editor/index.d.ts +7 -0
- package/dist/types/editor/model.d.ts +55 -0
- package/dist/types/editor/strings.d.ts +40 -0
- package/dist/types/player/MapBackground.d.ts +4 -0
- package/dist/types/player/StoryPlayer.d.ts +4 -1
- package/dist/types/player/geo.d.ts +8 -0
- package/package.json +21 -5
- package/src/editor/DateField.tsx +95 -0
- package/src/editor/SlideList.tsx +54 -0
- package/src/editor/StoryEditor.tsx +333 -0
- package/src/editor/StoryTextField.tsx +61 -0
- package/src/editor/dates.ts +21 -0
- package/src/editor/index.ts +10 -0
- package/src/editor/model.ts +167 -0
- package/src/editor/strings.ts +82 -0
- package/src/player/MapBackground.tsx +49 -18
- package/src/player/StoryPlayer.tsx +22 -3
- package/src/player/geo.ts +22 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export interface EditorStrings {
|
|
2
|
+
story: string;
|
|
3
|
+
storySettings: string;
|
|
4
|
+
title: string;
|
|
5
|
+
language: string;
|
|
6
|
+
languageHint: string;
|
|
7
|
+
map: string;
|
|
8
|
+
mapStandard: string;
|
|
9
|
+
mapHistorical: string;
|
|
10
|
+
mapAuto: string;
|
|
11
|
+
mapNone: string;
|
|
12
|
+
slides: string;
|
|
13
|
+
untitledSlide: string;
|
|
14
|
+
addSlide: string;
|
|
15
|
+
duplicateSlide: string;
|
|
16
|
+
deleteSlide: string;
|
|
17
|
+
moveUp: string;
|
|
18
|
+
moveDown: string;
|
|
19
|
+
slideTitle: string;
|
|
20
|
+
text: string;
|
|
21
|
+
bold: string;
|
|
22
|
+
italic: string;
|
|
23
|
+
date: string;
|
|
24
|
+
dateHint: string;
|
|
25
|
+
preciseDate: string;
|
|
26
|
+
preciseDateHint: string;
|
|
27
|
+
preciseDateFollows: string;
|
|
28
|
+
preciseDateNotUnderstood: string;
|
|
29
|
+
matchDateAsWritten: string;
|
|
30
|
+
noPreciseDate: string;
|
|
31
|
+
place: string;
|
|
32
|
+
photos: string;
|
|
33
|
+
undo: string;
|
|
34
|
+
redo: string;
|
|
35
|
+
present: string;
|
|
36
|
+
preview: string;
|
|
37
|
+
problems: (count: number) => string;
|
|
38
|
+
openingSlide: string;
|
|
39
|
+
}
|
|
40
|
+
export declare const EDITOR_ENGLISH: EditorStrings;
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import "maplibre-gl/dist/maplibre-gl.css";
|
|
2
2
|
import type { Place } from "../types";
|
|
3
|
+
import { type StoryPlaces } from "./geo";
|
|
3
4
|
import { type PlayerColors } from "./theme";
|
|
4
5
|
/** The current slide, as far as the map cares. `key` is the slide id:
|
|
5
6
|
* what tells "a new slide" from "the same slide, re-rendered". */
|
|
6
7
|
export interface MapSlide {
|
|
7
8
|
key: string;
|
|
8
9
|
place: Place | undefined;
|
|
10
|
+
/** Every place in the story, on an opening slide with no place of its
|
|
11
|
+
* own: framed together, a dot each, instead of the pin. */
|
|
12
|
+
overview: StoryPlaces | null;
|
|
9
13
|
}
|
|
10
14
|
export declare function MapBackground({ initialCenter, current, dark, colors, panelFraction, ohmYear, resolveMedia }: {
|
|
11
15
|
initialCenter: [number, number];
|
|
@@ -28,6 +28,9 @@ export interface StoryPlayerProps {
|
|
|
28
28
|
renderMapModeControl?: (props: MapModeControlProps) => ReactNode;
|
|
29
29
|
/** Shown before the title, e.g. ViewerBrand on a standalone page. */
|
|
30
30
|
brand?: ReactNode;
|
|
31
|
+
/** The host's own buttons, at the right of the title bar (before the
|
|
32
|
+
* close button), e.g. the viewer site's Edit. */
|
|
33
|
+
actions?: ReactNode;
|
|
31
34
|
initialSlide?: number;
|
|
32
35
|
}
|
|
33
|
-
export declare function StoryPlayer({ story, resolveMedia, dark, colors: colorOverrides, strings: stringOverrides, onClose, keyboard, onSlideChange, renderMapModeControl, brand, initialSlide, }: StoryPlayerProps): import("react").JSX.Element;
|
|
36
|
+
export declare function StoryPlayer({ story, resolveMedia, dark, colors: colorOverrides, strings: stringOverrides, onClose, keyboard, onSlideChange, renderMapModeControl, brand, actions, initialSlide, }: StoryPlayerProps): import("react").JSX.Element;
|
|
@@ -6,3 +6,11 @@ export declare function placeBounds(place: Place | undefined): Bounds | null;
|
|
|
6
6
|
/** Where to put the pin: the place's own point, else the middle of its
|
|
7
7
|
* shape and overlays. Null for a slide with no position at all. */
|
|
8
8
|
export declare function placeCenter(place: Place | undefined): LonLat | null;
|
|
9
|
+
export interface StoryPlaces {
|
|
10
|
+
points: LonLat[];
|
|
11
|
+
bounds: Bounds;
|
|
12
|
+
}
|
|
13
|
+
/** Every place in the story at once, for its opening view: one point per
|
|
14
|
+
* distinct place, and the box around all of them, shapes and overlays
|
|
15
|
+
* included. Null when no slide has a position. */
|
|
16
|
+
export declare function storyPlaces(places: (Place | undefined)[]): StoryPlaces | null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gramps-connect/story",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Read, write, validate and present .story.zip files: portable, versioned family-history stories.",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"type": "module",
|
|
@@ -31,6 +31,11 @@
|
|
|
31
31
|
"types": "./dist/types/player/index.d.ts",
|
|
32
32
|
"default": "./dist/player.js"
|
|
33
33
|
},
|
|
34
|
+
"./editor": {
|
|
35
|
+
"source": "./src/editor/index.ts",
|
|
36
|
+
"types": "./dist/types/editor/index.d.ts",
|
|
37
|
+
"default": "./dist/editor.js"
|
|
38
|
+
},
|
|
34
39
|
"./embed": "./dist/embed.js",
|
|
35
40
|
"./schema/1.0": "./spec/1.0/story.schema.json",
|
|
36
41
|
"./package.json": "./package.json"
|
|
@@ -51,17 +56,20 @@
|
|
|
51
56
|
"build:site": "node scripts/build.mjs && node scripts/build-site.mjs",
|
|
52
57
|
"prepack": "node scripts/build.mjs",
|
|
53
58
|
"generate": "node scripts/generate-validator.mjs",
|
|
54
|
-
"test": "tsx --test src/__tests__/*.test.ts",
|
|
59
|
+
"test": "tsx --conditions=source --test src/__tests__/*.test.ts",
|
|
55
60
|
"typecheck": "tsc --noEmit",
|
|
56
|
-
"test:browser": "node scripts/test-image.mjs && node scripts/test-player.mjs && npm run build:site && node scripts/test-embed.mjs && node scripts/test-site.mjs",
|
|
61
|
+
"test:browser": "node scripts/test-image.mjs && node scripts/test-player.mjs && npm run build:site && node scripts/test-embed.mjs && node scripts/test-site.mjs && node scripts/test-editor.mjs",
|
|
57
62
|
"test:pack": "node scripts/test-pack.mjs",
|
|
58
63
|
"test:python": "${PYTHON:-python3} -m unittest discover -s python && tsx scripts/test-python.ts"
|
|
59
64
|
},
|
|
60
65
|
"dependencies": {
|
|
61
|
-
"
|
|
62
|
-
"@openhistoricalmap/maplibre-gl-dates": "^1.4.0"
|
|
66
|
+
"@gramps-connect/gramps-date": "^0.1.0",
|
|
67
|
+
"@openhistoricalmap/maplibre-gl-dates": "^1.4.0",
|
|
68
|
+
"fflate": "^0.8.3"
|
|
63
69
|
},
|
|
64
70
|
"devDependencies": {
|
|
71
|
+
"@mantine/core": "^8.3.18",
|
|
72
|
+
"@mantine/hooks": "^8.3.18",
|
|
65
73
|
"@types/geojson": "^7946.0.16",
|
|
66
74
|
"@types/node": "^26.1.2",
|
|
67
75
|
"@types/react": "^18.3.12",
|
|
@@ -76,11 +84,19 @@
|
|
|
76
84
|
"typescript": "^7.0.2"
|
|
77
85
|
},
|
|
78
86
|
"peerDependencies": {
|
|
87
|
+
"@mantine/core": "^8",
|
|
88
|
+
"@mantine/hooks": "^8",
|
|
79
89
|
"maplibre-gl": ">=5",
|
|
80
90
|
"react": "^18.2 || ^19",
|
|
81
91
|
"react-dom": "^18.2 || ^19"
|
|
82
92
|
},
|
|
83
93
|
"peerDependenciesMeta": {
|
|
94
|
+
"@mantine/core": {
|
|
95
|
+
"optional": true
|
|
96
|
+
},
|
|
97
|
+
"@mantine/hooks": {
|
|
98
|
+
"optional": true
|
|
99
|
+
},
|
|
84
100
|
"maplibre-gl": {
|
|
85
101
|
"optional": true
|
|
86
102
|
},
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// A slide's date: the text readers see, and its precise form (EDTF), which
|
|
2
|
+
// follows the text as it's typed until someone changes it by hand.
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { Anchor, Group, Stack, Text, TextInput } from "@mantine/core";
|
|
5
|
+
import type { StoryDate } from "../types";
|
|
6
|
+
import { edtfYearRange } from "../player/dates";
|
|
7
|
+
import type { DateTextParser } from "./dates";
|
|
8
|
+
import { slideDate } from "./model";
|
|
9
|
+
import type { EditorStrings } from "./strings";
|
|
10
|
+
|
|
11
|
+
export interface DateFieldProps {
|
|
12
|
+
value: StoryDate | undefined;
|
|
13
|
+
language: string | undefined;
|
|
14
|
+
parse: DateTextParser;
|
|
15
|
+
strings: EditorStrings;
|
|
16
|
+
onChange: (date: StoryDate | undefined) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Keyed by slide in the parent, so `following` starts over per slide. */
|
|
20
|
+
export function DateField({ value, language, parse, strings, onChange }: DateFieldProps) {
|
|
21
|
+
// Whether the precise form is worked out from the text. Unknown (null)
|
|
22
|
+
// until the stored one has been compared with what the text gives.
|
|
23
|
+
const [following, setFollowing] = useState<boolean | null>(value?.edtf ? null : true);
|
|
24
|
+
const latest = useRef(value);
|
|
25
|
+
latest.current = value;
|
|
26
|
+
const request = useRef(0);
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (following !== null) return;
|
|
30
|
+
let live = true;
|
|
31
|
+
void parse(value?.text ?? "", language).then((edtf) => {
|
|
32
|
+
if (live) setFollowing(edtf === value?.edtf);
|
|
33
|
+
});
|
|
34
|
+
return () => { live = false; };
|
|
35
|
+
// Once, for the stored date.
|
|
36
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
37
|
+
}, []);
|
|
38
|
+
|
|
39
|
+
const follow = (text: string) => {
|
|
40
|
+
const id = ++request.current;
|
|
41
|
+
void parse(text, language).then((edtf) => {
|
|
42
|
+
// Only the latest text's answer, and only onto the date as it is now.
|
|
43
|
+
if (id !== request.current || latest.current?.text !== text) return;
|
|
44
|
+
onChange(slideDate(latest.current, text, edtf));
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const onText = (text: string) => {
|
|
49
|
+
onChange(slideDate(latest.current, text, latest.current?.edtf));
|
|
50
|
+
if (following !== false) follow(text);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const edtf = value?.edtf ?? "";
|
|
54
|
+
const understood = !edtf || edtfYearRange(edtf) !== null;
|
|
55
|
+
let note: string;
|
|
56
|
+
if (edtf && !understood) note = strings.preciseDateNotUnderstood;
|
|
57
|
+
else if (!edtf) note = strings.noPreciseDate;
|
|
58
|
+
else if (following) note = strings.preciseDateFollows;
|
|
59
|
+
else note = strings.preciseDateHint;
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<Stack gap={6}>
|
|
63
|
+
<TextInput
|
|
64
|
+
label={strings.date}
|
|
65
|
+
description={strings.dateHint}
|
|
66
|
+
value={value?.text ?? ""}
|
|
67
|
+
onChange={(e) => onText(e.currentTarget.value)}
|
|
68
|
+
/>
|
|
69
|
+
{value?.text && (
|
|
70
|
+
<Group gap="sm" align="flex-end" wrap="nowrap">
|
|
71
|
+
<TextInput
|
|
72
|
+
label={strings.preciseDate}
|
|
73
|
+
size="xs"
|
|
74
|
+
w={200}
|
|
75
|
+
value={edtf}
|
|
76
|
+
error={!understood}
|
|
77
|
+
styles={{ input: { fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace" } }}
|
|
78
|
+
onChange={(e) => {
|
|
79
|
+
setFollowing(false);
|
|
80
|
+
onChange(slideDate(latest.current, latest.current?.text ?? "", e.currentTarget.value.trim() || undefined));
|
|
81
|
+
}}
|
|
82
|
+
/>
|
|
83
|
+
<Stack gap={0} pb={4}>
|
|
84
|
+
<Text size="xs" c={understood ? "dimmed" : "red"}>{note}</Text>
|
|
85
|
+
{following === false && (
|
|
86
|
+
<Anchor component="button" type="button" size="xs" onClick={() => { setFollowing(true); follow(value.text); }}>
|
|
87
|
+
{strings.matchDateAsWritten}
|
|
88
|
+
</Anchor>
|
|
89
|
+
)}
|
|
90
|
+
</Stack>
|
|
91
|
+
</Group>
|
|
92
|
+
)}
|
|
93
|
+
</Stack>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// A list of slides to pick from and reorder by dragging -- generic over
|
|
2
|
+
// what a slide is, so any story editor can use it with its own rows.
|
|
3
|
+
import { useState, type ReactNode } from "react";
|
|
4
|
+
import { Stack, UnstyledButton } from "@mantine/core";
|
|
5
|
+
|
|
6
|
+
export interface SlideListProps<T> {
|
|
7
|
+
items: T[];
|
|
8
|
+
selected: number;
|
|
9
|
+
onSelect: (index: number) => void;
|
|
10
|
+
/** Called when a row is dropped somewhere else. */
|
|
11
|
+
onMove: (from: number, to: number) => void;
|
|
12
|
+
getKey: (item: T, index: number) => string;
|
|
13
|
+
renderItem: (item: T, index: number) => ReactNode;
|
|
14
|
+
label?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function SlideList<T>({ items, selected, onSelect, onMove, getKey, renderItem, label }: SlideListProps<T>) {
|
|
18
|
+
const [dragging, setDragging] = useState<number | null>(null);
|
|
19
|
+
const [over, setOver] = useState<number | null>(null);
|
|
20
|
+
const end = () => { setDragging(null); setOver(null); };
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<Stack gap={4} role="listbox" aria-label={label}>
|
|
24
|
+
{items.map((item, i) => {
|
|
25
|
+
const isSelected = i === selected;
|
|
26
|
+
// A line on the side the row will land: below when moving down.
|
|
27
|
+
const dropLine = over === i && dragging !== null && dragging !== i
|
|
28
|
+
? (dragging < i ? { borderBottom: "2px solid var(--mantine-primary-color-filled)" } : { borderTop: "2px solid var(--mantine-primary-color-filled)" })
|
|
29
|
+
: {};
|
|
30
|
+
return (
|
|
31
|
+
<UnstyledButton
|
|
32
|
+
key={getKey(item, i)}
|
|
33
|
+
role="option"
|
|
34
|
+
aria-selected={isSelected}
|
|
35
|
+
draggable
|
|
36
|
+
onClick={() => onSelect(i)}
|
|
37
|
+
onDragStart={(e) => { setDragging(i); e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", String(i)); }}
|
|
38
|
+
onDragOver={(e) => { if (dragging === null) return; e.preventDefault(); setOver(i); }}
|
|
39
|
+
onDrop={(e) => { e.preventDefault(); if (dragging !== null && dragging !== i) onMove(dragging, i); end(); }}
|
|
40
|
+
onDragEnd={end}
|
|
41
|
+
style={{
|
|
42
|
+
display: "block", width: "100%", padding: "6px 8px", borderRadius: 6,
|
|
43
|
+
background: isSelected ? "var(--mantine-primary-color-light)" : undefined,
|
|
44
|
+
opacity: dragging === i ? 0.5 : 1,
|
|
45
|
+
...dropLine,
|
|
46
|
+
}}
|
|
47
|
+
>
|
|
48
|
+
{renderItem(item, i)}
|
|
49
|
+
</UnstyledButton>
|
|
50
|
+
);
|
|
51
|
+
})}
|
|
52
|
+
</Stack>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// Edits a story document: a list of slides on the left, the selected
|
|
2
|
+
// slide's (or the story's own) fields in the middle, and the real player
|
|
3
|
+
// on the right showing that slide as it's changed. Works on the document
|
|
4
|
+
// only -- opening and saving files is the host's (the viewer site's editor
|
|
5
|
+
// page does it with the File System Access API or a download).
|
|
6
|
+
//
|
|
7
|
+
// Needs Mantine: wrap it in a MantineProvider and load @mantine/core's
|
|
8
|
+
// stylesheet, as with any Mantine component.
|
|
9
|
+
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
|
10
|
+
import {
|
|
11
|
+
ActionIcon, Badge, Box, Button, Divider, Group, Modal, Popover, Select, Stack, Text, TextInput, Tooltip, UnstyledButton,
|
|
12
|
+
} from "@mantine/core";
|
|
13
|
+
import { useMediaQuery } from "@mantine/hooks";
|
|
14
|
+
import type { MediaItem, Slide, StoryDoc } from "../types";
|
|
15
|
+
import { validate, type StoryIssue } from "../validate";
|
|
16
|
+
import { StoryPlayer } from "../player/StoryPlayer";
|
|
17
|
+
import type { PlayerStrings } from "../player/strings";
|
|
18
|
+
import { suggestEdtf, type DateTextParser } from "./dates";
|
|
19
|
+
import { DateField } from "./DateField";
|
|
20
|
+
import {
|
|
21
|
+
addSlide, deleteSlide, duplicateSlide, moveSlide, record, redo, startHistory, undo, updateSlide, updateStory,
|
|
22
|
+
type History,
|
|
23
|
+
} from "./model";
|
|
24
|
+
import { SlideList } from "./SlideList";
|
|
25
|
+
import { StoryTextField } from "./StoryTextField";
|
|
26
|
+
import { EDITOR_ENGLISH, type EditorStrings } from "./strings";
|
|
27
|
+
|
|
28
|
+
const BAR_HEIGHT = 56;
|
|
29
|
+
/** The story's own settings, in place of a slide index. */
|
|
30
|
+
const STORY = -1;
|
|
31
|
+
|
|
32
|
+
export interface StoryEditorProps {
|
|
33
|
+
/** The story to start from. Changing it later has no effect: remount
|
|
34
|
+
* (a new `key`) to edit another. */
|
|
35
|
+
initialStory: StoryDoc;
|
|
36
|
+
/** The story's files, for checking that every media file is there. */
|
|
37
|
+
files?: { has(path: string): boolean };
|
|
38
|
+
/** As for StoryPlayer. */
|
|
39
|
+
resolveMedia: (key: string, item: MediaItem) => Promise<string | null>;
|
|
40
|
+
/** Called with the document after every change, undo and redo included. */
|
|
41
|
+
onChange?: (doc: StoryDoc) => void;
|
|
42
|
+
/** The host's own buttons (Open, Save, ...), at the right of the top bar. */
|
|
43
|
+
toolbar?: ReactNode;
|
|
44
|
+
/** Shown first in the top bar, e.g. ViewerBrand. */
|
|
45
|
+
brand?: ReactNode;
|
|
46
|
+
dark?: boolean;
|
|
47
|
+
strings?: Partial<EditorStrings>;
|
|
48
|
+
playerStrings?: Partial<PlayerStrings>;
|
|
49
|
+
/** Date text -> EDTF. By default gramps-date, in the story's language. */
|
|
50
|
+
parseDateText?: DateTextParser;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The slide an issue's JSON Pointer is about, or STORY. */
|
|
54
|
+
function issueSlide(issue: StoryIssue): number {
|
|
55
|
+
const match = /^\/slides\/(\d+)/.exec(issue.path);
|
|
56
|
+
return match ? Number(match[1]) : STORY;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function Thumb({ doc, slide, resolveMedia }: { doc: StoryDoc; slide: Slide; resolveMedia: StoryEditorProps["resolveMedia"] }) {
|
|
60
|
+
const ref = slide.media?.[0]?.ref;
|
|
61
|
+
const item = ref && Object.prototype.hasOwnProperty.call(doc.media, ref) ? doc.media[ref] : undefined;
|
|
62
|
+
const [url, setUrl] = useState<string | null>(null);
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
let live = true;
|
|
65
|
+
setUrl(null);
|
|
66
|
+
if (ref && item) void resolveMedia(ref, item).then((u) => { if (live) setUrl(u); });
|
|
67
|
+
return () => { live = false; };
|
|
68
|
+
}, [ref, item, resolveMedia]);
|
|
69
|
+
return (
|
|
70
|
+
<Box
|
|
71
|
+
w={40} h={40}
|
|
72
|
+
style={{ flex: "none", borderRadius: 4, background: "var(--mantine-color-default-hover)", overflow: "hidden" }}
|
|
73
|
+
>
|
|
74
|
+
{url && <img src={url} alt="" style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }} />}
|
|
75
|
+
</Box>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function StoryEditor({
|
|
80
|
+
initialStory, files, resolveMedia, onChange, toolbar, brand, dark = false, strings: stringOverrides, playerStrings,
|
|
81
|
+
parseDateText = suggestEdtf,
|
|
82
|
+
}: StoryEditorProps) {
|
|
83
|
+
const strings = useMemo(() => ({ ...EDITOR_ENGLISH, ...stringOverrides }), [stringOverrides]);
|
|
84
|
+
const [history, setHistory] = useState<History>(() => startHistory(initialStory));
|
|
85
|
+
const [selected, setSelected] = useState(0);
|
|
86
|
+
const [presenting, setPresenting] = useState(false);
|
|
87
|
+
const [problemsOpen, setProblemsOpen] = useState(false);
|
|
88
|
+
const wide = useMediaQuery("(min-width: 1000px)", true);
|
|
89
|
+
const doc = history.present;
|
|
90
|
+
|
|
91
|
+
// Every change goes through here (and so into history and to the host).
|
|
92
|
+
const apply = useCallback((next: StoryDoc, key?: string) => {
|
|
93
|
+
setHistory((h) => record(h, next, key));
|
|
94
|
+
}, []);
|
|
95
|
+
useEffect(() => { onChange?.(doc); }, [doc, onChange]);
|
|
96
|
+
|
|
97
|
+
const index = selected === STORY ? STORY : Math.min(selected, doc.slides.length - 1);
|
|
98
|
+
const slide = index === STORY ? undefined : doc.slides[index];
|
|
99
|
+
const setSlide = (patch: Partial<Slide>, key: string) => {
|
|
100
|
+
if (index !== STORY) apply(updateSlide(history.present, index, patch), `${slide!.id}:${key}`);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const issues = useMemo(() => validate(doc, files).errors, [doc, files]);
|
|
104
|
+
|
|
105
|
+
// Undo/redo from the keyboard, except where a text field has its own.
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
const onKey = (e: KeyboardEvent) => {
|
|
108
|
+
if (!(e.ctrlKey || e.metaKey) || e.altKey) return;
|
|
109
|
+
const target = e.target as HTMLElement | null;
|
|
110
|
+
if (target?.closest("input, textarea, [contenteditable=true]")) return;
|
|
111
|
+
const key = e.key.toLowerCase();
|
|
112
|
+
if (key === "z" && !e.shiftKey) { e.preventDefault(); setHistory(undo); }
|
|
113
|
+
else if ((key === "z" && e.shiftKey) || key === "y") { e.preventDefault(); setHistory(redo); }
|
|
114
|
+
};
|
|
115
|
+
window.addEventListener("keydown", onKey);
|
|
116
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
117
|
+
}, []);
|
|
118
|
+
|
|
119
|
+
const slideActions = slide && (
|
|
120
|
+
<Group gap={6}>
|
|
121
|
+
<Tooltip label={strings.moveUp}>
|
|
122
|
+
<ActionIcon variant="default" aria-label={strings.moveUp} disabled={index === 0}
|
|
123
|
+
onClick={() => { apply(moveSlide(doc, index, index - 1)); setSelected(index - 1); }}>↑</ActionIcon>
|
|
124
|
+
</Tooltip>
|
|
125
|
+
<Tooltip label={strings.moveDown}>
|
|
126
|
+
<ActionIcon variant="default" aria-label={strings.moveDown} disabled={index === doc.slides.length - 1}
|
|
127
|
+
onClick={() => { apply(moveSlide(doc, index, index + 1)); setSelected(index + 1); }}>↓</ActionIcon>
|
|
128
|
+
</Tooltip>
|
|
129
|
+
<Button variant="default" size="xs" onClick={() => { const r = duplicateSlide(doc, index); apply(r.doc); setSelected(r.index); }}>
|
|
130
|
+
{strings.duplicateSlide}
|
|
131
|
+
</Button>
|
|
132
|
+
<Button variant="default" size="xs" color="red" disabled={doc.slides.length <= 1}
|
|
133
|
+
onClick={() => { const r = deleteSlide(doc, index); apply(r.doc); setSelected(r.index); }}>
|
|
134
|
+
{strings.deleteSlide}
|
|
135
|
+
</Button>
|
|
136
|
+
</Group>
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const storyForm = (
|
|
140
|
+
<Stack gap="md">
|
|
141
|
+
<Text fw={600} size="lg">{strings.story}</Text>
|
|
142
|
+
<TextInput
|
|
143
|
+
label={strings.title} required value={doc.title}
|
|
144
|
+
onChange={(e) => apply(updateStory(doc, { title: e.currentTarget.value }), "story:title")}
|
|
145
|
+
/>
|
|
146
|
+
<TextInput
|
|
147
|
+
label={strings.language} description={strings.languageHint} w={260} value={doc.language ?? ""}
|
|
148
|
+
onChange={(e) => apply(updateStory(doc, { language: e.currentTarget.value.trim() || undefined }), "story:language")}
|
|
149
|
+
/>
|
|
150
|
+
<Select
|
|
151
|
+
label={strings.map} w={320} allowDeselect={false}
|
|
152
|
+
data={[
|
|
153
|
+
{ value: "auto", label: strings.mapAuto },
|
|
154
|
+
{ value: "standard", label: strings.mapStandard },
|
|
155
|
+
{ value: "historical", label: strings.mapHistorical },
|
|
156
|
+
{ value: "none", label: strings.mapNone },
|
|
157
|
+
]}
|
|
158
|
+
value={doc.presentation?.basemap ?? "auto"}
|
|
159
|
+
onChange={(value) => value && apply(updateStory(doc, {
|
|
160
|
+
presentation: { ...doc.presentation, basemap: value as NonNullable<StoryDoc["presentation"]>["basemap"] },
|
|
161
|
+
}))}
|
|
162
|
+
/>
|
|
163
|
+
</Stack>
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const slideForm = slide && (
|
|
167
|
+
<Stack gap="md" key={slide.id}>
|
|
168
|
+
<Group justify="space-between" wrap="wrap" gap="xs">
|
|
169
|
+
<Text fw={600} size="lg">{index === 0 ? strings.openingSlide : `${index + 1} / ${doc.slides.length}`}</Text>
|
|
170
|
+
{slideActions}
|
|
171
|
+
</Group>
|
|
172
|
+
<TextInput
|
|
173
|
+
label={strings.slideTitle} value={slide.title ?? ""}
|
|
174
|
+
onChange={(e) => setSlide({ title: e.currentTarget.value || undefined }, "title")}
|
|
175
|
+
/>
|
|
176
|
+
<DateField
|
|
177
|
+
value={slide.date} language={doc.language} parse={parseDateText} strings={strings}
|
|
178
|
+
onChange={(date) => setSlide({ date }, "date")}
|
|
179
|
+
/>
|
|
180
|
+
<StoryTextField
|
|
181
|
+
label={strings.text} value={slide.text ?? ""} boldLabel={strings.bold} italicLabel={strings.italic} minRows={5}
|
|
182
|
+
onChange={(text) => setSlide({ text: text || undefined }, "text")}
|
|
183
|
+
/>
|
|
184
|
+
{slide.place && (
|
|
185
|
+
<Stack gap={2}>
|
|
186
|
+
<Text size="sm" fw={500}>{strings.place}</Text>
|
|
187
|
+
<Text size="sm">
|
|
188
|
+
{slide.place.name ?? ""}
|
|
189
|
+
{slide.place.lat !== undefined && slide.place.lon !== undefined && (
|
|
190
|
+
<Text span c="dimmed" size="sm"> {slide.place.lat.toFixed(4)}, {slide.place.lon.toFixed(4)}</Text>
|
|
191
|
+
)}
|
|
192
|
+
</Text>
|
|
193
|
+
</Stack>
|
|
194
|
+
)}
|
|
195
|
+
{slide.media && slide.media.length > 0 && (
|
|
196
|
+
<Stack gap={4}>
|
|
197
|
+
<Text size="sm" fw={500}>{strings.photos}</Text>
|
|
198
|
+
<Group gap="sm">
|
|
199
|
+
{slide.media.map((m, i) => (
|
|
200
|
+
<Group key={i} gap="xs" wrap="nowrap">
|
|
201
|
+
<Thumb doc={doc} slide={{ id: "", media: [m] }} resolveMedia={resolveMedia} />
|
|
202
|
+
<Text size="xs" c="dimmed" maw={200} lineClamp={2}>{m.caption ?? ""}</Text>
|
|
203
|
+
</Group>
|
|
204
|
+
))}
|
|
205
|
+
</Group>
|
|
206
|
+
</Stack>
|
|
207
|
+
)}
|
|
208
|
+
</Stack>
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
const list = (
|
|
212
|
+
<Stack gap="xs">
|
|
213
|
+
<UnstyledButton
|
|
214
|
+
onClick={() => setSelected(STORY)}
|
|
215
|
+
style={{ padding: "8px", borderRadius: 6, background: index === STORY ? "var(--mantine-primary-color-light)" : undefined }}
|
|
216
|
+
>
|
|
217
|
+
<Text fw={600} size="sm">{strings.story}</Text>
|
|
218
|
+
<Text size="xs" c="dimmed">{strings.storySettings}</Text>
|
|
219
|
+
</UnstyledButton>
|
|
220
|
+
<Divider label={strings.slides} labelPosition="left" />
|
|
221
|
+
<SlideList
|
|
222
|
+
label={strings.slides}
|
|
223
|
+
items={doc.slides}
|
|
224
|
+
selected={index}
|
|
225
|
+
onSelect={setSelected}
|
|
226
|
+
onMove={(from, to) => { apply(moveSlide(doc, from, to)); setSelected(to); }}
|
|
227
|
+
getKey={(s) => s.id}
|
|
228
|
+
renderItem={(s, i) => (
|
|
229
|
+
<Group gap="sm" wrap="nowrap">
|
|
230
|
+
<Thumb doc={doc} slide={s} resolveMedia={resolveMedia} />
|
|
231
|
+
<Box style={{ minWidth: 0 }}>
|
|
232
|
+
<Text size="sm" fw={500} truncate dir="auto" c={s.title ? undefined : "dimmed"}>
|
|
233
|
+
{s.title || strings.untitledSlide}
|
|
234
|
+
</Text>
|
|
235
|
+
<Text size="xs" c="dimmed" truncate>{i === 0 ? strings.openingSlide : s.date?.text ?? ""}</Text>
|
|
236
|
+
</Box>
|
|
237
|
+
</Group>
|
|
238
|
+
)}
|
|
239
|
+
/>
|
|
240
|
+
<Button
|
|
241
|
+
variant="light" size="xs" mt={4}
|
|
242
|
+
onClick={() => { const r = addSlide(doc, index === STORY ? doc.slides.length - 1 : index); apply(r.doc); setSelected(r.index); }}
|
|
243
|
+
>
|
|
244
|
+
+ {strings.addSlide}
|
|
245
|
+
</Button>
|
|
246
|
+
</Stack>
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
const preview = (
|
|
250
|
+
<Box style={{ position: "relative", height: wide ? "100%" : 460, borderRadius: 8, overflow: "hidden", border: "1px solid var(--mantine-color-default-border)" }}>
|
|
251
|
+
<StoryPlayer
|
|
252
|
+
story={doc} resolveMedia={resolveMedia} dark={dark} strings={playerStrings}
|
|
253
|
+
keyboard={false} initialSlide={index === STORY ? 0 : index}
|
|
254
|
+
onSlideChange={(i) => { if (i !== index && index !== STORY) setSelected(i); }}
|
|
255
|
+
/>
|
|
256
|
+
</Box>
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
const column = (children: ReactNode, width?: number | string) => (
|
|
260
|
+
<Box
|
|
261
|
+
style={{
|
|
262
|
+
width, flex: width ? "none" : 1, minWidth: 0, padding: 16,
|
|
263
|
+
overflowY: wide ? "auto" : undefined, height: wide ? "100%" : undefined,
|
|
264
|
+
}}
|
|
265
|
+
>
|
|
266
|
+
{children}
|
|
267
|
+
</Box>
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
return (
|
|
271
|
+
<Box style={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
|
272
|
+
<Group
|
|
273
|
+
h={BAR_HEIGHT} px="md" gap="sm" wrap="nowrap" justify="space-between"
|
|
274
|
+
style={{ flex: "none", borderBottom: "1px solid var(--mantine-color-default-border)" }}
|
|
275
|
+
>
|
|
276
|
+
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
|
277
|
+
{brand}
|
|
278
|
+
<Text fw={600} truncate dir="auto">{doc.title}</Text>
|
|
279
|
+
</Group>
|
|
280
|
+
<Group gap="xs" wrap="nowrap">
|
|
281
|
+
{issues.length > 0 && (
|
|
282
|
+
<Popover opened={problemsOpen} onChange={setProblemsOpen} width={360} position="bottom-end" withArrow>
|
|
283
|
+
<Popover.Target>
|
|
284
|
+
<Badge color="red" variant="light" style={{ cursor: "pointer" }} onClick={() => setProblemsOpen((o) => !o)}>
|
|
285
|
+
{strings.problems(issues.length)}
|
|
286
|
+
</Badge>
|
|
287
|
+
</Popover.Target>
|
|
288
|
+
<Popover.Dropdown>
|
|
289
|
+
<Stack gap={6}>
|
|
290
|
+
{issues.map((issue, i) => {
|
|
291
|
+
const at = issueSlide(issue);
|
|
292
|
+
return (
|
|
293
|
+
<UnstyledButton key={i} onClick={() => { setSelected(at); setProblemsOpen(false); }}>
|
|
294
|
+
<Text size="sm" fw={500}>{at === STORY ? strings.story : (doc.slides[at]?.title || `${strings.untitledSlide} ${at + 1}`)}</Text>
|
|
295
|
+
<Text size="xs" c="dimmed">{issue.path} {issue.message}</Text>
|
|
296
|
+
</UnstyledButton>
|
|
297
|
+
);
|
|
298
|
+
})}
|
|
299
|
+
</Stack>
|
|
300
|
+
</Popover.Dropdown>
|
|
301
|
+
</Popover>
|
|
302
|
+
)}
|
|
303
|
+
<Tooltip label={strings.undo}>
|
|
304
|
+
<ActionIcon variant="default" size="lg" aria-label={strings.undo} disabled={history.past.length === 0} onClick={() => setHistory(undo)}>↶</ActionIcon>
|
|
305
|
+
</Tooltip>
|
|
306
|
+
<Tooltip label={strings.redo}>
|
|
307
|
+
<ActionIcon variant="default" size="lg" aria-label={strings.redo} disabled={history.future.length === 0} onClick={() => setHistory(redo)}>↷</ActionIcon>
|
|
308
|
+
</Tooltip>
|
|
309
|
+
<Button variant="default" onClick={() => setPresenting(true)}>{strings.present}</Button>
|
|
310
|
+
{toolbar}
|
|
311
|
+
</Group>
|
|
312
|
+
</Group>
|
|
313
|
+
<Box
|
|
314
|
+
style={{
|
|
315
|
+
flex: 1, minHeight: 0, display: "flex", flexDirection: wide ? "row" : "column",
|
|
316
|
+
overflowY: wide ? undefined : "auto",
|
|
317
|
+
}}
|
|
318
|
+
>
|
|
319
|
+
{column(list, wide ? 280 : undefined)}
|
|
320
|
+
{column(index === STORY ? storyForm : slideForm, wide ? "min(520px, 36%)" : undefined)}
|
|
321
|
+
{column(preview)}
|
|
322
|
+
</Box>
|
|
323
|
+
<Modal opened={presenting} onClose={() => setPresenting(false)} fullScreen withCloseButton={false} padding={0}>
|
|
324
|
+
<Box style={{ position: "fixed", inset: 0 }}>
|
|
325
|
+
<StoryPlayer
|
|
326
|
+
story={doc} resolveMedia={resolveMedia} dark={dark} strings={playerStrings}
|
|
327
|
+
initialSlide={index === STORY ? 0 : index} onClose={() => setPresenting(false)}
|
|
328
|
+
/>
|
|
329
|
+
</Box>
|
|
330
|
+
</Modal>
|
|
331
|
+
</Box>
|
|
332
|
+
);
|
|
333
|
+
}
|