@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.
Files changed (195) hide show
  1. package/CHANGELOG +81 -0
  2. package/README.md +129 -12
  3. package/dist/assets/{demo-DBpeF31K.js → demo-bWnOyfUw.js} +1 -1
  4. package/dist/assets/harness-C3JUv5Ii.js +1 -0
  5. package/dist/assets/{harness-multi-CYHgI0a9.js → harness-multi-XZNex3tb.js} +1 -1
  6. package/dist/assets/{harness-support-Do7sIp2j.js → harness-support-CjfZIVeb.js} +1 -1
  7. package/dist/assets/main-DQ32hWBM.js +17 -0
  8. package/dist/context.json +13 -2
  9. package/dist/css/storymap.css +1 -1
  10. package/dist/demo.html +3 -2
  11. package/dist/docs/api/assets/navigation.js +1 -1
  12. package/dist/docs/api/assets/search.js +1 -1
  13. package/dist/docs/api/classes/StoryMap.html +777 -269
  14. package/dist/docs/api/functions/MediaType.html +1 -1
  15. package/dist/docs/api/functions/isPresentation3Collection.html +1 -1
  16. package/dist/docs/api/functions/isPresentation3Manifest.html +1 -1
  17. package/dist/docs/api/functions/isSlideshowCollection.html +51 -0
  18. package/dist/docs/api/functions/loadCSS.html +1 -1
  19. package/dist/docs/api/functions/manifestToStorymapData.html +1 -1
  20. package/dist/docs/api/functions/setLanguage.html +1 -1
  21. package/dist/docs/api/functions/slideshowToStorymapData.html +51 -0
  22. package/dist/docs/api/functions/slideshowVersion.html +48 -0
  23. package/dist/docs/api/functions/storymapToManifest.html +1 -1
  24. package/dist/docs/api/functions/validateStorymap.html +1 -1
  25. package/dist/docs/api/functions/validateStorymapAndReport.html +1 -1
  26. package/dist/docs/api/index.html +115 -12
  27. package/dist/docs/api/interfaces/AnimationHandle.html +2 -2
  28. package/dist/docs/api/interfaces/ImagereadyPayload.html +4 -4
  29. package/dist/docs/api/interfaces/LanguageEntry.html +6 -6
  30. package/dist/docs/api/interfaces/LatLngLiteral.html +4 -4
  31. package/dist/docs/api/interfaces/LoadOptions.html +2 -2
  32. package/dist/docs/api/interfaces/MapEvents.html +116 -0
  33. package/dist/docs/api/interfaces/MapMarkerEvents.html +66 -0
  34. package/dist/docs/api/interfaces/MarkerEventPayload.html +57 -0
  35. package/dist/docs/api/interfaces/MediaEvents.html +78 -0
  36. package/dist/docs/api/interfaces/MenuBarEvents.html +114 -0
  37. package/dist/docs/api/interfaces/MessageEvents.html +72 -0
  38. package/dist/docs/api/interfaces/OlLayer.html +1 -1
  39. package/dist/docs/api/interfaces/OlMap.html +1 -1
  40. package/dist/docs/api/interfaces/OlSource.html +1 -1
  41. package/dist/docs/api/interfaces/OlTileLayer.html +1 -1
  42. package/dist/docs/api/interfaces/OlVectorLayer.html +1 -1
  43. package/dist/docs/api/interfaces/OlView.html +1 -1
  44. package/dist/docs/api/interfaces/SlideBackgroundState.html +66 -0
  45. package/dist/docs/api/interfaces/SlideEvents.html +78 -0
  46. package/dist/docs/api/interfaces/SlideNavEvents.html +72 -0
  47. package/dist/docs/api/interfaces/SlideshowPlayerSettings.html +182 -0
  48. package/dist/docs/api/interfaces/SlideshowToStorymapResult.html +60 -0
  49. package/dist/docs/api/interfaces/SlideshowTranslatorOptions.html +65 -0
  50. package/dist/docs/api/interfaces/SlideshowWarning.html +72 -0
  51. package/dist/docs/api/interfaces/StoryMapEvents.html +146 -0
  52. package/dist/docs/api/interfaces/StoryMapMarker.html +763 -0
  53. package/dist/docs/api/interfaces/StorySliderEvents.html +102 -0
  54. package/dist/docs/api/interfaces/StorymapBasemap.html +69 -0
  55. package/dist/docs/api/interfaces/StorymapData.html +12 -6
  56. package/dist/docs/api/interfaces/StorymapDataWrapper.html +2 -2
  57. package/dist/docs/api/interfaces/StorymapDocument.html +2 -2
  58. package/dist/docs/api/interfaces/StorymapError.html +3 -3
  59. package/dist/docs/api/interfaces/StorymapGeoreference.html +5 -5
  60. package/dist/docs/api/interfaces/StorymapGeoreferenceBody.html +4 -4
  61. package/dist/docs/api/interfaces/StorymapGroundControlPoint.html +4 -4
  62. package/dist/docs/api/interfaces/StorymapLanguageMap.html +2 -2
  63. package/dist/docs/api/interfaces/StorymapManifest.html +12 -12
  64. package/dist/docs/api/interfaces/StorymapManifestAnnotation.html +43 -11
  65. package/dist/docs/api/interfaces/StorymapManifestBackground.html +6 -6
  66. package/dist/docs/api/interfaces/StorymapManifestCanvas.html +65 -17
  67. package/dist/docs/api/interfaces/StorymapManifestContentResource.html +8 -8
  68. package/dist/docs/api/interfaces/StorymapManifestFeatureCollection.html +4 -4
  69. package/dist/docs/api/interfaces/StorymapManifestGeoreferencing.html +7 -7
  70. package/dist/docs/api/interfaces/StorymapManifestImageService.html +4 -4
  71. package/dist/docs/api/interfaces/StorymapManifestRange.html +5 -5
  72. package/dist/docs/api/interfaces/StorymapManifestService.html +4 -4
  73. package/dist/docs/api/interfaces/StorymapManifestSpecificResource.html +4 -4
  74. package/dist/docs/api/interfaces/StorymapManifestStatement.html +3 -3
  75. package/dist/docs/api/interfaces/StorymapMapOptions.html +5 -5
  76. package/dist/docs/api/interfaces/StorymapOptions.html +277 -75
  77. package/dist/docs/api/interfaces/StorymapOverlayLayer.html +42 -11
  78. package/dist/docs/api/interfaces/StorymapSlide.html +70 -16
  79. package/dist/docs/api/interfaces/StorymapSlideBackground.html +4 -4
  80. package/dist/docs/api/interfaces/StorymapSlideLocation.html +58 -14
  81. package/dist/docs/api/interfaces/StorymapSlideMarker.html +8 -9
  82. package/dist/docs/api/interfaces/StorymapSlideMedia.html +49 -10
  83. package/dist/docs/api/interfaces/StorymapSlideNarration.html +61 -9
  84. package/dist/docs/api/interfaces/StorymapSlideText.html +4 -4
  85. package/dist/docs/api/interfaces/StorymapTilejson.html +8 -8
  86. package/dist/docs/api/interfaces/SwipableEvents.html +102 -0
  87. package/dist/docs/api/interfaces/TextEvents.html +66 -0
  88. package/dist/docs/api/media/events.md +66 -0
  89. package/dist/docs/api/media/migration-from-knightlab.md +89 -49
  90. package/dist/docs/api/media/slideshow.md +109 -0
  91. package/dist/docs/api/media/storymap-as-iiif-manifest.md +1007 -0
  92. package/dist/docs/api/modules.html +6 -6
  93. package/dist/docs/api/types/ContentState.html +3 -3
  94. package/dist/docs/api/types/FiredEvent.html +43 -0
  95. package/dist/docs/api/types/ImagereadyKind.html +1 -1
  96. package/dist/docs/api/types/MediaTypeMatch.html +5 -5
  97. package/dist/docs/api/types/SwipeDirection.html +36 -0
  98. package/dist/docs/api/types/TileSourceFactory.html +1 -1
  99. package/dist/docs/authoring.html +20 -7
  100. package/dist/docs/events.html +263 -0
  101. package/dist/docs/iiif.html +136 -22
  102. package/dist/docs/migration.html +66 -19
  103. package/dist/docs/readme.html +113 -12
  104. package/dist/embed/index.html +7 -0
  105. package/dist/examples/font-css-false.json +33 -0
  106. package/dist/examples/issue-image-region.json +1 -1
  107. package/dist/examples/layers-control.json +60 -0
  108. package/dist/examples/map-template-provider.json +36 -0
  109. package/dist/examples/multi-media.json +71 -0
  110. package/dist/examples-iiif/annotated-image.json +16 -0
  111. package/dist/examples-iiif/font-css-false.json +114 -0
  112. package/dist/examples-iiif/issue-image-region.json +1 -1
  113. package/dist/examples-iiif/layers-control.json +226 -0
  114. package/dist/examples-iiif/map-template-provider.json +133 -0
  115. package/dist/examples-iiif/multi-media.json +237 -0
  116. package/dist/examples-slideshow/single.json +79 -0
  117. package/dist/examples-slideshow/static/pixel.png +0 -0
  118. package/dist/examples-slideshow/static.json +44 -0
  119. package/dist/harness-multi.html +3 -3
  120. package/dist/harness.html +4 -3
  121. package/dist/index.html +3 -2
  122. package/dist/js/storymap.d.ts +1326 -289
  123. package/dist/js/storymap.js +6690 -4784
  124. package/dist/js/storymap.js.map +1 -1
  125. package/dist/navplace-properties.json +14 -0
  126. package/dist/site.css +54 -36
  127. package/docs/migration-from-knightlab.md +89 -49
  128. package/docs/storymap-as-iiif-manifest.md +199 -126
  129. package/package.json +4 -2
  130. package/schema/storymap.schema.json +334 -34
  131. package/src/core/Util.ts +3 -1
  132. package/src/core/mixins.ts +42 -28
  133. package/src/language/Language.ts +7 -0
  134. package/src/language/locale/.expected-gaps.json +287 -27
  135. package/src/language/locale/de.json +11 -1
  136. package/src/language/locale/en.json +11 -1
  137. package/src/main.ts +51 -0
  138. package/src/map/Map.ts +44 -6
  139. package/src/map/MapMarker.ts +16 -3
  140. package/src/map/openlayers/Map.OpenLayers.ts +787 -62
  141. package/src/map/openlayers/MapMarker.OpenLayers.ts +113 -13
  142. package/src/map/slideView.ts +149 -0
  143. package/src/map/types.ts +2 -0
  144. package/src/media/EmbedUtil.ts +27 -2
  145. package/src/media/Media.ts +27 -4
  146. package/src/media/embedId.ts +6 -0
  147. package/src/media/types/Flickr.ts +14 -3
  148. package/src/media/types/HtmlMedia.ts +66 -0
  149. package/src/media/types/Image.ts +15 -3
  150. package/src/media/types/SoundCloud.ts +1 -1
  151. package/src/media/types/Text.ts +27 -8
  152. package/src/media/types/Twitter.ts +9 -7
  153. package/src/media/types/Vimeo.ts +1 -1
  154. package/src/media/types/YouTube.ts +2 -2
  155. package/src/scss/Typography.scss +15 -15
  156. package/src/scss/VCO.StoryMap.scss +173 -8
  157. package/src/scss/core/Reset.scss +5 -18
  158. package/src/scss/map/VCO.Map.scss +1 -1
  159. package/src/scss/map/VCO.MapMarker.scss +20 -9
  160. package/src/scss/map/openlayers/VCO.Map.OpenLayers.scss +7 -4
  161. package/src/scss/media/VCO.Media.scss +7 -7
  162. package/src/scss/media/types/VCO.Media.Blockquote.scss +1 -1
  163. package/src/scss/media/types/VCO.Media.Text.scss +5 -5
  164. package/src/scss/media/types/VCO.Media.Twitter.scss +4 -4
  165. package/src/scss/media/types/VCO.Media.Wikipedia.scss +2 -19
  166. package/src/scss/site/site.scss +55 -36
  167. package/src/scss/slider/VCO.Slide.scss +49 -11
  168. package/src/scss/slider/VCO.SlideNav.scss +23 -7
  169. package/src/scss/slider/VCO.StorySlider.scss +50 -0
  170. package/src/scss/snapmap/VCO.SnapMap.scss +3 -3
  171. package/src/scss/ui/VCO.Consent.scss +20 -14
  172. package/src/scss/ui/VCO.Layers.scss +119 -0
  173. package/src/scss/ui/VCO.MenuBar.Button.scss +140 -19
  174. package/src/scss/ui/VCO.MenuBar.scss +9 -2
  175. package/src/scss/ui/VCO.Message.scss +14 -0
  176. package/src/slider/Slide.ts +232 -74
  177. package/src/slider/SlideNav.ts +15 -4
  178. package/src/slider/StorySlider.ts +203 -22
  179. package/src/storymap/Consent.ts +22 -0
  180. package/src/storymap/StoryMap.ts +944 -131
  181. package/src/storymap/content-state.ts +3 -8
  182. package/src/storymap/font-css.ts +72 -0
  183. package/src/storymap/from-slideshow.ts +785 -0
  184. package/src/storymap/iiif-shared.ts +228 -0
  185. package/src/storymap/iiif.ts +289 -100
  186. package/src/storymap/to-iiif.ts +131 -44
  187. package/src/types.ts +382 -17
  188. package/src/ui/LayersControl.ts +302 -0
  189. package/src/ui/MenuBar.ts +232 -21
  190. package/src/ui/Message.ts +36 -3
  191. package/src/ui/Swipable.ts +25 -7
  192. package/dist/assets/harness-Cr0rYpvt.js +0 -1
  193. package/dist/assets/main-DXalctuR.js +0 -17
  194. package/src/scss/VCO.StoryMap.Dark.scss +0 -150
  195. package/src/scss/Variables.Dark.scss +0 -57
