@gramps-connect/story 0.1.3 → 0.3.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,229 @@
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 { MediaItem, Slide, SlideMedia, 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
+ /** A prepared image to add to a story (see optimizeImage). */
104
+ export interface NewImage {
105
+ data: Uint8Array;
106
+ mime: string;
107
+ /** File extension, without the dot. */
108
+ ext: string;
109
+ width?: number;
110
+ height?: number;
111
+ }
112
+
113
+ function sameBytes(a: Uint8Array | undefined, b: Uint8Array): boolean {
114
+ if (!a || a.length !== b.length) return false;
115
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
116
+ return true;
117
+ }
118
+
119
+ /** The media key whose file is exactly these bytes, if any: a photo
120
+ * that's already in the story, chosen again. */
121
+ export function findMedia(doc: StoryDoc, files: Map<string, Uint8Array>, data: Uint8Array): string | undefined {
122
+ for (const [key, item] of Object.entries(doc.media)) {
123
+ if (sameBytes(files.get(item.src), data)) return key;
124
+ }
125
+ return undefined;
126
+ }
127
+
128
+ /** Adds an image to the story's media (not to any slide), or finds the
129
+ * identical one already there, so the same photo is stored once. Returns
130
+ * its media key. */
131
+ export function addImage(
132
+ doc: StoryDoc, files: Map<string, Uint8Array>, image: NewImage, stem = "photo",
133
+ ): { doc: StoryDoc; files: Map<string, Uint8Array>; key: string } {
134
+ const existing = findMedia(doc, files, image.data);
135
+ if (existing) return { doc, files, key: existing };
136
+ let n = 1;
137
+ const taken = (key: string) => Object.prototype.hasOwnProperty.call(doc.media, key) || files.has(`media/${key}.${image.ext}`);
138
+ while (taken(`${stem}-${n}`)) n += 1;
139
+ const key = `${stem}-${n}`;
140
+ const src = `media/${key}.${image.ext}`;
141
+ const item: MediaItem = {
142
+ src, mime: image.mime,
143
+ ...(image.width ? { width: image.width } : {}),
144
+ ...(image.height ? { height: image.height } : {}),
145
+ };
146
+ return {
147
+ doc: { ...doc, media: { ...doc.media, [key]: item } },
148
+ files: new Map(files).set(src, image.data),
149
+ key,
150
+ };
151
+ }
152
+
153
+ /** A slide's photo list; an empty one removes the field. */
154
+ export function setSlidePhotos(doc: StoryDoc, index: number, media: SlideMedia[]): StoryDoc {
155
+ return updateSlide(doc, index, { media: media.length ? media : undefined });
156
+ }
157
+
158
+ /** Changes a media item itself (its description, shared by every slide
159
+ * that shows it). */
160
+ export function updateMedia(doc: StoryDoc, key: string, patch: Partial<MediaItem>): StoryDoc {
161
+ if (!Object.prototype.hasOwnProperty.call(doc.media, key)) return doc;
162
+ return { ...doc, media: { ...doc.media, [key]: patched(doc.media[key], patch) } };
163
+ }
164
+
165
+ /** Every media key a slide or overlay refers to. */
166
+ export function usedMedia(doc: StoryDoc): Set<string> {
167
+ const used = new Set<string>();
168
+ for (const slide of doc.slides) {
169
+ for (const m of slide.media ?? []) used.add(m.ref);
170
+ for (const o of slide.place?.overlays ?? []) used.add(o.media);
171
+ }
172
+ return used;
173
+ }
174
+
175
+ /** The story as it should be saved: media nothing refers to any more left
176
+ * out, and the files only those that are still used. */
177
+ export function pruneMedia(doc: StoryDoc, files: Map<string, Uint8Array>): { doc: StoryDoc; files: Map<string, Uint8Array> } {
178
+ const used = usedMedia(doc);
179
+ const media = Object.fromEntries(Object.entries(doc.media).filter(([key]) => used.has(key)));
180
+ const srcs = new Set(Object.values(media).map((m) => m.src));
181
+ return {
182
+ doc: { ...doc, media },
183
+ files: new Map([...files].filter(([path]) => srcs.has(path))),
184
+ };
185
+ }
186
+
187
+ /** "Josef Meyer" -> "Josef Meyer.story.zip", minus characters some file
188
+ * systems refuse. */
189
+ export function storyFileName(title: string): string {
190
+ const safe = title.replace(/[\\/:*?"<>|\u0000-\u001f]+/g, " ").replace(/\s+/g, " ").trim().slice(0, 120);
191
+ return `${safe || "story"}.story.zip`;
192
+ }
193
+
194
+ /** Undo and redo over whole documents. Typing in one field makes one step,
195
+ * not one per keystroke: a change with the same `key` within COALESCE_MS
196
+ * of the last replaces it. */
197
+ export interface History {
198
+ past: StoryDoc[];
199
+ present: StoryDoc;
200
+ future: StoryDoc[];
201
+ lastKey?: string;
202
+ lastAt?: number;
203
+ }
204
+
205
+ export const COALESCE_MS = 1500;
206
+ const MAX_STEPS = 200;
207
+
208
+ export function startHistory(doc: StoryDoc): History {
209
+ return { past: [], present: doc, future: [] };
210
+ }
211
+
212
+ export function record(history: History, next: StoryDoc, key?: string, now = Date.now()): History {
213
+ if (next === history.present) return history;
214
+ const coalesce = key !== undefined && key === history.lastKey && history.lastAt !== undefined && now - history.lastAt < COALESCE_MS;
215
+ const past = coalesce ? history.past : [...history.past, history.present].slice(-MAX_STEPS);
216
+ return { past, present: next, future: [], lastKey: key, lastAt: now };
217
+ }
218
+
219
+ export function undo(history: History): History {
220
+ const previous = history.past[history.past.length - 1];
221
+ if (!previous) return history;
222
+ return { past: history.past.slice(0, -1), present: previous, future: [history.present, ...history.future] };
223
+ }
224
+
225
+ export function redo(history: History): History {
226
+ const [next, ...future] = history.future;
227
+ if (!next) return history;
228
+ return { past: [...history.past, history.present], present: next, future };
229
+ }
@@ -0,0 +1,106 @@
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
+ photosHint: string;
37
+ addPhoto: string;
38
+ replacePhoto: string;
39
+ movePhotoUp: string;
40
+ movePhotoDown: string;
41
+ removePhoto: string;
42
+ caption: string;
43
+ credit: string;
44
+ creditHint: string;
45
+ description: string;
46
+ descriptionHint: string;
47
+ photoFailed: (name: string) => string;
48
+ undo: string;
49
+ redo: string;
50
+ present: string;
51
+ preview: string;
52
+ problems: (count: number) => string;
53
+ openingSlide: string;
54
+ }
55
+
56
+ export const EDITOR_ENGLISH: EditorStrings = {
57
+ story: "Story",
58
+ storySettings: "Title, language and map",
59
+ title: "Title",
60
+ language: "Language",
61
+ languageHint: "The language the story is written in, e.g. en, de or fr-CA. Dates are read in it.",
62
+ map: "Map",
63
+ mapStandard: "Today's map",
64
+ mapHistorical: "Historical maps for each slide's year",
65
+ mapAuto: "Viewer's choice",
66
+ mapNone: "No map",
67
+ slides: "Slides",
68
+ untitledSlide: "Untitled slide",
69
+ addSlide: "Add slide",
70
+ duplicateSlide: "Duplicate",
71
+ deleteSlide: "Delete",
72
+ moveUp: "Move up",
73
+ moveDown: "Move down",
74
+ slideTitle: "Title",
75
+ text: "Text",
76
+ bold: "Bold",
77
+ italic: "Italic",
78
+ date: "Date",
79
+ dateHint: "As readers should see it, e.g. about 1850, 3 May 1850, between 1850 and 1860.",
80
+ preciseDate: "Precise date (EDTF)",
81
+ preciseDateHint: "Places the slide on the timeline and picks historical maps.",
82
+ preciseDateFollows: "Worked out from the date as written.",
83
+ preciseDateNotUnderstood: "Not a date the timeline can use.",
84
+ matchDateAsWritten: "Work it out from the date",
85
+ noPreciseDate: "None: this slide stays off the timeline.",
86
+ place: "Place",
87
+ photos: "Photos",
88
+ photosHint: "Add, drop or paste a photo. It's resized for the screen, and hidden details like camera and location are left out.",
89
+ addPhoto: "Add photo",
90
+ replacePhoto: "Replace",
91
+ movePhotoUp: "Move photo up",
92
+ movePhotoDown: "Move photo down",
93
+ removePhoto: "Remove",
94
+ caption: "Caption",
95
+ credit: "Credit",
96
+ creditHint: "Who took it, or where it's from",
97
+ description: "Description",
98
+ descriptionHint: "What the photo shows, for anyone who can't see it.",
99
+ photoFailed: (name) => `Couldn't use ${name}: this browser can't read it as an image.`,
100
+ undo: "Undo",
101
+ redo: "Redo",
102
+ present: "Present",
103
+ preview: "Preview",
104
+ problems: (count) => (count === 1 ? "1 problem to fix before saving" : `${count} problems to fix before saving`),
105
+ openingSlide: "Opening slide",
106
+ };
@@ -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
 
@@ -111,15 +114,49 @@ function SlidePhoto({ mediaKey, item, caption, credit, resolveMedia, colors }: {
111
114
  );
112
115
  }
113
116
 
114
- function SlideContent({ slide, story, resolveMedia, colors }: {
117
+ /** A small button showing one of a slide's photos. */
118
+ function PhotoThumb({ mediaKey, item, label, selected, onSelect, resolveMedia, colors }: {
119
+ mediaKey: string;
120
+ item: MediaItem;
121
+ label: string;
122
+ selected: boolean;
123
+ onSelect: () => void;
124
+ resolveMedia: StoryPlayerProps["resolveMedia"];
125
+ colors: PlayerColors;
126
+ }) {
127
+ const [src, setSrc] = useState<string | null>(null);
128
+ useEffect(() => {
129
+ let cancelled = false;
130
+ resolveMedia(mediaKey, item).then((url) => { if (!cancelled) setSrc(url); }, () => {});
131
+ return () => { cancelled = true; };
132
+ }, [mediaKey, item, resolveMedia]);
133
+ return (
134
+ <button
135
+ type="button" aria-label={label} title={label} aria-pressed={selected} onClick={onSelect}
136
+ style={{
137
+ width: 44, height: 44, padding: 0, borderRadius: 4, overflow: "hidden", cursor: "pointer",
138
+ border: `2px solid ${selected ? colors.mark : "transparent"}`, background: colors.border,
139
+ opacity: selected ? 1 : 0.7,
140
+ }}
141
+ >
142
+ {src && <img src={src} alt="" style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }} />}
143
+ </button>
144
+ );
145
+ }
146
+
147
+ function SlideContent({ slide, story, resolveMedia, colors, strings }: {
115
148
  slide: Slide | undefined;
116
149
  story: StoryDoc;
117
150
  resolveMedia: StoryPlayerProps["resolveMedia"];
118
151
  colors: PlayerColors;
152
+ strings: PlayerStrings;
119
153
  }) {
120
- // The format allows several photos per slide; the player shows the
121
- // first one that exists.
122
- const photo = slide?.media?.find((m) => Object.prototype.hasOwnProperty.call(story.media, m.ref));
154
+ // Every photo whose media exists; with several, thumbnails pick which
155
+ // one is shown. Keyed by slide in the caller, so each slide starts on
156
+ // its first photo.
157
+ const photos = (slide?.media ?? []).filter((m) => Object.prototype.hasOwnProperty.call(story.media, m.ref));
158
+ const [shown, setShown] = useState(0);
159
+ const photo = photos[Math.min(shown, photos.length - 1)];
123
160
  const eyebrow = [slide?.date?.text, slide?.place?.name].filter(Boolean).join(" · ");
124
161
  return (
125
162
  <>
@@ -133,6 +170,22 @@ function SlideContent({ slide, story, resolveMedia, colors }: {
133
170
  colors={colors}
134
171
  />
135
172
  )}
173
+ {photos.length > 1 && (
174
+ <div style={{ display: "flex", gap: 6, flexWrap: "wrap", justifyContent: "center" }}>
175
+ {photos.map((m, i) => (
176
+ <PhotoThumb
177
+ key={`${i}:${m.ref}`}
178
+ mediaKey={m.ref}
179
+ item={story.media[m.ref]}
180
+ label={strings.photoOf(i + 1, photos.length)}
181
+ selected={photo === m}
182
+ onSelect={() => setShown(i)}
183
+ resolveMedia={resolveMedia}
184
+ colors={colors}
185
+ />
186
+ ))}
187
+ </div>
188
+ )}
136
189
  <div style={{ display: "flex", flexDirection: "column", gap: 4, alignItems: "center", maxWidth: 640, textAlign: "center" }}>
137
190
  {eyebrow && <div dir="auto" style={{ fontSize: 14, color: colors.muted }}>{eyebrow}</div>}
138
191
  {slide?.title && <div dir="auto" style={{ fontSize: 20, fontWeight: 700, color: colors.text }}>{slide.title}</div>}
@@ -201,7 +254,7 @@ function NavArrows({ index, total, onPrev, onNext, variant, strings, colors }: {
201
254
 
202
255
  export function StoryPlayer({
203
256
  story, resolveMedia, dark = false, colors: colorOverrides, strings: stringOverrides, onClose,
204
- keyboard = true, onSlideChange, renderMapModeControl, brand, initialSlide = 0,
257
+ keyboard = true, onSlideChange, renderMapModeControl, brand, actions, initialSlide = 0,
205
258
  }: StoryPlayerProps) {
206
259
  const colors = useMemo(() => ({ ...defaultColors(dark), ...colorOverrides }), [dark, colorOverrides]);
207
260
  const strings = useMemo(
@@ -323,7 +376,9 @@ export function StoryPlayer({
323
376
  return item ? resolveMedia(key, item) : Promise.resolve(null);
324
377
  }, [story, resolveMedia]);
325
378
 
326
- const content = <SlideContent slide={slide} story={story} resolveMedia={resolveMedia} colors={colors} />;
379
+ const content = (
380
+ <SlideContent key={slide?.id ?? index} slide={slide} story={story} resolveMedia={resolveMedia} colors={colors} strings={strings} />
381
+ );
327
382
  const timeline = showTimeline && (
328
383
  <TimelineStrip points={datedSlides} currentIndex={index} onSelect={setIndex} colors={colors} />
329
384
  );
@@ -371,6 +426,16 @@ export function StoryPlayer({
371
426
  </div>
372
427
  </div>
373
428
  )}
429
+ {actions && (
430
+ <div
431
+ style={{
432
+ position: "absolute", top: 0, right: onClose ? 60 : 12, height: BAR_HEIGHT, zIndex: 3,
433
+ display: "flex", alignItems: "center", gap: 8,
434
+ }}
435
+ >
436
+ {actions}
437
+ </div>
438
+ )}
374
439
  {onClose && (
375
440
  <RoundButton
376
441
  label={strings.close} size={36} onClick={onClose}
@@ -15,6 +15,8 @@ export interface PlayerStrings {
15
15
  historicalMapYear: (year: number) => string;
16
16
  noDateStandardMap: string;
17
17
  historicalYear: string;
18
+ /** A photo's thumbnail button, on a slide with several. */
19
+ photoOf: (n: number, total: number) => string;
18
20
  }
19
21
 
20
22
  export const ENGLISH: PlayerStrings = {
@@ -28,6 +30,7 @@ export const ENGLISH: PlayerStrings = {
28
30
  historicalMapYear: (year) => `Historical map, ${year}`,
29
31
  noDateStandardMap: "No date here — standard map",
30
32
  historicalYear: "Historical map year",
33
+ photoOf: (n, total) => `Photo ${n} of ${total}`,
31
34
  };
32
35
 
33
36
  const TABLE: Record<string, PlayerStrings> = {
@@ -43,6 +46,7 @@ const TABLE: Record<string, PlayerStrings> = {
43
46
  historicalMapYear: (year) => `Historische Karte, ${year}`,
44
47
  noDateStandardMap: "Kein Datum hier — Standardkarte",
45
48
  historicalYear: "Jahr der historischen Karte",
49
+ photoOf: (n, total) => `Foto ${n} von ${total}`,
46
50
  },
47
51
  es: {
48
52
  previous: "Anterior",
@@ -55,6 +59,7 @@ const TABLE: Record<string, PlayerStrings> = {
55
59
  historicalMapYear: (year) => `Mapa histórico, ${year}`,
56
60
  noDateStandardMap: "Sin fecha aquí — mapa estándar",
57
61
  historicalYear: "Año del mapa histórico",
62
+ photoOf: (n, total) => `Foto ${n} de ${total}`,
58
63
  },
59
64
  fr: {
60
65
  previous: "Précédent",
@@ -67,6 +72,7 @@ const TABLE: Record<string, PlayerStrings> = {
67
72
  historicalMapYear: (year) => `Carte historique, ${year}`,
68
73
  noDateStandardMap: "Pas de date ici — carte standard",
69
74
  historicalYear: "Année de la carte historique",
75
+ photoOf: (n, total) => `Photo ${n} sur ${total}`,
70
76
  },
71
77
  nl: {
72
78
  previous: "Vorige",
@@ -79,6 +85,7 @@ const TABLE: Record<string, PlayerStrings> = {
79
85
  historicalMapYear: (year) => `Historische kaart, ${year}`,
80
86
  noDateStandardMap: "Geen datum hier — standaardkaart",
81
87
  historicalYear: "Jaar van de historische kaart",
88
+ photoOf: (n, total) => `Foto ${n} van ${total}`,
82
89
  },
83
90
  };
84
91