@projektemacher/storymapjs 0.10.8 → 0.11.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/CHANGELOG +81 -0
- package/README.md +129 -12
- package/dist/assets/{demo-DBpeF31K.js → demo-bWnOyfUw.js} +1 -1
- package/dist/assets/harness-C3JUv5Ii.js +1 -0
- package/dist/assets/{harness-multi-CYHgI0a9.js → harness-multi-XZNex3tb.js} +1 -1
- package/dist/assets/{harness-support-Do7sIp2j.js → harness-support-CjfZIVeb.js} +1 -1
- package/dist/assets/main-DQ32hWBM.js +17 -0
- package/dist/context.json +13 -2
- package/dist/css/storymap.css +1 -1
- package/dist/demo.html +3 -2
- package/dist/docs/api/assets/navigation.js +1 -1
- package/dist/docs/api/assets/search.js +1 -1
- package/dist/docs/api/classes/StoryMap.html +777 -269
- package/dist/docs/api/functions/MediaType.html +1 -1
- package/dist/docs/api/functions/isPresentation3Collection.html +1 -1
- package/dist/docs/api/functions/isPresentation3Manifest.html +1 -1
- package/dist/docs/api/functions/isSlideshowCollection.html +51 -0
- package/dist/docs/api/functions/loadCSS.html +1 -1
- package/dist/docs/api/functions/manifestToStorymapData.html +1 -1
- package/dist/docs/api/functions/setLanguage.html +1 -1
- package/dist/docs/api/functions/slideshowToStorymapData.html +51 -0
- package/dist/docs/api/functions/slideshowVersion.html +48 -0
- package/dist/docs/api/functions/storymapToManifest.html +1 -1
- package/dist/docs/api/functions/validateStorymap.html +1 -1
- package/dist/docs/api/functions/validateStorymapAndReport.html +1 -1
- package/dist/docs/api/index.html +115 -12
- package/dist/docs/api/interfaces/AnimationHandle.html +2 -2
- package/dist/docs/api/interfaces/ImagereadyPayload.html +4 -4
- package/dist/docs/api/interfaces/LanguageEntry.html +6 -6
- package/dist/docs/api/interfaces/LatLngLiteral.html +4 -4
- package/dist/docs/api/interfaces/LoadOptions.html +2 -2
- package/dist/docs/api/interfaces/MapEvents.html +116 -0
- package/dist/docs/api/interfaces/MapMarkerEvents.html +66 -0
- package/dist/docs/api/interfaces/MarkerEventPayload.html +57 -0
- package/dist/docs/api/interfaces/MediaEvents.html +78 -0
- package/dist/docs/api/interfaces/MenuBarEvents.html +114 -0
- package/dist/docs/api/interfaces/MessageEvents.html +72 -0
- package/dist/docs/api/interfaces/OlLayer.html +1 -1
- package/dist/docs/api/interfaces/OlMap.html +1 -1
- package/dist/docs/api/interfaces/OlSource.html +1 -1
- package/dist/docs/api/interfaces/OlTileLayer.html +1 -1
- package/dist/docs/api/interfaces/OlVectorLayer.html +1 -1
- package/dist/docs/api/interfaces/OlView.html +1 -1
- package/dist/docs/api/interfaces/SlideBackgroundState.html +66 -0
- package/dist/docs/api/interfaces/SlideEvents.html +78 -0
- package/dist/docs/api/interfaces/SlideNavEvents.html +72 -0
- package/dist/docs/api/interfaces/SlideshowPlayerSettings.html +182 -0
- package/dist/docs/api/interfaces/SlideshowToStorymapResult.html +60 -0
- package/dist/docs/api/interfaces/SlideshowTranslatorOptions.html +65 -0
- package/dist/docs/api/interfaces/SlideshowWarning.html +72 -0
- package/dist/docs/api/interfaces/StoryMapEvents.html +146 -0
- package/dist/docs/api/interfaces/StoryMapMarker.html +763 -0
- package/dist/docs/api/interfaces/StorySliderEvents.html +102 -0
- package/dist/docs/api/interfaces/StorymapBasemap.html +69 -0
- package/dist/docs/api/interfaces/StorymapData.html +12 -6
- package/dist/docs/api/interfaces/StorymapDataWrapper.html +2 -2
- package/dist/docs/api/interfaces/StorymapDocument.html +2 -2
- package/dist/docs/api/interfaces/StorymapError.html +3 -3
- package/dist/docs/api/interfaces/StorymapGeoreference.html +5 -5
- package/dist/docs/api/interfaces/StorymapGeoreferenceBody.html +4 -4
- package/dist/docs/api/interfaces/StorymapGroundControlPoint.html +4 -4
- package/dist/docs/api/interfaces/StorymapLanguageMap.html +2 -2
- package/dist/docs/api/interfaces/StorymapManifest.html +12 -12
- package/dist/docs/api/interfaces/StorymapManifestAnnotation.html +43 -11
- package/dist/docs/api/interfaces/StorymapManifestBackground.html +6 -6
- package/dist/docs/api/interfaces/StorymapManifestCanvas.html +65 -17
- package/dist/docs/api/interfaces/StorymapManifestContentResource.html +8 -8
- package/dist/docs/api/interfaces/StorymapManifestFeatureCollection.html +4 -4
- package/dist/docs/api/interfaces/StorymapManifestGeoreferencing.html +7 -7
- package/dist/docs/api/interfaces/StorymapManifestImageService.html +4 -4
- package/dist/docs/api/interfaces/StorymapManifestRange.html +5 -5
- package/dist/docs/api/interfaces/StorymapManifestService.html +4 -4
- package/dist/docs/api/interfaces/StorymapManifestSpecificResource.html +4 -4
- package/dist/docs/api/interfaces/StorymapManifestStatement.html +3 -3
- package/dist/docs/api/interfaces/StorymapMapOptions.html +5 -5
- package/dist/docs/api/interfaces/StorymapOptions.html +277 -75
- package/dist/docs/api/interfaces/StorymapOverlayLayer.html +42 -11
- package/dist/docs/api/interfaces/StorymapSlide.html +70 -16
- package/dist/docs/api/interfaces/StorymapSlideBackground.html +4 -4
- package/dist/docs/api/interfaces/StorymapSlideLocation.html +58 -14
- package/dist/docs/api/interfaces/StorymapSlideMarker.html +8 -9
- package/dist/docs/api/interfaces/StorymapSlideMedia.html +49 -10
- package/dist/docs/api/interfaces/StorymapSlideNarration.html +61 -9
- package/dist/docs/api/interfaces/StorymapSlideText.html +4 -4
- package/dist/docs/api/interfaces/StorymapTilejson.html +8 -8
- package/dist/docs/api/interfaces/SwipableEvents.html +102 -0
- package/dist/docs/api/interfaces/TextEvents.html +66 -0
- package/dist/docs/api/media/events.md +66 -0
- package/dist/docs/api/media/migration-from-knightlab.md +89 -49
- package/dist/docs/api/media/slideshow.md +109 -0
- package/dist/docs/api/media/storymap-as-iiif-manifest.md +1007 -0
- package/dist/docs/api/modules.html +6 -6
- package/dist/docs/api/types/ContentState.html +3 -3
- package/dist/docs/api/types/FiredEvent.html +43 -0
- package/dist/docs/api/types/ImagereadyKind.html +1 -1
- package/dist/docs/api/types/MediaTypeMatch.html +5 -5
- package/dist/docs/api/types/SwipeDirection.html +36 -0
- package/dist/docs/api/types/TileSourceFactory.html +1 -1
- package/dist/docs/authoring.html +20 -7
- package/dist/docs/events.html +263 -0
- package/dist/docs/iiif.html +136 -22
- package/dist/docs/migration.html +66 -19
- package/dist/docs/readme.html +113 -12
- package/dist/embed/index.html +7 -0
- package/dist/examples/font-css-false.json +33 -0
- package/dist/examples/issue-image-region.json +1 -1
- package/dist/examples/layers-control.json +60 -0
- package/dist/examples/map-template-provider.json +36 -0
- package/dist/examples/multi-media.json +71 -0
- package/dist/examples-iiif/annotated-image.json +16 -0
- package/dist/examples-iiif/font-css-false.json +114 -0
- package/dist/examples-iiif/issue-image-region.json +1 -1
- package/dist/examples-iiif/layers-control.json +226 -0
- package/dist/examples-iiif/map-template-provider.json +133 -0
- package/dist/examples-iiif/multi-media.json +237 -0
- package/dist/examples-slideshow/single.json +79 -0
- package/dist/examples-slideshow/static/pixel.png +0 -0
- package/dist/examples-slideshow/static.json +44 -0
- package/dist/harness-multi.html +3 -3
- package/dist/harness.html +4 -3
- package/dist/index.html +3 -2
- package/dist/js/storymap.d.ts +1326 -289
- package/dist/js/storymap.js +6690 -4784
- package/dist/js/storymap.js.map +1 -1
- package/dist/navplace-properties.json +14 -0
- package/dist/site.css +54 -36
- package/docs/migration-from-knightlab.md +89 -49
- package/docs/storymap-as-iiif-manifest.md +199 -126
- package/package.json +4 -2
- package/schema/storymap.schema.json +334 -34
- package/src/core/Util.ts +3 -1
- package/src/core/mixins.ts +42 -28
- package/src/language/Language.ts +7 -0
- package/src/language/locale/.expected-gaps.json +287 -27
- package/src/language/locale/de.json +11 -1
- package/src/language/locale/en.json +11 -1
- package/src/main.ts +51 -0
- package/src/map/Map.ts +44 -6
- package/src/map/MapMarker.ts +16 -3
- package/src/map/openlayers/Map.OpenLayers.ts +787 -62
- package/src/map/openlayers/MapMarker.OpenLayers.ts +113 -13
- package/src/map/slideView.ts +149 -0
- package/src/map/types.ts +2 -0
- package/src/media/EmbedUtil.ts +27 -2
- package/src/media/Media.ts +27 -4
- package/src/media/embedId.ts +6 -0
- package/src/media/types/Flickr.ts +14 -3
- package/src/media/types/HtmlMedia.ts +66 -0
- package/src/media/types/Image.ts +15 -3
- package/src/media/types/SoundCloud.ts +1 -1
- package/src/media/types/Text.ts +27 -8
- package/src/media/types/Twitter.ts +9 -7
- package/src/media/types/Vimeo.ts +1 -1
- package/src/media/types/YouTube.ts +2 -2
- package/src/scss/Typography.scss +15 -15
- package/src/scss/VCO.StoryMap.scss +173 -8
- package/src/scss/core/Reset.scss +5 -18
- package/src/scss/map/VCO.Map.scss +1 -1
- package/src/scss/map/VCO.MapMarker.scss +20 -9
- package/src/scss/map/openlayers/VCO.Map.OpenLayers.scss +7 -4
- package/src/scss/media/VCO.Media.scss +7 -7
- package/src/scss/media/types/VCO.Media.Blockquote.scss +1 -1
- package/src/scss/media/types/VCO.Media.Text.scss +5 -5
- package/src/scss/media/types/VCO.Media.Twitter.scss +4 -4
- package/src/scss/media/types/VCO.Media.Wikipedia.scss +2 -19
- package/src/scss/site/site.scss +55 -36
- package/src/scss/slider/VCO.Slide.scss +49 -11
- package/src/scss/slider/VCO.SlideNav.scss +23 -7
- package/src/scss/slider/VCO.StorySlider.scss +50 -0
- package/src/scss/snapmap/VCO.SnapMap.scss +3 -3
- package/src/scss/ui/VCO.Consent.scss +20 -14
- package/src/scss/ui/VCO.Layers.scss +119 -0
- package/src/scss/ui/VCO.MenuBar.Button.scss +140 -19
- package/src/scss/ui/VCO.MenuBar.scss +9 -2
- package/src/scss/ui/VCO.Message.scss +14 -0
- package/src/slider/Slide.ts +232 -74
- package/src/slider/SlideNav.ts +15 -4
- package/src/slider/StorySlider.ts +203 -22
- package/src/storymap/Consent.ts +22 -0
- package/src/storymap/StoryMap.ts +944 -131
- package/src/storymap/content-state.ts +3 -8
- package/src/storymap/font-css.ts +72 -0
- package/src/storymap/from-slideshow.ts +785 -0
- package/src/storymap/iiif-shared.ts +228 -0
- package/src/storymap/iiif.ts +289 -100
- package/src/storymap/to-iiif.ts +131 -44
- package/src/types.ts +382 -17
- package/src/ui/LayersControl.ts +302 -0
- package/src/ui/MenuBar.ts +232 -21
- package/src/ui/Message.ts +36 -3
- package/src/ui/Swipable.ts +25 -7
- package/dist/assets/harness-Cr0rYpvt.js +0 -1
- package/dist/assets/main-DXalctuR.js +0 -17
- package/src/scss/VCO.StoryMap.Dark.scss +0 -150
- package/src/scss/Variables.Dark.scss +0 -57
|
@@ -3,6 +3,7 @@ import { fromLonLat } from "ol/proj";
|
|
|
3
3
|
import type { Map as OlMap } from "ol";
|
|
4
4
|
import MapMarker from "../MapMarker";
|
|
5
5
|
import { clearTimer } from "../../core/Util";
|
|
6
|
+
import { Language } from "../../language/Language";
|
|
6
7
|
import { sanitizeSlideText } from "../../media/EmbedUtil";
|
|
7
8
|
import type { LatLngLiteral, MapMarkerData, StorymapOptions } from "../../types";
|
|
8
9
|
|
|
@@ -27,6 +28,10 @@ interface MarkerPresentation {
|
|
|
27
28
|
export default class OpenLayersMapMarker extends MapMarker {
|
|
28
29
|
declare "_overlay": Overlay;
|
|
29
30
|
declare "_onMarkerClickBound": ((e: Event) => void) | null;
|
|
31
|
+
/** Enter/Space activation for keyboard visitors, detached in dispose(). */
|
|
32
|
+
declare "_onMarkerKeyBound": ((e: KeyboardEvent) => void) | null;
|
|
33
|
+
/** Base accessible name (slide headline or label), without audio suffix. */
|
|
34
|
+
declare "_marker_label": string;
|
|
30
35
|
/** marker.popup: the active marker opens its card when clicked. */
|
|
31
36
|
declare "_popup_enabled": boolean;
|
|
32
37
|
/** marker.audioBadge: flag a slide that has narration or audio media. */
|
|
@@ -48,7 +53,7 @@ export default class OpenLayersMapMarker extends MapMarker {
|
|
|
48
53
|
================================================== */
|
|
49
54
|
/**
|
|
50
55
|
* The merged marker presentation: `marker.*` wins, `location.*` is the
|
|
51
|
-
* legacy spelling and still works
|
|
56
|
+
* legacy spelling and still works.
|
|
52
57
|
* `location` also stays the carrier of geography, so lat/lon are read
|
|
53
58
|
* from it alone — presentation never moves a marker.
|
|
54
59
|
*/
|
|
@@ -88,6 +93,15 @@ export default class OpenLayersMapMarker extends MapMarker {
|
|
|
88
93
|
}
|
|
89
94
|
|
|
90
95
|
this._marker = this._createMarkerElement(d as MapMarkerData, o);
|
|
96
|
+
// keyboard route to the same behaviour as a click: focusable,
|
|
97
|
+
// announced as a button, Enter/Space activate
|
|
98
|
+
const text = (d as MapMarkerData)?.text;
|
|
99
|
+
const headline =
|
|
100
|
+
typeof text?.headline === "string" && text.headline !== "" ? text.headline : null;
|
|
101
|
+
this._marker_label = presentation.label ?? headline ?? "Marker";
|
|
102
|
+
this._marker.setAttribute("tabindex", "0");
|
|
103
|
+
this._marker.setAttribute("role", "button");
|
|
104
|
+
this._marker.setAttribute("aria-label", this._marker_label);
|
|
91
105
|
// kept as a field so dispose() can detach it; the inline arrow
|
|
92
106
|
// had no other handle
|
|
93
107
|
this._onMarkerClickBound = (e: Event) => {
|
|
@@ -102,6 +116,23 @@ export default class OpenLayersMapMarker extends MapMarker {
|
|
|
102
116
|
this._onMarkerClick(e);
|
|
103
117
|
};
|
|
104
118
|
this._marker.addEventListener("click", this._onMarkerClickBound);
|
|
119
|
+
// Keyboard visitors get the click behaviour: Enter/Space
|
|
120
|
+
// activates, and an opened card takes focus so its close button
|
|
121
|
+
// is reachable without a pointer.
|
|
122
|
+
this._onMarkerKeyBound = (e: KeyboardEvent) => {
|
|
123
|
+
if (e.key !== "Enter" && e.key !== " " && e.key !== "Spacebar") return;
|
|
124
|
+
e.preventDefault();
|
|
125
|
+
const wasOpen = this.popupOpen;
|
|
126
|
+
this._onMarkerClickBound?.(e);
|
|
127
|
+
if (!wasOpen && this.popupOpen) {
|
|
128
|
+
(
|
|
129
|
+
this._popup_el?.querySelector(
|
|
130
|
+
".vco-marker-popup-close",
|
|
131
|
+
) as HTMLElement | null
|
|
132
|
+
)?.focus();
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
this._marker.addEventListener("keydown", this._onMarkerKeyBound);
|
|
105
136
|
// Escape closes the card; the listener is on the document and is
|
|
106
137
|
// detached in dispose(), so a torn-down map leaves nothing behind
|
|
107
138
|
this._onPopupKeyBound = (e: KeyboardEvent) => {
|
|
@@ -122,6 +153,8 @@ export default class OpenLayersMapMarker extends MapMarker {
|
|
|
122
153
|
el.className = "vco-mapmarker-custom";
|
|
123
154
|
const img = document.createElement("img");
|
|
124
155
|
img.src = this._custom_icon.url;
|
|
156
|
+
// decorative: the parent div carries the marker's title
|
|
157
|
+
img.alt = "";
|
|
125
158
|
img.style.width = this._custom_icon.size[0] + "px";
|
|
126
159
|
img.style.height = "auto";
|
|
127
160
|
el.appendChild(img);
|
|
@@ -131,6 +164,7 @@ export default class OpenLayersMapMarker extends MapMarker {
|
|
|
131
164
|
el.className = "vco-mapmarker-image-icon";
|
|
132
165
|
const img = document.createElement("img");
|
|
133
166
|
img.src = this._custom_image_icon;
|
|
167
|
+
img.alt = "";
|
|
134
168
|
img.style.width = "48px";
|
|
135
169
|
img.style.height = "auto";
|
|
136
170
|
el.appendChild(img);
|
|
@@ -210,23 +244,69 @@ export default class OpenLayersMapMarker extends MapMarker {
|
|
|
210
244
|
* marker's, and that is `latLon()`.
|
|
211
245
|
*/
|
|
212
246
|
_togglePopup(): void {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
247
|
+
this.togglePopup();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
_closePopup(): void {
|
|
251
|
+
this.closePopup();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** True when this marker is configured to show a popup card. */
|
|
255
|
+
isPopupEnabled(): boolean {
|
|
256
|
+
return this._popup_enabled === true;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Open the popup card for this marker.
|
|
261
|
+
*
|
|
262
|
+
* Returns false (and opens nothing) when popups are disabled for the
|
|
263
|
+
* marker or it is not a real map marker — e.g. an overview slide. A
|
|
264
|
+
* click on an inactive marker navigates instead of opening (see the
|
|
265
|
+
* click handler in `_createMarker`); programmatic callers that want the
|
|
266
|
+
* card on another stop should navigate first (`StoryMap.openMarkerPopup`
|
|
267
|
+
* does exactly that). Fires `popupopen` on success.
|
|
268
|
+
*/
|
|
269
|
+
openPopup(): boolean {
|
|
270
|
+
if (!this._popup_enabled) return false;
|
|
271
|
+
if (!this.data.real_marker) return false;
|
|
272
|
+
if (this._popup_el) return true;
|
|
218
273
|
const el = this._createPopupElement();
|
|
219
|
-
if (el === null) return;
|
|
274
|
+
if (el === null) return false;
|
|
220
275
|
this._marker.appendChild(el);
|
|
221
276
|
this._popup_el = el;
|
|
222
277
|
this._marker.classList.add("vco-mapmarker-popup-open");
|
|
278
|
+
this.fire("popupopen", { marker_number: this.marker_number });
|
|
279
|
+
return true;
|
|
223
280
|
}
|
|
224
281
|
|
|
225
|
-
|
|
282
|
+
/**
|
|
283
|
+
* Close the popup card if it is open. Fires `popupclose`. Also runs on
|
|
284
|
+
* Escape, the card's close button, deactivation and `dispose()`.
|
|
285
|
+
*/
|
|
286
|
+
closePopup(): void {
|
|
226
287
|
if (!this._popup_el) return;
|
|
288
|
+
// keyboard visitors may be inside the card (its close button is
|
|
289
|
+
// focusable): hand focus back to the marker instead of dropping it
|
|
290
|
+
// on the page body. Mouse visitors never had focus here, so theirs
|
|
291
|
+
// is untouched.
|
|
292
|
+
const hadFocus = this._popup_el.contains(document.activeElement);
|
|
227
293
|
this._popup_el.parentNode?.removeChild(this._popup_el);
|
|
228
294
|
this._popup_el = null;
|
|
229
295
|
this._marker?.classList?.remove("vco-mapmarker-popup-open");
|
|
296
|
+
if (hadFocus) this._marker?.focus();
|
|
297
|
+
this.fire("popupclose", { marker_number: this.marker_number });
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Open the card when closed, close it when open. Returns whether the
|
|
302
|
+
* card is open afterwards (false when popups are disabled).
|
|
303
|
+
*/
|
|
304
|
+
togglePopup(): boolean {
|
|
305
|
+
if (this._popup_el) {
|
|
306
|
+
this.closePopup();
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
return this.openPopup();
|
|
230
310
|
}
|
|
231
311
|
|
|
232
312
|
get popupOpen(): boolean {
|
|
@@ -244,6 +324,7 @@ export default class OpenLayersMapMarker extends MapMarker {
|
|
|
244
324
|
const data = this.data as unknown as {
|
|
245
325
|
text?: { headline?: string; text?: string };
|
|
246
326
|
media?: { thumb?: string | null; url?: string | null; caption?: string | null };
|
|
327
|
+
media_extra?: { thumb?: string | null; url?: string | null; caption?: string | null }[];
|
|
247
328
|
};
|
|
248
329
|
const card = document.createElement("div");
|
|
249
330
|
card.className = "vco-marker-popup";
|
|
@@ -262,11 +343,13 @@ export default class OpenLayersMapMarker extends MapMarker {
|
|
|
262
343
|
});
|
|
263
344
|
card.appendChild(close);
|
|
264
345
|
|
|
265
|
-
|
|
346
|
+
// first item with a thumb across the primary and the extras (#358)
|
|
347
|
+
const thumb_item = [data.media, ...(data.media_extra ?? [])].find((item) => !!item?.thumb);
|
|
348
|
+
if (thumb_item?.thumb) {
|
|
266
349
|
const img = document.createElement("img");
|
|
267
350
|
img.className = "vco-marker-popup-thumb";
|
|
268
|
-
img.src =
|
|
269
|
-
img.alt =
|
|
351
|
+
img.src = thumb_item.thumb;
|
|
352
|
+
img.alt = thumb_item.caption ?? "";
|
|
270
353
|
img.loading = "lazy";
|
|
271
354
|
card.appendChild(img);
|
|
272
355
|
}
|
|
@@ -297,9 +380,22 @@ export default class OpenLayersMapMarker extends MapMarker {
|
|
|
297
380
|
_updateAudioBadge(): void {
|
|
298
381
|
if (!this._audio_badge) return;
|
|
299
382
|
const media = (this.data as unknown as { media?: { mediatype?: { type?: string } } }).media;
|
|
300
|
-
|
|
301
|
-
|
|
383
|
+
// any audible item badges the marker, not just item 0: an
|
|
384
|
+
// image-first/audio-second slide is still an audible stop (#358)
|
|
385
|
+
const extra =
|
|
386
|
+
(this.data as unknown as { media_extra?: { mediatype?: { type?: string } }[] })
|
|
387
|
+
.media_extra ?? [];
|
|
388
|
+
const audible =
|
|
389
|
+
[media, ...extra].some((item) => {
|
|
390
|
+
const kind = item?.mediatype?.type;
|
|
391
|
+
return kind === "audio" || kind === "video";
|
|
392
|
+
}) || !!this.data.narration;
|
|
302
393
|
this._marker.classList.toggle("vco-mapmarker-has-audio", audible);
|
|
394
|
+
const suffix =
|
|
395
|
+
audible && typeof Language.buttons.audio_badge === "string"
|
|
396
|
+
? `, ${Language.buttons.audio_badge}`
|
|
397
|
+
: "";
|
|
398
|
+
this._marker.setAttribute("aria-label", `${this._marker_label}${suffix}`);
|
|
303
399
|
}
|
|
304
400
|
|
|
305
401
|
dispose(): void {
|
|
@@ -313,6 +409,10 @@ export default class OpenLayersMapMarker extends MapMarker {
|
|
|
313
409
|
marker?.removeEventListener?.("click", this._onMarkerClickBound);
|
|
314
410
|
this._onMarkerClickBound = null;
|
|
315
411
|
}
|
|
412
|
+
if (this._onMarkerKeyBound) {
|
|
413
|
+
marker?.removeEventListener?.("keydown", this._onMarkerKeyBound);
|
|
414
|
+
this._onMarkerKeyBound = null;
|
|
415
|
+
}
|
|
316
416
|
if (this._overlay) {
|
|
317
417
|
this._overlay.setElement(undefined);
|
|
318
418
|
this._overlay = null as unknown as Overlay;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure view helpers for slideshow-driven slide presentation: rotation,
|
|
3
|
+
* CSS filter grading, spotlight masks and per-slide basemaps.
|
|
4
|
+
*
|
|
5
|
+
* DOM- and OpenLayers-free so the CLI and unit tests can use them; the map
|
|
6
|
+
* engine (`Map.OpenLayers`) applies their output. Every helper treats
|
|
7
|
+
* absent/mistyped input as "feature off", so stories that never mention the
|
|
8
|
+
* new fields render exactly as before.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { StorymapSlideFilter, StorymapSlideLocation, StorymapSlideMask } from "../types";
|
|
12
|
+
import { asClampedNumber } from "../storymap/iiif-shared.ts";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Valid ranges for filter grading, shared by the translator (which reads
|
|
16
|
+
* them) and the renderer (which enforces them again — hand-written JSON
|
|
17
|
+
* reaches the renderer directly, so read-time clamping alone is not enough).
|
|
18
|
+
*/
|
|
19
|
+
export const FILTER_RANGES = {
|
|
20
|
+
brightness: { min: 0, max: 200, def: 100 },
|
|
21
|
+
contrast: { min: 0, max: 200, def: 100 },
|
|
22
|
+
saturate: { min: 0, max: 200, def: 100 },
|
|
23
|
+
hueRotate: { min: -180, max: 180, def: 0 },
|
|
24
|
+
sepia: { min: 0, max: 100, def: 0 },
|
|
25
|
+
blur: { min: 0, max: 20, def: 0 },
|
|
26
|
+
} as const;
|
|
27
|
+
|
|
28
|
+
/** View rotation in radians, or null when the slide states none. */
|
|
29
|
+
export function slideRotationRad(
|
|
30
|
+
location: StorymapSlideLocation | null | undefined,
|
|
31
|
+
): number | null {
|
|
32
|
+
const rotation = location?.rotation;
|
|
33
|
+
if (typeof rotation !== "number" || !Number.isFinite(rotation) || rotation === 0) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
return (rotation * Math.PI) / 180;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A `filter()` CSS value for the map viewport from a slide `filter` bag
|
|
41
|
+
* (slideshow `filters`): brightness/contrast/saturate in percent,
|
|
42
|
+
* hue-rotate in degrees, sepia in percent, blur in pixels. Unknown keys
|
|
43
|
+
* never reach CSS. Returns "" when nothing is graded.
|
|
44
|
+
*/
|
|
45
|
+
export function buildSlideFilter(filter: StorymapSlideFilter | null | undefined): string {
|
|
46
|
+
if (!filter || typeof filter !== "object") return "";
|
|
47
|
+
const parts: string[] = [];
|
|
48
|
+
const percent = (value: unknown, name: "brightness" | "contrast" | "saturate" | "sepia") => {
|
|
49
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return;
|
|
50
|
+
const { min, max, def } = FILTER_RANGES[name];
|
|
51
|
+
if (value === def) return;
|
|
52
|
+
parts.push(`${name}(${asClampedNumber(value, min, max)}%)`);
|
|
53
|
+
};
|
|
54
|
+
percent(filter.brightness, "brightness");
|
|
55
|
+
percent(filter.contrast, "contrast");
|
|
56
|
+
percent(filter.saturate, "saturate");
|
|
57
|
+
const hue = asClampedNumber(filter.hueRotate, ...filterRange("hueRotate"));
|
|
58
|
+
if (hue !== null && hue !== FILTER_RANGES.hueRotate.def) {
|
|
59
|
+
parts.push(`hue-rotate(${hue}deg)`);
|
|
60
|
+
}
|
|
61
|
+
percent(filter.sepia, "sepia");
|
|
62
|
+
const blur = asClampedNumber(filter.blur, ...filterRange("blur"));
|
|
63
|
+
if (blur !== null && blur !== FILTER_RANGES.blur.def) {
|
|
64
|
+
parts.push(`blur(${blur}px)`);
|
|
65
|
+
}
|
|
66
|
+
return parts.join(" ");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The `[min, max]` pair for a filter range (spread into `asClampedNumber`). */
|
|
70
|
+
function filterRange(key: keyof typeof FILTER_RANGES): [number, number] {
|
|
71
|
+
return [FILTER_RANGES[key].min, FILTER_RANGES[key].max];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const MASK_COLOR = /^(#[0-9a-f]{3}([0-9a-f]{3}([0-9a-f]{2})?)?|rgba?\([^)]*\))$/i;
|
|
75
|
+
|
|
76
|
+
export interface NormalizedMask {
|
|
77
|
+
x: number;
|
|
78
|
+
y: number;
|
|
79
|
+
w: number;
|
|
80
|
+
h: number;
|
|
81
|
+
color: string;
|
|
82
|
+
invert: boolean;
|
|
83
|
+
opacity: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A validated spotlight mask (slideshow `passepartout`): normalized
|
|
88
|
+
* viewport fractions plus presentation. Returns null for anything that is
|
|
89
|
+
* not a usable `{x, y, w, h}` shape; out-of-range fractions are clamped,
|
|
90
|
+
* an unparsable color falls back to translucent black.
|
|
91
|
+
*/
|
|
92
|
+
export function normalizeMask(mask: StorymapSlideMask | null | undefined): NormalizedMask | null {
|
|
93
|
+
if (!mask || typeof mask !== "object") return null;
|
|
94
|
+
const { x, y, w, h } = mask;
|
|
95
|
+
if (
|
|
96
|
+
typeof x !== "number" ||
|
|
97
|
+
typeof y !== "number" ||
|
|
98
|
+
typeof w !== "number" ||
|
|
99
|
+
typeof h !== "number" ||
|
|
100
|
+
![x, y, w, h].every(Number.isFinite)
|
|
101
|
+
) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
const clamp01 = (n: number) => Math.min(1, Math.max(0, n));
|
|
105
|
+
const nx = clamp01(x);
|
|
106
|
+
const ny = clamp01(y);
|
|
107
|
+
// shrink the far edge with a clamped origin (like clampRegion): a mask
|
|
108
|
+
// fully outside the viewport resolves to nothing, not a full mask
|
|
109
|
+
const nw = Math.min(1 - nx, Math.max(0, w + Math.min(0, x)));
|
|
110
|
+
const nh = Math.min(1 - ny, Math.max(0, h + Math.min(0, y)));
|
|
111
|
+
if (!(nw > 0) || !(nh > 0)) return null;
|
|
112
|
+
const rawColor = typeof mask.color === "string" ? mask.color.trim() : "";
|
|
113
|
+
const color = rawColor !== "" && MASK_COLOR.test(rawColor) ? rawColor : "rgba(0,0,0,0.5)";
|
|
114
|
+
const opacity =
|
|
115
|
+
typeof mask.opacity === "number" && Number.isFinite(mask.opacity)
|
|
116
|
+
? Math.min(1, Math.max(0, mask.opacity))
|
|
117
|
+
: 1;
|
|
118
|
+
return { x: nx, y: ny, w: nw, h: nh, color, invert: mask.invert === true, opacity };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The per-slide basemap key, or null when the slide keeps the story basemap. */
|
|
122
|
+
export function slideBasemapKey(location: StorymapSlideLocation | null | undefined): string | null {
|
|
123
|
+
const basemap = location?.basemap;
|
|
124
|
+
return typeof basemap === "string" && basemap !== "" ? basemap : null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* True for plain image-file URLs (no IIIF service, `{z}` template or
|
|
129
|
+
* keyword). Only unambiguous raster extensions: `.jpx`/`.jp2` can be IIIF
|
|
130
|
+
* bases too (Leipzig serves `info.json` off `.jpx`), so those go through
|
|
131
|
+
* the service attempt with a static fallback instead. Relative URLs
|
|
132
|
+
* (same-origin static files) count; templates never match (no extension).
|
|
133
|
+
*/
|
|
134
|
+
export function isStaticImageUrl(url: string): boolean {
|
|
135
|
+
if (url.includes("{z}")) return false;
|
|
136
|
+
if (!/\.(jpe?g|png|gif|webp|tif?f|bmp|avif)(\?.*)?$/i.test(url)) return false;
|
|
137
|
+
if (/^https?:\/\//i.test(url)) return true;
|
|
138
|
+
// same-origin relative file without any other scheme (mapbox:, osm:, …)
|
|
139
|
+
return !/^[a-z][a-z0-9+.-]*:/i.test(url);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* An image service base as its `info.json` URL (idempotent for URLs that
|
|
144
|
+
* already name it). Shared by the slideshow translator (story default) and
|
|
145
|
+
* the per-slide basemap builder so the suffix rule cannot drift.
|
|
146
|
+
*/
|
|
147
|
+
export function serviceToInfoJson(service: string): string {
|
|
148
|
+
return /info\.json$/i.test(service) ? service : `${service.replace(/\/$/, "")}/info.json`;
|
|
149
|
+
}
|
package/src/map/types.ts
CHANGED
|
@@ -7,6 +7,8 @@ export interface ViewToOptions {
|
|
|
7
7
|
calculate_zoom?: boolean;
|
|
8
8
|
duration?: number;
|
|
9
9
|
zoom?: number;
|
|
10
|
+
/** View rotation in degrees clockwise; absent leaves rotation unchanged. */
|
|
11
|
+
rotation?: number;
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
/** A point of the connection line: either a slide or a raw lat/lon pair. */
|
package/src/media/EmbedUtil.ts
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
in a storymap's JSON can never execute code in a viewer's browser.
|
|
10
10
|
================================================== */
|
|
11
11
|
|
|
12
|
+
import { Language } from "../language/Language";
|
|
13
|
+
|
|
12
14
|
// Attributes copied from a pasted <iframe> onto the rebuilt element.
|
|
13
15
|
// src is handled separately (validated); everything else is dropped.
|
|
14
16
|
const IFRAME_ATTRIBUTES = [
|
|
@@ -99,6 +101,11 @@ const SLIDE_TEXT_DROP_TAGS = DROP_TAGS.filter((tag) => tag !== "IFRAME");
|
|
|
99
101
|
*
|
|
100
102
|
* This list covers prose and media embeds. Anything else is unwrapped (its
|
|
101
103
|
* text is kept) rather than dropped with its contents.
|
|
104
|
+
*
|
|
105
|
+
* Note on `style` (trusted authors): inline declarations pass through
|
|
106
|
+
* verbatim — there is no script execution from declarations in modern
|
|
107
|
+
* engines, `url()` tracking is at parity with allowlisted `<img>`, and the
|
|
108
|
+
* slide root is positioned and isolated so nothing escapes the slide box.
|
|
102
109
|
*/
|
|
103
110
|
const SLIDE_TEXT_TAGS = [
|
|
104
111
|
// structure / text
|
|
@@ -165,13 +172,16 @@ const SLIDE_TEXT_TAGS = [
|
|
|
165
172
|
|
|
166
173
|
/**
|
|
167
174
|
* Attributes allowed on slide-text elements. `id`/`name` are deliberately
|
|
168
|
-
* absent: they enable DOM clobbering of host-page globals. `style`
|
|
169
|
-
*
|
|
175
|
+
* absent: they enable DOM clobbering of host-page globals. `style` passes
|
|
176
|
+
* through verbatim — tour authors are trusted; the slide root is positioned
|
|
177
|
+
* and isolated, so declarations cannot escape it (see VCO.Slide.scss).
|
|
178
|
+
* `class` is kept — the story fixtures and
|
|
170
179
|
* the migrated site content style slides through it, and a class name cannot
|
|
171
180
|
* execute anything.
|
|
172
181
|
*/
|
|
173
182
|
const SLIDE_TEXT_ATTRIBUTES = [
|
|
174
183
|
"class",
|
|
184
|
+
"style",
|
|
175
185
|
"title",
|
|
176
186
|
"lang",
|
|
177
187
|
"dir",
|
|
@@ -276,6 +286,21 @@ export function buildIframe(html: string): HTMLIFrameElement | null {
|
|
|
276
286
|
iframe.setAttribute("frameborder", "0");
|
|
277
287
|
iframe.setAttribute("allowfullscreen", "");
|
|
278
288
|
}
|
|
289
|
+
if (!iframe.hasAttribute("title")) {
|
|
290
|
+
const localeMedia = (Language as unknown as { media?: { iframe_title?: unknown } }).media;
|
|
291
|
+
const localeTitle =
|
|
292
|
+
typeof localeMedia?.iframe_title === "string" && localeMedia.iframe_title !== ""
|
|
293
|
+
? localeMedia.iframe_title
|
|
294
|
+
: undefined;
|
|
295
|
+
const messagesTitle = Language.messages["media.iframe_title"];
|
|
296
|
+
iframe.setAttribute(
|
|
297
|
+
"title",
|
|
298
|
+
localeTitle ??
|
|
299
|
+
(typeof messagesTitle === "string" && messagesTitle !== ""
|
|
300
|
+
? messagesTitle
|
|
301
|
+
: "Embedded media"),
|
|
302
|
+
);
|
|
303
|
+
}
|
|
279
304
|
return iframe;
|
|
280
305
|
}
|
|
281
306
|
|
package/src/media/Media.ts
CHANGED
|
@@ -50,6 +50,16 @@ export function loadErrorIcon(media_type: unknown): string {
|
|
|
50
50
|
return LOAD_ERROR_ICONS[type] ?? GENERIC_LOAD_ERROR_ICON;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Whether media captions/credits render: `show_info: false` hides them, but
|
|
55
|
+
* only for slideshow tours (`slideshow_source`) — internal documents always
|
|
56
|
+
* show their captions. Pure for testability.
|
|
57
|
+
*/
|
|
58
|
+
export function shouldShowInfo(options: MediaOptions): boolean {
|
|
59
|
+
if (options.slideshow_source !== true) return true;
|
|
60
|
+
return options.show_info !== false;
|
|
61
|
+
}
|
|
62
|
+
|
|
53
63
|
/* Options for Media and its subclasses: the fields Media itself sets
|
|
54
64
|
or reads. Everything else merged in via mergeData is absorbed by the
|
|
55
65
|
index signature. */
|
|
@@ -74,6 +84,14 @@ export interface MediaData extends StorymapSlideMedia {
|
|
|
74
84
|
[key: string]: unknown;
|
|
75
85
|
}
|
|
76
86
|
|
|
87
|
+
export interface MediaEvents {
|
|
88
|
+
loaded: MediaData;
|
|
89
|
+
media_loaded: MediaData;
|
|
90
|
+
media_ended: MediaData;
|
|
91
|
+
added: MediaData;
|
|
92
|
+
removed: MediaData;
|
|
93
|
+
}
|
|
94
|
+
|
|
77
95
|
/**
|
|
78
96
|
* The media element cache.
|
|
79
97
|
*
|
|
@@ -110,7 +128,7 @@ export class MediaBase {
|
|
|
110
128
|
declare "options": MediaOptions;
|
|
111
129
|
declare "animator": unknown;
|
|
112
130
|
declare "_media": unknown;
|
|
113
|
-
declare "fire": EventedInstance["fire"];
|
|
131
|
+
declare "fire": EventedInstance<MediaEvents>["fire"];
|
|
114
132
|
|
|
115
133
|
/* Constructor
|
|
116
134
|
================================================== */
|
|
@@ -230,8 +248,7 @@ export class MediaBase {
|
|
|
230
248
|
this._state.loaded = true;
|
|
231
249
|
this._updateDisplay();
|
|
232
250
|
} catch (e) {
|
|
233
|
-
console.
|
|
234
|
-
console.log(e);
|
|
251
|
+
console.warn("StoryMapJS: error loading media.", e);
|
|
235
252
|
this.loadErrorDisplay("Error loading media.");
|
|
236
253
|
}
|
|
237
254
|
}, 1200);
|
|
@@ -441,6 +458,9 @@ export class MediaBase {
|
|
|
441
458
|
const text = Dom.create("p", "");
|
|
442
459
|
text.appendChild(document.createTextNode(message));
|
|
443
460
|
this._el.content_item.appendChild(text);
|
|
461
|
+
// announced, not just shown: load failures otherwise pass silently
|
|
462
|
+
// for screen-reader visitors
|
|
463
|
+
this._el.content_item.setAttribute("role", "alert");
|
|
444
464
|
|
|
445
465
|
// After Loaded
|
|
446
466
|
this.onLoaded(true);
|
|
@@ -474,6 +494,9 @@ export class MediaBase {
|
|
|
474
494
|
|
|
475
495
|
/** Render the media credit and caption, if the slide has them. */
|
|
476
496
|
showMeta(): void {
|
|
497
|
+
// slideshow tours may hide the info block (show_info: false);
|
|
498
|
+
// internal documents always render it
|
|
499
|
+
if (!shouldShowInfo(this.options)) return;
|
|
477
500
|
this._state.show_meta = true;
|
|
478
501
|
// Credit and caption are author-supplied strings that may legitimately
|
|
479
502
|
// contain formatting (they are documented as HTML), so they go through
|
|
@@ -590,7 +613,7 @@ export class MediaBase {
|
|
|
590
613
|
_stopMedia() {}
|
|
591
614
|
}
|
|
592
615
|
|
|
593
|
-
export class Media extends Evented(MediaBase) {
|
|
616
|
+
export class Media extends Evented<MediaEvents, typeof MediaBase>(MediaBase) {
|
|
594
617
|
constructor(...args: ConstructorParameters<typeof MediaBase>) {
|
|
595
618
|
super(...args);
|
|
596
619
|
}
|
package/src/media/embedId.ts
CHANGED
|
@@ -102,3 +102,9 @@ export function flickrId(url: string): string | null {
|
|
|
102
102
|
export function tweetId(url: string): string | null {
|
|
103
103
|
return segmentAfter(url, /\/status(?:es)?\//i);
|
|
104
104
|
}
|
|
105
|
+
|
|
106
|
+
/** Tweet author (the profile segment before `/status/<id>`), or null. */
|
|
107
|
+
export function tweetAuthor(url: string): string | null {
|
|
108
|
+
const match = /(?:twitter\.com|x\.com)\/([^\s/?#]+)\/status(?:es)?\//i.exec(url);
|
|
109
|
+
return match ? match[1] : null;
|
|
110
|
+
}
|
|
@@ -8,6 +8,8 @@ import Dom from "../../dom/Dom";
|
|
|
8
8
|
|
|
9
9
|
export default class Flickr extends Media {
|
|
10
10
|
declare "media_id": string;
|
|
11
|
+
/** The `load` handler on the image, stored so dispose() can detach it. */
|
|
12
|
+
declare "_onLoad": (() => void) | null;
|
|
11
13
|
|
|
12
14
|
/* Load the media
|
|
13
15
|
================================================== */
|
|
@@ -22,10 +24,12 @@ export default class Flickr extends Media {
|
|
|
22
24
|
this._el.content,
|
|
23
25
|
);
|
|
24
26
|
|
|
25
|
-
// Media Loaded Event
|
|
26
|
-
|
|
27
|
+
// Media Loaded Event. The reference is kept so dispose() can detach
|
|
28
|
+
// it: an inline arrow function has no other handle.
|
|
29
|
+
this._onLoad = () => {
|
|
27
30
|
this.onMediaLoaded();
|
|
28
|
-
}
|
|
31
|
+
};
|
|
32
|
+
this._el.content_item.addEventListener("load", this._onLoad);
|
|
29
33
|
|
|
30
34
|
// Get Media ID
|
|
31
35
|
this.establishMediaID();
|
|
@@ -112,4 +116,11 @@ export default class Flickr extends Media {
|
|
|
112
116
|
|
|
113
117
|
return _size;
|
|
114
118
|
}
|
|
119
|
+
|
|
120
|
+
_disposeMedia(): void {
|
|
121
|
+
if (this._onLoad) {
|
|
122
|
+
this._el.content_item?.removeEventListener("load", this._onLoad);
|
|
123
|
+
this._onLoad = null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
115
126
|
}
|
|
@@ -51,6 +51,19 @@ export class HtmlMediaBase extends Media {
|
|
|
51
51
|
) as HTMLMediaElement;
|
|
52
52
|
this._el.content_item = media_item;
|
|
53
53
|
media_item.controls = true;
|
|
54
|
+
const captionText =
|
|
55
|
+
typeof this.data.caption === "string"
|
|
56
|
+
? this.data.caption
|
|
57
|
+
.replace(/<[^>]*>/g, " ")
|
|
58
|
+
.replace(/\s+/g, " ")
|
|
59
|
+
.trim()
|
|
60
|
+
: "";
|
|
61
|
+
const mediaName =
|
|
62
|
+
typeof this.options.media_name === "string" ? this.options.media_name : "";
|
|
63
|
+
media_item.setAttribute(
|
|
64
|
+
"aria-label",
|
|
65
|
+
captionText || mediaName || (spec.kind === "audio" ? "Audio" : "Video"),
|
|
66
|
+
);
|
|
54
67
|
|
|
55
68
|
const source_item = Dom.create("source", "", media_item) as HTMLSourceElement;
|
|
56
69
|
this._el.source_item = source_item;
|
|
@@ -58,9 +71,62 @@ export class HtmlMediaBase extends Media {
|
|
|
58
71
|
// Media Loaded Event. The references are kept so dispose() can detach
|
|
59
72
|
// them: an inline arrow function has no other handle.
|
|
60
73
|
this._onEnded = null;
|
|
74
|
+
// Playback flags (slideshow audio terms): loop repeats, offset seeks
|
|
75
|
+
// once the metadata is available, play:"auto" starts on load
|
|
76
|
+
// (absent stays click-to-play with controls, as before). Slideshow
|
|
77
|
+
// tours only — internal documents ignore the bag even when present.
|
|
78
|
+
const sourced = (this.options as { slideshow_source?: unknown }).slideshow_source === true;
|
|
79
|
+
const bag = (
|
|
80
|
+
sourced
|
|
81
|
+
? this.data
|
|
82
|
+
: ({} as {
|
|
83
|
+
loop?: boolean;
|
|
84
|
+
offset?: number;
|
|
85
|
+
play?: string;
|
|
86
|
+
})
|
|
87
|
+
) as {
|
|
88
|
+
loop?: boolean;
|
|
89
|
+
offset?: number;
|
|
90
|
+
play?: string;
|
|
91
|
+
};
|
|
92
|
+
media_item.loop = bag.loop === true;
|
|
93
|
+
const offset =
|
|
94
|
+
typeof bag.offset === "number" && Number.isFinite(bag.offset) && bag.offset > 0
|
|
95
|
+
? bag.offset
|
|
96
|
+
: 0;
|
|
97
|
+
const seek = () => {
|
|
98
|
+
if (offset <= 0) return;
|
|
99
|
+
try {
|
|
100
|
+
media_item.currentTime = offset;
|
|
101
|
+
} catch {
|
|
102
|
+
// pre-metadata seek throws in some browsers; canplay retries
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
const autoplay = bag.play === "auto";
|
|
61
106
|
this._onCanPlay = () => {
|
|
107
|
+
seek();
|
|
108
|
+
if (autoplay) {
|
|
109
|
+
void media_item.play()?.catch?.(() => {
|
|
110
|
+
// autoplay policy or missing gesture: controls stay for
|
|
111
|
+
// the visitor, like a click-armed slide
|
|
112
|
+
});
|
|
113
|
+
}
|
|
62
114
|
this.onLoaded();
|
|
63
115
|
};
|
|
116
|
+
// offset with metadata already present (cached media): seek now too
|
|
117
|
+
if (offset > 0) {
|
|
118
|
+
if (media_item.readyState >= 1) {
|
|
119
|
+
seek();
|
|
120
|
+
} else {
|
|
121
|
+
media_item.addEventListener(
|
|
122
|
+
"loadedmetadata",
|
|
123
|
+
() => {
|
|
124
|
+
seek();
|
|
125
|
+
},
|
|
126
|
+
{ once: true },
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
64
130
|
media_item.addEventListener("canplay", this._onCanPlay);
|
|
65
131
|
|
|
66
132
|
// Load Error Event (the source element fires it, not the media element)
|