@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.
- package/LICENSE +202 -0
- package/README.md +29 -2
- package/components/PmScene.tsx +18 -0
- package/package.json +61 -3
- package/src/client/SceneView.tsx +424 -0
- package/src/client/deck-scenes.ts +27 -0
- package/src/client/env.d.ts +4 -0
- package/src/client/scene.css +62 -0
- package/src/client/scene.ts +317 -0
- package/src/domain/index.ts +32 -0
- package/src/domain/messages.ts +35 -0
- package/src/node/presmium.ts +83 -0
- package/src/node/scene-starter.txt +6 -0
- package/src/node/scenes.ts +40 -0
|
@@ -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,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
|
+
}
|