@teleporthq/teleport-shared 0.43.70 → 0.43.71

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 (150) hide show
  1. package/__tests__/motion-runtime/motion-engine.ts +102 -0
  2. package/__tests__/motion-runtime/scroll-video-engine.ts +28 -0
  3. package/__tests__/scroll-scene/story-lanes.ts +126 -0
  4. package/dist/cjs/index.d.ts +5 -1
  5. package/dist/cjs/index.d.ts.map +1 -1
  6. package/dist/cjs/index.js +9 -1
  7. package/dist/cjs/index.js.map +1 -1
  8. package/dist/cjs/motion-runtime/index.d.ts +3 -0
  9. package/dist/cjs/motion-runtime/index.d.ts.map +1 -0
  10. package/dist/cjs/motion-runtime/index.js +8 -0
  11. package/dist/cjs/motion-runtime/index.js.map +1 -0
  12. package/dist/cjs/motion-runtime/motion-engine-source.d.ts +16 -0
  13. package/dist/cjs/motion-runtime/motion-engine-source.d.ts.map +1 -0
  14. package/dist/cjs/motion-runtime/motion-engine-source.js +20 -0
  15. package/dist/cjs/motion-runtime/motion-engine-source.js.map +1 -0
  16. package/dist/cjs/motion-runtime/scroll-video-engine-source.d.ts +9 -0
  17. package/dist/cjs/motion-runtime/scroll-video-engine-source.d.ts.map +1 -0
  18. package/dist/cjs/motion-runtime/scroll-video-engine-source.js +13 -0
  19. package/dist/cjs/motion-runtime/scroll-video-engine-source.js.map +1 -0
  20. package/dist/cjs/page-transition/index.d.ts +4 -0
  21. package/dist/cjs/page-transition/index.d.ts.map +1 -0
  22. package/dist/cjs/page-transition/index.js +20 -0
  23. package/dist/cjs/page-transition/index.js.map +1 -0
  24. package/dist/cjs/page-transition/morph-source.d.ts +20 -0
  25. package/dist/cjs/page-transition/morph-source.d.ts.map +1 -0
  26. package/dist/cjs/page-transition/morph-source.js +25 -0
  27. package/dist/cjs/page-transition/morph-source.js.map +1 -0
  28. package/dist/cjs/page-transition/variants.d.ts +87 -0
  29. package/dist/cjs/page-transition/variants.d.ts.map +1 -0
  30. package/dist/cjs/page-transition/variants.js +330 -0
  31. package/dist/cjs/page-transition/variants.js.map +1 -0
  32. package/dist/cjs/page-transition/view-transition-css.d.ts +21 -0
  33. package/dist/cjs/page-transition/view-transition-css.d.ts.map +1 -0
  34. package/dist/cjs/page-transition/view-transition-css.js +188 -0
  35. package/dist/cjs/page-transition/view-transition-css.js.map +1 -0
  36. package/dist/cjs/scroll-scene/chapter-index.d.ts +15 -0
  37. package/dist/cjs/scroll-scene/chapter-index.d.ts.map +1 -0
  38. package/dist/cjs/scroll-scene/chapter-index.js +30 -0
  39. package/dist/cjs/scroll-scene/chapter-index.js.map +1 -0
  40. package/dist/cjs/scroll-scene/controller-source.d.ts +26 -0
  41. package/dist/cjs/scroll-scene/controller-source.d.ts.map +1 -0
  42. package/dist/cjs/scroll-scene/controller-source.js +34 -0
  43. package/dist/cjs/scroll-scene/controller-source.js.map +1 -0
  44. package/dist/cjs/scroll-scene/engine-source.d.ts +2 -0
  45. package/dist/cjs/scroll-scene/engine-source.d.ts.map +1 -0
  46. package/dist/cjs/scroll-scene/engine-source.js +26 -0
  47. package/dist/cjs/scroll-scene/engine-source.js.map +1 -0
  48. package/dist/cjs/scroll-scene/index.d.ts +7 -0
  49. package/dist/cjs/scroll-scene/index.d.ts.map +1 -0
  50. package/dist/cjs/scroll-scene/index.js +22 -0
  51. package/dist/cjs/scroll-scene/index.js.map +1 -0
  52. package/dist/cjs/scroll-scene/moment.d.ts +21 -0
  53. package/dist/cjs/scroll-scene/moment.d.ts.map +1 -0
  54. package/dist/cjs/scroll-scene/moment.js +50 -0
  55. package/dist/cjs/scroll-scene/moment.js.map +1 -0
  56. package/dist/cjs/scroll-scene/points.d.ts +23 -0
  57. package/dist/cjs/scroll-scene/points.d.ts.map +1 -0
  58. package/dist/cjs/scroll-scene/points.js +51 -0
  59. package/dist/cjs/scroll-scene/points.js.map +1 -0
  60. package/dist/cjs/scroll-scene/unclip.d.ts +15 -0
  61. package/dist/cjs/scroll-scene/unclip.d.ts.map +1 -0
  62. package/dist/cjs/scroll-scene/unclip.js +44 -0
  63. package/dist/cjs/scroll-scene/unclip.js.map +1 -0
  64. package/dist/cjs/state-bindings/index.d.ts +74 -0
  65. package/dist/cjs/state-bindings/index.d.ts.map +1 -0
  66. package/dist/cjs/state-bindings/index.js +193 -0
  67. package/dist/cjs/state-bindings/index.js.map +1 -0
  68. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  69. package/dist/esm/index.d.ts +5 -1
  70. package/dist/esm/index.d.ts.map +1 -1
  71. package/dist/esm/index.js +5 -1
  72. package/dist/esm/index.js.map +1 -1
  73. package/dist/esm/motion-runtime/index.d.ts +3 -0
  74. package/dist/esm/motion-runtime/index.d.ts.map +1 -0
  75. package/dist/esm/motion-runtime/index.js +3 -0
  76. package/dist/esm/motion-runtime/index.js.map +1 -0
  77. package/dist/esm/motion-runtime/motion-engine-source.d.ts +16 -0
  78. package/dist/esm/motion-runtime/motion-engine-source.d.ts.map +1 -0
  79. package/dist/esm/motion-runtime/motion-engine-source.js +16 -0
  80. package/dist/esm/motion-runtime/motion-engine-source.js.map +1 -0
  81. package/dist/esm/motion-runtime/scroll-video-engine-source.d.ts +9 -0
  82. package/dist/esm/motion-runtime/scroll-video-engine-source.d.ts.map +1 -0
  83. package/dist/esm/motion-runtime/scroll-video-engine-source.js +9 -0
  84. package/dist/esm/motion-runtime/scroll-video-engine-source.js.map +1 -0
  85. package/dist/esm/page-transition/index.d.ts +4 -0
  86. package/dist/esm/page-transition/index.d.ts.map +1 -0
  87. package/dist/esm/page-transition/index.js +4 -0
  88. package/dist/esm/page-transition/index.js.map +1 -0
  89. package/dist/esm/page-transition/morph-source.d.ts +20 -0
  90. package/dist/esm/page-transition/morph-source.d.ts.map +1 -0
  91. package/dist/esm/page-transition/morph-source.js +21 -0
  92. package/dist/esm/page-transition/morph-source.js.map +1 -0
  93. package/dist/esm/page-transition/variants.d.ts +87 -0
  94. package/dist/esm/page-transition/variants.d.ts.map +1 -0
  95. package/dist/esm/page-transition/variants.js +318 -0
  96. package/dist/esm/page-transition/variants.js.map +1 -0
  97. package/dist/esm/page-transition/view-transition-css.d.ts +21 -0
  98. package/dist/esm/page-transition/view-transition-css.d.ts.map +1 -0
  99. package/dist/esm/page-transition/view-transition-css.js +161 -0
  100. package/dist/esm/page-transition/view-transition-css.js.map +1 -0
  101. package/dist/esm/scroll-scene/chapter-index.d.ts +15 -0
  102. package/dist/esm/scroll-scene/chapter-index.d.ts.map +1 -0
  103. package/dist/esm/scroll-scene/chapter-index.js +26 -0
  104. package/dist/esm/scroll-scene/chapter-index.js.map +1 -0
  105. package/dist/esm/scroll-scene/controller-source.d.ts +26 -0
  106. package/dist/esm/scroll-scene/controller-source.d.ts.map +1 -0
  107. package/dist/esm/scroll-scene/controller-source.js +26 -0
  108. package/dist/esm/scroll-scene/controller-source.js.map +1 -0
  109. package/dist/esm/scroll-scene/engine-source.d.ts +2 -0
  110. package/dist/esm/scroll-scene/engine-source.d.ts.map +1 -0
  111. package/dist/esm/scroll-scene/engine-source.js +22 -0
  112. package/dist/esm/scroll-scene/engine-source.js.map +1 -0
  113. package/dist/esm/scroll-scene/index.d.ts +7 -0
  114. package/dist/esm/scroll-scene/index.d.ts.map +1 -0
  115. package/dist/esm/scroll-scene/index.js +7 -0
  116. package/dist/esm/scroll-scene/index.js.map +1 -0
  117. package/dist/esm/scroll-scene/moment.d.ts +21 -0
  118. package/dist/esm/scroll-scene/moment.d.ts.map +1 -0
  119. package/dist/esm/scroll-scene/moment.js +46 -0
  120. package/dist/esm/scroll-scene/moment.js.map +1 -0
  121. package/dist/esm/scroll-scene/points.d.ts +23 -0
  122. package/dist/esm/scroll-scene/points.d.ts.map +1 -0
  123. package/dist/esm/scroll-scene/points.js +45 -0
  124. package/dist/esm/scroll-scene/points.js.map +1 -0
  125. package/dist/esm/scroll-scene/unclip.d.ts +15 -0
  126. package/dist/esm/scroll-scene/unclip.d.ts.map +1 -0
  127. package/dist/esm/scroll-scene/unclip.js +40 -0
  128. package/dist/esm/scroll-scene/unclip.js.map +1 -0
  129. package/dist/esm/state-bindings/index.d.ts +74 -0
  130. package/dist/esm/state-bindings/index.d.ts.map +1 -0
  131. package/dist/esm/state-bindings/index.js +176 -0
  132. package/dist/esm/state-bindings/index.js.map +1 -0
  133. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  134. package/package.json +3 -3
  135. package/src/index.ts +8 -0
  136. package/src/motion-runtime/index.ts +2 -0
  137. package/src/motion-runtime/motion-engine-source.ts +289 -0
  138. package/src/motion-runtime/scroll-video-engine-source.ts +591 -0
  139. package/src/page-transition/index.ts +3 -0
  140. package/src/page-transition/morph-source.ts +86 -0
  141. package/src/page-transition/variants.ts +407 -0
  142. package/src/page-transition/view-transition-css.ts +237 -0
  143. package/src/scroll-scene/chapter-index.ts +25 -0
  144. package/src/scroll-scene/controller-source.ts +451 -0
  145. package/src/scroll-scene/engine-source.ts +330 -0
  146. package/src/scroll-scene/index.ts +12 -0
  147. package/src/scroll-scene/moment.ts +64 -0
  148. package/src/scroll-scene/points.ts +46 -0
  149. package/src/scroll-scene/unclip.ts +44 -0
  150. package/src/state-bindings/index.ts +207 -0
