@gramps-connect/story 0.1.0 → 0.1.2

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,16 @@
1
+ export declare const VIEWER_NAME = "Gramps Connect Story Viewer";
2
+ export declare const VIEWER_URL = "https://dsblank.github.io/gramps-connect-stories/";
3
+ /** The Gramps Connect icon (app/public/favicon.svg): a three-box pedigree
4
+ * on a blue tile, which holds up on light and dark headers alike. */
5
+ export declare function ViewerIcon({ size }: {
6
+ size?: number;
7
+ }): import("react").JSX.Element;
8
+ export interface ViewerBrandProps {
9
+ /** Where the link goes; the viewer site by default. */
10
+ href?: string;
11
+ /** "_blank" (the default) opens it beside the story, not in its place. */
12
+ target?: string;
13
+ size?: number;
14
+ }
15
+ /** The icon, as a link to the viewer. */
16
+ export declare function ViewerBrand({ href, target, size }: ViewerBrandProps): import("react").JSX.Element;
@@ -1,4 +1,5 @@
1
1
  export { StoryPlayer, type StoryPlayerProps } from "./StoryPlayer";
2
+ export { VIEWER_NAME, VIEWER_URL, ViewerBrand, ViewerIcon, type ViewerBrandProps } from "./brand";
2
3
  export { MapModeControl, type MapMode, type MapModeControlProps } from "./MapModeControl";
3
4
  export { defaultColors, PIN_PATH_D, type PlayerColors } from "./theme";
4
5
  export { ENGLISH, PLAYER_LANGUAGES, stringsFor, type PlayerStrings } from "./strings";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gramps-connect/story",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
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",
@@ -7,6 +7,8 @@
7
7
  // src URL of a .story.zip (needs CORS if on another site)
8
8
  // slide slide to open on: an index, or a slide id like "s3"
9
9
  // theme "light", "dark", or "auto" (the default: the viewer's setting)
10
+ // brand present: the Story Viewer's icon before the title, linking to
11
+ // it (for a page that is just the story, e.g. one a program wrote)
10
12
  //
11
13
  // It renders into a shadow root, so the page's CSS and the player's can't
12
14
  // collide, and is 480px tall unless the page styles it otherwise.
@@ -16,7 +18,7 @@ import * as maplibregl from "maplibre-gl";
16
18
  // Text, not a side effect: it has to go inside the shadow root.
17
19
  import maplibreCss from "maplibre-gl/dist/maplibre-gl.css";
18
20
  import { mediaBlob, readStoryZip, type StoryPackage } from "../index";
19
- import { StoryPlayer } from "../player";
21
+ import { StoryPlayer, ViewerBrand } from "../player";
20
22
 
