@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.
- package/README.md +4 -0
- package/dist/embed.js +97 -84
- package/dist/embed.js.LEGAL.txt +1 -2
- package/dist/maplibre-gl-shared.mjs +0 -6
- package/dist/maplibre-gl-worker.mjs +6 -2
- package/dist/player.js +67 -16
- package/dist/player.js.map +4 -4
- package/dist/types/player/StoryPlayer.d.ts +3 -1
- package/dist/types/player/brand.d.ts +16 -0
- package/dist/types/player/index.d.ts +1 -0
- package/package.json +1 -1
- package/src/embed/index.tsx +5 -2
- package/src/player/MapBackground.tsx +33 -18
- package/src/player/StoryPlayer.tsx +5 -2
- package/src/player/brand.tsx +51 -0
- package/src/player/index.ts +1 -0
|
@@ -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
package/src/embed/index.tsx
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
+
}
|
package/src/player/index.ts
CHANGED
|
@@ -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";
|