@@ -1,9 +1,10 @@
1
- import { mergeData } from "../core/Util";
1
+ import { mergeData, prefersReducedMotion } from "../core/Util";
2
2
  import { DomMixed, Evented, type EventedInstance } from "../core/mixins";
3
3
  import { DomEvent } from "../dom/DomEvent";
4
4
  import Dom from "../dom/Dom";
5
5
  import { easeInSpline } from "../animation/easings";
6
6
  import MediaType from "../media/MediaType";
7
+ import type { MediaEvents } from "../media/Media";
7
8
  import { sanitizeSlideText } from "../media/EmbedUtil";
8
9
  import Text from "../media/types/Text";
9
10
  import { Browser } from "../core/Browser";
@@ -50,9 +51,26 @@ interface MediaInstance {
50
51
  stopMedia: () => void;
51
52
  updateDisplay: (w?: number, h?: number, l?: string) => void;
52
53
  dispose?: () => void;
53
- on?: EventedInstance["on"];
54
- off?: EventedInstance["off"];
54
+ on?: EventedInstance<MediaEvents>["on"];
55
+ off?: EventedInstance<MediaEvents>["off"];
55
56
  _state?: { loaded?: boolean; eager?: boolean };
57
+ /** Per-item consent identity: each item is built with its own
58
+ `media_name`/`media_type`, which `hasPlayableMedia` reads back (#358). */
59
+ options?: { media_type?: string };
60
+ }
61
+
62
+ export interface SlideBackgroundState {
63
+ image: boolean;
64
+ color: boolean;
65
+ color_value: string;
66
+ }
67
+
68
+ export interface SlideEvents {
69
+ background_change: SlideBackgroundState;
70
+ call_to_action: Event;
71
+ loaded: StorymapSlide;
72
+ added: StorymapSlide;
73
+ removed: StorymapSlide;
56
74
  }
