explico 0.13.1 → 0.15.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 (256) hide show
  1. package/CHANGELOG.md +990 -1
  2. package/README.md +200 -30
  3. package/dist/compiler/astro.d.ts +15 -2
  4. package/dist/compiler/astro.js +40 -11
  5. package/dist/compiler/cli-main.d.ts +100 -0
  6. package/dist/compiler/cli-main.js +433 -0
  7. package/dist/compiler/cli-reference.d.ts +50 -0
  8. package/dist/compiler/cli-reference.js +213 -0
  9. package/dist/compiler/cli-verbs.d.ts +45 -0
  10. package/dist/compiler/cli-verbs.js +224 -0
  11. package/dist/compiler/cli-verify.d.ts +51 -0
  12. package/dist/compiler/cli-verify.js +151 -0
  13. package/dist/compiler/cli.d.ts +408 -24
  14. package/dist/compiler/cli.js +1328 -8
  15. package/dist/compiler/collections.d.ts +88 -1
  16. package/dist/compiler/collections.js +170 -25
  17. package/dist/compiler/content-release.d.ts +49 -0
  18. package/dist/compiler/content-release.js +63 -0
  19. package/dist/compiler/content-schemas.d.ts +2 -2
  20. package/dist/compiler/course-config.d.ts +1 -1
  21. package/dist/compiler/course-config.js +105 -1
  22. package/dist/compiler/decks.d.ts +161 -0
  23. package/dist/compiler/decks.js +235 -0
  24. package/dist/compiler/exports/combined-html.d.ts +5 -1
  25. package/dist/compiler/exports/deck-fit.d.ts +55 -0
  26. package/dist/compiler/exports/deck-fit.js +90 -0
  27. package/dist/compiler/exports/export-chapters.d.ts +37 -6
  28. package/dist/compiler/exports/export-chapters.js +64 -5
  29. package/dist/compiler/exports/export-pages.d.ts +30 -3
  30. package/dist/compiler/exports/export-pages.js +24 -1
  31. package/dist/compiler/exports/latex/beamerthemeexplico.sty +124 -0
  32. package/dist/compiler/exports/latex/cache.d.ts +78 -0
  33. package/dist/compiler/exports/latex/cache.js +129 -0
  34. package/dist/compiler/exports/latex/capture.d.ts +10 -1
  35. package/dist/compiler/exports/latex/capture.js +3 -1
  36. package/dist/compiler/exports/latex/compile.d.ts +13 -2
  37. package/dist/compiler/exports/latex/compile.js +12 -1
  38. package/dist/compiler/exports/latex/deck.d.ts +92 -0
  39. package/dist/compiler/exports/latex/deck.js +349 -0
  40. package/dist/compiler/exports/latex/document.d.ts +51 -6
  41. package/dist/compiler/exports/latex/document.js +71 -12
  42. package/dist/compiler/exports/latex/explico-common.sty +198 -0
  43. package/dist/compiler/exports/latex/explico.cls +13 -183
  44. package/dist/compiler/exports/latex/serialize.d.ts +60 -4
  45. package/dist/compiler/exports/latex/serialize.js +4 -2
  46. package/dist/compiler/exports/latex-export.d.ts +24 -2
  47. package/dist/compiler/exports/latex-export.js +107 -26
  48. package/dist/compiler/exports/markdown/serialize.d.ts +24 -4
  49. package/dist/compiler/exports/markdown/serialize.js +85 -8
  50. package/dist/compiler/exports/markdown-export.d.ts +6 -1
  51. package/dist/compiler/exports/markdown-export.js +50 -11
  52. package/dist/compiler/exports/standalone-html.d.ts +10 -5
  53. package/dist/compiler/exports/standalone-html.js +3 -3
  54. package/dist/compiler/exports/static-tree.d.ts +38 -1
  55. package/dist/compiler/exports/static-tree.js +120 -4
  56. package/dist/compiler/knowledge-version.d.ts +26 -2
  57. package/dist/compiler/knowledge-version.js +27 -17
  58. package/dist/compiler/lesson-derivation.d.ts +143 -6
  59. package/dist/compiler/lesson-derivation.js +145 -13
  60. package/dist/compiler/manifest.d.ts +133 -4
  61. package/dist/compiler/manifest.js +364 -58
  62. package/dist/compiler/page-versions.d.ts +184 -0
  63. package/dist/compiler/page-versions.js +217 -0
  64. package/dist/compiler/render-diagnostics.d.ts +5 -1
  65. package/dist/compiler/resolution-report.d.ts +24 -6
  66. package/dist/compiler/resolution-report.js +22 -4
  67. package/dist/compiler/validate-content.d.ts +7 -0
  68. package/dist/compiler/validate-content.js +160 -0
  69. package/dist/{course-config-rRtME5UV.d.ts → course-config-BLtIdkei.d.ts} +164 -1
  70. package/dist/curriculum/competency-graph.d.ts +54 -0
  71. package/dist/curriculum/competency-graph.js +41 -0
  72. package/dist/reference/base-library.d.mts +8 -0
  73. package/dist/reference/base-library.mjs +19 -0
  74. package/dist/reference/consistency.d.ts +8 -4
  75. package/dist/reference/consistency.js +82 -26
  76. package/dist/reference/deck-remark.d.mts +123 -0
  77. package/dist/reference/deck-remark.mjs +600 -0
  78. package/dist/reference/deck-source.d.ts +107 -0
  79. package/dist/reference/deck-source.js +140 -0
  80. package/dist/reference/decks-validate.d.ts +133 -0
  81. package/dist/reference/decks-validate.js +359 -0
  82. package/dist/reference/derived-entries.d.ts +5 -1
  83. package/dist/reference/diagram-fit.d.ts +6 -2
  84. package/dist/reference/diagram-fit.js +5 -1
  85. package/dist/reference/figure-symbols.d.ts +23 -6
  86. package/dist/reference/figure-symbols.js +51 -15
  87. package/dist/reference/float-name.d.ts +43 -0
  88. package/dist/reference/float-name.js +58 -0
  89. package/dist/reference/folds-validate.d.ts +1 -1
  90. package/dist/reference/gate-abbreviations.d.ts +1 -1
  91. package/dist/reference/gate-indices.js +5 -4
  92. package/dist/reference/gate-math.d.ts +29 -2
  93. package/dist/reference/gate-math.js +99 -18
  94. package/dist/reference/gate-part-pages.d.ts +1 -1
  95. package/dist/reference/lab-katex-validate.d.ts +10 -6
  96. package/dist/reference/lab-katex-validate.js +17 -9
  97. package/dist/reference/lab-math-scope.js +29 -4
  98. package/dist/reference/math-glyphs.d.mts +25 -0
  99. package/dist/reference/math-glyphs.mjs +189 -25
  100. package/dist/reference/page-notation.d.ts +22 -3
  101. package/dist/reference/page-notation.js +32 -10
  102. package/dist/reference/references.d.ts +14 -1
  103. package/dist/reference/references.js +13 -0
  104. package/dist/reference/registry-scope.d.mts +33 -0
  105. package/dist/reference/registry-scope.mjs +34 -0
  106. package/dist/reference/registry.js +29 -10
  107. package/dist/reference/rehype-deck.d.mts +6 -0
  108. package/dist/reference/rehype-deck.mjs +286 -0
  109. package/dist/reference/rehype-heading-anchors.d.mts +20 -0
  110. package/dist/reference/rehype-heading-anchors.mjs +48 -6
  111. package/dist/reference/remark-notation.mjs +249 -40
  112. package/dist/reference/source-lines.d.mts +12 -0
  113. package/dist/reference/source-lines.mjs +65 -0
  114. package/dist/reference/types.d.ts +28 -0
  115. package/dist/rules/catalog.d.ts +21 -4
  116. package/dist/rules/catalog.js +237 -2
  117. package/dist/rules/course-rules.d.ts +5 -1
  118. package/dist/rules/course-rules.js +17 -3
  119. package/dist/rules/course-rules.schema.json +12 -0
  120. package/dist/rules/in-effect.d.ts +13 -3
  121. package/dist/rules/in-effect.js +34 -10
  122. package/dist/rules/rules.schema.json +280 -1
  123. package/dist/rules/settings.d.ts +1 -1
  124. package/dist/session/PreferenceStore.d.ts +6 -0
  125. package/dist/session/PreferenceStore.js +18 -1
  126. package/dist/session/deck-memory.d.ts +116 -0
  127. package/dist/session/deck-memory.js +143 -0
  128. package/dist/session/reading-memory.d.ts +96 -0
  129. package/dist/session/reading-memory.js +133 -0
  130. package/docs/VERSIONING.md +21 -18
  131. package/docs/authoring/README.md +31 -9
  132. package/docs/authoring/agents.md +72 -18
  133. package/docs/authoring/cli-reference.json +2172 -0
  134. package/docs/authoring/cli.md +887 -0
  135. package/docs/authoring/components.md +15 -0
  136. package/docs/authoring/deck.md +612 -0
  137. package/docs/authoring/migrating.md +1195 -16
  138. package/docs/authoring/notation.md +49 -6
  139. package/docs/authoring/structure.md +80 -1
  140. package/docs/authoring/versioning.md +56 -3
  141. package/docs/authoring/visualizations.md +7 -2
  142. package/docs/course-agent/AGENTS.md +40 -0
  143. package/docs/course-agent/agents/rules-reviewer.md +33 -0
  144. package/docs/course-agent/skills/draft-lesson/SKILL.md +38 -0
  145. package/docs/course-agent/skills/migrate-course/SKILL.md +32 -0
  146. package/docs/course-agent/skills/release-content/SKILL.md +26 -0
  147. package/docs/diagram-scaling.md +148 -0
  148. package/docs/privacy.md +59 -0
  149. package/package.json +8 -2
  150. package/src/compiler/astro.ts +60 -21
  151. package/src/compiler/cli-main.ts +557 -0
  152. package/src/compiler/cli-reference.ts +285 -0
  153. package/src/compiler/cli-verbs.ts +270 -0
  154. package/src/compiler/cli-verify.ts +241 -0
  155. package/src/compiler/cli.ts +2119 -170
  156. package/src/compiler/collections.ts +284 -25
  157. package/src/compiler/content-release.ts +91 -0
  158. package/src/compiler/course-config.ts +267 -0
  159. package/src/compiler/decks.ts +455 -0
  160. package/src/compiler/exports/deck-fit.ts +127 -0
  161. package/src/compiler/exports/export-chapters.ts +105 -6
  162. package/src/compiler/exports/export-pages.ts +46 -2
  163. package/src/compiler/exports/latex/beamerthemeexplico.sty +124 -0
  164. package/src/compiler/exports/latex/cache.ts +183 -0
  165. package/src/compiler/exports/latex/capture.ts +5 -2
  166. package/src/compiler/exports/latex/compile.ts +18 -0
  167. package/src/compiler/exports/latex/deck.ts +419 -0
  168. package/src/compiler/exports/latex/document.ts +117 -21
  169. package/src/compiler/exports/latex/explico-common.sty +198 -0
  170. package/src/compiler/exports/latex/explico.cls +13 -183
  171. package/src/compiler/exports/latex/serialize.ts +25 -21
  172. package/src/compiler/exports/latex/texlive-packages.txt +6 -2
  173. package/src/compiler/exports/latex-export.ts +187 -45
  174. package/src/compiler/exports/markdown/serialize.ts +118 -9
  175. package/src/compiler/exports/markdown-export.ts +56 -11
  176. package/src/compiler/exports/standalone-html.ts +8 -7
  177. package/src/compiler/exports/static-tree.ts +175 -3
  178. package/src/compiler/knowledge-version.ts +56 -21
  179. package/src/compiler/lesson-derivation.ts +282 -16
  180. package/src/compiler/manifest.ts +568 -63
  181. package/src/compiler/page-versions.ts +401 -0
  182. package/src/compiler/resolution-report.ts +38 -7
  183. package/src/compiler/validate-content.ts +215 -0
  184. package/src/components/CourseContents.astro +124 -6
  185. package/src/components/CurriculumMap.astro +49 -1
  186. package/src/components/FloatIndex.astro +1 -1
  187. package/src/components/PartOverview.astro +51 -5
  188. package/src/components/assessments/AssessmentSet.astro +41 -15
  189. package/src/components/block-heading.ts +15 -6
  190. package/src/components/card-carry.ts +7 -4
  191. package/src/components/card-manager.ts +14 -2
  192. package/src/components/card-memory.ts +13 -9
  193. package/src/components/deck/DeckEnhancer.astro +13 -0
  194. package/src/components/deck/deck-context.ts +104 -0
  195. package/src/components/deck/deck-controller.ts +1287 -0
  196. package/src/components/deck/deck-map.ts +91 -0
  197. package/src/components/deck-lane.ts +163 -0
  198. package/src/components/diagram/Diagram.astro +131 -86
  199. package/src/components/disclosure/persisted-disclosure.ts +9 -1
  200. package/src/components/figure/Figure.astro +7 -2
  201. package/src/components/figure/FloatCaption.astro +5 -3
  202. package/src/components/fold/Fold.astro +2 -2
  203. package/src/components/notation/NotationFormula.astro +81 -11
  204. package/src/components/notation/NotationGlossary.astro +132 -1
  205. package/src/components/notation/NotationLayer.astro +15 -7
  206. package/src/components/starlight/AboutPanel.astro +186 -3
  207. package/src/components/starlight/CollapsibleSections.astro +24 -3
  208. package/src/components/starlight/DiagramEnhancer.astro +5 -1
  209. package/src/components/starlight/HeadingAnchorEnhancer.astro +1 -1
  210. package/src/components/starlight/LayoutHeader.astro +17 -0
  211. package/src/components/starlight/LayoutPageTitle.astro +110 -0
  212. package/src/components/starlight/LayoutSidebar.astro +807 -21
  213. package/src/components/starlight/LayoutSocialIcons.astro +62 -6
  214. package/src/components/starlight/LessonExportActions.astro +27 -9
  215. package/src/components/starlight/LessonFooter.astro +59 -0
  216. package/src/components/starlight/ReadingMemoryNotice.astro +114 -0
  217. package/src/components/starlight/TextSizeControl.astro +24 -13
  218. package/src/components/starlight/page-marks.ts +139 -0
  219. package/src/components/starlight/section-summary.ts +1 -1
  220. package/src/components/starlight/sidebar-editions.ts +245 -0
  221. package/src/components/table/Table.astro +15 -7
  222. package/src/content-config.ts +87 -1
  223. package/src/curriculum/competency-graph.ts +95 -0
  224. package/src/reference/base-library.mjs +19 -0
  225. package/src/reference/consistency.ts +137 -39
  226. package/src/reference/deck-remark.mjs +600 -0
  227. package/src/reference/deck-source.ts +273 -0
  228. package/src/reference/decks-validate.ts +604 -0
  229. package/src/reference/diagram-fit.ts +18 -4
  230. package/src/reference/diagram-layout.ts +3 -1
  231. package/src/reference/figure-symbols.ts +108 -31
  232. package/src/reference/folds-validate.ts +1 -1
  233. package/src/reference/gate-indices.ts +5 -3
  234. package/src/reference/gate-math.ts +169 -25
  235. package/src/reference/lab-katex-validate.ts +30 -15
  236. package/src/reference/lab-math-scope.ts +34 -3
  237. package/src/reference/math-glyphs.mjs +189 -25
  238. package/src/reference/page-notation.ts +69 -15
  239. package/src/reference/references.ts +28 -0
  240. package/src/reference/registry-scope.mjs +34 -0
  241. package/src/reference/registry.ts +41 -11
  242. package/src/reference/rehype-deck.mjs +286 -0
  243. package/src/reference/rehype-heading-anchors.mjs +48 -6
  244. package/src/reference/remark-notation.mjs +249 -40
  245. package/src/reference/source-lines.mjs +65 -0
  246. package/src/reference/types.ts +28 -0
  247. package/src/rules/catalog.ts +274 -4
  248. package/src/rules/course-rules.ts +21 -1
  249. package/src/rules/in-effect.ts +58 -11
  250. package/src/session/PreferenceStore.ts +23 -0
  251. package/src/session/deck-memory.ts +263 -0
  252. package/src/session/reading-memory.ts +243 -0
  253. package/src/styles/deck.css +1150 -0
  254. package/src/styles/global.css +958 -27
  255. package/src/styles/zones.css +277 -0
  256. /package/src/{components → reference}/float-name.ts +0 -0