@@ -0,0 +1,451 @@
1
+ /**
2
+ * The parts of a scroll scene's controller that are plain DOM JavaScript, as
3
+ * SOURCE TEXT, shared word for word by the Next export's TqScrollScene and the
4
+ * HTML export's vanilla controller. Each block talks to its surroundings through
5
+ * the same names in both hosts:
6
+ *
7
+ * trackRef, progressRef, chapterHelpersRef, chapterStateRef,
8
+ * scenePointStateRef, awaitedTagsRef, restackRef objects with a `current`
9
+ * pin, layout, chapterSnap, shouldReduceMotion the scene's settings
10
+ *
11
+ * In React those are refs and props; in the HTML runtime they are plain objects
12
+ * and constants of the same names. The engine (engine-source.ts) must be in
13
+ * scope. Bodies that were React effects keep their early `return undefined` and
14
+ * their returned cleanup, so a host wraps them in a function.
15
+ */
16
+
17
+ /** Scene points and chapters announced as DOM events, with the stamps late listeners read. */
18
+ export const announcementsSource =
19
+ (): string => ` // Announces the points passed on the way down, in scroll order, one
20
+ // microtask later (the ancestors' effects, where generated listeners attach,
21
+ // have run by then). The stamp is written with the dispatch, never before it,
22
+ // so a listener attaching in the same effect flush sees no stamp and gets the
23
+ // event, while one attaching later reads the stamp and nothing is announced
24
+ // twice. The first announcement measures from below the scene, so a page
25
+ // that opens already past a point (an anchor link, a restored scroll) counts
26
+ // it as passed. Going up announces nothing; the next way down announces again.
27
+ const announceScenePoints = (p) => {
28
+ const state = scenePointStateRef.current
29
+ const from = state.last === null ? -1 : state.last
30
+ state.last = p
31
+ const passed = passedScenePoints(from, p)
32
+ if (passed.length === 0) {
33
+ return
34
+ }
35
+ state.pending = state.pending.concat(passed)
36
+ if (state.scheduled) {
37
+ return
38
+ }
39
+ state.scheduled = true
40
+ Promise.resolve().then(() => {
41
+ const current = scenePointStateRef.current
42
+ current.scheduled = false
43
+ const batch = current.pending
44
+ current.pending = []
45
+ const track = trackRef.current
46
+ if (!track) {
47
+ return
48
+ }
49
+ for (const key of batch) {
50
+ if (scenePointRank(key) > scenePointRank(track.getAttribute(SCENE_POINT_ATTR) || '')) {
51
+ track.setAttribute(SCENE_POINT_ATTR, key)
52
+ }
53
+ track.dispatchEvent(
54
+ new CustomEvent(SCENE_POINT_EVENT, {
55
+ bubbles: true,
56
+ detail: { point: key, progress: current.last },
57
+ })
58
+ )
59
+ }
60
+ })
61
+ }
62
+
63
+ // The chapters in document order with the moment each one is on stage —
64
+ // the same moment chapter snap and anchor navigation aim at; a chapter
65
+ // without a moment of its own takes the start of its even share.
66
+ const chapterRecords = () => {
67
+ const track = trackRef.current
68
+ const helpers = chapterHelpersRef.current
69
+ if (!track || !helpers) {
70
+ return []
71
+ }
72
+ const chapters = helpers.chapterElements(track)
73
+ return chapters.map((element, index) => {
74
+ const moment = helpers.chapterProgress(element)
75
+ return {
76
+ element,
77
+ moment:
78
+ moment === null ? index / chapters.length : Math.min(1, Math.max(0, moment)),
79
+ }
80
+ })
81
+ }
82
+
83
+ // Announces chapters as the visitor passes their moments: every chapter
84
+ // between the last announced one and the one now on stage gets its
85
+ // tq-chapter-reached (bubbling) in travel order, so a jump past a chapter
86
+ // still counts as reaching it. The current chapter carries
87
+ // data-chapter-active / data-chapter-count for listeners that attach later.
88
+ // Dispatch is deferred to a microtask: the ancestors' effects (where the
89
+ // generated listeners attach) have run by then, and nothing is a frame late.
90
+ const announceChapter = (p) => {
91
+ const state = chapterStateRef.current
92
+ if (state.records === null) {
93
+ state.records = chapterRecords()
94
+ state.moments = state.records.map((record) => record.moment)
95
+ // A rebuilt list may hold NEW elements for the same chapters (a repeater
96
+ // re-rendered): the stamp moves to the current chapter's new element
97
+ // without announcing it again.
98
+ const current = state.index >= 0 ? state.records[state.index] : null
99
+ if (current && current.element !== state.element) {
100
+ stampChapter(current.element, state.index + 1, state.records.length)
101
+ } else if (!current && state.index >= 0) {
102
+ state.index = -1
103
+ state.element = null
104
+ }
105
+ }
106
+ const direction = p >= state.lastProgress ? 'down' : 'up'
107
+ state.lastProgress = p
108
+ if (state.moments.length === 0 || !state.onScreen) {
109
+ return
110
+ }
111
+ const next = activeChapterIndex(state.moments, p)
112
+ if (next === state.index) {
113
+ return
114
+ }
115
+ const from = state.index
116
+ state.index = next
117
+ if (next === -1) {
118
+ stampChapter(null, 0, 0)
119
+ return
120
+ }
121
+ const passed = []
122
+ if (from < next) {
123
+ for (let index = from + 1; index <= next; index++) {
124
+ passed.push(index)
125
+ }
126
+ } else {
127
+ for (let index = from - 1; index >= next; index--) {
128
+ passed.push(index)
129
+ }
130
+ }
131
+ stampChapter(state.records[next].element, next + 1, state.records.length)
132
+ state.pending = { passed, progress: p, direction }
133
+ Promise.resolve().then(() => {
134
+ const batch = chapterStateRef.current.pending
135
+ if (!batch) {
136
+ return
137
+ }
138
+ chapterStateRef.current.pending = null
139
+ const records = chapterStateRef.current.records || []
140
+ for (const index of batch.passed) {
141
+ const record = records[index]
142
+ if (!record) {
143
+ continue
144
+ }
145
+ record.element.dispatchEvent(
146
+ new CustomEvent(CHAPTER_REACHED_EVENT, {
147
+ bubbles: true,
148
+ detail: {
149
+ chapterIndex: index + 1,
150
+ chapterCount: records.length,
151
+ progress: batch.progress,
152
+ direction: batch.direction,
153
+ },
154
+ })
155
+ )
156
+ }
157
+ })
158
+ }
159
+
160
+ const stampChapter = (element, chapterIndex, chapterCount) => {
161
+ const state = chapterStateRef.current
162
+ if (state.element && state.element !== element) {
163
+ state.element.removeAttribute(CHAPTER_ACTIVE_ATTR)
164
+ state.element.removeAttribute(CHAPTER_COUNT_ATTR)
165
+ }
166
+ state.element = element
167
+ if (element) {
168
+ element.setAttribute(CHAPTER_ACTIVE_ATTR, String(chapterIndex))
169
+ element.setAttribute(CHAPTER_COUNT_ATTR, String(chapterCount))
170
+ }
171
+ }`
172
+
173
+ /** Body of `restack`: every real grid item of a chapters stage goes into the same cell. */
174
+ export const restackBodySource =
175
+ (): string => ` if (layout !== 'chapters' || !trackRef.current) {
176
+ return
177
+ }
178
+ const stage = trackRef.current.querySelector(':scope > [data-scene-stage]')
179
+ if (!stage) {
180
+ return
181
+ }
182
+ const place = (el) => {
183
+ // Duck-typed, never instanceof — mirrors the editor runtime, where
184
+ // canvas elements belong to another realm and instanceof always fails.
185
+ if (!el || el.nodeType !== 1 || el.tagName === 'STYLE') {
186
+ return
187
+ }
188
+ if (!el.style || typeof el.style.removeProperty !== 'function') {
189
+ return
190
+ }
191
+ let display = ''
192
+ try {
193
+ display = getComputedStyle(el).display
194
+ } catch (e) {
195
+ // detached node — skip
196
+ }
197
+ if (display === 'contents') {
198
+ // A pre-upgrade pass may have pinned this wrapper as a box — clean up.
199
+ if (el.style.gridArea) {
200
+ el.style.removeProperty('grid-area')
201
+ }
202
+ if (el.shadowRoot) {
203
+ Array.from(el.shadowRoot.children).forEach(place)
204
+ return
205
+ }
206
+ if (typeof el.assignedElements === 'function') {
207
+ const assigned = el.assignedElements({ flatten: true })
208
+ ;(assigned.length > 0 ? assigned : Array.from(el.children)).forEach(place)
209
+ return
210
+ }
211
+ Array.from(el.children).forEach(place)
212
+ return
213
+ }
214
+ const tag = el.tagName.toLowerCase()
215
+ if (
216
+ tag.includes('-') &&
217
+ typeof customElements !== 'undefined' &&
218
+ !customElements.get(tag) &&
219
+ !awaitedTagsRef.current.has(tag)
220
+ ) {
221
+ awaitedTagsRef.current.add(tag)
222
+ customElements
223
+ .whenDefined(tag)
224
+ .then(() => restackRef.current && restackRef.current())
225
+ .catch(() => undefined)
226
+ }
227
+ el.style.gridArea = '1 / 1'
228
+ }
229
+ Array.from(stage.children).forEach(place)`
230
+
231
+ /** Chapter geometry: where a chapter is on stage, which children are chapters, how to scroll there. */
232
+ export const chapterHelpersSource = (): string => ` if (chapterHelpersRef.current === null) {
233
+ const chapterProgress = (chapterRoot) => {
234
+ // 1. The author's own snap point ("Snap here" on the timeline) wins.
235
+ const override = parseFloat(chapterRoot.getAttribute('data-chapter-moment') || '')
236
+ if (Number.isFinite(override)) {
237
+ return Math.min(1, Math.max(0, override))
238
+ }
239
+ // 2. The settled moment — everything arrived, nothing leaving — read
240
+ // from the root's and every descendant's lanes.
241
+ const boundLanes = []
242
+ const boundElements = [chapterRoot].concat(
243
+ Array.from(chapterRoot.querySelectorAll('[' + SCROLL_BIND_ATTR + ']'))
244
+ )
245
+ for (const element of boundElements) {
246
+ const lanes = parseScrollBind(element.getAttribute(SCROLL_BIND_ATTR))
247
+ for (const lane of lanes) {
248
+ boundLanes.push(lane)
249
+ }
250
+ }
251
+ const settled = settledMomentForLanes(boundLanes)
252
+ if (settled !== null) {
253
+ return settled
254
+ }
255
+ // 3. Through-motion only (a crossing line): the window's middle.
256
+ const recorded = String(chapterRoot.getAttribute('data-chapter-window') || '')
257
+ .split(',')
258
+ .map((part) => parseFloat(part))
259
+ if (recorded.length === 2 && recorded.every((value) => Number.isFinite(value))) {
260
+ return (recorded[0] + recorded[1]) / 2
261
+ }
262
+ const lanes = parseScrollBind(chapterRoot.getAttribute(SCROLL_BIND_ATTR))
263
+ const stops = lanes.flatMap((lane) => lane.at)
264
+ if (stops.length === 0) {
265
+ return null
266
+ }
267
+ return (Math.min.apply(null, stops) + Math.max.apply(null, stops)) / 2
268
+ }
269
+ chapterHelpersRef.current = {
270
+ stageOf: (track) => track.querySelector(':scope > [data-scene-stage]'),
271
+ chapterProgress,
272
+ // The chapters: the stage's children when the scene pins, the track's own
273
+ // children when it does not. Never the style tag, never a backdrop: the
274
+ // background video, a bare media child (img / video / picture) or any
275
+ // child marked data-scene-backdrop is the scene's background — it sits
276
+ // under the chapters and owns no story window.
277
+ chapterElements: (track) => {
278
+ const stage = chapterHelpersRef.current.stageOf(track)
279
+ const host = stage || track
280
+ return Array.from(host.children).filter(
281
+ (child) =>
282
+ !!child.getAttribute &&
283
+ child.tagName !== 'STYLE' &&
284
+ child.tagName !== 'IMG' &&
285
+ child.tagName !== 'VIDEO' &&
286
+ child.tagName !== 'PICTURE' &&
287
+ !child.hasAttribute('data-scroll-video') &&
288
+ !child.hasAttribute('data-scene-backdrop') &&
289
+ !child.hasAttribute('data-scene-stage')
290
+ )
291
+ },
292
+ chapterMoments: (track) => {
293
+ const moments = []
294
+ for (const child of chapterHelpersRef.current.chapterElements(track)) {
295
+ const progress = chapterProgress(child)
296
+ if (progress !== null) {
297
+ moments.push(Math.min(1, Math.max(0, progress)))
298
+ }
299
+ }
300
+ return moments.sort((a, b) => a - b)
301
+ },
302
+ scrollToProgress: (track, progress, behavior) => {
303
+ const rect = track.getBoundingClientRect()
304
+ const trackTop = rect.top + window.scrollY
305
+ const travel = Math.max(0, track.offsetHeight - window.innerHeight)
306
+ window.scrollTo({
307
+ top: trackTop + Math.min(1, Math.max(0, progress)) * travel,
308
+ behavior,
309
+ })
310
+ },
311
+ }
312
+ }`
313
+
314
+ /** Body of the anchor-navigation effect: a hash that points into a pinned scene scrolls to that chapter. */
315
+ export const anchorNavigationBodySource = (): string => ` const track = trackRef.current
316
+ if (!track || !pin || typeof window === 'undefined') {
317
+ return undefined
318
+ }
319
+ const helpers = chapterHelpersRef.current
320
+ const chapterRootFor = (target) => {
321
+ const stage = helpers.stageOf(track)
322
+ if (!stage || !stage.contains(target)) {
323
+ return null
324
+ }
325
+ let current = target
326
+ while (current && current.parentElement !== stage) {
327
+ current = current.parentElement
328
+ }
329
+ return current
330
+ }
331
+ const scrollToChapter = (id, behavior) => {
332
+ const target = id ? document.getElementById(id) : null
333
+ const chapterRoot = target ? chapterRootFor(target) : null
334
+ if (!chapterRoot) {
335
+ return false
336
+ }
337
+ const progress = helpers.chapterProgress(chapterRoot)
338
+ if (progress === null) {
339
+ return false
340
+ }
341
+ helpers.scrollToProgress(track, progress, behavior)
342
+ return true
343
+ }
344
+ const onClick = (event) => {
345
+ if (event.defaultPrevented || event.button !== 0) {
346
+ return
347
+ }
348
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
349
+ return
350
+ }
351
+ const anchor = event.target && event.target.closest ? event.target.closest('a[href]') : null
352
+ if (!anchor) {
353
+ return
354
+ }
355
+ const href = anchor.getAttribute('href') || ''
356
+ const hashIndex = href.indexOf('#')
357
+ if (hashIndex === -1) {
358
+ return
359
+ }
360
+ const beforeHash = href.slice(0, hashIndex)
361
+ if (beforeHash && beforeHash !== window.location.pathname) {
362
+ return
363
+ }
364
+ const id = decodeURIComponent(href.slice(hashIndex + 1))
365
+ if (scrollToChapter(id, 'smooth')) {
366
+ event.preventDefault()
367
+ try {
368
+ window.history.pushState(null, '', '#' + id)
369
+ } catch (e) {
370
+ // history can refuse in sandboxed frames; the scroll already happened
371
+ }
372
+ }
373
+ }
374
+ const onHashChange = () => {
375
+ scrollToChapter(decodeURIComponent(window.location.hash.slice(1)), 'smooth')
376
+ }
377
+ document.addEventListener('click', onClick, true)
378
+ window.addEventListener('hashchange', onHashChange)
379
+ let settleTimer = 0
380
+ const initialId = decodeURIComponent(window.location.hash.slice(1))
381
+ if (initialId) {
382
+ window.requestAnimationFrame(() => scrollToChapter(initialId, 'auto'))
383
+ // late images/fonts shift the track's document position — one correction
384
+ settleTimer = window.setTimeout(() => scrollToChapter(initialId, 'auto'), 350)
385
+ }
386
+ return () => {
387
+ document.removeEventListener('click', onClick, true)
388
+ window.removeEventListener('hashchange', onHashChange)
389
+ window.clearTimeout(settleTimer)
390
+ }`
391
+
392
+ /** Body of the gentle chapter-snap effect: a visitor who pauses mid-scene settles on the nearest chapter. */
393
+ export const gentleSnapBodySource = (): string => ` const track = trackRef.current
394
+ if (!track || !pin || chapterSnap !== 'gentle' || typeof window === 'undefined') {
395
+ return undefined
396
+ }
397
+ if (shouldReduceMotion) {
398
+ return undefined
399
+ }
400
+ const helpers = chapterHelpersRef.current
401
+ let settleTimer = 0
402
+ let suppressUntil = 0
403
+ let lastProgress = progressRef.current
404
+ let direction = 0
405
+ const settle = () => {
406
+ if (Date.now() < suppressUntil) {
407
+ return
408
+ }
409
+ const progress = progressRef.current
410
+ if (progress <= 0.001 || progress >= 0.999) {
411
+ return
412
+ }
413
+ const moments = helpers.chapterMoments(track)
414
+ if (moments.length === 0) {
415
+ return
416
+ }
417
+ // Settle in the direction the visitor was travelling: a short scroll
418
+ // forward lands on the next chapter, never back on the one just left.
419
+ const ahead = moments.filter((moment) =>
420
+ direction > 0 ? moment >= progress - 0.02 : direction < 0 ? moment <= progress + 0.02 : true
421
+ )
422
+ const candidates = ahead.length > 0 ? ahead : moments
423
+ let nearest = candidates[0]
424
+ for (const moment of candidates) {
425
+ if (Math.abs(moment - progress) < Math.abs(nearest - progress)) {
426
+ nearest = moment
427
+ }
428
+ }
429
+ if (Math.abs(nearest - progress) < 0.005) {
430
+ return
431
+ }
432
+ suppressUntil = Date.now() + 900
433
+ helpers.scrollToProgress(track, nearest, 'smooth')
434
+ }
435
+ const onScroll = () => {
436
+ window.clearTimeout(settleTimer)
437
+ const progress = progressRef.current
438
+ if (progress !== lastProgress) {
439
+ direction = progress > lastProgress ? 1 : -1
440
+ lastProgress = progress
441
+ }
442
+ if (Date.now() < suppressUntil) {
443
+ return
444
+ }
445
+ settleTimer = window.setTimeout(settle, 180)
446
+ }
447
+ window.addEventListener('scroll', onScroll, { passive: true })
448
+ return () => {
449
+ window.removeEventListener('scroll', onScroll)
450
+ window.clearTimeout(settleTimer)
451
+ }`