@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,317 @@
1
+ import type * as AnimeModule from 'animejs'
2
+ import type * as D3Module from 'd3'
3
+ import type { gsap as Gsap } from 'gsap'
4
+ import type * as MotionModule from 'motion'
5
+ import type * as ReactModule from 'react'
6
+ import type * as RemotionModule from 'remotion'
7
+ import type * as ThreeModule from 'three'
8
+
9
+ /** What a scene gets to draw itself. */
10
+ export interface SceneContext {
11
+ /** Complete parameters and semantic state resolved for the authored Step ID. */
12
+ parameters: Readonly<Record<string, unknown>>
13
+ state?: string
14
+ /** The element the scene draws in; the scene owns its children. */
15
+ root: HTMLElement
16
+ /** The length of each step of the scene (ms), from `:durations:`. */
17
+ durations: readonly number[]
18
+ /** Where step `k` starts on the scene's timeline (ms). */
19
+ at(step: number): number
20
+ /** The length of the timeline (ms). */
21
+ total: number
22
+ /** The layout size of `root` in CSS pixels, before any ancestor's presentation scaling. */
23
+ width: number
24
+ height: number
25
+ /** A color of the design system as the page shows it now, as `rgb()` or `rgba()`: `color('accent')` is `--pm-color-accent`. */
26
+ color(name: string): string
27
+ }
28
+
29
+ /** A scene drawn at a place on its timeline. Its look is a function of that place only. */
30
+ export interface Scene {
31
+ /** Shows the scene at `ms` on its timeline. */
32
+ seek(ms: number): void
33
+ /** `root` changed size (the scene is shown again at its place afterwards). */
34
+ resize?(width: number, height: number): void
35
+ dispose?(): void
36
+ }
37
+
38
+ export interface SceneDefinition {
39
+ engine: string
40
+ create(ctx: SceneContext): Promise<Scene>
41
+ }
42
+
43
+ type Made = Scene | ((ms: number) => void)
44
+
45
+ function scene(made: Made): Scene {
46
+ return typeof made === 'function' ? { seek: made } : made
47
+ }
48
+
49
+ /** A scene of its own code: `create` draws in `ctx.root` and returns the function of the time (or a `Scene`). */
50
+ export function defineScene(create: (ctx: SceneContext) => Made | Promise<Made>): SceneDefinition {
51
+ return { engine: 'custom', create: async ctx => scene(await create(ctx)) }
52
+ }
53
+
54
+ type GsapAnimation = ReturnType<typeof Gsap.timeline> | ReturnType<typeof Gsap.to>
55
+
56
+ /** GSAP's plugins that draw the same at the same time; ScrambleText (random each render) and the scroll and drag ones are left out. */
57
+ async function gsapPlugins(gsap: typeof Gsap) {
58
+ // Each plugin registers itself with `window.gsap` when it loads. GSAP sets that global only when nothing holds the
59
+ // name, and an element with the id `gsap` (a section labelled `(gsap)=`) does, so the plugins would find the element.
60
+ const page = window as unknown as { gsap?: unknown }
61
+ if (page.gsap !== gsap) page.gsap = gsap
62
+ const [{ SplitText }, { TextPlugin }, { DrawSVGPlugin }, { MorphSVGPlugin }, { MotionPathPlugin }, { CustomEase }, { CustomBounce }, { CustomWiggle }, { Physics2DPlugin }, { PhysicsPropsPlugin }, { Flip }] = await Promise.all([
63
+ import('gsap/SplitText'),
64
+ import('gsap/TextPlugin'),
65
+ import('gsap/DrawSVGPlugin'),
66
+ import('gsap/MorphSVGPlugin'),
67
+ import('gsap/MotionPathPlugin'),
68
+ import('gsap/CustomEase'),
69
+ import('gsap/CustomBounce'),
70
+ import('gsap/CustomWiggle'),
71
+ import('gsap/Physics2DPlugin'),
72
+ import('gsap/PhysicsPropsPlugin'),
73
+ import('gsap/Flip'),
74
+ ])
75
+ return { SplitText, TextPlugin, DrawSVGPlugin, MorphSVGPlugin, MotionPathPlugin, CustomEase, CustomBounce, CustomWiggle, Physics2DPlugin, PhysicsPropsPlugin, Flip }
76
+ }
77
+
78
+ export type GsapPlugins = Awaited<ReturnType<typeof gsapPlugins>>
79
+
80
+ /**
81
+ * A GSAP timeline (or tween), built in a `gsap.context` scoped to `ctx.root`, so selectors find the
82
+ * scene's elements and disposing reverts them. Times are seconds, as in GSAP: `ctx.at(k) / 1000`.
83
+ * `plugins` are registered before `build`: tweens can use `drawSVG`, `morphSVG`, `motionPath`, `text`,
84
+ * `physics2D`, `physicsProps`, and the scene can call `plugins.SplitText.create`, `plugins.CustomEase.create`.
85
+ */
86
+ export function gsapScene(build: (ctx: SceneContext & { gsap: typeof Gsap, plugins: GsapPlugins }) => GsapAnimation): SceneDefinition {
87
+ return {
88
+ engine: 'gsap',
89
+ async create(ctx) {
90
+ const { gsap } = await import('gsap')
91
+ const plugins = await gsapPlugins(gsap)
92
+ // A tweened box on its own 3D layer is drawn a little differently from the same box at rest, so a frame would
93
+ // depend on how it was reached; scenes keep 2D transforms.
94
+ gsap.config({ force3D: false })
95
+ gsap.registerPlugin(...Object.values(plugins))
96
+ let animation: GsapAnimation | undefined
97
+ const context = gsap.context(() => {}, ctx.root)
98
+ try {
99
+ context.add(() => {
100
+ animation = build({ ...ctx, gsap, plugins })
101
+ })
102
+ animation?.pause()
103
+ return {
104
+ seek: ms => animation?.totalTime(ms / 1000, true),
105
+ dispose: () => context.revert(),
106
+ }
107
+ } catch (error) {
108
+ context.revert()
109
+ throw error
110
+ }
111
+ },
112
+ }
113
+ }
114
+
115
+ type AnimeAnimation = ReturnType<typeof AnimeModule.createTimeline> | ReturnType<typeof AnimeModule.animate>
116
+
117
+ /** An Anime.js timeline (or animation), made in a scope rooted at `ctx.root`. Times are milliseconds. */
118
+ export function animeScene(build: (ctx: SceneContext & { anime: typeof AnimeModule }) => AnimeAnimation): SceneDefinition {
119
+ return {
120
+ engine: 'anime',
121
+ async create(ctx) {
122
+ const anime = await import('animejs')
123
+ const scope = anime.createScope({ root: ctx.root })
124
+ const animation = scope.execute(() => build({ ...ctx, anime }))
125
+ animation.pause()
126
+ // Until a tween has been reached once it shows the element's own value, not its from-value: going to the end and
127
+ // back sets them all up, so a time looks the same whatever was shown before it.
128
+ animation.seek(animation.duration, true)
129
+ animation.seek(0, true)
130
+ return {
131
+ // From the start each time: going back past a later tween that gives its own from-value does not restore
132
+ // the value before it, so a time is reached the same way whichever time was shown before.
133
+ seek(ms) {
134
+ animation.seek(0, true)
135
+ animation.seek(ms, true)
136
+ },
137
+ dispose: () => scope.revert(),
138
+ }
139
+ },
140
+ }
141
+ }
142
+
143
+ type MotionControls = ReturnType<typeof MotionModule.animate>
144
+
145
+ /** A Motion sequence, to type the segments a scene builds. */
146
+ export type MotionSequence = MotionModule.AnimationSequence
147
+
148
+ /** Motion animations (`animate`, a sequence, or several); Motion counts seconds, so the scene sets `time` to `ms / 1000`. */
149
+ export function motionScene(build: (ctx: SceneContext & { motion: typeof MotionModule }) => MotionControls | MotionControls[]): SceneDefinition {
150
+ return {
151
+ engine: 'motion',
152
+ async create(ctx) {
153
+ const motion = await import('motion')
154
+ const controls = [build({ ...ctx, motion })].flat()
155
+ for (const control of controls) control.pause()
156
+ return {
157
+ seek(ms) {
158
+ for (const control of controls) control.time = ms / 1000
159
+ // Only what the browser animates (opacity, background color, filter, clip-path) shows at once; transforms,
160
+ // SVG, CSS variables and function segments wait for Motion's next frame, so it is drawn now.
161
+ for (const step of ['update', 'preRender', 'render'] as const) motion.frameSteps[step].process(motion.frameData)
162
+ },
163
+ dispose() {
164
+ for (const control of controls) control.cancel()
165
+ },
166
+ }
167
+ },
168
+ }
169
+ }
170
+
171
+ export interface ThreeView {
172
+ /** Draws the scene at `ms` on the timeline (`renderer.render(scene, camera)`). */
173
+ render(ms: number): void
174
+ resize?(width: number, height: number): void
175
+ dispose?(): void
176
+ }
177
+
178
+ /** The three.js addons a scene can use: HTML labels, lines wider than a pixel, and geometries the core lacks. */
179
+ async function threeAddons() {
180
+ const [{ CSS2DRenderer, CSS2DObject }, { Line2 }, { LineGeometry }, { LineMaterial }, { ParametricGeometry }, { RoundedBoxGeometry }, { ConvexGeometry }] = await Promise.all([
181
+ import('three/addons/renderers/CSS2DRenderer.js'),
182
+ import('three/addons/lines/Line2.js'),
183
+ import('three/addons/lines/LineGeometry.js'),
184
+ import('three/addons/lines/LineMaterial.js'),
185
+ import('three/addons/geometries/ParametricGeometry.js'),
186
+ import('three/addons/geometries/RoundedBoxGeometry.js'),
187
+ import('three/addons/geometries/ConvexGeometry.js'),
188
+ ])
189
+ return { CSS2DRenderer, CSS2DObject, Line2, LineGeometry, LineMaterial, ParametricGeometry, RoundedBoxGeometry, ConvexGeometry }
190
+ }
191
+
192
+ export type ThreeAddons = Awaited<ReturnType<typeof threeAddons>>
193
+
194
+ /**
195
+ * A Three.js scene: the adapter keeps logical dimensions for the scene and a drawing buffer matched
196
+ * to `root`'s displayed pixels, including presentation scaling; `build` returns (or resolves to) how to draw it at a time.
197
+ */
198
+ export function threeScene(build: (ctx: SceneContext & { THREE: typeof ThreeModule, renderer: ThreeModule.WebGLRenderer, addons: ThreeAddons }) => ThreeView | ThreeView['render'] | Promise<ThreeView | ThreeView['render']>): SceneDefinition {
199
+ return {
200
+ engine: 'three',
201
+ async create(ctx) {
202
+ const [THREE, addons] = await Promise.all([import('three'), threeAddons()])
203
+ // The drawing buffer is kept so a screenshot of a frame (the lecture's video) shows it.
204
+ const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, preserveDrawingBuffer: true })
205
+ Object.assign(renderer.domElement.style, { width: '100%', height: '100%', display: 'block' })
206
+ ctx.root.append(renderer.domElement)
207
+ let width = ctx.width, height = ctx.height
208
+ let bufferWidth = 0, bufferHeight = 0, sizedWidth = 0, sizedHeight = 0
209
+ const gl = renderer.getContext()
210
+ const maxSize = Math.min(renderer.capabilities.maxTextureSize, Number(gl.getParameter(gl.MAX_RENDERBUFFER_SIZE)))
211
+ const syncBuffer = () => {
212
+ const box = ctx.root.getBoundingClientRect()
213
+ const dpr = window.devicePixelRatio || 1
214
+ // Hidden or newly mounted roots can have no height yet. The next seek measures them again.
215
+ const scale = box.width > 0 && box.height > 0 ? Math.max(box.width / width, box.height / height) : 1
216
+ // Scale down for a smaller presentation too; only the GPU's size limit caps magnification.
217
+ const ratio = Math.max(1 / Math.min(width, height), Math.min(dpr * scale, maxSize / width, maxSize / height))
218
+ const nextWidth = Math.floor(width * ratio), nextHeight = Math.floor(height * ratio)
219
+ if (nextWidth === bufferWidth && nextHeight === bufferHeight && width === sizedWidth && height === sizedHeight) return false
220
+ renderer.setDrawingBufferSize(width, height, ratio)
221
+ bufferWidth = nextWidth; bufferHeight = nextHeight
222
+ sizedWidth = width; sizedHeight = height
223
+ return true
224
+ }
225
+ syncBuffer()
226
+ // An async build can load its textures (`new THREE.TextureLoader().loadAsync(url)`) before the first frame.
227
+ const made = await build({ ...ctx, THREE, renderer, addons })
228
+ const view: ThreeView = typeof made === 'function' ? { render: made } : made
229
+ return {
230
+ seek(ms) {
231
+ // Ancestor transforms change displayed pixels without triggering a layout ResizeObserver.
232
+ if (syncBuffer()) view.resize?.(width, height)
233
+ view.render(ms)
234
+ },
235
+ resize(w, h) {
236
+ if (!(w > 0 && h > 0)) return
237
+ width = w; height = h
238
+ syncBuffer()
239
+ view.resize?.(width, height)
240
+ },
241
+ dispose() {
242
+ view.dispose?.()
243
+ renderer.dispose()
244
+ renderer.forceContextLoss()
245
+ renderer.domElement.remove()
246
+ },
247
+ }
248
+ },
249
+ }
250
+ }
251
+
252
+ /**
253
+ * A D3 scene: D3 turns the data into shapes (scales, axes, layouts, shape generators), and the scene draws them at
254
+ * a time with D3's interpolators and eases. `build` returns the drawing function `(ms) => void` (or a `Scene`);
255
+ * transitions run on D3's own timer, so they have no place here.
256
+ */
257
+ export function d3Scene(build: (ctx: SceneContext & { d3: typeof D3Module }) => Made | Promise<Made>): SceneDefinition {
258
+ return {
259
+ engine: 'd3',
260
+ async create(ctx) {
261
+ const d3 = await import('d3')
262
+ return scene(await build({ ...ctx, d3 }))
263
+ },
264
+ }
265
+ }
266
+
267
+ export interface RemotionComposition {
268
+ component: ReactModule.ComponentType<Record<string, unknown>>
269
+ fps: number
270
+ width: number
271
+ height: number
272
+ /** Default: the scene's timeline at `fps`. */
273
+ durationInFrames?: number
274
+ inputProps?: Record<string, unknown>
275
+ }
276
+
277
+ /**
278
+ * A Remotion composition, drawn frame by frame with the player's `Thumbnail`: the scene's place on
279
+ * its timeline picks the frame. `build` gets React and Remotion to write the component with
280
+ * `React.createElement`.
281
+ */
282
+ export function remotionScene(build: (ctx: SceneContext & { React: typeof ReactModule, remotion: typeof RemotionModule }) => RemotionComposition | Promise<RemotionComposition>): SceneDefinition {
283
+ return {
284
+ engine: 'remotion',
285
+ async create(ctx) {
286
+ const [React, { flushSync }, { createRoot }, remotion, { Thumbnail }] = await Promise.all([
287
+ import('react'),
288
+ import('react-dom'),
289
+ import('react-dom/client'),
290
+ import('remotion'),
291
+ import('@remotion/player'),
292
+ ])
293
+ const composition = await build({ ...ctx, React, remotion })
294
+ const frames = composition.durationInFrames ?? Math.max(1, Math.ceil(ctx.total * composition.fps / 1000))
295
+ const root = createRoot(ctx.root)
296
+ return {
297
+ seek(ms) {
298
+ // Half a millisecond of slack: the lecture export asks for whole milliseconds (frame 1 at 30 fps is
299
+ // 33 ms, not 33.3), which would otherwise show the frame before, every third frame.
300
+ const frame = Math.min(frames - 1, Math.max(0, Math.floor((ms + 0.5) * composition.fps / 1000)))
301
+ flushSync(() => root.render(React.createElement(Thumbnail, {
302
+ component: composition.component,
303
+ compositionWidth: composition.width,
304
+ compositionHeight: composition.height,
305
+ durationInFrames: frames,
306
+ fps: composition.fps,
307
+ frameToDisplay: frame,
308
+ inputProps: composition.inputProps ?? {},
309
+ noSuspense: true,
310
+ style: { width: '100%', height: '100%' },
311
+ })))
312
+ },
313
+ dispose: () => root.unmount(),
314
+ }
315
+ },
316
+ }
317
+ }
@@ -0,0 +1,32 @@
1
+ export const MOTION_ID = 'presmium.motion'
2
+
3
+ /** The directory of a deck's scenes, as `components/` holds its components. */
4
+ export const SCENES_DIR = 'scenes'
5
+
6
+ // Not .tsx nor .jsx: the file does not tell which runtime its JSX is for (React's, Vue's).
7
+ export const SCENE_EXTENSIONS = ['.ts', '.js', '.mjs'] as const
8
+
9
+ /** The scene names a deck can use: the path under `scenes/` without its extension, `/` between folders. */
10
+ export const SCENE_NAME = /^[\w-]+(?:\/[\w-]+)*$/
11
+
12
+ /** Where each step starts on a scene's timeline (ms), and its end. */
13
+ export function stepStarts(durations: readonly number[]): number[] {
14
+ const starts = [0]
15
+ for (const ms of durations) starts.push(starts.at(-1)! + ms)
16
+ return starts
17
+ }
18
+
19
+ /**
20
+ * The scene's place on its timeline (ms) at block step `step` (from 0), `time` ms into it: step k
21
+ * plays the k-th part of the timeline, `durations[k]` long. Before the block's first step the scene
22
+ * is at its start; after the last, at its end; `time` is `Infinity` when the step is settled.
23
+ */
24
+ export function timelinePosition(durations: readonly number[], step: number, time: number): number {
25
+ const starts = stepStarts(durations)
26
+ if (step < 0) return 0
27
+ if (step >= durations.length) return starts.at(-1)!
28
+ return starts[step]! + Math.min(Math.max(0, time), durations[step]!)
29
+ }
30
+
31
+ export { MOTION_MESSAGES } from './messages.ts'
32
+ export type { MotionMessages } from './messages.ts'
@@ -0,0 +1,35 @@
1
+ import type { Locale } from '@lincyaw/kernel/contracts'
2
+
3
+ export interface MotionMessages {
4
+ steps: string
5
+ jump(step: number): string
6
+ loading: string
7
+ missing(scene: string): string
8
+ failed(scene: string, reason: string): string
9
+ play: string
10
+ pause: string
11
+ scrub: string
12
+ }
13
+
14
+ export const MOTION_MESSAGES: Record<Locale, MotionMessages> = {
15
+ 'zh-CN': {
16
+ steps: '动画步骤',
17
+ jump: step => `跳到动画第 ${step} 步`,
18
+ loading: '正在加载动画…',
19
+ missing: scene => `文稿的 scenes/ 中没有动画 ${scene}`,
20
+ failed: (scene, reason) => `无法播放动画 ${scene}:${reason}`,
21
+ play: '播放动画',
22
+ pause: '暂停动画',
23
+ scrub: '动画进度',
24
+ },
25
+ en: {
26
+ steps: 'Animation steps',
27
+ jump: step => `Jump to Animation Step ${step}`,
28
+ loading: 'Loading the animation…',
29
+ missing: scene => `The document’s scenes/ has no animation ${scene}`,
30
+ failed: (scene, reason) => `Couldn’t play the animation ${scene}: ${reason}`,
31
+ play: 'Play Animation',
32
+ pause: 'Pause Animation',
33
+ scrub: 'Animation progress',
34
+ },
35
+ }
@@ -0,0 +1,83 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { createRequire } from 'node:module'
3
+ import { dirname, join, relative, resolve, sep } from 'node:path'
4
+ import { parseDuration } from '@lincyaw/kernel'
5
+ import type { ComponentNode, DirectiveSpec, MdastNode, PresmiumNodePlugin } from '@lincyaw/kernel'
6
+ import { MOTION_ID, SCENE_EXTENSIONS, SCENE_NAME, SCENES_DIR } from '../domain/index.ts'
7
+ import { deckScenes, sceneFile, SCENES_MODULE, scenesModule } from './scenes.ts'
8
+
9
+ const { version } = createRequire(import.meta.url)('../../package.json') as { version: string }
10
+
11
+ const posix = (path: string) => path.split(sep).join('/')
12
+
13
+ const ASPECT = /^\d+(?:\.\d+)?\s*[/:]\s*\d+(?:\.\d+)?$/
14
+ const STARTER = readFileSync(new URL('./scene-starter.txt', import.meta.url), 'utf8')
15
+
16
+ const scene: DirectiveSpec = {
17
+ name: 'scene',
18
+ arg: true,
19
+ body: true,
20
+ options: { durations: 'list', aspect: 'string', height: 'string', still: 'string' },
21
+ toIR(input, ctx) {
22
+ const report = (message: string, severity: 'error' | 'warning' = 'error') => ctx.report({ checkId: MOTION_ID, severity, message: `{scene}: ${message}` })
23
+ const name = input.arg?.trim() ?? ''
24
+ if (!name) report(`needs the name of a scene of the deck's ${SCENES_DIR}/, e.g. {scene} merge for ${SCENES_DIR}/merge.ts`)
25
+ else if (!SCENE_NAME.test(name)) report(`"${name}" is not a scene name: the path under ${SCENES_DIR}/ without its extension, e.g. merge or bpe/merge`)
26
+ else {
27
+ // Every file the scene could be, so that adding it (or another extension of it) compiles the deck again.
28
+ ctx.watch(...SCENE_EXTENSIONS.map(ext => join(ctx.deckDir, SCENES_DIR, `${name}${ext}`)))
29
+ if (!sceneFile(ctx.deckDir, name)) report(`the deck has no ${SCENES_DIR}/${name}.ts (nor .js, .mjs)`)
30
+ }
31
+ const listed = Array.isArray(input.options.durations) ? input.options.durations as string[] : []
32
+ const durations = listed.map(parseDuration)
33
+ const bad = listed.filter((_, i) => durations[i] === undefined)
34
+ if (bad.length) report(`:durations: takes durations as {cue}\`pause\` does (1.2, 1.2s or 800ms), not ${bad.join(', ')}`)
35
+ const steps = durations.filter((ms): ms is number => ms !== undefined)
36
+ if (steps.length === 0) steps.push(0)
37
+ const aspect = typeof input.options.aspect === 'string' ? input.options.aspect.trim() : undefined
38
+ if (aspect && !ASPECT.test(aspect)) report(`:aspect: takes width/height, e.g. 16/9, not ${aspect}`)
39
+ const height = typeof input.options.height === 'string' ? input.options.height.trim() : undefined
40
+ const caption = input.body.trim()
41
+ const parsed = caption ? ctx.parse(caption) : undefined
42
+ const fallback: MdastNode = { type: 'root', children: [...(parsed?.children as MdastNode[] | undefined) ?? []] }
43
+ // The durations go in the node only: the renderers give them to the component as the prop durations.
44
+ const props: Record<string, unknown> = { scene: name }
45
+ if (aspect && ASPECT.test(aspect)) props.aspect = aspect.replace(':', '/').replace(/\s+/g, '')
46
+ if (height) props.height = height
47
+ if (caption) props.alt = caption.replace(/\s+/g, ' ')
48
+ const node: ComponentNode = { type: 'component', plugin: MOTION_ID, name: 'PmScene', props, durations: steps, fallback }
49
+ const definition = SCENE_NAME.test(name) ? sceneFile(ctx.deckDir, name) : undefined
50
+ if (definition) node.definition = { file: definition, language: definition.endsWith('.ts') ? 'typescript' : 'javascript' }
51
+ const still = typeof input.options.still === 'string' ? input.options.still.trim() : undefined
52
+ if (still) {
53
+ const file = resolve(dirname(ctx.file), still)
54
+ ctx.watch(file)
55
+ if (!existsSync(file)) report(`:still: file not found: ${still}`)
56
+ const url = posix(relative(dirname(ctx.file), file))
57
+ props.still = url
58
+ node.assets = { still: url }
59
+ fallback.children!.unshift({ type: 'paragraph', children: [{ type: 'image', url, alt: caption || name }] })
60
+ }
61
+ return { node, span: steps.length - 1 }
62
+ },
63
+ }
64
+
65
+ const plugin: PresmiumNodePlugin = {
66
+ id: MOTION_ID,
67
+ version,
68
+ kernel: '^1.0.0',
69
+ directives: [scene],
70
+ passes: [{
71
+ name: 'presmium.motion.catalog',
72
+ after: 'execute',
73
+ run(doc, ctx) {
74
+ const scenes = deckScenes(ctx.deckDir)
75
+ ctx.watch(join(ctx.deckDir, SCENES_DIR), ...scenes.values())
76
+ const own = doc.config.presmium[MOTION_ID]
77
+ doc.config.presmium[MOTION_ID] = { ...(typeof own === 'object' && own !== null ? own : {}), scenes: [...scenes.keys()], starter: { language: 'typescript', code: STARTER } }
78
+ },
79
+ }],
80
+ documentModules: ({ deckDir }) => ({ [SCENES_MODULE]: scenesModule(deckDir) }),
81
+ }
82
+
83
+ export default plugin
@@ -0,0 +1,6 @@
1
+ import { gsapScene } from '@lincyaw/addon-motion/scene'
2
+
3
+ export default gsapScene(({ root, gsap }) => {
4
+ root.innerHTML = '<div style="width:var(--pm-space-12);height:var(--pm-space-12);background:var(--pm-color-accent)"></div>'
5
+ return gsap.timeline().fromTo(root.firstElementChild!, { xPercent: 0 }, { xPercent: 200, duration: 1 })
6
+ })
@@ -0,0 +1,40 @@
1
+ import { existsSync, readdirSync, statSync } from 'node:fs'
2
+ import { join, relative, sep } from 'node:path'
3
+ import { SCENE_EXTENSIONS, SCENE_NAME, SCENES_DIR } from '../domain/index.ts'
4
+
5
+ export const SCENES_MODULE = 'virtual:presmium-motion/scenes'
6
+
7
+ /** The file of scene `name` in the deck (`scenes/<name>.ts` and the other extensions), if there is one. */
8
+ export function sceneFile(deckDir: string, name: string): string | undefined {
9
+ if (!SCENE_NAME.test(name)) return undefined
10
+ return SCENE_EXTENSIONS.map(ext => join(deckDir, SCENES_DIR, `${name}${ext}`)).find(file => existsSync(file))
11
+ }
12
+
13
+ /** The deck's scenes by name: the files under `scenes/`, the first extension of SCENE_EXTENSIONS winning. */
14
+ export function deckScenes(deckDir: string): Map<string, string> {
15
+ const root = join(deckDir, SCENES_DIR)
16
+ const scenes = new Map<string, string>()
17
+ const walk = (dir: string) => {
18
+ for (const entry of readdirSync(dir).sort()) {
19
+ const path = join(dir, entry)
20
+ if (statSync(path).isDirectory()) {
21
+ walk(path)
22
+ continue
23
+ }
24
+ const ext = SCENE_EXTENSIONS.find(e => entry.endsWith(e))
25
+ if (!ext) continue
26
+ const name = relative(root, path).slice(0, -ext.length).split(sep).join('/')
27
+ const known = scenes.get(name)
28
+ if (SCENE_NAME.test(name) && (!known || SCENE_EXTENSIONS.indexOf(ext) < SCENE_EXTENSIONS.findIndex(e => known.endsWith(e)))) scenes.set(name, path)
29
+ }
30
+ }
31
+ if (existsSync(root) && statSync(root).isDirectory()) walk(root)
32
+ return scenes
33
+ }
34
+
35
+ /** The source of `virtual:presmium-motion/scenes`: each scene of the deck, by name, to an import of its file. */
36
+ export function scenesModule(deckDir: string): string {
37
+ const entries = [...deckScenes(deckDir)].map(([name, file]) => ` ${JSON.stringify(name)}: () => import(${JSON.stringify(file)}),`)
38
+ return `export default {\n${entries.join('\n')}\n}\n`
39
+ }
40
+