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

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,424 @@
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 } 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
+ }
128
+ useEffect(() => {
129
+ const before = followedNavigation.current
130
+ followedNavigation.current = { source: navigation, position: navigated }
131
+ if (before.source !== navigation || before.position === navigated) return
132
+ if (!scope.localPreview || !expanded || !navigated || navigated.slide !== cursor.slide) return
133
+ const own = localNavigation.current
134
+ localNavigation.current = undefined
135
+ if (own?.slide === navigated.slide && own.step === navigated.step) return
136
+ const step = scope.localStepAt(navigated.step)
137
+ if (step >= 0 && step < durations.length) {
138
+ seekLocal(starts[step]! + (navigated.boundary === 'start' ? 0 : durations[step]!))
139
+ if (navigated.boundary !== 'start') previewReplay.current = step
140
+ }
141
+ }, [navigation, navigated, expanded, cursor.slide, scope.localPreview, scope.at, props.durations])
142
+ useEffect(() => {
143
+ if (!scope.localPreview || local === undefined || !navigation) return
144
+ const target = scope.step(selectedStep), current = navigation.position.getSnapshot()
145
+ if (target < 0 || (Number.isFinite(hostStep) && current.slide === cursor.slide && current.step === target)) return
146
+ // Use the existing local clock's discrete step boundary. Its own navigation
147
+ // updates semantic state without seeking or restarting that clock.
148
+ localNavigation.current = { slide: cursor.slide, step: target }
149
+ navigation.go(localNavigation.current)
150
+ }, [scope.localPreview, local !== undefined, selectedStep, navigation, cursor.slide, Number.isFinite(hostStep)])
151
+ useEffect(() => {
152
+ const generation = activePlayback.current
153
+ if (!playing || generation === undefined || generation !== playbackGeneration.current) return
154
+ const from =
155
+ localTime.current === undefined || localTime.current >= total
156
+ ? 0
157
+ : localTime.current
158
+ const began = performance.now() - from
159
+ const tick = () => {
160
+ if (activePlayback.current !== generation || playbackGeneration.current !== generation) return
161
+ const at = Math.min(total, performance.now() - began)
162
+ localTime.current = at
163
+ setLocal(at)
164
+ if (at < total) frame.current = requestAnimationFrame(tick)
165
+ else stop()
166
+ }
167
+ frame.current = requestAnimationFrame(tick)
168
+ return () => cancelAnimationFrame(frame.current)
169
+ }, [playing, total])
170
+ useLayoutEffect(() => {
171
+ try {
172
+ instance.current?.seek(shown)
173
+ } catch (error) {
174
+ fail(error)
175
+ const work = Promise.reject(error)
176
+ work.catch(() => {})
177
+ clock?.wait(work)
178
+ }
179
+ }, [shown, clock])
180
+ useEffect(() => {
181
+ if (seen) return
182
+ if (printed || !expanded || typeof IntersectionObserver === 'undefined') {
183
+ setSeen(true)
184
+ return
185
+ }
186
+ const root = stage.current
187
+ if (!root) return
188
+ // Observe the scene's stage against the viewport; after first visibility,
189
+ // retain the instance when reading past it so steps remain reversible.
190
+ const observer = new IntersectionObserver(
191
+ (entries) => {
192
+ if (!entries.some((entry) => entry.isIntersecting)) return
193
+ setSeen(true)
194
+ observer.disconnect()
195
+ },
196
+ { rootMargin: '200px' },
197
+ )
198
+ observer.observe(root)
199
+ return () => observer.disconnect()
200
+ }, [seen, printed, expanded])
201
+ useEffect(() => () => {
202
+ instance.current?.dispose?.()
203
+ instance.current = undefined
204
+ }, [])
205
+ useEffect(() => {
206
+ const root = stage.current
207
+ if (!enabled || !root) return
208
+ let disposed = false
209
+ let generation = 0
210
+ let observer: ResizeObserver | undefined
211
+ let resizeTimer: ReturnType<typeof setTimeout> | undefined
212
+ let dimensions = { width: root.clientWidth, height: root.clientHeight }
213
+ const build = async (version: number) => {
214
+ const current = () => !disposed && version === generation
215
+ const definition = await loadScene(props.scene)
216
+ if (!current()) return
217
+ if (!definition) {
218
+ setStatus('missing')
219
+ throw new Error(`the deck has no scene ${props.scene}`)
220
+ }
221
+ // A rebuild (another step's state, the theme, a resize) is made in a hidden layer while the
222
+ // shown scene keeps following the time; the two swap in one task. Disposed first, the shown
223
+ // scene would paint its authored DOM (a from() tween's end, later steps' elements) for frames.
224
+ const layer = root.appendChild(document.createElement('div'))
225
+ layer.className = 'pm-scene__layer'
226
+ layer.dataset.pending = ''
227
+ const box = { width: root.clientWidth, height: root.clientHeight }
228
+ dimensions = { width: box.width, height: box.height }
229
+ let swatch: CanvasRenderingContext2D | null | undefined
230
+ // A build that draws text on a canvas measures and paints it with the page's fonts, so they load first.
231
+ await document.fonts.ready
232
+ if (!current()) { layer.remove(); return }
233
+ const made = await definition.create({
234
+ root: layer,
235
+ parameters: sceneParameters,
236
+ state: sceneState,
237
+ durations,
238
+ at: (step) => starts[Math.max(0, Math.min(step, durations.length))]!,
239
+ total,
240
+ width: box.width || 640,
241
+ height: box.height || 360,
242
+ color(name) {
243
+ swatch ??= document
244
+ .createElement('canvas')
245
+ .getContext('2d', { willReadFrequently: true })
246
+ const css = getComputedStyle(root)
247
+ .getPropertyValue(`--pm-color-${name}`)
248
+ .trim()
249
+ if (!swatch || !css) return css
250
+ swatch.clearRect(0, 0, 1, 1)
251
+ swatch.fillStyle = css
252
+ swatch.fillRect(0, 0, 1, 1)
253
+ const [r, g, b, a] = swatch.getImageData(0, 0, 1, 1).data
254
+ return a === 255
255
+ ? `rgb(${r}, ${g}, ${b})`
256
+ : `rgba(${r}, ${g}, ${b}, ${Math.round((a! / 255) * 1000) / 1000})`
257
+ },
258
+ }).catch((error: unknown) => {
259
+ layer.remove()
260
+ if (current()) {
261
+ instance.current?.dispose?.()
262
+ instance.current = undefined
263
+ root.replaceChildren()
264
+ }
265
+ throw error
266
+ })
267
+ // The first frame shows the scene's pictures: the video export captures a step as soon as it is ready.
268
+ await Promise.all([...layer.querySelectorAll('img')].map((img) => img.decode().catch(() => undefined)))
269
+ if (!current()) {
270
+ try { made.dispose?.() }
271
+ finally { layer.remove() }
272
+ return
273
+ }
274
+ instance.current?.dispose?.()
275
+ for (const child of [...root.children]) if (child !== layer) child.remove()
276
+ delete layer.dataset.pending
277
+ instance.current = made
278
+ made.seek(shownNow.current)
279
+ setStatus('ready')
280
+ }
281
+ const rebuild = () => {
282
+ const version = ++generation
283
+ const work = build(version).catch((error: unknown) => {
284
+ if (!disposed && version === generation) throw error
285
+ })
286
+ work.catch((error) => {
287
+ if (!disposed && version === generation) fail(error)
288
+ })
289
+ clock?.wait(work)
290
+ }
291
+ preloadScenes()
292
+ rebuild()
293
+ observer = new ResizeObserver(([entry]) => {
294
+ const box = entry!.contentRect
295
+ if (!instance.current || !box.width || !box.height) return
296
+ if (instance.current.resize) {
297
+ instance.current.resize(box.width, box.height)
298
+ instance.current.seek(shownNow.current)
299
+ dimensions = { width: box.width, height: box.height }
300
+ } else if (
301
+ Math.abs(box.width - dimensions.width) > 1 ||
302
+ Math.abs(box.height - dimensions.height) > 1
303
+ ) {
304
+ clearTimeout(resizeTimer)
305
+ resizeTimer = setTimeout(rebuild, 150)
306
+ }
307
+ })
308
+ observer.observe(root)
309
+ return () => {
310
+ disposed = true
311
+ clearTimeout(resizeTimer)
312
+ observer?.disconnect()
313
+ }
314
+ }, [enabled, props.scene, JSON.stringify(durations), JSON.stringify(sceneParameters), sceneState, dark, clock])
315
+ const box: CSSProperties = props.height
316
+ ? { height: props.height }
317
+ : { aspectRatio: (props.aspect ?? '16/9').replace('/', ' / ') }
318
+ const notice =
319
+ status === 'loading'
320
+ ? t.loading
321
+ : status === 'missing'
322
+ ? t.missing(props.scene)
323
+ : status === 'failed'
324
+ ? t.failed(props.scene, failure)
325
+ : undefined
326
+ return (
327
+ <figure
328
+ className="pm-scene"
329
+ data-scene={props.scene}
330
+ data-status={status}
331
+ data-height={props.height ? '' : undefined}
332
+ data-presentation={viewRole !== 'reader' ? '' : undefined}
333
+ data-position={Math.round(shown)}
334
+ >
335
+ <div
336
+ className="pm-scene__frame"
337
+ style={box}
338
+ role="img"
339
+ aria-label={props.alt ?? props.scene}
340
+ >
341
+ <div ref={stage} className="pm-scene__stage" />
342
+ {notice && (
343
+ <p
344
+ className="pm-scene__notice"
345
+ role={status === 'loading' ? undefined : 'alert'}
346
+ >
347
+ {notice}
348
+ </p>
349
+ )}
350
+ </div>
351
+ {expanded && !printed && status === 'ready' && durations.length > 1 && (
352
+ <div
353
+ className="pm-scene__steps"
354
+ role="group"
355
+ aria-label={t.steps}
356
+ onKeyDown={(event) => event.stopPropagation()}
357
+ >
358
+ {durations.map((_, step) => (
359
+ <button
360
+ key={step}
361
+ type="button"
362
+ className="pm-scene__step"
363
+ aria-label={t.jump(step + 1)}
364
+ title={t.jump(step + 1)}
365
+ aria-pressed={selectedStep === step}
366
+ data-scene-jump={scope.step(step)}
367
+ disabled={scope.step(step) < 0}
368
+ onClick={() => jump(step)}
369
+ >
370
+ {step + 1}
371
+ </button>
372
+ ))}
373
+ </div>
374
+ )}
375
+ {expanded && status === 'ready' && total > 0 && (
376
+ <div
377
+ className="pm-scene__controls"
378
+ role="group"
379
+ onKeyDown={(event) => event.stopPropagation()}
380
+ >
381
+ <PmIconButton
382
+ small
383
+ icon={playing ? 'pause' : 'play'}
384
+ label={playing ? t.pause : t.play}
385
+ aria-pressed={playing}
386
+ onClick={() => {
387
+ if (playing) stop()
388
+ else {
389
+ const lease = playbackLease.current, generation = ++playbackGeneration.current
390
+ const start = () => {
391
+ if (generation !== playbackGeneration.current || lease !== playbackLease.current) return
392
+ const replay = previewReplay.current
393
+ previewReplay.current = undefined
394
+ if (scope.localPreview && replay !== undefined && replay < durations.length) {
395
+ localTime.current = starts[replay]!
396
+ setLocal(localTime.current)
397
+ // The exact start is also the previous step's end. Keep
398
+ // the selected step's identity while replaying its entry.
399
+ localNavigation.current = { slide: cursor.slide, step: scope.step(replay) }
400
+ navigation?.go({ ...localNavigation.current, boundary: 'start' })
401
+ }
402
+ activePlayback.current = generation
403
+ setPlaying(true)
404
+ }
405
+ if (lease) void lease.requestStart().then(allowed => { if (allowed) start() }).catch(error => { if (generation === playbackGeneration.current) fail(error) })
406
+ else start()
407
+ }
408
+ }}
409
+ />
410
+ <input
411
+ type="range"
412
+ className="pm-scene__scrub"
413
+ min="0"
414
+ max={total}
415
+ step="1"
416
+ value={Math.round(shown)}
417
+ aria-label={t.scrub}
418
+ onChange={(event) => seekLocal(Number(event.target.value))}
419
+ />
420
+ </div>
421
+ )}
422
+ </figure>
423
+ )
424
+ }
@@ -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,62 @@
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
+ .pm-scene__controls {
45
+ display: flex;
46
+ align-items: center;
47
+ gap: var(--pm-space-2);
48
+ margin-top: var(--pm-space-2);
49
+ }
50
+ .pm-scene__scrub {
51
+ flex: 1;
52
+ accent-color: var(--pm-color-accent);
53
+ }
54
+ .pm-scene__steps { display: flex; gap: var(--pm-space-1); overflow-x: auto; padding-block: var(--pm-space-1); }
55
+ .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; }
56
+ .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); }
57
+ html.pm-lecture-exporting .pm-scene__steps { display: none; }
58
+ html.pm-lecture-exporting .pm-scene__frame { border-radius: 0; }
59
+ @media (pointer: coarse) {
60
+ .pm-scene__step { min-width: var(--pm-control-hit-area); min-height: var(--pm-control-hit-area); }
61
+ .pm-scene__scrub { min-height: var(--pm-control-hit-area); }
62
+ }