21
23
  /** maplibre's worker sits next to this script. Same origin: use it as is.
22
24
  * Another origin (the usual case, a CDN): a page can't start a worker from
@@ -41,7 +43,7 @@ ${maplibreCss}
41
43
  `;
42
44
 
43
45
  class GrampsConnectStoryElement extends HTMLElement {
44
- static observedAttributes = ["src", "slide", "theme"];
46
+ static observedAttributes = ["src", "slide", "theme", "brand"];
45
47
 
46
48
  private frame: HTMLDivElement | null = null;
47
49
  private reactRoot: Root | null = null;
@@ -141,6 +143,7 @@ class GrampsConnectStoryElement extends HTMLElement {
141
143
  // One of maybe several on the page: arrow keys only once focused.
142
144
  keyboard: "focus",
143
145
  initialSlide: this.initialSlide(),
146
+ brand: this.hasAttribute("brand") ? createElement(ViewerBrand) : undefined,
144
147
  }));
145
148
  }
146
149
  }
@@ -31,6 +31,25 @@ export interface MapSlide {
31
31
  place: Place | undefined;
32
32
  }
33
33
 
34
+ /** Frame the place's shape or overlays (usually a field, a route or an old
35
+ * map, far tighter than a town-level zoom), else fly to its point. */
36
+ function moveCamera(map: MapLibreMap, place: Place | undefined, duration: number) {
37
+ const bounds = placeBounds(place);
38
+ if (bounds) {
39
+ map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { maxZoom: 17, duration });
40
+ return;
41
+ }
42
+ const center = placeCenter(place);
43
+ if (center) map.flyTo({ center, zoom: 9, essential: true, duration });
44
+ }
45
+
46
+ /** The pin on the place; held where it was, dimmed, for a slide with none. */
47
+ function placeMarker(marker: MapLibreMarker | null, center: [number, number] | null) {
48
+ if (!marker) return;
49
+ if (center) marker.setLngLat(center);
50
+ marker.getElement().style.opacity = center ? "1" : "0.4";
51
+ }
52
+
34
53
  export function MapBackground({ initialCenter, current, dark, colors, panelFraction, ohmYear, resolveMedia }: {
35
54
  initialCenter: [number, number];
36
55
  current: MapSlide;
@@ -64,6 +83,8 @@ export function MapBackground({ initialCenter, current, dark, colors, panelFract
64
83
  // What the map was built or last swapped with, so a re-render with the
65
84
  // same effective style never triggers a reload.
66
85
  const appliedStyleKeyRef = useRef(mapStyleKey(dark, ohmYear));
86
+ const currentRef = useRef(current);
87
+ currentRef.current = current;
67
88
 
68
89
  useEffect(() => {
69
90
  if (!containerEl) return;
@@ -81,7 +102,13 @@ export function MapBackground({ initialCenter, current, dark, colors, panelFract
81
102
  map.on("style.load", () => setReady(true));
82
103
  // Center points in the part of the map the slide panel doesn't cover.
83
104
  map.setPadding({ top: 0, bottom: 0, left: 0, right: containerEl.clientWidth * panelFraction });
84
- map.jumpTo({ center: initialCenter });
105
+ // Straight to the opening slide's place, unanimated. The camera effect
106
+ // below ran for it before this map existed (the container arrives a
107
+ // render late), so a story opened partway through starts there too.
108
+ const start = currentRef.current.place;
109
+ const startCenter = placeCenter(start);
110
+ map.jumpTo({ center: startCenter ?? initialCenter });
111
+ if (startCenter) moveCamera(map, start, 0);
85
112
  map.on("load", () => {
86
113
  const c = colorsRef.current;
87
114
  const el = document.createElement("div");
@@ -93,6 +120,7 @@ export function MapBackground({ initialCenter, current, dark, colors, panelFract
93
120
  + `</svg>`;
94
121
  // The pin's tip, not its middle, sits on the coordinate.
95
122
  markerRef.current = new maplibregl.Marker({ element: el, anchor: "bottom" }).setLngLat(initialCenter).addTo(map);
123
+ placeMarker(markerRef.current, placeCenter(currentRef.current.place));
96
124
  });
97
125
  return () => {
98
126
  markerRef.current?.remove();
@@ -171,29 +199,16 @@ export function MapBackground({ initialCenter, current, dark, colors, panelFract
171
199
  source?.setData((place?.geometry as GeoJSON | undefined) ?? EMPTY);
172
200
  }, [ready, current.key, place]);
173
201
 
174
- // Camera: once per slide. A shape or overlay is framed (it's usually a
175
- // field, a route or an old map, far tighter than a town-level zoom);
176
- // otherwise fly to the point. Keyed to the slide, not to `ready`, so a
202
+ // Camera: once per slide (see moveCamera). Keyed to the slide, not to `ready`, so a
177
203
  // map-mode switch never yanks the view back from wherever the viewer
178
204
  // has since panned.
179
205
  useEffect(() => {
180
206
  const map = mapRef.current;
181
207
  if (!map) return;
182
208
  const center = placeCenter(place);
183
- const marker = markerRef.current;
184
- if (!center) {
185
- // No place: the camera stays put and the marker dims.
186
- if (marker) marker.getElement().style.opacity = "0.4";
187
- return;
188
- }
189
- marker?.setLngLat(center);
190
- if (marker) marker.getElement().style.opacity = "1";
191
- const bounds = placeBounds(place);
192
- if (bounds) {
193
- map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { maxZoom: 17, duration: 900 });
194
- } else {
195
- map.flyTo({ center, zoom: 9, essential: true, duration: 900 });
196
- }
209
+ placeMarker(markerRef.current, center);
210
+ // No place: the camera stays put.
211
+ if (center) moveCamera(map, place, 900);
197
212
  // eslint-disable-next-line react-hooks/exhaustive-deps
198
213
  }, [current.key]);
199
214
 
@@ -42,6 +42,8 @@ export interface StoryPlayerProps {
42
42
  /** Replaces the built-in map-mode control, e.g. with one from the host's
43
43
  * own UI kit. */
44
44
  renderMapModeControl?: (props: MapModeControlProps) => ReactNode;
45
+ /** Shown before the title, e.g. ViewerBrand on a standalone page. */
46
+ brand?: ReactNode;
45
47
  initialSlide?: number;
46
48
  }
47
49
 
@@ -199,7 +201,7 @@ function NavArrows({ index, total, onPrev, onNext, variant, strings, colors }: {
199
201
 
200
202
  export function StoryPlayer({
201
203
  story, resolveMedia, dark = false, colors: colorOverrides, strings: stringOverrides, onClose,
202
- keyboard = true, onSlideChange, renderMapModeControl, initialSlide = 0,
204
+ keyboard = true, onSlideChange, renderMapModeControl, brand, initialSlide = 0,
203
205
  }: StoryPlayerProps) {
204
206
  const colors = useMemo(() => ({ ...defaultColors(dark), ...colorOverrides }), [dark, colorOverrides]);
205
207
  const strings = useMemo(
@@ -341,9 +343,10 @@ export function StoryPlayer({
341
343
  dir="auto"
342
344
  style={{
343
345
  position: "absolute", top: 0, left: 20, height: BAR_HEIGHT, maxWidth: "26%", zIndex: 3,
344
- display: "flex", alignItems: "center", fontWeight: 600,
346
+ display: "flex", alignItems: "center", gap: 10, fontWeight: 600,
345
347
  }}
346
348
  >
349
+ {brand && <span style={{ flex: "none", display: "flex" }}>{brand}</span>}
347
350
  <span style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{story.title}</span>
348
351
  </div>
349
352
  {/* Centered on the whole bar, as a toolbar for the presentation; the
@@ -0,0 +1,51 @@
1
+ // The Gramps Connect Story Viewer's mark, linking to the viewer: for
2
+ // StoryPlayer's `brand` slot on standalone pages (the viewer site, a page
3
+ // a program writes around a story), so whoever is watching can find where
4
+ // it came from.
5
+ import type { CSSProperties } from "react";
6
+
7
+ export const VIEWER_NAME = "Gramps Connect Story Viewer";
8
+ export const VIEWER_URL = "https://dsblank.github.io/gramps-connect-stories/";
9
+
10
+ /** The Gramps Connect icon (app/public/favicon.svg): a three-box pedigree
11
+ * on a blue tile, which holds up on light and dark headers alike. */
12
+ export function ViewerIcon({ size = 24 }: { size?: number }) {
13
+ return (
14
+ <svg viewBox="0 0 64 64" width={size} height={size} aria-hidden="true" focusable="false">
15
+ <rect width="64" height="64" rx="15" fill="#1866ba" />
16
+ <g transform="translate(9.6 9.6) scale(0.7)">
17
+ <path d="M25 32h7M32 15v34M32 15h7M32 49h7" fill="none" stroke="#fff" strokeWidth="5" strokeLinecap="round" />
18
+ <g fill="#fff">
19
+ <rect x="1" y="24" width="24" height="16" rx="4" />
20
+ <rect x="39" y="7" width="24" height="16" rx="4" />
21
+ <rect x="39" y="41" width="24" height="16" rx="4" />
22
+ </g>
23
+ </g>
24
+ </svg>
25
+ );
26
+ }
27
+
28
+ export interface ViewerBrandProps {
29
+ /** Where the link goes; the viewer site by default. */
30
+ href?: string;
31
+ /** "_blank" (the default) opens it beside the story, not in its place. */
32
+ target?: string;
33
+ size?: number;
34
+ }
35
+
36
+ /** The icon, as a link to the viewer. */
37
+ export function ViewerBrand({ href = VIEWER_URL, target = "_blank", size = 24 }: ViewerBrandProps) {
38
+ const style: CSSProperties = { display: "flex", lineHeight: 0, borderRadius: 6 };
39
+ return (
40
+ <a
41
+ href={href}
42
+ target={target}
43
+ rel={target === "_blank" ? "noopener" : undefined}
44
+ title={VIEWER_NAME}
45
+ aria-label={VIEWER_NAME}
46
+ style={style}
47
+ >
48
+ <ViewerIcon size={size} />
49
+ </a>
50
+ );
51
+ }
@@ -1,4 +1,5 @@
1
1
  export { StoryPlayer, type StoryPlayerProps } from "./StoryPlayer";
2
+ export { VIEWER_NAME, VIEWER_URL, ViewerBrand, ViewerIcon, type ViewerBrandProps } from "./brand";
2
3
  export { MapModeControl, type MapMode, type MapModeControlProps } from "./MapModeControl";
3
4
  export { defaultColors, PIN_PATH_D, type PlayerColors } from "./theme";
4
5
  export { ENGLISH, PLAYER_LANGUAGES, stringsFor, type PlayerStrings } from "./strings";