57
75
 
58
76
  interface SlideHas {
@@ -60,32 +78,33 @@ interface SlideHas {
60
78
  text: boolean;
61
79
  media: boolean;
62
80
  title: boolean;
63
- background: {
64
- image: boolean;
65
- color: boolean;
66
- color_value: string;
67
- };
81
+ background: SlideBackgroundState;
68
82
  }
69
83
 
70
84
  class SlideBase {
71
85
  /** `call_to_action` is only created when the slide shows one. */
72
86
  declare "_el": SlideElements;
87
+ /** The primary media item; always `_medias[0]` when present (#358). */
73
88
  declare "_media": MediaInstance | null;
74
- declare "_mediaclass": unknown;
89
+ /** Every media item: `[media, ...media_extra]` with a non-empty url. */
90
+ declare "_medias": MediaInstance[];
75
91
  declare "_text": Text;
76
92
  declare "_state": { loaded: boolean };
77
93
  declare "_scroll_hint": HTMLElement | null;
78
94
  declare "_scroll_hint_dismissed": boolean;
79
95
  declare "_onSlideScrollBound": EventListener;
80
- /** Every `media_ended` handler registered via `onMediaEnded`, kept so `dispose()` can remove them. */
81
- declare "_media_ended_fns": (() => void)[];
96
+ /** Every `media_ended` handler registered via `onMediaEnded`, with the
97
+ media it was attached to so `dispose()` can remove them. */
98
+ declare "_media_ended_fns": { media: MediaInstance; fn: () => void }[];
99
+ /** The `media_loaded` handlers below, kept for the same reason. */
100
+ declare "_media_loaded_fns": { media: MediaInstance; fn: () => void }[];
82
101
  declare "has": SlideHas;
83
102
  declare "title": string;
84
103
  declare "data": StorymapSlide;
85
104
  declare "options": SlideOptions;
86
105
  declare "active": boolean;
87
106
  declare "animator": unknown;
88
- declare "fire": EventedInstance["fire"];
107
+ declare "fire": EventedInstance<SlideEvents>["fire"];
89
108
  declare "onLoaded": () => void;
90
109
 
91
110
  //_el: {},
@@ -105,7 +124,7 @@ class SlideBase {
105
124
 
106
125
  // Components
107
126
  this._media = null;
108
- this._mediaclass = {};
127
+ this._medias = [];
109
128
  this._text = {} as Text;
110
129
 
111
130
  // State
@@ -161,6 +180,7 @@ class SlideBase {
161
180
 
162
181
  this._onSlideScrollBound = null as unknown as EventListener;
163
182
  this._media_ended_fns = [];
183
+ this._media_loaded_fns = [];
164
184
  mergeData(this.options, options);
165
185
  mergeData(this.data, data);
166
186
 
@@ -195,13 +215,17 @@ class SlideBase {
195
215
  }
196
216
  this._el.container?.getAnimations?.().forEach((a) => a.cancel());
197
217
  // the media_ended handlers registered via onMediaEnded() live on the
198
- // media object, which outlives this slide once nulled — remove them
218
+ // media objects, which outlive this slide once nulled — remove them
199
219
  // here or a disposed slide's advance closure fires on a later ended
200
- for (const fn of this._media_ended_fns) {
201
- this._media?.off?.("media_ended", fn);
220
+ this.clearMediaEnded();
221
+ for (const { media, fn } of this._media_loaded_fns) {
222
+ media.off?.("media_loaded", fn);
202
223
  }
203
- this._media_ended_fns = [];
204
- this._media?.dispose?.();
224
+ this._media_loaded_fns = [];
225
+ for (const media of this._medias) {
226
+ media.dispose?.();
227
+ }
228
+ this._medias = [];
205
229
  this._media = null;
206
230
  this._el.container?.remove();
207
231
  }
@@ -209,6 +233,19 @@ class SlideBase {
209
233
  setActive(is_active: boolean) {
210
234
  this.active = is_active;
211
235
 
236
+ // Off-screen slides are translated away but stay in the DOM, so a
237
+ // screen reader would read every slide's headline, body and caption.
238
+ // Hide inactive slides from assistive tech next to the flip that
239
+ // already marks them inactive; `inert` additionally drops their
240
+ // controls from keyboard reach. Neither affects loading: preloaded
241
+ // media (including iframes) still builds and fetches underneath.
242
+ this._el.container.setAttribute("aria-hidden", String(!is_active));
243
+ if (is_active) {
244
+ this._el.container.removeAttribute("inert");
245
+ } else {
246
+ this._el.container.setAttribute("inert", "");
247
+ }
248
+
212
249
  if (this.active) {
213
250
  if (this.data.background) {
214
251
  this.fire("background_change", this.has.background);
@@ -219,8 +256,10 @@ class SlideBase {
219
256
  // the active slide's images load eagerly: media built after this
220
257
  // point (the 1200ms load timer) honors the flag, already-built
221
258
  // images are upgraded below
222
- if (this._media?._state) {
223
- this._media._state.eager = true;
259
+ for (const media of this._medias) {
260
+ if (media._state) {
261
+ media._state.eager = true;
262
+ }
224
263
  }
225
264
  this.loadMedia();
226
265
  this._eagerLoadImages();
@@ -236,8 +275,10 @@ class SlideBase {
236
275
  }
237
276
 
238
277
  loadMedia() {
239
- if (this._media && !this._state.loaded) {
240
- this._media.loadMedia();
278
+ if (this._medias.length > 0 && !this._state.loaded) {
279
+ for (const media of this._medias) {
280
+ media.loadMedia();
281
+ }
241
282
  this._state.loaded = true;
242
283
  }
243
284
  }
@@ -254,29 +295,62 @@ class SlideBase {
254
295
  }
255
296
 
256
297
  stopMedia() {
257
- if (this._media && this._state.loaded) {
258
- try {
259
- this._media.stopMedia();
260
- } catch (e: unknown) {
261
- // Some sort of race condition or other ordering condition can cause
262
- // an error when the preview tab is selected in the editor due to
263
- // the stopped media not being properly formed.
264
- if (
265
- (e as Error).message === "this._el.content_item.querySelector is not a function"
266
- ) {
267
- console.log("Ignoring error in editor context: " + (e as Error).message);
268
- } else {
269
- throw e;
298
+ if (this._medias.length > 0 && this._state.loaded) {
299
+ for (const media of this._medias) {
300
+ // An audio bed that outlives its slide
301
+ // (`media.stopOnExit: false`, slideshow ambient audio) keeps
302
+ // playing; StoryMap stops it when another slide claims audio.
303
+ // Slideshow-only: internal documents always stop.
304
+ const mediaData = (media as unknown as { data?: { stopOnExit?: boolean } }).data;
305
+ const sourced =
306
+ (this.options as { slideshow_source?: unknown }).slideshow_source === true;
307
+ if (sourced && mediaData?.stopOnExit === false) continue;
308
+ try {
309
+ media.stopMedia();
310
+ } catch (e: unknown) {
311
+ // Some sort of race condition or other ordering condition can cause
312
+ // an error when the preview tab is selected in the editor due to
313
+ // the stopped media not being properly formed.
314
+ if (
315
+ (e as Error).message ===
316
+ "this._el.content_item.querySelector is not a function"
317
+ ) {
318
+ console.warn("Ignoring error in editor context: " + (e as Error).message);
319
+ } else {
320
+ throw e;
321
+ }
270
322
  }
271
323
  }
272
- // If the media load never started (its pending timer was
273
- // cancelled on a quick pass-through), allow a revisit to retry
274
- if (!this._media._state?.loaded) {
324
+ // If no media load started (pending timers were cancelled on a
325
+ // quick pass-through), allow a revisit to retry
326
+ if (!this._medias.some((media) => media._state?.loaded)) {
275
327
  this._state.loaded = false;
276
328
  }
277
329
  }
278
330
  }
279
331
 
332
+ /**
333
+ * Stop this slide's ambient audio beds (`media.stopOnExit: false`) —
334
+ * called by the viewer when another slide claims the audio, not on every
335
+ * deactivation (see `stopMedia`, which skips them).
336
+ */
337
+ stopAmbientMedia() {
338
+ // Slideshow-only by construction (see stopMedia): without the source
339
+ // flag no media can be ambient, so there is nothing to stop.
340
+ if ((this.options as { slideshow_source?: unknown }).slideshow_source !== true) return;
341
+ if (this._medias.length > 0 && this._state.loaded) {
342
+ for (const media of this._medias) {
343
+ const mediaData = (media as unknown as { data?: { stopOnExit?: boolean } }).data;
344
+ if (mediaData?.stopOnExit !== false) continue;
345
+ try {
346
+ media.stopMedia();
347
+ } catch {
348
+ // same editor-context race stopMedia() already tolerates
349
+ }
350
+ }
351
+ }
352
+ }
353
+
280
354
  getBackground() {
281
355
  return this.has.background;
282
356
  }
@@ -287,20 +361,49 @@ class SlideBase {
287
361
  * its timer; everything else keeps the timer.
288
362
  */
289
363
  hasPlayableMedia(): boolean {
290
- const kind = this.options.media_type as string | undefined;
291
- return kind === "audio" || kind === "video";
364
+ if (this._medias.length === 0) {
365
+ const kind = this.options.media_type as string | undefined;
366
+ return kind === "audio" || kind === "video";
367
+ }
368
+ return this._medias.some((media) => {
369
+ const kind = media.options?.media_type;
370
+ return kind === "audio" || kind === "video";
371
+ });
292
372
  }
293
373
 
294
374
  /**
295
375
  * Call `fn` when this slide's media finishes playing. A slide with no
296
376
  * media — or media that cannot end — never calls it, which is why
297
377
  * `autoplay_media` keeps its timer as a fallback rather than waiting on
298
- * an event that may not arrive.
378
+ * an event that may not arrive. Multi-media slides never call it either:
379
+ * waiting for the last of several items to end would stall autoplay, so
380
+ * the timer stays the advance for those (#358).
299
381
  */
300
382
  onMediaEnded(fn: () => void): void {
301
- if (!this.hasPlayableMedia() || !this._media?.on) return;
302
- this._media.on("media_ended", fn);
303
- this._media_ended_fns.push(fn);
383
+ const playable = this._medias.filter((media) => {
384
+ const kind = media.options?.media_type;
385
+ return (kind === "audio" || kind === "video") && media.on;
386
+ });
387
+ if (playable.length !== 1) return;
388
+ playable[0].on?.("media_ended", fn);
389
+ this._media_ended_fns.push({ media: playable[0], fn });
390
+ }
391
+
392
+ /**
393
+ * Drop `media_ended` handlers armed by `onMediaEnded()` without waiting
394
+ * for dispose: re-arming (autoplay revisit) or navigating away must not
395
+ * accumulate stale advance closures — an ambient bed outliving its slide
396
+ * would otherwise fire a previous slide's advance on ending.
397
+ */
398
+ clearMediaEnded(): void {
399
+ for (const { media, fn } of this._media_ended_fns) {
400
+ try {
401
+ media.off?.("media_ended", fn);
402
+ } catch {
403
+ // same editor-context race stopMedia() already tolerates
404
+ }
405
+ }
406
+ this._media_ended_fns = [];
304
407
  }
305
408
 
306
409
  scrollToTop() {
@@ -327,7 +430,10 @@ class SlideBase {
327
430
  return;
328
431
  }
329
432
  if (!this._scroll_hint) {
330
- this._scroll_hint = Dom.create("div", "vco-slide-scroll-hint", el);
433
+ // a real button for keyboard/AT operability (issue #385 wave)
434
+ this._scroll_hint = Dom.create("button", "vco-slide-scroll-hint", el);
435
+ this._scroll_hint.setAttribute("type", "button");
436
+ this._scroll_hint.setAttribute("aria-label", "Scroll down");
331
437
  this._scroll_hint.innerHTML = "<span class='vco-icon-arrow-down'></span>";
332
438
  DomEvent.addListener(this._scroll_hint, "click", this._onScrollHintClick, this);
333
439
  }
@@ -343,7 +449,10 @@ class SlideBase {
343
449
  _onScrollHintClick() {
344
450
  // scroll down one step, then hide (the scroll event dismisses too)
345
451
  const el = this._el.container;
346
- el.scrollBy({ top: el.clientHeight * 0.8, behavior: "smooth" });
452
+ el.scrollBy({
453
+ top: el.clientHeight * 0.8,
454
+ behavior: prefersReducedMotion() ? "auto" : "smooth",
455
+ });
347
456
  this._scroll_hint_dismissed = true;
348
457
  this._hideScrollHint();
349
458
  }
@@ -359,11 +468,14 @@ class SlideBase {
359
468
  }
360
469
 
361
470
  addCallToAction(str: string) {
471
+ // a real button for keyboard/AT operability (issue #385 wave), like
472
+ // the menubar buttons; the classes carry the look
362
473
  this._el.call_to_action = Dom.create(
363
- "div",
474
+ "button",
364
475
  "vco-slide-calltoaction",
365
476
  this._el.content_container,
366
477
  );
478
+ this._el.call_to_action.setAttribute("type", "button");
367
479
  // The text comes from `call_to_action_text` in the storymap JSON, so
368
480
  // it goes through the sanitizer like every other author string
369
481
  // instead of being concatenated into markup.
@@ -429,8 +541,13 @@ class SlideBase {
429
541
  }
430
542
  }
431
543
 
432
- // Determine Assets for layout and loading
433
- if (this.data.media && this.data.media.url && this.data.media.url !== "") {
544
+ // Determine Assets for layout and loading. A slide has media when
545
+ // any item — the primary or an extra — carries a non-empty url, so
546
+ // an empty `media.url` still yields false (#358).
547
+ const slide_media_items = [this.data.media, ...(this.data.media_extra ?? [])].filter(
548
+ (item): item is NonNullable<StorymapSlide["media"]> => !!item?.url,
549
+ );
550
+ if (slide_media_items.length > 0) {
434
551
  this.has.media = true;
435
552
  }
436
553
  if (this.data.text && this.data.text.text) {
@@ -441,19 +558,35 @@ class SlideBase {
441
558
  this.title = this.data.text.headline;
442
559
  }
443
560
 
444
- // Create Media
445
- const slide_media = this.data.media;
446
- if (this.has.media && slide_media) {
447
- // Determine the media type
448
- slide_media.mediatype = MediaType(slide_media) as MediaTypeMatch;
449
- this.options.media_name = slide_media.mediatype.name;
450
- this.options.media_type = slide_media.mediatype.type;
451
-
452
- // Create a media object using the matched class name
453
- this._media = new slide_media.mediatype.cls(slide_media, this.options) as MediaInstance;
454
- // loaded media changes the content height — the scroll hint
455
- // may appear or disappear
456
- this._media.on?.("media_loaded", () => this._updateScrollHint());
561
+ // Create Media (#358): the primary item plus every extra with a
562
+ // non-empty url, in order. Each item gets its own MediaType match and
563
+ // its own consent identity (media_name/media_type), so per-slide
564
+ // consent asks and the audio badge tell items apart.
565
+ const media_items = slide_media_items;
566
+ if (media_items.length > 0) {
567
+ for (const item of media_items) {
568
+ // Determine the media type
569
+ item.mediatype = MediaType(item) as MediaTypeMatch;
570
+ // Item 0 keeps the historical write-back: the marker icon
571
+ // class reads it off the slide data.
572
+ if (item === media_items[0]) {
573
+ this.options.media_name = item.mediatype.name;
574
+ this.options.media_type = item.mediatype.type;
575
+ }
576
+ // Create a media object using the matched class name
577
+ const media = new item.mediatype.cls(item, {
578
+ ...this.options,
579
+ media_name: item.mediatype.name,
580
+ media_type: item.mediatype.type,
581
+ }) as MediaInstance;
582
+ // loaded media changes the content height — the scroll hint
583
+ // may appear or disappear
584
+ const on_loaded = () => this._updateScrollHint();
585
+ media.on?.("media_loaded", on_loaded);
586
+ this._media_loaded_fns.push({ media, fn: on_loaded });
587
+ this._medias.push(media);
588
+ }
589
+ this._media = this._medias[0] ?? null;
457
590
  }
458
591
 
459
592
  // Create Text
@@ -461,20 +594,37 @@ class SlideBase {
461
594
  this._text = new Text(this.data.text, {
462
595
  title: this.has.title,
463
596
  text_align: this.options.text_align as string | undefined,
597
+ language: this.data.language ?? undefined,
464
598
  });
465
599
  }
466
600
 
467
601
  // Add to DOM. Each branch has just established the members it uses
468
- // (has.media -> _media, has.text/headline -> _text).
602
+ // (has.media -> _medias, has.text/headline -> _text). The media loop
603
+ // preserves the historical ordering: media-then-text, or
604
+ // text-then-media when there is a headline but no body text.
605
+ const medias = this._medias;
606
+ if (medias.length > 1) {
607
+ // `row` seats two items side by side; anything else stacks (D4)
608
+ const layout =
609
+ this.data.media_layout === "row" && medias.length === 2 ? "row" : "stack";
610
+ this._el.container.classList.add("vco-slide-media-multiple");
611
+ this._el.container.setAttribute("data-layout", layout);
612
+ }
469
613
  if (!this.has.text && !this.has.headline && this.has.media) {
470
614
  this._el.container.className += " vco-slide-media-only";
471
- this._media?.addTo(this._el.content);
615
+ for (const media of medias) {
616
+ media.addTo(this._el.content);
617
+ }
472
618
  } else if (this.has.headline && this.has.media && !this.has.text) {
473
619
  this._el.container.className += " vco-slide-media-only";
474
620
  this._text.addTo(this._el.content);
475
- this._media?.addTo(this._el.content);
621
+ for (const media of medias) {
622
+ media.addTo(this._el.content);
623
+ }
476
624
  } else if (this.has.text && this.has.media) {
477
- this._media?.addTo(this._el.content);
625
+ for (const media of medias) {
626
+ media.addTo(this._el.content);
627
+ }
478
628
  this._text.addTo(this._el.content);
479
629
  } else if (this.has.text || this.has.headline) {
480
630
  this._el.container.className += " vco-slide-text-only";
@@ -530,15 +680,19 @@ class SlideBase {
530
680
  this.options.height = this._el.container.offsetHeight;
531
681
  }
532
682
 
533
- if (this._media) {
683
+ if (this._medias.length > 0) {
534
684
  if (!this.has.text && this.has.headline) {
535
- this._media.updateDisplay(
536
- this.options.width,
537
- this.options.height - this._text.headlineHeight(),
538
- layout,
539
- );
685
+ for (const media of this._medias) {
686
+ media.updateDisplay(
687
+ this.options.width,
688
+ this.options.height - this._text.headlineHeight(),
689
+ layout,
690
+ );
691
+ }
540
692
  } else {
541
- this._media.updateDisplay(this.options.width, this.options.height, layout);
693
+ for (const media of this._medias) {
694
+ media.updateDisplay(this.options.width, this.options.height, layout);
695
+ }
542
696
  }
543
697
  }
544
698
 
@@ -547,7 +701,11 @@ class SlideBase {
547
701
  }
548
702
  }
549
703
 
550
- export default class Slide extends DomMixed(Evented(SlideBase)) {
704
+ const EventedSlideBase = Evented<SlideEvents, typeof SlideBase>(SlideBase);
705
+
706
+ export default class Slide extends DomMixed<SlideEvents, typeof EventedSlideBase>(
707
+ EventedSlideBase,
708
+ ) {
551
709
  constructor(...args: ConstructorParameters<typeof SlideBase>) {
552
710
  super(...args);
553
711
  }
@@ -1,4 +1,4 @@
1
- import { mergeData } from "../core/Util";
1
+ import { mergeData, prefersReducedMotion } from "../core/Util";
2
2
  import { DomMixed, Evented, type EventedInstance } from "../core/mixins";
3
3
  import Dom from "../dom/Dom";
4
4
  import Animate from "../animation/tween";
@@ -22,6 +22,13 @@ interface SlideNavOptions {
22
22
  [key: string]: unknown;
23
23
  }
24
24
 
25
+ export interface SlideNavEvents {
26
+ clicked: SlideNavOptions;
27
+ loaded: SlideNavData;
28
+ added: SlideNavData;
29
+ removed: SlideNavData;
30
+ }
31
+
25
32
  class SlideNavBase {
26
33
  declare "_el": Record<string, HTMLElement>;
27
34
  declare "mediatype": unknown;
@@ -29,7 +36,7 @@ class SlideNavBase {
29
36
  declare "options": SlideNavOptions;
30
37
  declare "animator": AnimationHandle | null;
31
38
  declare "animator_position": AnimationHandle | null;
32
- declare "fire": EventedInstance["fire"];
39
+ declare "fire": EventedInstance<SlideNavEvents>["fire"];
33
40
 
34
41
  //_el: {},
35
42
 
@@ -112,7 +119,7 @@ class SlideNavBase {
112
119
  return_to_default: boolean,
113
120
  ) {
114
121
  const ani: AnimateOptions = {
115
- duration: duration,
122
+ duration: prefersReducedMotion() ? 0 : duration,
116
123
  easing: ease,
117
124
  complete: () => {
118
125
  this._onUpdatePositionComplete(return_to_default);
@@ -239,7 +246,11 @@ class SlideNavBase {
239
246
  }
240
247
  }
241
248
 
242
- export default class SlideNav extends DomMixed(Evented(SlideNavBase)) {
249
+ const EventedSlideNavBase = Evented<SlideNavEvents, typeof SlideNavBase>(SlideNavBase);
250
+
251
+ export default class SlideNav extends DomMixed<SlideNavEvents, typeof EventedSlideNavBase>(
252
+ EventedSlideNavBase,
253
+ ) {
243
254
  constructor(...args: ConstructorParameters<typeof SlideNavBase>) {
244
255
  super(...args);
245
256
  }