@gramps-connect/story 0.1.3 → 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.
@@ -0,0 +1,61 @@
1
+ // A plain Textarea plus Bold/Italic buttons that wrap the selection in the
2
+ // story format's `**`/`*` markers -- the only formatting a slide's text has
3
+ // (parseStoryText is the matching reader), so no rich-text editor.
4
+ import { useRef } from "react";
5
+ import { Button, Group, Stack, Text, Textarea } from "@mantine/core";
6
+
7
+ /** Wraps the textarea's selection in `marker` (or unwraps it, if the
8
+ * selection is already exactly `marker…marker`); with nothing selected,
9
+ * inserts an empty pair and leaves the cursor between them. */
10
+ function applyMarker(el: HTMLTextAreaElement, marker: string, value: string, onChange: (v: string) => void) {
11
+ const start = el.selectionStart ?? value.length;
12
+ const end = el.selectionEnd ?? value.length;
13
+ if (start === end) {
14
+ onChange(value.slice(0, start) + marker + marker + value.slice(end));
15
+ requestAnimationFrame(() => {
16
+ el.focus();
17
+ el.setSelectionRange(start + marker.length, start + marker.length);
18
+ });
19
+ return;
20
+ }
21
+ const selected = value.slice(start, end);
22
+ const alreadyWrapped = selected.length > marker.length * 2 && selected.startsWith(marker) && selected.endsWith(marker);
23
+ const replacement = alreadyWrapped ? selected.slice(marker.length, selected.length - marker.length) : `${marker}${selected}${marker}`;
24
+ onChange(value.slice(0, start) + replacement + value.slice(end));
25
+ }
26
+
27
+ export interface StoryTextFieldProps {
28
+ label: string;
29
+ value: string;
30
+ onChange: (value: string) => void;
31
+ boldLabel?: string;
32
+ italicLabel?: string;
33
+ minRows?: number;
34
+ }
35
+
36
+ export function StoryTextField({ label, value, onChange, boldLabel = "Bold", italicLabel = "Italic", minRows = 3 }: StoryTextFieldProps) {
37
+ const ref = useRef<HTMLTextAreaElement>(null);
38
+ return (
39
+ <Stack gap={4}>
40
+ <Group gap="xs">
41
+ <Text size="sm" fw={500}>{label}</Text>
42
+ <Button
43
+ variant="default" size="compact-xs" fw={700} aria-label={boldLabel} title={boldLabel}
44
+ onClick={() => ref.current && applyMarker(ref.current, "**", value, onChange)}
45
+ >
46
+ B
47
+ </Button>
48
+ <Button
49
+ variant="default" size="compact-xs" fs="italic" aria-label={italicLabel} title={italicLabel}
50
+ onClick={() => ref.current && applyMarker(ref.current, "*", value, onChange)}
51
+ >
52
+ i
53
+ </Button>
54
+ </Group>
55
+ <Textarea
56
+ ref={ref} aria-label={label} autosize minRows={minRows} maxRows={14} value={value}
57
+ onChange={(e) => onChange(e.currentTarget.value)}
58
+ />
59
+ </Stack>
60
+ );
61
+ }
@@ -0,0 +1,21 @@
1
+ // A date as typed ("about 1850", "um 1850") to its precise form (EDTF),
2
+ // read the way Gramps reads dates, in the story's language: the player
3
+ // needs the precise form for the timeline and for picking historical maps.
4
+ import { loadLocale, parseDate, resolveLocaleCode, toEdtf } from "@gramps-connect/gramps-date";
5
+
6
+ /** Turns date text into EDTF, or undefined when the text isn't a date
7
+ * that has one ("spring 1850", a non-Gregorian date). */
8
+ export type DateTextParser = (text: string, language: string | undefined) => Promise<string | undefined>;
9
+
10
+ /** The default parser: gramps-date in the story's language (loaded on first
11
+ * use), English when the story has none or gramps-date doesn't know it. */
12
+ export const suggestEdtf: DateTextParser = async (text, language) => {
13
+ if (!text.trim()) return undefined;
14
+ const code = (language && resolveLocaleCode(language.replace(/-/g, "_"))) || "en";
15
+ if (code !== "en" && !(await loadLocale(code))) return undefined;
16
+ try {
17
+ return toEdtf(parseDate(text, { locale: code }));
18
+ } catch {
19
+ return undefined;
20
+ }
21
+ };
@@ -0,0 +1,10 @@
1
+ export { StoryEditor, type StoryEditorProps } from "./StoryEditor";
2
+ export { StoryTextField, type StoryTextFieldProps } from "./StoryTextField";
3
+ export { SlideList, type SlideListProps } from "./SlideList";
4
+ export { DateField, type DateFieldProps } from "./DateField";
5
+ export { suggestEdtf, type DateTextParser } from "./dates";
6
+ export { EDITOR_ENGLISH, type EditorStrings } from "./strings";
7
+ export {
8
+ addSlide, blankStory, COALESCE_MS, deleteSlide, duplicateSlide, moveSlide, nextSlideId, pruneMedia, record, redo,
9
+ slideDate, startHistory, storyFileName, undo, updateSlide, updateStory, usedMedia, type History,
10
+ } from "./model";
@@ -0,0 +1,167 @@
1
+ // The editor's changes to a story, as pure functions from one StoryDoc to
2
+ // the next -- never mutating, so undo is just keeping the old ones. Every
3
+ // change spreads the object it touches, so fields this version doesn't
4
+ // know (a newer minor version's, an extension's) ride along untouched:
5
+ // opening and saving a story must never lose anything.
6
+ import { FORMAT_ID, FORMAT_VERSION } from "../version";
7
+ import type { Slide, StoryDate, StoryDoc } from "../types";
8
+
9
+ /** A story with one empty slide: the smallest valid one. */
10
+ export function blankStory(title: string, language?: string): StoryDoc {
11
+ return {
12
+ format: FORMAT_ID,
13
+ version: FORMAT_VERSION,
14
+ title,
15
+ ...(language ? { language } : {}),
16
+ slides: [{ id: "s0", title }],
17
+ media: {},
18
+ };
19
+ }
20
+
21
+ /** "s<n>" past every id already of that form, so it's unique however the
22
+ * story's other ids were made. */
23
+ export function nextSlideId(doc: StoryDoc): string {
24
+ let max = -1;
25
+ for (const slide of doc.slides) {
26
+ const match = /^s(\d+)$/.exec(slide.id);
27
+ if (match) max = Math.max(max, Number(match[1]));
28
+ }
29
+ let n = max + 1;
30
+ const ids = new Set(doc.slides.map((s) => s.id));
31
+ while (ids.has(`s${n}`)) n += 1;
32
+ return `s${n}`;
33
+ }
34
+
35
+ function withSlides(doc: StoryDoc, slides: Slide[]): StoryDoc {
36
+ return { ...doc, slides };
37
+ }
38
+
39
+ /** A new empty slide after `index`; returns the story and the new slide's
40
+ * index. */
41
+ export function addSlide(doc: StoryDoc, index: number): { doc: StoryDoc; index: number } {
42
+ const slides = [...doc.slides];
43
+ const at = Math.min(Math.max(index + 1, 0), slides.length);
44
+ slides.splice(at, 0, { id: nextSlideId(doc) });
45
+ return { doc: withSlides(doc, slides), index: at };
46
+ }
47
+
48
+ /** A copy of the slide right after it, with its own id. */
49
+ export function duplicateSlide(doc: StoryDoc, index: number): { doc: StoryDoc; index: number } {
50
+ const slides = [...doc.slides];
51
+ const copy = structuredClone(slides[index]);
52
+ copy.id = nextSlideId(doc);
53
+ slides.splice(index + 1, 0, copy);
54
+ return { doc: withSlides(doc, slides), index: index + 1 };
55
+ }
56
+
57
+ /** Removes a slide, unless it's the only one (a story has at least one). */
58
+ export function deleteSlide(doc: StoryDoc, index: number): { doc: StoryDoc; index: number } {
59
+ if (doc.slides.length <= 1) return { doc, index };
60
+ const slides = doc.slides.filter((_, i) => i !== index);
61
+ return { doc: withSlides(doc, slides), index: Math.min(index, slides.length - 1) };
62
+ }
63
+
64
+ export function moveSlide(doc: StoryDoc, from: number, to: number): StoryDoc {
65
+ if (from === to || to < 0 || to >= doc.slides.length) return doc;
66
+ const slides = [...doc.slides];
67
+ const [slide] = slides.splice(from, 1);
68
+ slides.splice(to, 0, slide);
69
+ return withSlides(doc, slides);
70
+ }
71
+
72
+ /** Sets fields on an object; an undefined value removes the field, so
73
+ * cleared text doesn't linger as "". */
74
+ function patched<T extends object>(base: T, patch: Partial<T>): T {
75
+ const next = { ...base } as Record<string, unknown>;
76
+ for (const [key, value] of Object.entries(patch)) {
77
+ if (value === undefined) delete next[key];
78
+ else next[key] = value;
79
+ }
80
+ return next as T;
81
+ }
82
+
83
+ export function updateSlide(doc: StoryDoc, index: number, patch: Partial<Slide>): StoryDoc {
84
+ const slides = [...doc.slides];
85
+ slides[index] = patched(slides[index], patch);
86
+ return withSlides(doc, slides);
87
+ }
88
+
89
+ export function updateStory(doc: StoryDoc, patch: Partial<Omit<StoryDoc, "slides" | "media">>): StoryDoc {
90
+ return patched(doc, patch as Partial<StoryDoc>);
91
+ }
92
+
93
+ /** A slide date from what was typed and its precise form. Empty text is no
94
+ * date at all. A new precise form drops a `sortYear` it would contradict
95
+ * (the timeline reads sortYear first); the date's other fields stay. */
96
+ export function slideDate(previous: StoryDate | undefined, text: string, edtf: string | undefined): StoryDate | undefined {
97
+ if (!text.trim()) return undefined;
98
+ const next = patched(previous ?? { text }, { text, edtf: edtf || undefined });
99
+ if (next.edtf !== previous?.edtf) delete next.sortYear;
100
+ return next;
101
+ }
102
+
103
+ /** Every media key a slide or overlay refers to. */
104
+ export function usedMedia(doc: StoryDoc): Set<string> {
105
+ const used = new Set<string>();
106
+ for (const slide of doc.slides) {
107
+ for (const m of slide.media ?? []) used.add(m.ref);
108
+ for (const o of slide.place?.overlays ?? []) used.add(o.media);
109
+ }
110
+ return used;
111
+ }
112
+
113
+ /** The story as it should be saved: media nothing refers to any more left
114
+ * out, and the files only those that are still used. */
115
+ export function pruneMedia(doc: StoryDoc, files: Map<string, Uint8Array>): { doc: StoryDoc; files: Map<string, Uint8Array> } {
116
+ const used = usedMedia(doc);
117
+ const media = Object.fromEntries(Object.entries(doc.media).filter(([key]) => used.has(key)));
118
+ const srcs = new Set(Object.values(media).map((m) => m.src));
119
+ return {
120
+ doc: { ...doc, media },
121
+ files: new Map([...files].filter(([path]) => srcs.has(path))),
122
+ };
123
+ }
124
+
125
+ /** "Josef Meyer" -> "Josef Meyer.story.zip", minus characters some file
126
+ * systems refuse. */
127
+ export function storyFileName(title: string): string {
128
+ const safe = title.replace(/[\\/:*?"<>|\u0000-\u001f]+/g, " ").replace(/\s+/g, " ").trim().slice(0, 120);
129
+ return `${safe || "story"}.story.zip`;
130
+ }
131
+
132
+ /** Undo and redo over whole documents. Typing in one field makes one step,
133
+ * not one per keystroke: a change with the same `key` within COALESCE_MS
134
+ * of the last replaces it. */
135
+ export interface History {
136
+ past: StoryDoc[];
137
+ present: StoryDoc;
138
+ future: StoryDoc[];
139
+ lastKey?: string;
140
+ lastAt?: number;
141
+ }
142
+
143
+ export const COALESCE_MS = 1500;
144
+ const MAX_STEPS = 200;
145
+
146
+ export function startHistory(doc: StoryDoc): History {
147
+ return { past: [], present: doc, future: [] };
148
+ }
149
+
150
+ export function record(history: History, next: StoryDoc, key?: string, now = Date.now()): History {
151
+ if (next === history.present) return history;
152
+ const coalesce = key !== undefined && key === history.lastKey && history.lastAt !== undefined && now - history.lastAt < COALESCE_MS;
153
+ const past = coalesce ? history.past : [...history.past, history.present].slice(-MAX_STEPS);
154
+ return { past, present: next, future: [], lastKey: key, lastAt: now };
155
+ }
156
+
157
+ export function undo(history: History): History {
158
+ const previous = history.past[history.past.length - 1];
159
+ if (!previous) return history;
160
+ return { past: history.past.slice(0, -1), present: previous, future: [history.present, ...history.future] };
161
+ }
162
+
163
+ export function redo(history: History): History {
164
+ const [next, ...future] = history.future;
165
+ if (!next) return history;
166
+ return { past: [...history.past, history.present], present: next, future };
167
+ }
@@ -0,0 +1,82 @@
1
+ // The editor's own words. English here; a host with its own translations
2
+ // (Gramps Connect) passes StoryEditor's `strings` instead.
3
+
4
+ export interface EditorStrings {
5
+ story: string;
6
+ storySettings: string;
7
+ title: string;
8
+ language: string;
9
+ languageHint: string;
10
+ map: string;
11
+ mapStandard: string;
12
+ mapHistorical: string;
13
+ mapAuto: string;
14
+ mapNone: string;
15
+ slides: string;
16
+ untitledSlide: string;
17
+ addSlide: string;
18
+ duplicateSlide: string;
19
+ deleteSlide: string;
20
+ moveUp: string;
21
+ moveDown: string;
22
+ slideTitle: string;
23
+ text: string;
24
+ bold: string;
25
+ italic: string;
26
+ date: string;
27
+ dateHint: string;
28
+ preciseDate: string;
29
+ preciseDateHint: string;
30
+ preciseDateFollows: string;
31
+ preciseDateNotUnderstood: string;
32
+ matchDateAsWritten: string;
33
+ noPreciseDate: string;
34
+ place: string;
35
+ photos: string;
36
+ undo: string;
37
+ redo: string;
38
+ present: string;
39
+ preview: string;
40
+ problems: (count: number) => string;
41
+ openingSlide: string;
42
+ }
43
+
44
+ export const EDITOR_ENGLISH: EditorStrings = {
45
+ story: "Story",
46
+ storySettings: "Title, language and map",
47
+ title: "Title",
48
+ language: "Language",
49
+ languageHint: "The language the story is written in, e.g. en, de or fr-CA. Dates are read in it.",
50
+ map: "Map",
51
+ mapStandard: "Today's map",
52
+ mapHistorical: "Historical maps for each slide's year",
53
+ mapAuto: "Viewer's choice",
54
+ mapNone: "No map",
55
+ slides: "Slides",
56
+ untitledSlide: "Untitled slide",
57
+ addSlide: "Add slide",
58
+ duplicateSlide: "Duplicate",
59
+ deleteSlide: "Delete",
60
+ moveUp: "Move up",
61
+ moveDown: "Move down",
62
+ slideTitle: "Title",
63
+ text: "Text",
64
+ bold: "Bold",
65
+ italic: "Italic",
66
+ date: "Date",
67
+ dateHint: "As readers should see it, e.g. about 1850, 3 May 1850, between 1850 and 1860.",
68
+ preciseDate: "Precise date (EDTF)",
69
+ preciseDateHint: "Places the slide on the timeline and picks historical maps.",
70
+ preciseDateFollows: "Worked out from the date as written.",
71
+ preciseDateNotUnderstood: "Not a date the timeline can use.",
72
+ matchDateAsWritten: "Work it out from the date",
73
+ noPreciseDate: "None: this slide stays off the timeline.",
74
+ place: "Place",
75
+ photos: "Photos",
76
+ undo: "Undo",
77
+ redo: "Redo",
78
+ present: "Present",
79
+ preview: "Preview",
80
+ problems: (count) => (count === 1 ? "1 problem to fix before saving" : `${count} problems to fix before saving`),
81
+ openingSlide: "Opening slide",
82
+ };
@@ -44,6 +44,9 @@ export interface StoryPlayerProps {
44
44
  renderMapModeControl?: (props: MapModeControlProps) => ReactNode;
45
45
  /** Shown before the title, e.g. ViewerBrand on a standalone page. */
46
46
  brand?: ReactNode;
47
+ /** The host's own buttons, at the right of the title bar (before the
48
+ * close button), e.g. the viewer site's Edit. */
49
+ actions?: ReactNode;
47
50
  initialSlide?: number;
48
51
  }
49
52
 
@@ -201,7 +204,7 @@ function NavArrows({ index, total, onPrev, onNext, variant, strings, colors }: {
201
204
 
202
205
  export function StoryPlayer({
203
206
  story, resolveMedia, dark = false, colors: colorOverrides, strings: stringOverrides, onClose,
204
- keyboard = true, onSlideChange, renderMapModeControl, brand, initialSlide = 0,
207
+ keyboard = true, onSlideChange, renderMapModeControl, brand, actions, initialSlide = 0,
205
208
  }: StoryPlayerProps) {
206
209
  const colors = useMemo(() => ({ ...defaultColors(dark), ...colorOverrides }), [dark, colorOverrides]);
207
210
  const strings = useMemo(
@@ -371,6 +374,16 @@ export function StoryPlayer({
371
374
  </div>
372
375
  </div>
373
376
  )}
377
+ {actions && (
378
+ <div
379
+ style={{
380
+ position: "absolute", top: 0, right: onClose ? 60 : 12, height: BAR_HEIGHT, zIndex: 3,
381
+ display: "flex", alignItems: "center", gap: 8,
382
+ }}
383
+ >
384
+ {actions}
385
+ </div>
386
+ )}
374
387
  {onClose && (
375
388
  <RoundButton
376
389
  label={strings.close} size={36} onClick={onClose}