@lincyaw/addon-motion 0.0.0-stage → 1.0.0-rc.10

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.
@@ -0,0 +1,425 @@
1
+ import {
2
+ NAVIGATION,
3
+ PLAYBACK_CONTROL,
4
+ PmIconButton,
5
+ STEP_CLOCK,
6
+ useAppearance,
7
+ useCursor,
8
+ useMessages,
9
+ useService,
10
+ useStep,
11
+ useStepScope,
12
+ useStepTime,
13
+ useStaticRender,
14
+ useStore,
15
+ useViewRole,
16
+ } from '@lincyaw/host'
17
+ import type { PlaybackLease } from '@lincyaw/host'
18
+ import { errorMessage, reportStaleModule } from '@lincyaw/kernel'
19
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react'
20
+ import type { CSSProperties } from 'react'
21
+ import type { SceneProps } from '../../components/PmScene.tsx'
22
+ import {
23
+ MOTION_MESSAGES,
24
+ stepStarts,
25
+ timelinePosition,
26
+ } from '../domain/index.ts'
27
+ import { loadScene, preloadScenes } from './deck-scenes.ts'
28
+ import type { Scene } from './scene.ts'
29
+ import './scene.css'
30
+
31
+ export default function SceneView(props: SceneProps) {
32
+ const t = useMessages(MOTION_MESSAGES)
33
+ const durations = props.durations?.length ? props.durations : [0]
34
+ const starts = stepStarts(durations)
35
+ const total = starts.at(-1)!
36
+ const scope = useStepScope()
37
+ const hostStep = useStep()
38
+ const cursor = useCursor()
39
+ const viewRole = useViewRole()
40
+ const navigation = useService(NAVIGATION)
41
+ const navigated = useStore(navigation?.position, undefined)
42
+ const clock = useService(STEP_CLOCK)
43
+ const playbackControl = useService(PLAYBACK_CONTROL)
44
+ const printed = useStaticRender()
45
+ const stepTime = useStepTime({ until: Math.max(...durations) })
46
+ const { dark } = useAppearance()
47
+ const expanded = scope.localPreview === true || !Number.isFinite(hostStep)
48
+ const [seen, setSeen] = useState(!expanded || printed)
49
+ const enabled = seen || printed || !expanded
50
+ const [local, setLocal] = useState<number>()
51
+ const [playing, setPlaying] = useState(false)
52
+ const [status, setStatus] = useState<
53
+ 'loading' | 'ready' | 'missing' | 'failed'
54
+ >('loading')
55
+ const [failure, setFailure] = useState('')
56
+ const stage = useRef<HTMLDivElement>(null)
57
+ const instance = useRef<Scene | undefined>(undefined)
58
+ const frame = useRef(0)
59
+ const localTime = useRef<number | undefined>(undefined)
60
+ const previewReplay = useRef<number>()
61
+ const playbackLease = useRef<PlaybackLease>()
62
+ const playbackGeneration = useRef(0)
63
+ const activePlayback = useRef<number>()
64
+ const localNavigation = useRef<{ slide: number, step: number }>()
65
+ const followedNavigation = useRef({ source: navigation, position: navigated })
66
+ const shown =
67
+ expanded && local !== undefined
68
+ ? local
69
+ : timelinePosition(durations, scope.localStepAt(hostStep), stepTime)
70
+ const shownNow = useRef(shown)
71
+ shownNow.current = shown
72
+ const activeLocalStep = scope.localStepAt(hostStep)
73
+ const requestedStart = scope.localPreview && navigated?.boundary === 'start' && Number.isFinite(activeLocalStep) && local === starts[activeLocalStep] ? activeLocalStep : undefined
74
+ const selectedStep = Math.max(
75
+ 0,
76
+ Math.min(
77
+ durations.length - 1,
78
+ expanded && local !== undefined
79
+ ? requestedStart ?? Math.max(
80
+ 0,
81
+ starts.findIndex(
82
+ (start, i) =>
83
+ i < durations.length && local <= start + durations[i]!,
84
+ ),
85
+ )
86
+ : activeLocalStep,
87
+ ),
88
+ )
89
+ // A reader's local controls do not navigate the full document. Resolve their
90
+ // own authored snapshot while retaining the same local animation clock.
91
+ const localState = expanded && !scope.localPreview && local !== undefined ? scope.stateAt(selectedStep) : undefined
92
+ const sceneState = localState ? localState.state : scope.state
93
+ const sceneParameters = localState ? localState.parameters ?? {} : scope.parameters ?? {}
94
+ const stop = () => {
95
+ playbackGeneration.current++
96
+ activePlayback.current = undefined
97
+ playbackLease.current?.cancel()
98
+ localNavigation.current = undefined
99
+ cancelAnimationFrame(frame.current)
100
+ setPlaying(false)
101
+ }
102
+ useEffect(() => {
103
+ const lease = playbackControl?.register(stop)
104
+ playbackLease.current = lease
105
+ return () => {
106
+ if (playbackLease.current === lease) playbackLease.current = undefined
107
+ lease?.dispose()
108
+ }
109
+ }, [playbackControl])
110
+ const seekLocal = (at: number) => {
111
+ stop()
112
+ previewReplay.current = undefined
113
+ localTime.current = at
114
+ setLocal(at)
115
+ }
116
+ const jump = (step: number) => {
117
+ seekLocal(starts[step]! + durations[step]!)
118
+ if (scope.localPreview) previewReplay.current = step
119
+ const target = scope.step(step)
120
+ if (scope.localPreview && target >= 0) navigation?.go({ slide: cursor.slide, step: target })
121
+ }
122
+ const fail = (error: unknown) => {
123
+ stop()
124
+ setStatus('failed')
125
+ setFailure(errorMessage(error))
126
+ console.error(`[presmium.motion] the scene ${props.scene} failed`, error)
127
+ reportStaleModule(error)
128
+ }
129
+ useEffect(() => {
130
+ const before = followedNavigation.current
131
+ followedNavigation.current = { source: navigation, position: navigated }
132
+ if (before.source !== navigation || before.position === navigated) return
133
+ if (!scope.localPreview || !expanded || !navigated || navigated.slide !== cursor.slide) return
134
+ const own = localNavigation.current
135
+ localNavigation.current = undefined
136
+ if (own?.slide === navigated.slide && own.step === navigated.step) return
137
+ const step = scope.localStepAt(navigated.step)
138
+ if (step >= 0 && step < durations.length) {
139
+ seekLocal(starts[step]! + (navigated.boundary === 'start' ? 0 : durations[step]!))
140
+ if (navigated.boundary !== 'start') previewReplay.current = step
141
+ }
142
+ }, [navigation, navigated, expanded, cursor.slide, scope.localPreview, scope.at, props.durations])
143
+ useEffect(() => {
144
+ if (!scope.localPreview || local === undefined || !navigation) return
145
+ const target = scope.step(selectedStep), current = navigation.position.getSnapshot()
146
+ if (target < 0 || (Number.isFinite(hostStep) && current.slide === cursor.slide && current.step === target)) return
147
+ // Use the existing local clock's discrete step boundary. Its own navigation
148
+ // updates semantic state without seeking or restarting that clock.
149
+ localNavigation.current = { slide: cursor.slide, step: target }
150
+ navigation.go(localNavigation.current)
151
+ }, [scope.localPreview, local !== undefined, selectedStep, navigation, cursor.slide, Number.isFinite(hostStep)])
152
+ useEffect(() => {
153
+ const generation = activePlayback.current
154
+ if (!playing || generation === undefined || generation !== playbackGeneration.current) return
155
+ const from =
156
+ localTime.current === undefined || localTime.current >= total
157
+ ? 0
158
+ : localTime.current
159
+ const began = performance.now() - from
160
+ const tick = () => {
161
+ if (activePlayback.current !== generation || playbackGeneration.current !== generation) return
162
+ const at = Math.min(total, performance.now() - began)
163
+ localTime.current = at
164
+ setLocal(at)
165
+ if (at < total) frame.current = requestAnimationFrame(tick)
166
+ else stop()
167
+ }
168
+ frame.current = requestAnimationFrame(tick)
169
+ return () => cancelAnimationFrame(frame.current)
170
+ }, [playing, total])
171
+ useLayoutEffect(() => {
172
+ try {
173
+ instance.current?.seek(shown)
174
+ } catch (error) {
175
+ fail(error)
176
+ const work = Promise.reject(error)
177
+ work.catch(() => {})
178
+ clock?.wait(work)
179
+ }
180
+ }, [shown, clock])
181
+ useEffect(() => {
182
+ if (seen) return
183
+ if (printed || !expanded || typeof IntersectionObserver === 'undefined') {
184
+ setSeen(true)
185
+ return
186
+ }
187
+ const root = stage.current
188
+ if (!root) return
189
+ // Observe the scene's stage against the viewport; after first visibility,
190
+ // retain the instance when reading past it so steps remain reversible.
191
+ const observer = new IntersectionObserver(
192
+ (entries) => {
193
+ if (!entries.some((entry) => entry.isIntersecting)) return
194
+ setSeen(true)
195
+ observer.disconnect()
196
+ },
197
+ { rootMargin: '200px' },
198
+ )
199
+ observer.observe(root)
200
+ return () => observer.disconnect()
201
+ }, [seen, printed, expanded])
202
+ useEffect(() => () => {
203
+ instance.current?.dispose?.()
204
+ instance.current = undefined
205
+ }, [])
206
+ useEffect(() => {
207
+ const root = stage.current
208
+ if (!enabled || !root) return
209
+ let disposed = false
210
+ let generation = 0
211
+ let observer: ResizeObserver | undefined
212
+ let resizeTimer: ReturnType<typeof setTimeout> | undefined
213
+ let dimensions = { width: root.clientWidth, height: root.clientHeight }
214
+ const build = async (version: number) => {
215
+ const current = () => !disposed && version === generation
216
+ const definition = await loadScene(props.scene)
217
+ if (!current()) return
218
+ if (!definition) {
219
+ setStatus('missing')
220
+ throw new Error(`the deck has no scene ${props.scene}`)
221
+ }
222
+ // A rebuild (another step's state, the theme, a resize) is made in a hidden layer while the
223
+ // shown scene keeps following the time; the two swap in one task. Disposed first, the shown
224
+ // scene would paint its authored DOM (a from() tween's end, later steps' elements) for frames.
225
+ const layer = root.appendChild(document.createElement('div'))
226
+ layer.className = 'pm-scene__layer'
227
+ layer.dataset.pending = ''
228
+ const box = { width: root.clientWidth, height: root.clientHeight }
229
+ dimensions = { width: box.width, height: box.height }
230
+ let swatch: CanvasRenderingContext2D | null | undefined
231
+ // A build that draws text on a canvas measures and paints it with the page's fonts, so they load first.
232
+ await document.fonts.ready
233
+ if (!current()) { layer.remove(); return }
234
+ const made = await definition.create({
235
+ root: layer,
236
+ parameters: sceneParameters,
237
+ state: sceneState,
238
+ durations,
239
+ at: (step) => starts[Math.max(0, Math.min(step, durations.length))]!,
240
+ total,
241
+ width: box.width || 640,
242
+ height: box.height || 360,
243
+ color(name) {
244
+ swatch ??= document
245
+ .createElement('canvas')
246
+ .getContext('2d', { willReadFrequently: true })
247
+ const css = getComputedStyle(root)
248
+ .getPropertyValue(`--pm-color-${name}`)
249
+ .trim()
250
+ if (!swatch || !css) return css
251
+ swatch.clearRect(0, 0, 1, 1)
252
+ swatch.fillStyle = css
253
+ swatch.fillRect(0, 0, 1, 1)
254
+ const [r, g, b, a] = swatch.getImageData(0, 0, 1, 1).data
255
+ return a === 255
256
+ ? `rgb(${r}, ${g}, ${b})`
257
+ : `rgba(${r}, ${g}, ${b}, ${Math.round((a! / 255) * 1000) / 1000})`
258
+ },
259
+ }).catch((error: unknown) => {
260
+ layer.remove()
261
+ if (current()) {
262
+ instance.current?.dispose?.()
263
+ instance.current = undefined
264
+ root.replaceChildren()
265
+ }
266
+ throw error
267
+ })
268
+ // The first frame shows the scene's pictures: the video export captures a step as soon as it is ready.
269
+ await Promise.all([...layer.querySelectorAll('img')].map((img) => img.decode().catch(() => undefined)))
270
+ if (!current()) {
271
+ try { made.dispose?.() }
272
+ finally { layer.remove() }
273
+ return
274
+ }
275
+ instance.current?.dispose?.()
276
+ for (const child of [...root.children]) if (child !== layer) child.remove()
277
+ delete layer.dataset.pending
278
+ instance.current = made
279
+ made.seek(shownNow.current)
280
+ setStatus('ready')
281
+ }
282
+ const rebuild = () => {
283
+ const version = ++generation
284
+ const work = build(version).catch((error: unknown) => {
285
+ if (!disposed && version === generation) throw error
286
+ })
287
+ work.catch((error) => {
288
+ if (!disposed && version === generation) fail(error)
289
+ })
290
+ clock?.wait(work)
291
+ }
292
+ preloadScenes()
293
+ rebuild()
294
+ observer = new ResizeObserver(([entry]) => {
295
+ const box = entry!.contentRect
296
+ if (!instance.current || !box.width || !box.height) return
297
+ if (instance.current.resize) {
298
+ instance.current.resize(box.width, box.height)
299
+ instance.current.seek(shownNow.current)
300
+ dimensions = { width: box.width, height: box.height }
301
+ } else if (
302
+ Math.abs(box.width - dimensions.width) > 1 ||
303
+ Math.abs(box.height - dimensions.height) > 1
304
+ ) {
305
+ clearTimeout(resizeTimer)
306
+ resizeTimer = setTimeout(rebuild, 150)
307
+ }
308
+ })
309
+ observer.observe(root)
310
+ return () => {
311
+ disposed = true
312
+ clearTimeout(resizeTimer)
313
+ observer?.disconnect()
314
+ }
315
+ }, [enabled, props.scene, JSON.stringify(durations), JSON.stringify(sceneParameters), sceneState, dark, clock])
316
+ const box: CSSProperties = props.height
317
+ ? { height: props.height }
318
+ : { aspectRatio: (props.aspect ?? '16/9').replace('/', ' / ') }
319
+ const notice =
320
+ status === 'loading'
321
+ ? t.loading
322
+ : status === 'missing'
323
+ ? t.missing(props.scene)
324
+ : status === 'failed'
325
+ ? t.failed(props.scene, failure)
326
+ : undefined
327
+ return (
328
+ <figure
329
+ className="pm-scene"
330
+ data-scene={props.scene}
331
+ data-status={status}
332
+ data-height={props.height ? '' : undefined}
333
+ data-presentation={viewRole !== 'reader' ? '' : undefined}
334
+ data-position={Math.round(shown)}
335
+ >
336
+ <div
337
+ className="pm-scene__frame"
338
+ style={box}
339
+ role="img"
340
+ aria-label={props.alt ?? props.scene}
341
+ >
342
+ <div ref={stage} className="pm-scene__stage" />
343
+ {notice && (
344
+ <p
345
+ className="pm-scene__notice"
346
+ role={status === 'loading' ? undefined : 'alert'}
347
+ >
348
+ {notice}
349
+ </p>
350
+ )}
351
+ </div>
352
+ {expanded && !printed && status === 'ready' && durations.length > 1 && (
353
+ <div
354
+ className="pm-scene__steps"
355
+ role="group"
356
+ aria-label={t.steps}
357
+ onKeyDown={(event) => event.stopPropagation()}
358
+ >
359
+ {durations.map((_, step) => (
360
+ <button
361
+ key={step}
362
+ type="button"
363
+ className="pm-scene__step"
364
+ aria-label={t.jump(step + 1)}
365
+ title={t.jump(step + 1)}
366
+ aria-pressed={selectedStep === step}
367
+ data-scene-jump={scope.step(step)}
368
+ disabled={scope.step(step) < 0}
369
+ onClick={() => jump(step)}
370
+ >
371
+ {step + 1}
372
+ </button>
373
+ ))}
374
+ </div>
375
+ )}
376
+ {expanded && status === 'ready' && total > 0 && (
377
+ <div
378
+ className="pm-scene__controls"
379
+ role="group"
380
+ onKeyDown={(event) => event.stopPropagation()}
381
+ >
382
+ <PmIconButton
383
+ small
384
+ icon={playing ? 'pause' : 'play'}
385
+ label={playing ? t.pause : t.play}
386
+ aria-pressed={playing}
387
+ onClick={() => {
388
+ if (playing) stop()
389
+ else {
390
+ const lease = playbackLease.current, generation = ++playbackGeneration.current
391
+ const start = () => {
392
+ if (generation !== playbackGeneration.current || lease !== playbackLease.current) return
393
+ const replay = previewReplay.current
394
+ previewReplay.current = undefined
395
+ if (scope.localPreview && replay !== undefined && replay < durations.length) {
396
+ localTime.current = starts[replay]!
397
+ setLocal(localTime.current)
398
+ // The exact start is also the previous step's end. Keep
399
+ // the selected step's identity while replaying its entry.
400
+ localNavigation.current = { slide: cursor.slide, step: scope.step(replay) }
401
+ navigation?.go({ ...localNavigation.current, boundary: 'start' })
402
+ }
403
+ activePlayback.current = generation
404
+ setPlaying(true)
405
+ }
406
+ if (lease) void lease.requestStart().then(allowed => { if (allowed) start() }).catch(error => { if (generation === playbackGeneration.current) fail(error) })
407
+ else start()
408
+ }
409
+ }}
410
+ />
411
+ <input
412
+ type="range"
413
+ className="pm-scene__scrub"
414
+ min="0"
415
+ max={total}
416
+ step="1"
417
+ value={Math.round(shown)}
418
+ aria-label={t.scrub}
419
+ onChange={(event) => seekLocal(Number(event.target.value))}
420
+ />
421
+ </div>
422
+ )}
423
+ </figure>
424
+ )
425
+ }
@@ -0,0 +1,27 @@
1
+ import scenes from 'virtual:presmium-motion/scenes'
2
+ import type { SceneDefinition } from './scene.ts'
3
+
4
+ function isScene(value: unknown): value is SceneDefinition {
5
+ return typeof value === 'object' && value !== null && typeof (value as SceneDefinition).create === 'function'
6
+ }
7
+
8
+ let preloading = false
9
+
10
+ /** Starts loading every scene of the deck while the page is idle, so a scene the presenter goes to is drawn at once. */
11
+ export function preloadScenes(): void {
12
+ if (preloading || typeof window === 'undefined') return
13
+ preloading = true
14
+ const idle = window.requestIdleCallback ?? ((run: () => void) => setTimeout(run, 200))
15
+ idle(() => {
16
+ for (const load of Object.values(scenes)) void load().catch(() => undefined)
17
+ })
18
+ }
19
+
20
+ /** The scene `name` of the deck's `scenes/`; undefined when there is no such file. */
21
+ export async function loadScene(name: string): Promise<SceneDefinition | undefined> {
22
+ const load = scenes[name]
23
+ if (!load) return undefined
24
+ const { default: scene } = await load()
25
+ if (!isScene(scene)) throw new Error(`scenes/${name} has no scene as its default export, e.g. export default gsapScene(...)`)
26
+ return scene
27
+ }
@@ -0,0 +1,4 @@
1
+ declare module 'virtual:presmium-motion/scenes' {
2
+ const scenes: Record<string, () => Promise<{ default?: unknown }>>
3
+ export default scenes
4
+ }
@@ -0,0 +1,70 @@
1
+ .pm-scene {
2
+ margin: 0 auto;
3
+ width: 100%;
4
+ max-width: var(--pm-scene-max-width);
5
+ }
6
+ .pm-scene[data-height],
7
+ .pm-scene[data-presentation] {
8
+ max-width: none;
9
+ }
10
+ .pm-scene__frame {
11
+ position: relative;
12
+ width: 100%;
13
+ overflow: hidden;
14
+ border-radius: var(--pm-radius-control);
15
+ }
16
+ .pm-scene__stage {
17
+ position: absolute;
18
+ inset: 0;
19
+ /* A scene can size its layout to the picture with cqw and cqh. */
20
+ container-type: size;
21
+ }
22
+ .pm-scene__layer {
23
+ position: absolute;
24
+ inset: 0;
25
+ /* The scene's root, so its own cqw and cqh are the picture's, as the stage's are. */
26
+ container-type: size;
27
+ }
28
+ /* Laid out at the stage's size, so the scene being made can measure itself. */
29
+ .pm-scene__layer[data-pending] {
30
+ visibility: hidden;
31
+ }
32
+ .pm-scene__notice {
33
+ position: absolute;
34
+ inset: 0;
35
+ display: grid;
36
+ place-items: center;
37
+ margin: 0;
38
+ padding: var(--pm-space-3);
39
+ color: var(--pm-color-muted);
40
+ font-family: var(--pm-font-sans);
41
+ font-size: var(--pm-text-sm);
42
+ text-align: center;
43
+ }
44
+ /* "Loading" shows only when loading takes long: a quick load goes from nothing to the scene. */
45
+ .pm-scene[data-status='loading'] .pm-scene__notice {
46
+ opacity: 0;
47
+ animation: pm-scene-notice var(--pm-duration-fast) linear var(--pm-delay-notice) forwards;
48
+ }
49
+ @keyframes pm-scene-notice {
50
+ to { opacity: 1; }
51
+ }
52
+ .pm-scene__controls {
53
+ display: flex;
54
+ align-items: center;
55
+ gap: var(--pm-space-2);
56
+ margin-top: var(--pm-space-2);
57
+ }
58
+ .pm-scene__scrub {
59
+ flex: 1;
60
+ accent-color: var(--pm-color-accent);
61
+ }
62
+ .pm-scene__steps { display: flex; gap: var(--pm-space-1); overflow-x: auto; padding-block: var(--pm-space-1); }
63
+ .pm-scene__step { flex: none; min-width: var(--pm-size-tool); min-height: var(--pm-size-tool); padding: 0 var(--pm-space-1); border: 0; border-radius: var(--pm-radius-chip); background: transparent; color: var(--pm-color-muted); font-family: var(--pm-font-mono); font-size: var(--pm-text-xs); cursor: pointer; }
64
+ .pm-scene__step[aria-pressed='true'], .pm-scene__step:hover, .pm-scene__step:focus-visible { background: var(--pm-color-accent-soft); color: var(--pm-color-accent); }
65
+ html.pm-lecture-exporting .pm-scene__steps { display: none; }
66
+ html.pm-lecture-exporting .pm-scene__frame { border-radius: 0; }
67
+ @media (pointer: coarse) {
68
+ .pm-scene__step { min-width: var(--pm-control-hit-area); min-height: var(--pm-control-hit-area); }
69
+ .pm-scene__scrub { min-height: var(--pm-control-hit-area); }
70
+ }