@@ -0,0 +1,1287 @@
1
+ /**
2
+ * A deck page in the browser (docs/authoring/deck.md).
3
+ *
4
+ * - **The study page.** Every frame's canvas is laid out at the course's canvas
5
+ * size and scaled to its stage (`--deck-scale`), every step shown.
6
+ * - **Present mode.** One frame on the whole screen; keys, a swipe or the
7
+ * controls step through it. → ↓ Space PageDown step forward; ← ↑ PageUp
8
+ * Backspace step back; Home and End; a number and Enter jump; F full screen;
9
+ * N the notes beside the frame; P the presenter window; B blacks the screen
10
+ * out; Esc leaves. A lab's slider keeps its arrow keys; PageUp and PageDown
11
+ * (what a clicker sends) always move the deck.
12
+ * - **The presenter window** (`?presenter`): this frame, the next, the notes and
13
+ * a clock, kept in step with the room's window by a `BroadcastChannel`.
14
+ * - **A step's effects.** `data-step-control` moves a lab's control along to a
15
+ * value; `data-step-highlight` lights every occurrence of a symbol; a frame
16
+ * with `data-morph` morphs from the frame before it with the browser's View
17
+ * Transitions, each bound symbol keeping its identity. Reduced motion jumps
18
+ * to each effect's end.
19
+ * - **Where the reader stopped**, the frame and its step, kept with the
20
+ * course's reading memory and offered back as a button that presents from
21
+ * there — or from the frame after it, once its every step was shown — on
22
+ * the next visit and whenever Present mode ends.
23
+ * - **The slides a reader has seen** (`session/deck-memory.ts`): each frame Present
24
+ * mode shows them is kept as it was, for the tab before they answer the
25
+ * reading-memory question and across visits with their yes. A line above
26
+ * each frame says whether it was shown, not yet, or changed since, and holds
27
+ * "Continue here" on the frame to continue at and "Present from here" on
28
+ * every other; the slide map under the title shows every frame the same
29
+ * way (`deck-map.ts`), pinned under the header while the deck scrolls; a
30
+ * line under Present counts them and offers "Mark all as seen" and "Mark all
31
+ * as unseen".
32
+ * - **The chapter on screen**, on a part's deck: the chapter of the frame in
33
+ * view on the study page, or shown in Present mode and the presenter, is
34
+ * told to the sidebar, which lights its section (`sidebar-editions.ts`).
35
+ */
36
+ import {
37
+ answerKey,
38
+ deckPositionsKey,
39
+ READING_MEMORY_EVENT,
40
+ readingAnswer,
41
+ readingMemoryStore,
42
+ } from '../../session/reading-memory';
43
+ import { slideMap, type SlideMapItem } from './deck-map';
44
+ import {
45
+ DECK_MEMORY_EVENT,
46
+ decodeDeckMemory,
47
+ encodeDeckMemory,
48
+ frameMark,
49
+ resumeAt,
50
+ withAllSeen,
51
+ withNoneSeen,
52
+ withPosition,
53
+ withShown,
54
+ type DeckFrameId,
55
+ type DeckFrameSteps,
56
+ type DeckMemory,
57
+ type FrameMark,
58
+ type Resume,
59
+ } from '../../session/deck-memory';
60
+ import {
61
+ DECK_CHAPTER_ATTRIBUTE,
62
+ DECK_FRAME_EVENT,
63
+ type DeckFrameDetail,
64
+ } from '../starlight/sidebar-editions';
65
+
66
+ interface Frame {
67
+ readonly element: HTMLElement;
68
+ readonly number: number;
69
+ /** What it shows (`data-frame-id`, `ManifestDeckFrame.id`); empty on a page built without one. */
70
+ readonly id: string;
71
+ /** On a part's deck, the chapter its section covers (`data-chapter`, a lesson slug); empty otherwise. */
72
+ readonly chapter: string;
73
+ readonly steps: number;
74
+ readonly stage: HTMLElement | null;
75
+ }
76
+
77
+ interface Deck {
78
+ readonly root: HTMLElement;
79
+ readonly slug: string;
80
+ readonly frames: readonly Frame[];
81
+ readonly canvasWidth: number;
82
+ readonly canvasHeight: number;
83
+ /** The marks the course shows (`sidebarMarks`, `data-deck-marks` on the actions row): none when it keeps no reading memory. */
84
+ readonly marks: ReadonlySet<FrameMark>;
85
+ /**
86
+ * The reader's own controls: the resume button, the marks and their line,
87
+ * each frame's line, the slide map. Not in the presenter window, and not in
88
+ * a downloaded deck (`data-explico-export-chrome`).
89
+ */
90
+ readonly chrome: boolean;
91
+ /** Each frame's line on the study page (`installLines`), by index. */
92
+ readonly lines: FrameLine[];
93
+ /** The slide map (`installMap`). */
94
+ map?: SlideMap;
95
+ /** The reader's record as the study page last showed it (`readReading`). */
96
+ reading?: Reading;
97
+ index: number;
98
+ step: number;
99
+ presenting: boolean;
100
+ presenter: boolean;
101
+ channel?: BroadcastChannel;
102
+ /** The chapter on screen as last told (`showChapter`). */
103
+ chapter?: string;
104
+ }
105
+
106
+ /** A frame's line above it on the study page: its state, and the button that presents from it. */
107
+ interface FrameLine {
108
+ readonly state: HTMLElement;
109
+ readonly button: HTMLButtonElement;
110
+ readonly label: HTMLElement;
111
+ }
112
+
113
+ /** The slide map under the deck's title. */
114
+ interface SlideMap {
115
+ readonly nav: HTMLElement;
116
+ readonly list: HTMLOListElement;
117
+ /** Its places by key (`slide-3`, `gap-4-55`), kept from one drawing to the next. */
118
+ readonly places: Map<string, HTMLLIElement>;
119
+ /** The frame in view, by index. */
120
+ current: number;
121
+ }
122
+
123
+ const reducedMotion = (): boolean =>
124
+ typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
125
+
126
+ function pixels(value: string, fallback: number): number {
127
+ const parsed = Number.parseFloat(value);
128
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
129
+ }
130
+
131
+ // --- scaling ------------------------------------------------------------------
132
+
133
+ function scaleStage(deck: Deck, stage: HTMLElement): void {
134
+ const width = stage.clientWidth;
135
+ if (width > 0) stage.style.setProperty('--deck-scale', String(width / deck.canvasWidth));
136
+ }
137
+
138
+ /** The least a figure's tools or picture is scaled to fit its frame; past it, the frame does not fit. */
139
+ const LEAST_FIT = 0.6;
140
+
141
+ /**
142
+ * A figure on a frame takes the room the frame has (rule D7): its tools are
143
+ * scaled down, and its picture narrowed, to the height left below the
144
+ * figure's top — as far as {@link LEAST_FIT}, past which the frame does not
145
+ * fit (rule D2). A picture is narrowed rather than zoomed, because most take
146
+ * their width from their column (an inline SVG, a lab's chart box) and keep
147
+ * their shape: zoomed, they would fill the same column again.
148
+ */
149
+ function fitFigures(frame: HTMLElement): void {
150
+ const body = frame.querySelector<HTMLElement>('.deck-frame__body');
151
+ if (!body) return;
152
+ const canvas = frame.querySelector<HTMLElement>('.deck-canvas');
153
+ // Screen pixels per canvas pixel: the canvas lays out at its own size, then scales.
154
+ const scale = canvas && canvas.offsetWidth > 0 ? canvas.getBoundingClientRect().width / canvas.offsetWidth : 1;
155
+ const roomBelow = (element: HTMLElement) => body.getBoundingClientRect().bottom - element.getBoundingClientRect().top;
156
+ for (const figure of body.querySelectorAll<HTMLElement>('.keyed-figure')) {
157
+ const tools = figure.querySelector<HTMLElement>(':scope > .keyed-figure__tools-fold');
158
+ const picture = figure.querySelector<HTMLElement>(':scope > .keyed-figure__body');
159
+ tools?.style.removeProperty('zoom');
160
+ picture?.style.removeProperty('zoom');
161
+ picture?.style.removeProperty('max-width');
162
+ picture?.style.removeProperty('margin-inline');
163
+ if (tools) {
164
+ // Both in screen pixels, so the canvas's own scale cancels out.
165
+ const height = tools.getBoundingClientRect().height;
166
+ const room = roomBelow(tools);
167
+ if (height > room + 1) tools.style.setProperty('zoom', String(Math.max(LEAST_FIT, (room / height) * 0.98)));
168
+ }
169
+ if (!picture) continue;
170
+ if (picture.scrollWidth > picture.clientWidth + 1) {
171
+ // Content of a fixed width, wider than its column (a long formula, a
172
+ // plot drawn at its size): zoomed, for its width and its height alike.
173
+ const height = picture.getBoundingClientRect().height + figureCaptionHeight(figure);
174
+ const room = roomBelow(picture);
175
+ const tall = height > room + 1 ? (room / height) * 0.98 : 1;
176
+ picture.style.setProperty('zoom', String(Math.max(LEAST_FIT, Math.min(picture.clientWidth / picture.scrollWidth, tall))));
177
+ continue;
178
+ }
179
+ // What runs past the frame's body, in canvas pixels, comes off the
180
+ // picture's height, unless the tools' column is the taller one.
181
+ const over = body.scrollHeight - body.clientHeight;
182
+ const column = Math.max(picture.getBoundingClientRect().bottom, figure.querySelector(':scope > figcaption')?.getBoundingClientRect().bottom ?? 0);
183
+ if (over > 1 && !(tools && tools.getBoundingClientRect().bottom > column + 1)) {
184
+ const height = picture.getBoundingClientRect().height / scale;
185
+ const fit = Math.max(LEAST_FIT, (Math.max(height - over, 0) / height) * 0.98);
186
+ picture.style.setProperty('max-width', `${(picture.getBoundingClientRect().width / scale) * fit}px`);
187
+ picture.style.setProperty('margin-inline', 'auto');
188
+ }
189
+ }
190
+ }
191
+
192
+ /** The caption line under a figure's picture, in its column beside the tools or under the picture alone. */
193
+ function figureCaptionHeight(figure: HTMLElement): number {
194
+ const caption = figure.querySelector<HTMLElement>(':scope > figcaption');
195
+ return caption ? caption.getBoundingClientRect().height : 0;
196
+ }
197
+
198
+ /**
199
+ * The frames whose content runs past their canvas (rule D2), by number. The
200
+ * canvas is laid out at its own size whatever the scale, so the measure is
201
+ * the same on every screen; the build calls it in a browser to fail on one.
202
+ */
203
+ export function overflowingFrames(root: ParentNode = document): number[] {
204
+ const numbers: number[] = [];
205
+ for (const frame of root.querySelectorAll<HTMLElement>('.deck .deck-frame')) {
206
+ fitFigures(frame);
207
+ const body = frame.querySelector<HTMLElement>('.deck-frame__body');
208
+ const canvas = frame.querySelector<HTMLElement>('.deck-canvas');
209
+ const over =
210
+ (body !== null && body.scrollHeight > body.clientHeight + 1) ||
211
+ (body !== null && body.scrollWidth > body.clientWidth + 1) ||
212
+ (canvas !== null && canvas.scrollHeight > canvas.clientHeight + 1);
213
+ frame.toggleAttribute('data-deck-overflow', over);
214
+ if (over) numbers.push(Number(frame.dataset.frame));
215
+ }
216
+ return numbers;
217
+ }
218
+
219
+ function installScaling(deck: Deck): void {
220
+ deck.root.style.setProperty('--deck-ratio', String(deck.canvasWidth / deck.canvasHeight));
221
+ deck.root.dataset.deckReady = '';
222
+ const stages = deck.frames.flatMap((frame) => (frame.stage ? [frame.stage] : []));
223
+ for (const stage of stages) scaleStage(deck, stage);
224
+ if (typeof ResizeObserver === 'function') {
225
+ const observer = new ResizeObserver((entries) => {
226
+ for (const entry of entries) scaleStage(deck, entry.target as HTMLElement);
227
+ });
228
+ for (const stage of stages) observer.observe(stage);
229
+ }
230
+ }
231
+
232
+ // --- steps and their effects -----------------------------------------------------
233
+
234
+ const prior = new WeakMap<HTMLElement, string>();
235
+
236
+ function stepElements(frame: Frame): HTMLElement[] {
237
+ return [...frame.element.querySelectorAll<HTMLElement>('.deck-step[data-step]')];
238
+ }
239
+
240
+ function showSteps(frame: Frame, step: number | 'all'): void {
241
+ for (const element of stepElements(frame)) {
242
+ const at = Number(element.dataset.step);
243
+ element.classList.toggle('is-future', step !== 'all' && at > step);
244
+ }
245
+ }
246
+
247
+ /** The text a control is known by: its label, its `aria-label`, or what `aria-labelledby` names. */
248
+ function controlName(control: HTMLElement): string {
249
+ const parts: string[] = [];
250
+ const aria = control.getAttribute('aria-label');
251
+ if (aria) parts.push(aria);
252
+ const labelledBy = control.getAttribute('aria-labelledby');
253
+ if (labelledBy) {
254
+ for (const id of labelledBy.split(/\s+/)) parts.push(document.getElementById(id)?.textContent ?? '');
255
+ }
256
+ const labels = (control as HTMLInputElement).labels;
257
+ if (labels) for (const label of labels) parts.push(label.textContent ?? '');
258
+ const title = control.getAttribute('title');
259
+ if (title) parts.push(title);
260
+ return parts.join(' ').replace(/\s+/g, ' ').trim().toLowerCase();
261
+ }
262
+
263
+ function findControl(frame: Frame, name: string): HTMLInputElement | HTMLSelectElement | undefined {
264
+ const wanted = name.trim().toLowerCase();
265
+ const controls = [...frame.element.querySelectorAll<HTMLInputElement | HTMLSelectElement>('.deck-canvas input, .deck-canvas select')];
266
+ return (
267
+ controls.find((control) => controlName(control) === wanted) ??
268
+ controls.find((control) => controlName(control).includes(wanted))
269
+ );
270
+ }
271
+
272
+ /** Set a control's value as a reader's hand would: the value, then the events a lab listens to. */
273
+ function setControl(control: HTMLInputElement | HTMLSelectElement, value: string, final: boolean): void {
274
+ const prototype = control instanceof HTMLSelectElement ? HTMLSelectElement.prototype : HTMLInputElement.prototype;
275
+ const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set;
276
+ if (setter) setter.call(control, value);
277
+ else control.value = value;
278
+ control.dispatchEvent(new Event('input', { bubbles: true }));
279
+ if (final) control.dispatchEvent(new Event('change', { bubbles: true }));
280
+ }
281
+
282
+ const running = new WeakMap<HTMLElement, number>();
283
+
284
+ function driveControl(step: HTMLElement, frame: Frame): void {
285
+ const name = step.dataset.stepControl;
286
+ const to = Number(step.dataset.stepTo);
287
+ if (!name || !Number.isFinite(to)) return;
288
+ const control = findControl(frame, name);
289
+ if (!control) {
290
+ console.warn(`[explico deck] slide ${frame.number}: no control labelled “${name}” to drive.`);
291
+ return;
292
+ }
293
+ if (!prior.has(step)) prior.set(step, control.value);
294
+ const fromValue = step.dataset.stepFrom !== undefined ? Number(step.dataset.stepFrom) : Number(control.value);
295
+ const seconds = Number(step.dataset.stepSeconds ?? '1.5');
296
+ const previous = running.get(step);
297
+ if (previous !== undefined) cancelAnimationFrame(previous);
298
+ if (reducedMotion() || !Number.isFinite(fromValue) || !(seconds > 0) || control instanceof HTMLSelectElement) {
299
+ setControl(control, String(to), true);
300
+ return;
301
+ }
302
+ const start = performance.now();
303
+ const duration = seconds * 1000;
304
+ const stepSize = Number((control as HTMLInputElement).step) || 0;
305
+ const tick = (now: number) => {
306
+ const t = Math.min(1, (now - start) / duration);
307
+ const eased = t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2;
308
+ let value = fromValue + (to - fromValue) * eased;
309
+ if (stepSize > 0) value = Math.round(value / stepSize) * stepSize;
310
+ setControl(control, String(t === 1 ? to : value), t === 1);
311
+ if (t < 1) running.set(step, requestAnimationFrame(tick));
312
+ else running.delete(step);
313
+ };
314
+ running.set(step, requestAnimationFrame(tick));
315
+ }
316
+
317
+ function undriveControl(step: HTMLElement, frame: Frame): void {
318
+ const name = step.dataset.stepControl;
319
+ const before = prior.get(step);
320
+ const previous = running.get(step);
321
+ if (previous !== undefined) cancelAnimationFrame(previous);
322
+ if (!name || before === undefined) return;
323
+ const control = findControl(frame, name);
324
+ if (control) setControl(control, before, true);
325
+ prior.delete(step);
326
+ }
327
+
328
+ function spotlight(frame: Frame, key: string | undefined, on: boolean, source = 'step'): void {
329
+ if (!key) return;
330
+ for (const element of frame.element.querySelectorAll<HTMLElement>(`.deck-canvas [data-notation-key="${CSS.escape(key)}"]`)) {
331
+ const holders = new Set((element.dataset.deckSpotlight ?? '').split(' ').filter(Boolean));
332
+ if (on) holders.add(source);
333
+ else holders.delete(source);
334
+ element.dataset.deckSpotlight = [...holders].join(' ');
335
+ element.classList.toggle('deck-spotlight', holders.size > 0);
336
+ }
337
+ }
338
+
339
+ /** Run what steps `from`+1 … `to` do, forward, or undo them, backward. */
340
+ function stepEffects(frame: Frame, from: number, to: number): void {
341
+ const steps = stepElements(frame);
342
+ if (to > from) {
343
+ for (const element of steps) {
344
+ const at = Number(element.dataset.step);
345
+ if (at <= from || at > to) continue;
346
+ if (element.dataset.stepControl) driveControl(element, frame);
347
+ if (element.dataset.stepHighlight) spotlight(frame, element.dataset.stepHighlight, true);
348
+ }
349
+ } else if (to < from) {
350
+ for (const element of [...steps].reverse()) {
351
+ const at = Number(element.dataset.step);
352
+ if (at <= to || at > from) continue;
353
+ if (element.dataset.stepControl) undriveControl(element, frame);
354
+ if (element.dataset.stepHighlight) spotlight(frame, element.dataset.stepHighlight, false);
355
+ }
356
+ }
357
+ }
358
+
359
+ // --- morph ----------------------------------------------------------------------
360
+
361
+ function nameShared(frame: Frame): void {
362
+ const counts = new Map<string, number>();
363
+ const title = frame.element.querySelector<HTMLElement>('.deck-frame__title');
364
+ if (title) title.style.setProperty('view-transition-name', 'deck-title');
365
+ for (const element of frame.element.querySelectorAll<HTMLElement>('.deck-frame__body [data-notation-key]')) {
366
+ if (element.closest('.deck-step.is-future')) continue;
367
+ const key = element.dataset.notationKey ?? '';
368
+ const count = (counts.get(key) ?? 0) + 1;
369
+ counts.set(key, count);
370
+ element.style.setProperty('view-transition-name', `deck-${key.replace(/[^a-z0-9-]/gi, '-')}-${count}`);
371
+ }
372
+ for (const element of frame.element.querySelectorAll<HTMLElement>('.deck-frame__body .keyed-equation[data-eq-key]')) {
373
+ element.style.setProperty('view-transition-name', `deck-eq-${element.dataset.eqKey}`);
374
+ }
375
+ }
376
+
377
+ function clearNames(frame: Frame): void {
378
+ for (const element of frame.element.querySelectorAll<HTMLElement>('[style*="view-transition-name"]')) {
379
+ element.style.removeProperty('view-transition-name');
380
+ }
381
+ }
382
+
383
+ // --- moving ---------------------------------------------------------------------
384
+
385
+ function markCurrent(deck: Deck): void {
386
+ deck.frames.forEach((frame, index) => {
387
+ frame.element.classList.toggle('is-current', index === deck.index);
388
+ frame.element.classList.toggle('is-next', index === deck.index + 1);
389
+ });
390
+ }
391
+
392
+ function announce(deck: Deck): void {
393
+ const frame = deck.frames[deck.index];
394
+ const live = deck.root.querySelector<HTMLElement>('.deck-live');
395
+ if (!frame || !live) return;
396
+ const title = frame.element.querySelector('.deck-frame__title, .deck-title__title')?.textContent?.trim() ?? '';
397
+ live.textContent = `Slide ${frame.number} of ${deck.frames.length}${title ? `: ${title}` : ''}`;
398
+ const counter = deck.root.querySelector<HTMLElement>('.deck-hud__count');
399
+ if (counter) counter.textContent = `${frame.number} / ${deck.frames.length}`;
400
+ }
401
+
402
+ /**
403
+ * Tell the sidebar the chapter of the frame at `index`, when it changed: on
404
+ * `<html>` (`DECK_CHAPTER_ATTRIBUTE`, empty for a frame of no chapter), for
405
+ * a sidebar script that runs later, and as `DECK_FRAME_EVENT`.
406
+ */
407
+ function showChapter(deck: Deck, index: number): void {
408
+ const chapter = deck.frames[index]?.chapter ?? '';
409
+ if (chapter === deck.chapter) return;
410
+ deck.chapter = chapter;
411
+ document.documentElement.setAttribute(DECK_CHAPTER_ATTRIBUTE, chapter);
412
+ window.dispatchEvent(
413
+ new CustomEvent<DeckFrameDetail>(DECK_FRAME_EVENT, { detail: { deck: deck.slug, chapter } }),
414
+ );
415
+ }
416
+
417
+ // --- what the reader has seen ------------------------------------------------------
418
+
419
+ const idOf = (frame: Frame): DeckFrameId => ({ number: frame.number, id: frame.id });
420
+ const stepsOf = (frame: Frame): DeckFrameSteps => ({ ...idOf(frame), steps: frame.steps });
421
+
422
+ /** Every deck's record, as the course's reading memory holds it (`deck-memory.ts`). */
423
+ function readMemory(): DeckMemory {
424
+ try {
425
+ return decodeDeckMemory(readingMemoryStore(baseOf()).get(deckPositionsKey(baseOf())));
426
+ } catch {
427
+ return new Map();
428
+ }
429
+ }
430
+
431
+ /** Write a change to the record; `tell` the page, which reads it again (`DECK_MEMORY_EVENT`). */
432
+ function changeMemory(change: (memory: DeckMemory) => DeckMemory, { tell = true } = {}): void {
433
+ try {
434
+ const store = readingMemoryStore(baseOf());
435
+ const key = deckPositionsKey(baseOf());
436
+ store.set(key, encodeDeckMemory(change(decodeDeckMemory(store.get(key)))));
437
+ } catch {
438
+ // Reading memory is a convenience; a blocked store keeps nothing.
439
+ }
440
+ if (tell) window.dispatchEvent(new CustomEvent(DECK_MEMORY_EVENT));
441
+ }
442
+
443
+ /**
444
+ * Whether the frames Present mode shows are kept and marked: the course marks
445
+ * them, and the reader has not said no to reading memory. With their yes they
446
+ * are kept across visits; before an answer, for the tab (`readingMemoryStore`),
447
+ * as kept cards are.
448
+ */
449
+ function keepsShown(deck: Deck): boolean {
450
+ return deck.marks.size > 0 && readingAnswer(baseOf()) !== 'no';
451
+ }
452
+
453
+ /**
454
+ * Where Present mode stands, the step too, and, while the frames shown are
455
+ * kept, the frame it shows as it is (D8: the current frame, in the room's
456
+ * window or the presenter's, at any step). Otherwise the place alone, for the
457
+ * tab. The page reads it again when Present mode ends.
458
+ */
459
+ function remember(deck: Deck): void {
460
+ const frame = deck.frames[deck.index];
461
+ if (!frame || (!deck.presenting && !deck.presenter)) return;
462
+ const shown = frame.id !== '' && keepsShown(deck);
463
+ changeMemory(
464
+ (memory) =>
465
+ shown
466
+ ? withShown(memory, deck.slug, idOf(frame), deck.step)
467
+ : withPosition(memory, deck.slug, frame.number, deck.step),
468
+ { tell: false },
469
+ );
470
+ }
471
+
472
+ function go(deck: Deck, index: number, step: number, { broadcast = true, effects = true } = {}): void {
473
+ const target = Math.max(0, Math.min(deck.frames.length - 1, index));
474
+ const frame = deck.frames[target];
475
+ if (!frame) return;
476
+ const nextStep = Math.max(1, Math.min(frame.steps, step));
477
+ const old = deck.frames[deck.index];
478
+ const changing = target !== deck.index;
479
+ const morph =
480
+ changing &&
481
+ old !== undefined &&
482
+ ((target === deck.index + 1 && frame.element.hasAttribute('data-morph')) ||
483
+ (target === deck.index - 1 && old.element.hasAttribute('data-morph')));
484
+
485
+ const apply = () => {
486
+ if (changing && old) {
487
+ stepEffects(old, deck.step, 0);
488
+ spotlight(old, undefined, false);
489
+ }
490
+ const fromStep = changing ? 0 : deck.step;
491
+ deck.index = target;
492
+ deck.step = nextStep;
493
+ if (deck.presenting || deck.presenter) showSteps(frame, nextStep);
494
+ markCurrent(deck);
495
+ if (effects) stepEffects(frame, fromStep, nextStep);
496
+ // Once the frame is the current one: a morph applies it a frame later.
497
+ if (deck.presenting || deck.presenter) {
498
+ announce(deck);
499
+ showChapter(deck, target);
500
+ }
501
+ remember(deck);
502
+ };
503
+
504
+ const transition = (document as Document & { startViewTransition?: (update: () => void) => { finished: Promise<void> } })
505
+ .startViewTransition;
506
+ if (morph && deck.presenting && transition && !reducedMotion()) {
507
+ nameShared(old!);
508
+ const view = transition.call(document, () => {
509
+ clearNames(old!);
510
+ apply();
511
+ nameShared(frame);
512
+ });
513
+ void view.finished.finally(() => clearNames(frame));
514
+ } else {
515
+ apply();
516
+ }
517
+
518
+ if (deck.presenting || deck.presenter) {
519
+ history.replaceState(history.state, '', `${location.pathname}${location.search}#slide-${frame.number}`);
520
+ }
521
+ if (broadcast) deck.channel?.postMessage({ type: 'go', index: target, step: nextStep });
522
+ }
523
+
524
+ function next(deck: Deck): void {
525
+ const frame = deck.frames[deck.index];
526
+ if (frame && deck.step < frame.steps) go(deck, deck.index, deck.step + 1);
527
+ else if (deck.index < deck.frames.length - 1) go(deck, deck.index + 1, 1);
528
+ }
529
+
530
+ function previous(deck: Deck): void {
531
+ if (deck.step > 1) go(deck, deck.index, deck.step - 1);
532
+ else if (deck.index > 0) {
533
+ // Back to the frame before, whole: as beamer goes back.
534
+ const before = deck.frames[deck.index - 1];
535
+ go(deck, deck.index - 1, before?.steps ?? 1);
536
+ }
537
+ }
538
+
539
+ // --- Present mode ----------------------------------------------------------------
540
+
541
+ function frameInView(deck: Deck): number {
542
+ const middle = window.innerHeight / 3;
543
+ let best = 0;
544
+ let distance = Number.POSITIVE_INFINITY;
545
+ deck.frames.forEach((frame, index) => {
546
+ const box = frame.element.getBoundingClientRect();
547
+ const d = Math.abs(box.top - middle);
548
+ if (box.bottom > 0 && d < distance) {
549
+ distance = d;
550
+ best = index;
551
+ }
552
+ });
553
+ return best;
554
+ }
555
+
556
+ function rescaleCurrent(deck: Deck): void {
557
+ requestAnimationFrame(() => {
558
+ for (const frame of deck.frames) if (frame.stage) scaleStage(deck, frame.stage);
559
+ });
560
+ }
561
+
562
+ function present(deck: Deck, index = frameInView(deck)): void {
563
+ deck.presenting = true;
564
+ document.documentElement.classList.add('deck-presenting');
565
+ deck.index = -1;
566
+ go(deck, index, 1);
567
+ for (const frame of deck.frames) if (deck.frames.indexOf(frame) !== deck.index) showSteps(frame, 1);
568
+ rescaleCurrent(deck);
569
+ const root = document.documentElement;
570
+ if (root.requestFullscreen && !document.fullscreenElement) {
571
+ root.requestFullscreen().catch(() => undefined);
572
+ }
573
+ deck.frames[deck.index]?.element.focus({ preventScroll: true });
574
+ }
575
+
576
+ function leave(deck: Deck): void {
577
+ if (!deck.presenting) return;
578
+ const frame = deck.frames[deck.index];
579
+ deck.presenting = false;
580
+ document.documentElement.classList.remove('deck-presenting', 'deck-notes-open', 'deck-blackout', 'deck-hud-visible');
581
+ for (const each of deck.frames) {
582
+ showSteps(each, 'all');
583
+ each.element.classList.remove('is-current', 'is-next');
584
+ }
585
+ if (document.fullscreenElement && document.exitFullscreen) document.exitFullscreen().catch(() => undefined);
586
+ rescaleCurrent(deck);
587
+ frame?.element.scrollIntoView({ block: 'start' });
588
+ // Focus is where the reader is now, not on the frame Present mode started at.
589
+ frame?.element.focus({ preventScroll: true });
590
+ // What Present mode recorded: the resume button, the marks, the sidebar's line read it again.
591
+ window.dispatchEvent(new CustomEvent(DECK_MEMORY_EVENT));
592
+ }
593
+
594
+ function openPresenter(deck: Deck): void {
595
+ const frame = deck.frames[deck.index];
596
+ const url = `${location.pathname}?presenter#slide-${frame?.number ?? 1}`;
597
+ window.open(url, `explico-presenter-${deck.slug}`, 'popup,width=1280,height=800');
598
+ }
599
+
600
+ function isEditable(target: EventTarget | null): boolean {
601
+ const element = target as HTMLElement | null;
602
+ if (!element || !element.closest) return false;
603
+ return Boolean(element.closest('input, select, textarea, [contenteditable=""], [contenteditable="true"], [role="slider"]'));
604
+ }
605
+
606
+ function installKeys(deck: Deck): void {
607
+ let digits = '';
608
+ document.addEventListener('keydown', (event) => {
609
+ if (!deck.presenting && !deck.presenter) return;
610
+ if (event.altKey || event.ctrlKey || event.metaKey) return;
611
+ const key = event.key;
612
+ // A clicker's PageDown/PageUp always move the deck; the rest yield to a control.
613
+ if (isEditable(event.target) && key !== 'PageDown' && key !== 'PageUp' && key !== 'Escape') return;
614
+ let handled = true;
615
+ if (/^[0-9]$/.test(key)) {
616
+ digits += key;
617
+ } else if (key === 'Enter' && digits) {
618
+ const number = Number(digits);
619
+ digits = '';
620
+ const index = deck.frames.findIndex((frame) => frame.number === number);
621
+ if (index >= 0) go(deck, index, 1);
622
+ } else if (['ArrowRight', 'ArrowDown', ' ', 'PageDown', 'Enter'].includes(key)) {
623
+ next(deck);
624
+ } else if (['ArrowLeft', 'ArrowUp', 'PageUp', 'Backspace'].includes(key)) {
625
+ previous(deck);
626
+ } else if (key === 'Home') {
627
+ go(deck, 0, 1);
628
+ } else if (key === 'End') {
629
+ go(deck, deck.frames.length - 1, deck.frames.at(-1)?.steps ?? 1);
630
+ } else if (key === 'f' || key === 'F') {
631
+ if (document.fullscreenElement) document.exitFullscreen?.().catch(() => undefined);
632
+ else document.documentElement.requestFullscreen?.().catch(() => undefined);
633
+ } else if (key === 'n' || key === 'N') {
634
+ document.documentElement.classList.toggle('deck-notes-open');
635
+ rescaleCurrent(deck);
636
+ } else if ((key === 'p' || key === 'P') && deck.presenting) {
637
+ openPresenter(deck);
638
+ } else if (key === 'b' || key === 'B' || key === '.') {
639
+ document.documentElement.classList.toggle('deck-blackout');
640
+ } else if (key === 'Escape' && deck.presenting) {
641
+ leave(deck);
642
+ } else {
643
+ handled = false;
644
+ }
645
+ if (handled) {
646
+ event.preventDefault();
647
+ if (!/^[0-9]$/.test(key)) digits = key === 'Enter' ? '' : digits;
648
+ }
649
+ });
650
+ }
651
+
652
+ function installTouch(deck: Deck): void {
653
+ let startX = 0;
654
+ let startY = 0;
655
+ deck.root.addEventListener('pointerdown', (event) => {
656
+ startX = event.clientX;
657
+ startY = event.clientY;
658
+ });
659
+ deck.root.addEventListener('pointerup', (event) => {
660
+ if (!deck.presenting || event.pointerType === 'mouse' || isEditable(event.target)) return;
661
+ const dx = event.clientX - startX;
662
+ const dy = event.clientY - startY;
663
+ if (Math.abs(dx) > 60 && Math.abs(dx) > 2 * Math.abs(dy)) {
664
+ if (dx < 0) next(deck);
665
+ else previous(deck);
666
+ }
667
+ });
668
+ }
669
+
670
+ function installHud(deck: Deck): void {
671
+ const hud = document.createElement('div');
672
+ hud.className = 'deck-hud';
673
+ hud.innerHTML =
674
+ '<button type="button" data-deck-hud="previous" aria-label="Previous step">◀</button>' +
675
+ '<span class="deck-hud__count" aria-hidden="true"></span>' +
676
+ '<button type="button" data-deck-hud="next" aria-label="Next step">▶</button>' +
677
+ '<button type="button" data-deck-hud="notes">Notes</button>' +
678
+ '<button type="button" data-deck-hud="presenter">Presenter</button>' +
679
+ '<button type="button" data-deck-hud="leave">Exit</button>';
680
+ deck.root.append(hud);
681
+ const live = document.createElement('div');
682
+ live.className = 'deck-live sr-only';
683
+ live.setAttribute('aria-live', 'polite');
684
+ deck.root.append(live);
685
+ hud.addEventListener('click', (event) => {
686
+ const action = (event.target as HTMLElement).closest<HTMLElement>('[data-deck-hud]')?.dataset.deckHud;
687
+ if (action === 'previous') previous(deck);
688
+ else if (action === 'next') next(deck);
689
+ else if (action === 'notes') {
690
+ document.documentElement.classList.toggle('deck-notes-open');
691
+ rescaleCurrent(deck);
692
+ } else if (action === 'presenter') openPresenter(deck);
693
+ else if (action === 'leave') leave(deck);
694
+ });
695
+ let timer: number | undefined;
696
+ document.addEventListener('pointermove', () => {
697
+ if (!deck.presenting) return;
698
+ document.documentElement.classList.add('deck-hud-visible');
699
+ window.clearTimeout(timer);
700
+ timer = window.setTimeout(() => document.documentElement.classList.remove('deck-hud-visible'), 2200);
701
+ });
702
+ }
703
+
704
+ /** A symbol under the pointer lights every place it shows on the frame. */
705
+ function installHoverSpotlight(deck: Deck): void {
706
+ deck.root.addEventListener('pointerover', (event) => {
707
+ if (!deck.presenting) return;
708
+ const element = (event.target as HTMLElement).closest<HTMLElement>('.deck-canvas [data-notation-key]');
709
+ const frame = deck.frames[deck.index];
710
+ if (element && frame) spotlight(frame, element.dataset.notationKey, true, 'pointer');
711
+ });
712
+ deck.root.addEventListener('pointerout', (event) => {
713
+ const element = (event.target as HTMLElement).closest<HTMLElement>('.deck-canvas [data-notation-key]');
714
+ const frame = deck.frames[deck.index];
715
+ if (element && frame) spotlight(frame, element.dataset.notationKey, false, 'pointer');
716
+ });
717
+ }
718
+
719
+ function installPresenterWindow(deck: Deck): void {
720
+ deck.presenter = true;
721
+ document.documentElement.classList.add('deck-presenter-window');
722
+ const clock = document.createElement('div');
723
+ clock.className = 'deck-clock';
724
+ document.body.append(clock);
725
+ const started = Date.now();
726
+ const tick = () => {
727
+ const seconds = Math.floor((Date.now() - started) / 1000);
728
+ clock.textContent = `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`;
729
+ };
730
+ tick();
731
+ window.setInterval(tick, 1000);
732
+ const hash = /^#slide-(\d+)$/.exec(location.hash);
733
+ const index = hash ? deck.frames.findIndex((frame) => frame.number === Number(hash[1])) : 0;
734
+ deck.index = -1;
735
+ go(deck, Math.max(0, index), 1, { broadcast: false });
736
+ rescaleCurrent(deck);
737
+ }
738
+
739
+ function baseOf(): string {
740
+ return document.querySelector<HTMLElement>('[data-explico-card-config]')?.dataset.base ?? '/';
741
+ }
742
+
743
+ // --- the study page: the reader's record ------------------------------------------
744
+
745
+ /** A frame's state on the study page: shown as it is now, not shown yet, or changed since it was. */
746
+ type FrameState = 'shown' | FrameMark;
747
+
748
+ /** What a frame's line says for its state; the frame is described by it. */
749
+ const STATE_TEXT: Record<FrameState, string> = {
750
+ shown: 'Shown',
751
+ unseen: 'Not shown yet',
752
+ changed: 'Changed since shown',
753
+ };
754
+
755
+ /** The reader's record of this deck, as the study page shows it. */
756
+ interface Reading {
757
+ /** Each frame's state, by index: none while the frames shown are not kept, nor where the course marks nothing. */
758
+ readonly states: readonly (FrameState | undefined)[];
759
+ readonly kept: boolean;
760
+ /** How many frames the record holds as shown. */
761
+ readonly seen: number;
762
+ readonly resume?: Resume;
763
+ /** The frame "Continue here" is on, by index: -1 for none, at the first frame and for "Present again". */
764
+ readonly continueAt: number;
765
+ }
766
+
767
+ /**
768
+ * The record as the study page shows it. A frame is "Shown" once Present
769
+ * mode showed it as it is now, while the course marks a frame not shown yet;
770
+ * "Not shown yet" and "Changed since shown" are the sidebar's two marks
771
+ * (`sidebarMarks`), each only while the course has it on. Nothing is marked
772
+ * while the frames shown are not kept (`keepsShown`). Where to continue
773
+ * (`resumeAt`) is kept in either case.
774
+ */
775
+ function readReading(deck: Deck): Reading {
776
+ const kept = keepsShown(deck);
777
+ const record = readMemory().get(deck.slug);
778
+ const states = deck.frames.map((frame): FrameState | undefined => {
779
+ if (!kept || frame.id === '') return undefined;
780
+ const mark = frameMark(idOf(frame), record);
781
+ if (mark === undefined) return deck.marks.has('unseen') ? 'shown' : undefined;
782
+ return deck.marks.has(mark) ? mark : undefined;
783
+ });
784
+ const resume = resumeAt(record, deck.frames.map(stepsOf));
785
+ const index = resume && !resume.again ? deck.frames.findIndex((frame) => frame.number === resume.number) : -1;
786
+ return {
787
+ states,
788
+ kept,
789
+ seen: kept ? (record?.seen.size ?? 0) : 0,
790
+ ...(resume ? { resume } : {}),
791
+ continueAt: index > 0 ? index : -1,
792
+ };
793
+ }
794
+
795
+ /**
796
+ * "Continue at slide 7": a button beside Present that presents from the frame
797
+ * to continue at (`resumeAt`), or "Present again", from the first frame, once
798
+ * the reader was shown all of the last. Run as the page opens and again
799
+ * whenever Present mode ends, so it names the latest place, whatever slide the
800
+ * address names. None before a reader has presented, while the place is the
801
+ * first frame, nor for a frame the deck no longer has. It is the reader's own
802
+ * (`data-explico-export-chrome`): a downloaded deck never carries it.
803
+ */
804
+ function refreshResume(deck: Deck, reading: Reading): void {
805
+ const actions = document.querySelector<HTMLElement>('[data-deck-actions]');
806
+ if (!actions) return;
807
+ const { resume, continueAt } = reading;
808
+ let button = actions.querySelector<HTMLButtonElement>('[data-deck-resume]');
809
+ if (!resume || (!resume.again && continueAt < 0)) {
810
+ button?.remove();
811
+ return;
812
+ }
813
+ if (!button) {
814
+ const created = document.createElement('button');
815
+ created.type = 'button';
816
+ created.className = 'deck-actions__resume';
817
+ created.dataset.deckResume = '';
818
+ created.dataset.explicoExportChrome = 'deck-resume';
819
+ created.addEventListener('click', () => present(deck, Number(created.dataset.deckResume) || 0));
820
+ const presentButton = actions.querySelector('[data-deck-present]');
821
+ if (presentButton) presentButton.after(created);
822
+ else actions.prepend(created);
823
+ button = created;
824
+ }
825
+ button.dataset.deckResume = String(resume.again ? 0 : continueAt);
826
+ button.textContent = resume.again ? 'Present again' : `Continue at slide ${resume.number}`;
827
+ }
828
+
829
+ const MARKS: readonly FrameMark[] = ['unseen', 'changed'];
830
+
831
+ function countText(mark: FrameMark, count: number): string {
832
+ const slides = `${count} ${count === 1 ? 'slide' : 'slides'}`;
833
+ return mark === 'unseen' ? `${slides} not shown yet` : `${slides} changed since shown`;
834
+ }
835
+
836
+ /** A button out of use keeps its place and its focus, so it is `aria-disabled`, not `disabled`, as the sidebar's are. */
837
+ function setUsable(button: HTMLButtonElement | null, usable: boolean): void {
838
+ if (usable) button?.removeAttribute('aria-disabled');
839
+ else button?.setAttribute('aria-disabled', 'true');
840
+ }
841
+
842
+ /**
843
+ * The marks' line, last in the actions row: how many frames carry each mark,
844
+ * "Mark all as seen" and "Mark all as unseen" for this deck, and a status
845
+ * line that says what a button did. The buttons keep their place, so focus
846
+ * stays where it was.
847
+ */
848
+ function buildMarksLine(deck: Deck, actions: HTMLElement): HTMLElement {
849
+ const line = document.createElement('div');
850
+ line.className = 'deck-actions__marks';
851
+ line.dataset.deckMarksLine = '';
852
+ line.dataset.explicoExportChrome = 'deck-marks';
853
+ for (const mark of MARKS) {
854
+ const count = document.createElement('span');
855
+ count.className = 'deck-actions__mark';
856
+ count.dataset.frameMark = mark;
857
+ count.hidden = true;
858
+ line.append(count);
859
+ }
860
+ const buttons = document.createElement('span');
861
+ buttons.className = 'deck-actions__mark-buttons';
862
+ // There from the start, empty, so a screen reader hears what it gains.
863
+ const status = document.createElement('p');
864
+ status.className = 'deck-actions__marks-status';
865
+ status.setAttribute('role', 'status');
866
+ for (const [action, label, done] of [
867
+ ['seen', 'Mark all as seen', 'Every slide is marked as seen.'],
868
+ ['unseen', 'Mark all as unseen', 'Every slide is marked as not shown yet.'],
869
+ ] as const) {
870
+ const button = document.createElement('button');
871
+ button.type = 'button';
872
+ button.className = 'deck-actions__mark-button';
873
+ button.dataset.deckMarksAction = action;
874
+ button.textContent = label;
875
+ button.addEventListener('click', () => {
876
+ if (button.getAttribute('aria-disabled') === 'true') return;
877
+ changeMemory((memory) =>
878
+ action === 'seen'
879
+ ? withAllSeen(memory, deck.slug, deck.frames.flatMap((frame) => (frame.id === '' ? [] : [idOf(frame)])))
880
+ : withNoneSeen(memory, deck.slug),
881
+ );
882
+ status.textContent = done;
883
+ });
884
+ buttons.append(button);
885
+ }
886
+ line.append(buttons, status);
887
+ actions.append(line);
888
+ return line;
889
+ }
890
+
891
+ /**
892
+ * The marks' summary under the title (D8): each frame's mark on the frame
893
+ * itself (`data-frame-mark`), and the line that counts them, while the
894
+ * frames shown are kept (`keepsShown`). After a no, and in a course that
895
+ * marks nothing, no mark and no line.
896
+ */
897
+ function showMarks(deck: Deck, reading: Reading): void {
898
+ const counts: Record<FrameMark, number> = { unseen: 0, changed: 0 };
899
+ deck.frames.forEach((frame, index) => {
900
+ const state = reading.states[index];
901
+ if (state === 'unseen' || state === 'changed') {
902
+ frame.element.dataset.frameMark = state;
903
+ counts[state] += 1;
904
+ } else if (frame.element.dataset.frameMark !== undefined) {
905
+ delete frame.element.dataset.frameMark;
906
+ }
907
+ });
908
+ const actions = document.querySelector<HTMLElement>('[data-deck-actions]');
909
+ if (!actions) return;
910
+ let line = actions.querySelector<HTMLElement>('[data-deck-marks-line]');
911
+ if (!reading.kept) {
912
+ line?.remove();
913
+ return;
914
+ }
915
+ line ??= buildMarksLine(deck, actions);
916
+ for (const mark of MARKS) {
917
+ const count = line.querySelector<HTMLElement>(`.deck-actions__mark[data-frame-mark="${mark}"]`);
918
+ if (!count) continue;
919
+ count.hidden = counts[mark] === 0;
920
+ count.textContent = countText(mark, counts[mark]);
921
+ }
922
+ setUsable(line.querySelector('[data-deck-marks-action="seen"]'), counts.unseen + counts.changed > 0);
923
+ setUsable(line.querySelector('[data-deck-marks-action="unseen"]'), reading.seen > 0);
924
+ }
925
+
926
+ // --- the study page: each frame's line ---------------------------------------------
927
+
928
+ const PLAY_GLYPH =
929
+ '<svg viewBox="0 0 24 24" width="12" height="12" aria-hidden="true"><path d="M7 4l13 8-13 8z" fill="currentColor"/></svg>';
930
+
931
+ /**
932
+ * A line above each frame (Q1), outside its canvas, in the page's own type
933
+ * whatever the frame's scale: "Slide 5", the frame's state, and a button
934
+ * that presents from the frame (Q2) — "Continue here", filled and always
935
+ * shown, on the frame to continue at; "Present from here" on every other,
936
+ * shown while the pointer is on the frame or focus is in it, the frame
937
+ * ringed then, and always where there is no hover, as its glyph alone
938
+ * (`deck.css`). A real button in the tab order, whose name says which frame.
939
+ * Present mode never shows the line, and print and a downloaded deck never
940
+ * carry it.
941
+ */
942
+ function installLines(deck: Deck): void {
943
+ deck.root.dataset.deckLines = '';
944
+ deck.frames.forEach((frame, index) => {
945
+ const line = document.createElement('div');
946
+ line.className = 'deck-frame__line not-content';
947
+ line.dataset.explicoExportChrome = 'deck-frame-line';
948
+ const name = document.createElement('span');
949
+ name.className = 'deck-frame__name';
950
+ name.textContent = `Slide ${frame.number}`;
951
+ const state = document.createElement('span');
952
+ state.className = 'deck-frame__state';
953
+ state.id = `explico-deck-state-${frame.number}`;
954
+ state.hidden = true;
955
+ const button = document.createElement('button');
956
+ button.type = 'button';
957
+ button.className = 'deck-frame__present';
958
+ button.dataset.deckPresentFrom = String(frame.number);
959
+ button.innerHTML = PLAY_GLYPH;
960
+ const label = document.createElement('span');
961
+ label.className = 'deck-frame__present-label';
962
+ button.append(label);
963
+ button.addEventListener('click', () => present(deck, index));
964
+ line.append(name, state, button);
965
+ frame.element.prepend(line);
966
+ deck.lines.push({ state, button, label });
967
+ });
968
+ }
969
+
970
+ /** Each frame's line for `reading`: its state, which also describes the frame, and its button. */
971
+ function showLines(deck: Deck, reading: Reading): void {
972
+ deck.frames.forEach((frame, index) => {
973
+ const line = deck.lines[index];
974
+ if (!line) return;
975
+ const state = reading.states[index];
976
+ line.state.hidden = state === undefined;
977
+ line.state.textContent = state === undefined ? '' : STATE_TEXT[state];
978
+ if (state === undefined) {
979
+ delete line.state.dataset.frameState;
980
+ frame.element.removeAttribute('aria-describedby');
981
+ } else {
982
+ line.state.dataset.frameState = state;
983
+ frame.element.setAttribute('aria-describedby', line.state.id);
984
+ }
985
+ const here = index === reading.continueAt;
986
+ frame.element.toggleAttribute('data-deck-continue', here);
987
+ const text = here ? 'Continue here' : 'Present from here';
988
+ line.label.textContent = text;
989
+ // Its name starts with what it shows (WCAG 2.5.3), then says which frame.
990
+ line.button.setAttribute('aria-label', `${text}, slide ${frame.number}`);
991
+ });
992
+ }
993
+
994
+ // --- the study page: the slide map ------------------------------------------------
995
+
996
+ /**
997
+ * The slide map (Q1): every frame as a numbered square under the deck's
998
+ * title, a link to it on the page, pinned under the header while the deck
999
+ * scrolls. A square is filled once its frame was shown, outlined before, and
1000
+ * dotted when it changed since; the frame to continue at is ringed and the
1001
+ * one in view underlined. A deck too long for the map's line keeps its ends,
1002
+ * the frames around the one to continue at and around the one in view
1003
+ * (`deck-map.ts`); each gap is a link to the frame in its middle.
1004
+ */
1005
+ function installMap(deck: Deck): void {
1006
+ if (deck.frames.length < 2) return;
1007
+ const nav = document.createElement('nav');
1008
+ nav.className = 'deck-map not-content';
1009
+ nav.dataset.explicoExportChrome = 'deck-map';
1010
+ const label = document.createElement('span');
1011
+ label.className = 'deck-map__label';
1012
+ label.id = `explico-deck-map-${deck.slug.replace(/[^a-z0-9-]/giu, '-')}`;
1013
+ label.textContent = 'Slides';
1014
+ nav.setAttribute('aria-labelledby', label.id);
1015
+ const list = document.createElement('ol');
1016
+ list.className = 'deck-map__list';
1017
+ nav.append(label, list);
1018
+ deck.root.prepend(nav);
1019
+ deck.root.dataset.deckMap = '';
1020
+ const map: SlideMap = { nav, list, places: new Map(), current: 0 };
1021
+ deck.map = map;
1022
+ let pending = 0;
1023
+ const follow = () => {
1024
+ pending = 0;
1025
+ if (deck.presenting) return;
1026
+ // Pinned: the deck's top has gone up under the header, and the map stays.
1027
+ nav.toggleAttribute('data-stuck', deck.root.getBoundingClientRect().top < nav.getBoundingClientRect().top - 1);
1028
+ const current = frameAtLine(deck, nav);
1029
+ showChapter(deck, current);
1030
+ if (current === map.current) return;
1031
+ map.current = current;
1032
+ drawMap(deck);
1033
+ };
1034
+ map.current = frameAtLine(deck, nav);
1035
+ showChapter(deck, map.current);
1036
+ window.addEventListener('scroll', () => (pending ||= window.requestAnimationFrame(follow)), { passive: true });
1037
+ if (typeof ResizeObserver === 'function') {
1038
+ let width = 0;
1039
+ new ResizeObserver(() => {
1040
+ if (deck.root.clientWidth === width) return;
1041
+ width = deck.root.clientWidth;
1042
+ drawMap(deck);
1043
+ }).observe(deck.root);
1044
+ }
1045
+ }
1046
+
1047
+ /**
1048
+ * The frame in view, by index: the last whose top is above the reading line,
1049
+ * a third of the way down the window below the map.
1050
+ */
1051
+ function frameAtLine(deck: Deck, nav: HTMLElement): number {
1052
+ const top = Math.max(0, nav.getBoundingClientRect().bottom);
1053
+ const line = top + (window.innerHeight - top) / 3;
1054
+ let at = 0;
1055
+ for (const [index, frame] of deck.frames.entries()) {
1056
+ if (frame.element.getBoundingClientRect().top > line) break;
1057
+ at = index;
1058
+ }
1059
+ return at;
1060
+ }
1061
+
1062
+ function slidePlace(number: number): HTMLLIElement {
1063
+ const place = document.createElement('li');
1064
+ const link = document.createElement('a');
1065
+ link.className = 'deck-map__slide';
1066
+ link.href = `#slide-${number}`;
1067
+ link.textContent = String(number);
1068
+ place.append(link);
1069
+ return place;
1070
+ }
1071
+
1072
+ function gapPlace(from: number, to: number): HTMLLIElement {
1073
+ const place = document.createElement('li');
1074
+ place.className = 'deck-map__gap';
1075
+ const link = document.createElement('a');
1076
+ link.href = `#slide-${Math.round((from + to) / 2)}`;
1077
+ link.textContent = '…';
1078
+ link.title = `Slides ${from}–${to}`;
1079
+ link.setAttribute('aria-label', `Slides ${from} to ${to}`);
1080
+ place.append(link);
1081
+ return place;
1082
+ }
1083
+
1084
+ /** Put `items` in the map's list, each place kept from the drawing before when it was there, and mark each square. */
1085
+ function placeItems(map: SlideMap, items: readonly SlideMapItem[], reading: Reading): void {
1086
+ const keys = items.map((item) => (item.kind === 'slide' ? `slide-${item.number}` : `gap-${item.from}-${item.to}`));
1087
+ const nodes = items.map((item, index) => {
1088
+ const key = keys[index]!;
1089
+ let place = map.places.get(key);
1090
+ if (!place) {
1091
+ place = item.kind === 'slide' ? slidePlace(item.number) : gapPlace(item.from, item.to);
1092
+ map.places.set(key, place);
1093
+ }
1094
+ return place;
1095
+ });
1096
+ const children = map.list.children;
1097
+ if (children.length !== nodes.length || nodes.some((node, index) => children[index] !== node)) {
1098
+ map.list.replaceChildren(...nodes);
1099
+ }
1100
+ const wanted = new Set(keys);
1101
+ for (const key of [...map.places.keys()]) if (!wanted.has(key)) map.places.delete(key);
1102
+ items.forEach((item, position) => {
1103
+ if (item.kind !== 'slide') return;
1104
+ const link = nodes[position]!.firstElementChild as HTMLAnchorElement;
1105
+ const index = item.number - 1;
1106
+ const state = reading.states[index];
1107
+ const here = index === reading.continueAt;
1108
+ if (state === undefined) delete link.dataset.frameState;
1109
+ else link.dataset.frameState = state;
1110
+ link.toggleAttribute('data-deck-continue', here);
1111
+ if (index === map.current) link.setAttribute('aria-current', 'true');
1112
+ else link.removeAttribute('aria-current');
1113
+ const name = [`Slide ${item.number}`, state === undefined ? '' : STATE_TEXT[state].toLowerCase(), here ? 'continue here' : '']
1114
+ .filter(Boolean)
1115
+ .join(', ');
1116
+ link.setAttribute('aria-label', name);
1117
+ });
1118
+ }
1119
+
1120
+ /**
1121
+ * How many squares the map's line has room for: the deck's width, less the
1122
+ * map's padding and its label, in squares as wide as the widest number's.
1123
+ * A gap is narrower than a square, so the count errs short.
1124
+ */
1125
+ function mapRoom(deck: Deck, map: SlideMap): number {
1126
+ const navStyle = getComputedStyle(map.nav);
1127
+ const label = map.nav.querySelector<HTMLElement>('.deck-map__label');
1128
+ const beside = label && getComputedStyle(label).position !== 'absolute' ? label.offsetWidth + (Number.parseFloat(navStyle.columnGap) || 0) : 0;
1129
+ const width = deck.root.clientWidth - (Number.parseFloat(navStyle.paddingInlineStart) || 0) - (Number.parseFloat(navStyle.paddingInlineEnd) || 0) - beside;
1130
+ const probe = slidePlace(deck.frames.length);
1131
+ probe.style.position = 'absolute';
1132
+ probe.style.visibility = 'hidden';
1133
+ map.list.append(probe);
1134
+ const square = probe.getBoundingClientRect().width;
1135
+ probe.remove();
1136
+ const gap = Number.parseFloat(getComputedStyle(map.list).columnGap) || 0;
1137
+ return square > 0 ? Math.floor((width + gap) / (square + gap)) : deck.frames.length;
1138
+ }
1139
+
1140
+ /**
1141
+ * Draw the map for the record as the page shows it and the frame in view:
1142
+ * as much of the deck as its line has room for (`slideMap`), one place fewer
1143
+ * while it still runs over, and wrapped past the least a shortened map
1144
+ * keeps. Focus stays on the square it was on; the frames keep their tops
1145
+ * clear of the pinned map (`--deck-map-height`, `deck.css`).
1146
+ */
1147
+ function drawMap(deck: Deck): void {
1148
+ const map = deck.map;
1149
+ const reading = deck.reading;
1150
+ if (!map || !reading) return;
1151
+ const unseen = reading.states.indexOf('unseen');
1152
+ const anchor = reading.continueAt >= 0 ? reading.continueAt : unseen;
1153
+ const focus = { ...(anchor >= 0 ? { anchor: anchor + 1 } : {}), current: map.current + 1 };
1154
+ const active = document.activeElement;
1155
+ map.list.removeAttribute('data-wrap');
1156
+ let room = mapRoom(deck, map);
1157
+ for (;;) {
1158
+ const items = slideMap(deck.frames.length, focus, room);
1159
+ placeItems(map, items, reading);
1160
+ if (map.list.scrollWidth <= map.list.clientWidth + 1) break;
1161
+ // Fewer places, until the least a shortened map keeps.
1162
+ if (room >= items.length && items.length > 1) {
1163
+ room = items.length - 1;
1164
+ continue;
1165
+ }
1166
+ map.list.setAttribute('data-wrap', '');
1167
+ break;
1168
+ }
1169
+ if (active instanceof HTMLElement && active !== document.activeElement && map.list.contains(active)) {
1170
+ active.focus({ preventScroll: true });
1171
+ }
1172
+ deck.root.style.setProperty('--deck-map-height', `${map.nav.offsetHeight}px`);
1173
+ }
1174
+
1175
+ export function installDeck(root: HTMLElement): void {
1176
+ const style = getComputedStyle(root);
1177
+ const frames = [...root.querySelectorAll<HTMLElement>(':scope > .deck-frame')].map((element) => ({
1178
+ element,
1179
+ number: Number(element.dataset.frame) || 0,
1180
+ id: /^[0-9a-f]{12}$/u.test(element.dataset.frameId ?? '') ? (element.dataset.frameId as string) : '',
1181
+ chapter: element.dataset.chapter ?? '',
1182
+ steps: Math.max(1, Number(element.dataset.steps) || 1),
1183
+ stage: element.querySelector<HTMLElement>(':scope > .deck-stage'),
1184
+ }));
1185
+ for (const frame of frames) {
1186
+ frame.element.tabIndex = -1;
1187
+ // A lab's tools are the frame's: always open beside its picture (rule D7).
1188
+ for (const fold of frame.element.querySelectorAll<HTMLDetailsElement>('details.keyed-figure__tools-fold')) {
1189
+ fold.open = true;
1190
+ }
1191
+ }
1192
+ const presenterWindow = new URLSearchParams(location.search).has('presenter');
1193
+ const marks = document.querySelector<HTMLElement>('[data-deck-actions]')?.dataset.deckMarks ?? '';
1194
+ const deck: Deck = {
1195
+ root,
1196
+ slug: root.dataset.deck ?? location.pathname,
1197
+ frames,
1198
+ canvasWidth: pixels(style.getPropertyValue('--deck-canvas-width'), 1280),
1199
+ canvasHeight: pixels(style.getPropertyValue('--deck-canvas-height'), 720),
1200
+ marks: new Set(MARKS.filter((mark) => marks.split(/\s+/u).includes(mark))),
1201
+ chrome: !presenterWindow && !document.documentElement.hasAttribute('data-explico-standalone'),
1202
+ lines: [],
1203
+ index: 0,
1204
+ step: 1,
1205
+ presenting: false,
1206
+ presenter: false,
1207
+ };
1208
+ installScaling(deck);
1209
+ // Measured once the fonts are in, and again whenever a figure changes size
1210
+ // (a lab hydrating, a plot redrawn): the build's own check calls the same
1211
+ // measure (`compiler/exports/deck-fit.ts`).
1212
+ let refit: number | undefined;
1213
+ const measure = () => {
1214
+ window.cancelAnimationFrame(refit ?? 0);
1215
+ if (root.dataset.deckStatic !== undefined) return;
1216
+ refit = window.requestAnimationFrame(() => overflowingFrames(root));
1217
+ };
1218
+ // The export's figure capture (`markdown` mode) draws each figure at its own
1219
+ // size: no canvas scale and no fitting zoom (`data-deck-static`, deck.css).
1220
+ // Print keeps the slides as they are.
1221
+ window.addEventListener('explico:prepare-static-export', (event) => {
1222
+ if ((event as CustomEvent<{ mode?: unknown }>).detail?.mode !== 'markdown') return;
1223
+ if (deck.presenting) leave(deck);
1224
+ root.dataset.deckStatic = '';
1225
+ for (const part of root.querySelectorAll<HTMLElement>('.keyed-figure > *')) {
1226
+ for (const property of ['zoom', 'max-width', 'margin-inline']) part.style.removeProperty(property);
1227
+ }
1228
+ });
1229
+ window.addEventListener('explico:restore-static-export', () => {
1230
+ if (root.dataset.deckStatic === undefined) return;
1231
+ delete root.dataset.deckStatic;
1232
+ measure();
1233
+ });
1234
+ void (document.fonts?.ready ?? Promise.resolve()).then(measure);
1235
+ if (typeof ResizeObserver === 'function') {
1236
+ const figures = new ResizeObserver(measure);
1237
+ for (const figure of root.querySelectorAll('.deck-frame__body .keyed-figure, .deck-frame__body .keyed-table, .deck-frame__body .diagram')) {
1238
+ figures.observe(figure);
1239
+ }
1240
+ }
1241
+ (window as Window & { explicoDeckOverflow?: () => number[] }).explicoDeckOverflow = () => overflowingFrames(root);
1242
+ installHud(deck);
1243
+ installKeys(deck);
1244
+ installTouch(deck);
1245
+ installHoverSpotlight(deck);
1246
+ if (typeof BroadcastChannel === 'function') {
1247
+ deck.channel = new BroadcastChannel(`explico-deck:${deck.slug}`);
1248
+ deck.channel.addEventListener('message', (event: MessageEvent<{ type?: string; index?: number; step?: number }>) => {
1249
+ if (event.data?.type !== 'go' || (!deck.presenting && !deck.presenter)) return;
1250
+ go(deck, Number(event.data.index), Number(event.data.step), { broadcast: false });
1251
+ });
1252
+ }
1253
+ for (const button of document.querySelectorAll<HTMLElement>('[data-deck-present]')) {
1254
+ button.addEventListener('click', () => present(deck));
1255
+ }
1256
+ document.addEventListener('fullscreenchange', () => rescaleCurrent(deck));
1257
+ if (presenterWindow) {
1258
+ installPresenterWindow(deck);
1259
+ return;
1260
+ }
1261
+ if (!deck.chrome) return;
1262
+ installLines(deck);
1263
+ installMap(deck);
1264
+ // A deck too short for a map: its first frame, the title, has no chapter.
1265
+ if (!deck.map) showChapter(deck, 0);
1266
+ // The record and the answer, read again whenever either changes: on this
1267
+ // page (Present mode ended, a mark action here or in the sidebar, the
1268
+ // reading-memory question answered) or in another tab.
1269
+ const refresh = () => {
1270
+ const reading = readReading(deck);
1271
+ deck.reading = reading;
1272
+ refreshResume(deck, reading);
1273
+ showMarks(deck, reading);
1274
+ showLines(deck, reading);
1275
+ drawMap(deck);
1276
+ };
1277
+ window.addEventListener(DECK_MEMORY_EVENT, refresh);
1278
+ window.addEventListener(READING_MEMORY_EVENT, refresh);
1279
+ window.addEventListener('storage', (event) => {
1280
+ if (event.key === null || event.key === deckPositionsKey(baseOf()) || event.key === answerKey(baseOf())) refresh();
1281
+ });
1282
+ refresh();
1283
+ }
1284
+
1285
+ export function installDecks(): void {
1286
+ for (const root of document.querySelectorAll<HTMLElement>('.deck[data-deck]')) installDeck(root);
1287
+ }