@markdy/renderer-dom 0.1.0

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hoang Yell (https://hoangyell.com)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,83 @@
1
+ # @markdy/renderer-dom
2
+
3
+ Web Animations API renderer for [MarkdyScript](../../docs/SYNTAX.md) scenes. Translates a parsed AST into DOM elements and drives the animation timeline.
4
+
5
+ ## Features
6
+
7
+ - **Browser-native** — Web Animations API + CSS transforms, no Canvas or GSAP
8
+ - **Emoji stick figures** — `figure` actor type with articulatable limbs (punch, kick, rotate_part)
9
+ - **Seek-safe** — manual `currentTime` control enables reliable `seek()` in any direction
10
+ - **Face expressions** — instant emoji face swaps that work correctly on seek-back
11
+ - **Speech bubbles** — auto-positioned bubbles with fade-in/fade-out
12
+ - **Single dependency** — only `@markdy/core`
13
+
14
+ ## Installation
15
+
16
+ ```sh
17
+ pnpm add @markdy/core @markdy/renderer-dom
18
+ ```
19
+
20
+ ## Usage
21
+
22
+ ```typescript
23
+ import { createPlayer } from "@markdy/renderer-dom";
24
+
25
+ const player = createPlayer({
26
+ container: document.getElementById("scene")!,
27
+ code: `
28
+ scene width=600 height=300 bg=white
29
+ actor hero = figure(#c68642, m, 😎) at (200, 150)
30
+ @0.0: hero.enter(from=left, dur=0.8)
31
+ @1.5: hero.say("Hello!", dur=1.2)
32
+ @1.5: hero.face("😄")
33
+ `,
34
+ autoplay: true,
35
+ });
36
+
37
+ // Playback control
38
+ player.pause();
39
+ player.seek(1.5); // jump to 1.5 seconds
40
+ player.play();
41
+ player.destroy(); // clean up DOM + cancel animations
42
+ ```
43
+
44
+ ## API
45
+
46
+ ### `createPlayer(options: PlayerOptions): Player`
47
+
48
+ | Option | Type | Default | Description |
49
+ |---|---|---|---|
50
+ | `container` | `HTMLElement` | *(required)* | DOM element to mount the scene into |
51
+ | `code` | `string` | *(required)* | MarkdyScript source code |
52
+ | `assets` | `Record<string, string>` | `{}` | Asset URL overrides (key = asset name) |
53
+ | `autoplay` | `boolean` | `false` | Start playing immediately |
54
+
55
+ ### `Player`
56
+
57
+ | Method | Description |
58
+ |---|---|
59
+ | `play()` | Start or resume playback |
60
+ | `pause()` | Pause at current position |
61
+ | `seek(seconds)` | Jump to a specific time |
62
+ | `destroy()` | Remove DOM elements and cancel all animations |
63
+
64
+ ## Module Structure
65
+
66
+ ```
67
+ src/
68
+ types.ts — ActorState, FaceSwap, easing utilities
69
+ figure.ts — Stick-figure DOM factory (emoji body parts)
70
+ actors.ts — Actor element factory (sprite, text, figure, box)
71
+ animations.ts — Timeline → WAAPI Animation builder
72
+ player.ts — Public API, rAF loop, face-swap engine
73
+ index.ts — Barrel exports
74
+ ```
75
+
76
+ ## Documentation
77
+
78
+ - **[Syntax Reference](../../docs/SYNTAX.md)** — complete DSL language spec
79
+ - **[Architecture](../../docs/ARCHITECTURE.md)** — renderer internals and playback design
80
+
81
+ ## License
82
+
83
+ [MIT](../../LICENSE)
@@ -0,0 +1,36 @@
1
+ /**
2
+ * @markdy/renderer-dom — Player
3
+ *
4
+ * Translates a MarkdyScript program into DOM elements and drives the
5
+ * timeline via the Web Animations API (WAAPI).
6
+ *
7
+ * Playback architecture: all WAAPI animations stay permanently paused.
8
+ * A requestAnimationFrame loop advances `sceneMs` each frame and sets
9
+ * `anim.currentTime = sceneMs` on every animation. This avoids two
10
+ * known pitfalls with WAAPI's startTime-based resumption:
11
+ *
12
+ * 1. Setting `startTime` on a paused animation does not reliably change
13
+ * the play state to "running" across all browsers.
14
+ * 2. `fill:"both"` causes later-created animations to win the cascade
15
+ * during their before-phase, overriding earlier animations' off-screen
16
+ * backward fill.
17
+ *
18
+ * By using `fill:"forwards"` only and pre-initialising actor inline styles,
19
+ * each actor's before-phase state falls through to the inline style we set,
20
+ * which gives correct initial positions and opacity values.
21
+ */
22
+ interface PlayerOptions {
23
+ container: HTMLElement;
24
+ code: string;
25
+ assets?: Record<string, string>;
26
+ autoplay?: boolean;
27
+ }
28
+ interface Player {
29
+ play(): void;
30
+ pause(): void;
31
+ seek(seconds: number): void;
32
+ destroy(): void;
33
+ }
34
+ declare function createPlayer(opts: PlayerOptions): Player;
35
+
36
+ export { type Player, type PlayerOptions, createPlayer };
package/dist/index.js ADDED
@@ -0,0 +1,739 @@
1
+ // src/player.ts
2
+ import { parse } from "@markdy/core";
3
+
4
+ // src/types.ts
5
+ function stateFrom(def) {
6
+ return {
7
+ x: def.x,
8
+ y: def.y,
9
+ scale: def.scale ?? 1,
10
+ rotate: def.rotate ?? 0,
11
+ opacity: def.opacity ?? 1
12
+ };
13
+ }
14
+ function tx(s) {
15
+ return `translate(${s.x}px, ${s.y}px) scale(${s.scale}) rotate(${s.rotate}deg)`;
16
+ }
17
+ var EASE_MAP = {
18
+ linear: "linear",
19
+ in: "ease-in",
20
+ out: "ease-out",
21
+ inout: "ease-in-out"
22
+ };
23
+ function toEasing(val) {
24
+ return EASE_MAP[String(val ?? "")] ?? "linear";
25
+ }
26
+
27
+ // src/figure.ts
28
+ function createFigureEl(def) {
29
+ const skinColor = def.args[0] || "#ffdbac";
30
+ const isFemale = def.args[1] === "f";
31
+ const startFace = def.args[2] || (isFemale ? "\u{1F642}" : "\u{1F636}");
32
+ const ink = "#2a2a2a";
33
+ const WRAP_W = 80;
34
+ const FACE_FS = 40;
35
+ const SHIRT_FS = isFemale ? 48 : 44;
36
+ const vShirtW = SHIRT_FS * 0.9;
37
+ const shLx = (WRAP_W - vShirtW) / 2 + vShirtW * 0.18;
38
+ const shRx = WRAP_W - shLx;
39
+ const shY = Math.round(SHIRT_FS * 0.28);
40
+ const ARM_W = 36, ARM_H = 22;
41
+ const LEG_H = 54, LEG_STICK_H = 34;
42
+ const wrap = document.createElement("div");
43
+ Object.assign(wrap.style, {
44
+ position: "relative",
45
+ display: "flex",
46
+ flexDirection: "column",
47
+ alignItems: "center",
48
+ width: `${WRAP_W}px`,
49
+ overflow: "visible"
50
+ });
51
+ const faceEl = document.createElement("span");
52
+ faceEl.dataset.figFace = "";
53
+ faceEl.dataset.figHead = "";
54
+ faceEl.textContent = startFace;
55
+ Object.assign(faceEl.style, {
56
+ fontSize: `${FACE_FS}px`,
57
+ lineHeight: "1",
58
+ flexShrink: "0",
59
+ userSelect: "none",
60
+ pointerEvents: "none",
61
+ zIndex: "5"
62
+ });
63
+ const neck = document.createElement("div");
64
+ Object.assign(neck.style, {
65
+ width: "8px",
66
+ height: "8px",
67
+ background: skinColor,
68
+ borderRadius: "3px",
69
+ flexShrink: "0",
70
+ marginTop: "-2px",
71
+ marginBottom: "-2px",
72
+ zIndex: "4"
73
+ });
74
+ const shirtRow = document.createElement("div");
75
+ Object.assign(shirtRow.style, {
76
+ position: "relative",
77
+ width: `${WRAP_W}px`,
78
+ height: `${SHIRT_FS}px`,
79
+ textAlign: "center",
80
+ flexShrink: "0",
81
+ zIndex: "2",
82
+ overflow: "visible"
83
+ });
84
+ const torso = document.createElement("span");
85
+ torso.dataset.figBody = "";
86
+ torso.textContent = isFemale ? "\u{1F457}" : "\u{1F455}";
87
+ Object.assign(torso.style, {
88
+ fontSize: `${SHIRT_FS}px`,
89
+ lineHeight: "1",
90
+ userSelect: "none",
91
+ pointerEvents: "none"
92
+ });
93
+ shirtRow.appendChild(torso);
94
+ const armHandEmoji = isFemale ? "\u{1F485}" : "\u{1F91C}";
95
+ shirtRow.appendChild(
96
+ buildArm("left", armHandEmoji, skinColor, {
97
+ w: ARM_W,
98
+ h: ARM_H,
99
+ anchorX: WRAP_W - shLx,
100
+ anchorY: shY,
101
+ restDeg: 20,
102
+ flipFist: !isFemale
103
+ })
104
+ );
105
+ shirtRow.appendChild(
106
+ buildArm("right", armHandEmoji, skinColor, {
107
+ w: ARM_W,
108
+ h: ARM_H,
109
+ anchorX: shRx,
110
+ anchorY: shY,
111
+ restDeg: -20,
112
+ flipFist: false
113
+ })
114
+ );
115
+ const legsRow = document.createElement("div");
116
+ Object.assign(legsRow.style, {
117
+ display: "flex",
118
+ justifyContent: "center",
119
+ gap: "10px",
120
+ flexShrink: "0",
121
+ overflow: "visible"
122
+ });
123
+ legsRow.append(
124
+ buildLeg(true, isFemale, ink, LEG_H, LEG_STICK_H),
125
+ buildLeg(false, isFemale, ink, LEG_H, LEG_STICK_H)
126
+ );
127
+ wrap.append(faceEl, neck, shirtRow, legsRow);
128
+ return wrap;
129
+ }
130
+ function buildArm(side, handEmoji, skinColor, g) {
131
+ const arm = document.createElement("div");
132
+ const ds = arm.dataset;
133
+ ds[side === "left" ? "figArmL" : "figArmR"] = "";
134
+ const isLeft = side === "left";
135
+ const origin = isLeft ? "right center" : "left center";
136
+ Object.assign(arm.style, {
137
+ position: "absolute",
138
+ width: `${g.w}px`,
139
+ height: `${g.h}px`,
140
+ ...isLeft ? { right: `${g.anchorX}px` } : { left: `${g.anchorX}px` },
141
+ top: `${g.anchorY - g.h / 2}px`,
142
+ transformOrigin: origin,
143
+ transform: `rotate(${g.restDeg}deg)`,
144
+ zIndex: "4",
145
+ overflow: "visible"
146
+ });
147
+ const stick = document.createElement("div");
148
+ Object.assign(stick.style, {
149
+ position: "absolute",
150
+ ...isLeft ? { right: "5px" } : { left: "5px" },
151
+ top: `${g.h / 2 - 2}px`,
152
+ width: "18px",
153
+ height: "3px",
154
+ background: skinColor,
155
+ borderRadius: "2px"
156
+ });
157
+ const fist = document.createElement("span");
158
+ fist.textContent = handEmoji;
159
+ Object.assign(fist.style, {
160
+ position: "absolute",
161
+ fontSize: "17px",
162
+ lineHeight: "1",
163
+ ...isLeft ? { left: "0" } : { right: "0" },
164
+ top: `${g.h / 2 - 10}px`,
165
+ ...g.flipFist ? { transform: "scaleX(-1)" } : {},
166
+ userSelect: "none",
167
+ pointerEvents: "none"
168
+ });
169
+ arm.append(stick, fist);
170
+ return arm;
171
+ }
172
+ function buildLeg(isLeft, isFemale, ink, legH, stickH) {
173
+ const leg = document.createElement("div");
174
+ const ds = leg.dataset;
175
+ ds[isLeft ? "figLegL" : "figLegR"] = "";
176
+ Object.assign(leg.style, {
177
+ position: "relative",
178
+ width: "20px",
179
+ height: `${legH}px`,
180
+ transformOrigin: "top center",
181
+ transform: "rotate(0deg)",
182
+ overflow: "visible"
183
+ });
184
+ const stick = document.createElement("div");
185
+ Object.assign(stick.style, {
186
+ position: "absolute",
187
+ width: "3px",
188
+ height: `${stickH}px`,
189
+ background: ink,
190
+ borderRadius: "1px",
191
+ left: "50%",
192
+ top: "0",
193
+ transform: "translateX(-50%)"
194
+ });
195
+ const shoe = document.createElement("span");
196
+ shoe.textContent = isFemale ? "\u{1F460}" : "\u{1F45F}";
197
+ Object.assign(shoe.style, {
198
+ position: "absolute",
199
+ fontSize: "17px",
200
+ lineHeight: "1",
201
+ bottom: "0",
202
+ userSelect: "none",
203
+ pointerEvents: "none"
204
+ });
205
+ if (isLeft) {
206
+ shoe.style.left = "0";
207
+ shoe.style.transform = "scaleX(-1)";
208
+ } else {
209
+ shoe.style.right = "0";
210
+ }
211
+ leg.append(stick, shoe);
212
+ return leg;
213
+ }
214
+ var PART_SEL = {
215
+ head: "[data-fig-head]",
216
+ face: "[data-fig-face]",
217
+ body: "[data-fig-body]",
218
+ arm_left: "[data-fig-arm-l]",
219
+ arm_right: "[data-fig-arm-r]",
220
+ leg_left: "[data-fig-leg-l]",
221
+ leg_right: "[data-fig-leg-r]"
222
+ };
223
+ function readRotation(el) {
224
+ const m = /rotate\((-?[\d.]+)deg\)/.exec(el.style.transform ?? "");
225
+ return m ? Number(m[1]) : 0;
226
+ }
227
+
228
+ // src/actors.ts
229
+ function createActorEl(name, def, assetDefs, assetOverrides) {
230
+ let el;
231
+ switch (def.type) {
232
+ case "sprite": {
233
+ const assetName = def.args[0] ?? "";
234
+ const assetDef = assetDefs[assetName];
235
+ if (assetDef?.type === "icon") {
236
+ const span = document.createElement("span");
237
+ span.style.display = "inline-block";
238
+ span.style.fontSize = `${def.size ?? 32}px`;
239
+ span.style.lineHeight = "1";
240
+ span.dataset.icon = assetDef.value;
241
+ span.setAttribute("aria-label", assetDef.value.split(":").pop() ?? "icon");
242
+ el = span;
243
+ } else {
244
+ const img = document.createElement("img");
245
+ img.src = assetOverrides[assetName] ?? assetDef?.value ?? "";
246
+ img.alt = assetName;
247
+ img.style.display = "block";
248
+ img.setAttribute("draggable", "false");
249
+ el = img;
250
+ }
251
+ break;
252
+ }
253
+ case "text": {
254
+ const div = document.createElement("div");
255
+ div.textContent = def.args[0] ?? "";
256
+ div.style.fontSize = `${def.size ?? 24}px`;
257
+ div.style.fontFamily = "sans-serif";
258
+ div.style.whiteSpace = "nowrap";
259
+ div.style.userSelect = "none";
260
+ div.style.pointerEvents = "none";
261
+ el = div;
262
+ break;
263
+ }
264
+ case "figure": {
265
+ el = createFigureEl(def);
266
+ break;
267
+ }
268
+ default: {
269
+ const div = document.createElement("div");
270
+ div.style.width = "100px";
271
+ div.style.height = "100px";
272
+ div.style.background = "#999";
273
+ div.style.boxSizing = "border-box";
274
+ el = div;
275
+ break;
276
+ }
277
+ }
278
+ el.dataset.markdyActor = name;
279
+ el.style.position = "absolute";
280
+ el.style.left = "0";
281
+ el.style.top = "0";
282
+ el.style.transformOrigin = "center center";
283
+ el.style.transform = tx(stateFrom(def));
284
+ el.style.opacity = String(def.opacity ?? 1);
285
+ return el;
286
+ }
287
+
288
+ // src/animations.ts
289
+ function buildAnimations(ast, actorEls, scene, assetOverrides, faceSwaps) {
290
+ const anims = [];
291
+ const states = /* @__PURE__ */ new Map();
292
+ for (const [name, def] of Object.entries(ast.actors)) {
293
+ states.set(name, stateFrom(def));
294
+ }
295
+ const events = [...ast.events].sort((a, b) => a.time - b.time);
296
+ preInitInlineStyles(ast, actorEls, states, events);
297
+ for (const ev of events) {
298
+ const el = actorEls.get(ev.actor);
299
+ const s = states.get(ev.actor);
300
+ if (!el || !s) continue;
301
+ const delayMs = ev.time * 1e3;
302
+ const durMs = Math.max(
303
+ 1,
304
+ (typeof ev.params.dur === "number" ? ev.params.dur : 0.5) * 1e3
305
+ );
306
+ const easing = toEasing(ev.params.ease);
307
+ const baseOpts = {
308
+ delay: delayMs,
309
+ duration: durMs,
310
+ fill: "forwards",
311
+ easing
312
+ };
313
+ buildAction(ev, el, s, baseOpts, delayMs, durMs, ast, states, scene, assetOverrides, faceSwaps, anims);
314
+ }
315
+ return anims;
316
+ }
317
+ function preInitInlineStyles(ast, actorEls, states, events) {
318
+ const firstEventByActor = /* @__PURE__ */ new Map();
319
+ for (const ev of events) {
320
+ if (!firstEventByActor.has(ev.actor)) firstEventByActor.set(ev.actor, ev);
321
+ }
322
+ for (const [name, def] of Object.entries(ast.actors)) {
323
+ const el = actorEls.get(name);
324
+ const s = states.get(name);
325
+ if (!el || !s) continue;
326
+ const firstEv = firstEventByActor.get(name);
327
+ if (firstEv?.action === "enter") {
328
+ const from = String(firstEv.params.from ?? "left");
329
+ const offscreen = { ...s };
330
+ switch (from) {
331
+ case "left":
332
+ offscreen.x = -ast.meta.width * 1.1;
333
+ break;
334
+ case "right":
335
+ offscreen.x = ast.meta.width * 2.1;
336
+ break;
337
+ case "top":
338
+ offscreen.y = -ast.meta.height * 1.1;
339
+ break;
340
+ case "bottom":
341
+ offscreen.y = ast.meta.height * 2.1;
342
+ break;
343
+ }
344
+ el.style.transform = tx(offscreen);
345
+ }
346
+ if (firstEv?.action === "fade_in" && (def.opacity === void 0 || def.opacity > 0)) {
347
+ el.style.opacity = "0";
348
+ }
349
+ }
350
+ }
351
+ function buildAction(ev, el, s, baseOpts, delayMs, durMs, ast, states, scene, assetOverrides, faceSwaps, anims) {
352
+ switch (ev.action) {
353
+ // ── move ────────────────────────────────────────────────────────────────
354
+ case "move": {
355
+ const toArr = ev.params.to;
356
+ const toX = toArr?.[0] ?? s.x;
357
+ const toY = toArr?.[1] ?? s.y;
358
+ anims.push(
359
+ el.animate(
360
+ [{ transform: tx(s) }, { transform: tx({ ...s, x: toX, y: toY }) }],
361
+ baseOpts
362
+ )
363
+ );
364
+ s.x = toX;
365
+ s.y = toY;
366
+ break;
367
+ }
368
+ // ── enter ───────────────────────────────────────────────────────────────
369
+ case "enter": {
370
+ const from = String(ev.params.from ?? "left");
371
+ const fromState = { ...s };
372
+ switch (from) {
373
+ case "left":
374
+ fromState.x = -ast.meta.width;
375
+ break;
376
+ case "right":
377
+ fromState.x = ast.meta.width * 2;
378
+ break;
379
+ case "top":
380
+ fromState.y = -ast.meta.height;
381
+ break;
382
+ case "bottom":
383
+ fromState.y = ast.meta.height * 2;
384
+ break;
385
+ }
386
+ anims.push(
387
+ el.animate(
388
+ [{ transform: tx(fromState) }, { transform: tx(s) }],
389
+ baseOpts
390
+ )
391
+ );
392
+ break;
393
+ }
394
+ // ── fade_in ─────────────────────────────────────────────────────────────
395
+ case "fade_in": {
396
+ anims.push(el.animate([{ opacity: 0 }, { opacity: 1 }], baseOpts));
397
+ s.opacity = 1;
398
+ break;
399
+ }
400
+ // ── fade_out ────────────────────────────────────────────────────────────
401
+ case "fade_out": {
402
+ anims.push(
403
+ el.animate([{ opacity: s.opacity }, { opacity: 0 }], baseOpts)
404
+ );
405
+ s.opacity = 0;
406
+ break;
407
+ }
408
+ // ── scale ───────────────────────────────────────────────────────────────
409
+ case "scale": {
410
+ const toScale = typeof ev.params.to === "number" ? ev.params.to : s.scale;
411
+ anims.push(
412
+ el.animate(
413
+ [{ transform: tx(s) }, { transform: tx({ ...s, scale: toScale }) }],
414
+ baseOpts
415
+ )
416
+ );
417
+ s.scale = toScale;
418
+ break;
419
+ }
420
+ // ── rotate ──────────────────────────────────────────────────────────────
421
+ case "rotate": {
422
+ const toDeg = typeof ev.params.to === "number" ? ev.params.to : s.rotate;
423
+ anims.push(
424
+ el.animate(
425
+ [{ transform: tx(s) }, { transform: tx({ ...s, rotate: toDeg }) }],
426
+ baseOpts
427
+ )
428
+ );
429
+ s.rotate = toDeg;
430
+ break;
431
+ }
432
+ // ── shake ───────────────────────────────────────────────────────────────
433
+ case "shake": {
434
+ const mag = typeof ev.params.intensity === "number" ? ev.params.intensity : 5;
435
+ anims.push(
436
+ el.animate(
437
+ [
438
+ { transform: tx(s), offset: 0 },
439
+ { transform: tx({ ...s, x: s.x + mag }), offset: 0.2 },
440
+ { transform: tx({ ...s, x: s.x - mag }), offset: 0.4 },
441
+ { transform: tx({ ...s, x: s.x + mag }), offset: 0.6 },
442
+ { transform: tx({ ...s, x: s.x - mag }), offset: 0.8 },
443
+ { transform: tx(s), offset: 1 }
444
+ ],
445
+ { ...baseOpts, easing: "linear" }
446
+ )
447
+ );
448
+ break;
449
+ }
450
+ // ── punch ───────────────────────────────────────────────────────────────
451
+ case "punch": {
452
+ const pSide = String(ev.params.side ?? "right");
453
+ const pArmEl = el.querySelector(
454
+ pSide === "left" ? "[data-fig-arm-l]" : "[data-fig-arm-r]"
455
+ );
456
+ if (!pArmEl) break;
457
+ const pRest = readRotation(pArmEl);
458
+ const pExtend = pSide === "left" ? -75 : 75;
459
+ anims.push(
460
+ pArmEl.animate(
461
+ [
462
+ { transform: `rotate(${pRest}deg)` },
463
+ { transform: `rotate(${pExtend}deg)`, offset: 0.35 },
464
+ { transform: `rotate(${pRest}deg)` }
465
+ ],
466
+ { ...baseOpts, easing: "ease-in-out", fill: "forwards" }
467
+ )
468
+ );
469
+ break;
470
+ }
471
+ // ── kick ────────────────────────────────────────────────────────────────
472
+ case "kick": {
473
+ const kSide = String(ev.params.side ?? "right");
474
+ const kLegEl = el.querySelector(
475
+ kSide === "left" ? "[data-fig-leg-l]" : "[data-fig-leg-r]"
476
+ );
477
+ if (!kLegEl) break;
478
+ const kRest = readRotation(kLegEl);
479
+ const kExtend = kSide === "left" ? -100 : 100;
480
+ anims.push(
481
+ kLegEl.animate(
482
+ [
483
+ { transform: `rotate(${kRest}deg)` },
484
+ { transform: `rotate(${kExtend}deg)`, offset: 0.38 },
485
+ { transform: `rotate(${kRest}deg)` }
486
+ ],
487
+ { ...baseOpts, easing: "ease-in-out", fill: "forwards" }
488
+ )
489
+ );
490
+ break;
491
+ }
492
+ // ── rotate_part ─────────────────────────────────────────────────────────
493
+ case "rotate_part": {
494
+ const rpName = String(ev.params.part ?? "");
495
+ const rpSel = PART_SEL[rpName];
496
+ if (!rpSel) break;
497
+ const rpEl = el.querySelector(rpSel);
498
+ if (!rpEl) break;
499
+ const rpFrom = readRotation(rpEl);
500
+ const rpTo = typeof ev.params.to === "number" ? ev.params.to : rpFrom;
501
+ anims.push(
502
+ rpEl.animate(
503
+ [
504
+ { transform: `rotate(${rpFrom}deg)` },
505
+ { transform: `rotate(${rpTo}deg)` }
506
+ ],
507
+ { ...baseOpts, fill: "forwards" }
508
+ )
509
+ );
510
+ rpEl.style.transform = rpEl.style.transform.replace(
511
+ /rotate\([^)]*\)/,
512
+ `rotate(${rpTo}deg)`
513
+ );
514
+ break;
515
+ }
516
+ // ── face ────────────────────────────────────────────────────────────────
517
+ case "face": {
518
+ const fEl = el.querySelector("[data-fig-face]");
519
+ if (!fEl) break;
520
+ const emoji = String(ev.params.text ?? ev.params._0 ?? "");
521
+ if (emoji) faceSwaps.push({ timeMs: ev.time * 1e3, el: fEl, emoji });
522
+ break;
523
+ }
524
+ // ── say ─────────────────────────────────────────────────────────────────
525
+ case "say": {
526
+ const text = String(ev.params.text ?? "");
527
+ const inverseScale = 1 / (s.scale || 1);
528
+ const bubble = document.createElement("div");
529
+ bubble.textContent = text;
530
+ bubble.style.opacity = "0";
531
+ Object.assign(bubble.style, {
532
+ position: "absolute",
533
+ bottom: "calc(100% + 8px)",
534
+ left: "50%",
535
+ transform: `translateX(-50%) scale(${inverseScale})`,
536
+ transformOrigin: "center bottom",
537
+ background: "white",
538
+ border: "2px solid #222",
539
+ borderRadius: "10px",
540
+ padding: "4px 10px",
541
+ fontFamily: "sans-serif",
542
+ fontSize: "14px",
543
+ whiteSpace: "nowrap",
544
+ pointerEvents: "none",
545
+ zIndex: "10",
546
+ boxShadow: "0 2px 6px rgba(0,0,0,0.15)"
547
+ });
548
+ const tail = document.createElement("span");
549
+ Object.assign(tail.style, {
550
+ position: "absolute",
551
+ bottom: "-10px",
552
+ left: "50%",
553
+ transform: "translateX(-50%)",
554
+ width: "0",
555
+ height: "0",
556
+ borderLeft: "6px solid transparent",
557
+ borderRight: "6px solid transparent",
558
+ borderTop: "10px solid #222"
559
+ });
560
+ bubble.appendChild(tail);
561
+ el.style.overflow = "visible";
562
+ el.appendChild(bubble);
563
+ const fadeDur = Math.min(200, durMs * 0.15);
564
+ anims.push(
565
+ bubble.animate([{ opacity: 0 }, { opacity: 1 }], {
566
+ delay: delayMs,
567
+ duration: fadeDur,
568
+ fill: "forwards"
569
+ }),
570
+ bubble.animate([{ opacity: 1 }, { opacity: 0 }], {
571
+ delay: delayMs + durMs - fadeDur,
572
+ duration: fadeDur,
573
+ fill: "forwards"
574
+ })
575
+ );
576
+ break;
577
+ }
578
+ // ── throw ───────────────────────────────────────────────────────────────
579
+ case "throw": {
580
+ const assetName = String(ev.params.asset ?? "");
581
+ const targetActorName = String(ev.params.to ?? "");
582
+ const targetState = states.get(targetActorName);
583
+ const assetDef = ast.assets[assetName];
584
+ if (!assetDef || !targetState) break;
585
+ let projectile;
586
+ if (assetDef.type === "image") {
587
+ const img = document.createElement("img");
588
+ img.src = assetOverrides[assetName] ?? assetDef.value;
589
+ img.alt = assetName;
590
+ img.setAttribute("draggable", "false");
591
+ img.style.width = "32px";
592
+ img.style.height = "32px";
593
+ projectile = img;
594
+ } else {
595
+ const span = document.createElement("span");
596
+ span.dataset.icon = assetDef.value;
597
+ span.style.fontSize = "32px";
598
+ span.style.lineHeight = "1";
599
+ span.style.display = "inline-block";
600
+ projectile = span;
601
+ }
602
+ Object.assign(projectile.style, {
603
+ position: "absolute",
604
+ left: "0",
605
+ top: "0",
606
+ pointerEvents: "none",
607
+ zIndex: "9",
608
+ opacity: "0"
609
+ });
610
+ scene.appendChild(projectile);
611
+ const throwAnim = projectile.animate(
612
+ [
613
+ { transform: tx(s), opacity: 1 },
614
+ { transform: tx(targetState), opacity: 0 }
615
+ ],
616
+ { ...baseOpts, easing: "ease-in" }
617
+ );
618
+ throwAnim.addEventListener("finish", () => {
619
+ if (projectile.parentNode === scene) scene.removeChild(projectile);
620
+ });
621
+ anims.push(throwAnim);
622
+ break;
623
+ }
624
+ default:
625
+ break;
626
+ }
627
+ }
628
+
629
+ // src/player.ts
630
+ function createPlayer(opts) {
631
+ const { container, code, assets: assetOverrides = {}, autoplay = false } = opts;
632
+ const ast = parse(code);
633
+ const scene = document.createElement("div");
634
+ Object.assign(scene.style, {
635
+ position: "relative",
636
+ width: `${ast.meta.width}px`,
637
+ height: `${ast.meta.height}px`,
638
+ background: ast.meta.bg,
639
+ overflow: "hidden",
640
+ userSelect: "none"
641
+ });
642
+ container.appendChild(scene);
643
+ const actorEls = /* @__PURE__ */ new Map();
644
+ for (const [name, def] of Object.entries(ast.actors)) {
645
+ const el = createActorEl(name, def, ast.assets, assetOverrides);
646
+ scene.appendChild(el);
647
+ actorEls.set(name, el);
648
+ }
649
+ const faceSwaps = [];
650
+ const allAnims = buildAnimations(ast, actorEls, scene, assetOverrides, faceSwaps);
651
+ faceSwaps.sort((a, b) => a.timeMs - b.timeMs);
652
+ for (const anim of allAnims) {
653
+ anim.pause();
654
+ anim.currentTime = 0;
655
+ }
656
+ let sceneMs = 0;
657
+ let lastRafTs = null;
658
+ let isPlaying = false;
659
+ let rafId = null;
660
+ for (const { el } of faceSwaps) {
661
+ if (!el.dataset["figFaceInitial"]) {
662
+ el.dataset["figFaceInitial"] = el.textContent ?? "";
663
+ }
664
+ }
665
+ function applyCurrentTime() {
666
+ for (const anim of allAnims) {
667
+ anim.currentTime = sceneMs;
668
+ }
669
+ applyFaceSwaps();
670
+ }
671
+ function applyFaceSwaps() {
672
+ if (faceSwaps.length === 0) return;
673
+ const elEmoji = /* @__PURE__ */ new Map();
674
+ for (const { timeMs, el, emoji } of faceSwaps) {
675
+ if (timeMs <= sceneMs) elEmoji.set(el, emoji);
676
+ }
677
+ for (const [el, emoji] of elEmoji) {
678
+ if (el.textContent !== emoji) el.textContent = emoji;
679
+ }
680
+ const elFirst = /* @__PURE__ */ new Map();
681
+ for (const { el, emoji } of faceSwaps) {
682
+ if (!elFirst.has(el)) elFirst.set(el, emoji);
683
+ }
684
+ for (const [el, firstEmoji] of elFirst) {
685
+ if (!elEmoji.has(el)) {
686
+ const initial = el.dataset["figFaceInitial"] ?? firstEmoji;
687
+ if (el.textContent !== initial) el.textContent = initial;
688
+ }
689
+ }
690
+ }
691
+ function rafTick(timestamp) {
692
+ if (lastRafTs !== null) {
693
+ sceneMs += timestamp - lastRafTs;
694
+ }
695
+ lastRafTs = timestamp;
696
+ const totalMs = (ast.meta.duration ?? 0) * 1e3;
697
+ if (totalMs > 0 && sceneMs >= totalMs) {
698
+ sceneMs = totalMs;
699
+ applyCurrentTime();
700
+ isPlaying = false;
701
+ lastRafTs = null;
702
+ rafId = null;
703
+ return;
704
+ }
705
+ applyCurrentTime();
706
+ rafId = requestAnimationFrame(rafTick);
707
+ }
708
+ const player = {
709
+ play() {
710
+ if (isPlaying) return;
711
+ isPlaying = true;
712
+ lastRafTs = null;
713
+ rafId = requestAnimationFrame(rafTick);
714
+ },
715
+ pause() {
716
+ if (!isPlaying) return;
717
+ isPlaying = false;
718
+ if (rafId !== null) {
719
+ cancelAnimationFrame(rafId);
720
+ rafId = null;
721
+ }
722
+ lastRafTs = null;
723
+ },
724
+ seek(seconds) {
725
+ sceneMs = seconds * 1e3;
726
+ applyCurrentTime();
727
+ },
728
+ destroy() {
729
+ player.pause();
730
+ for (const anim of allAnims) anim.cancel();
731
+ if (scene.parentNode === container) container.removeChild(scene);
732
+ }
733
+ };
734
+ if (autoplay) player.play();
735
+ return player;
736
+ }
737
+ export {
738
+ createPlayer
739
+ };
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@markdy/renderer-dom",
3
+ "version": "0.1.0",
4
+ "description": "Web Animations API renderer for MarkdyScript scenes.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "files": [
9
+ "dist",
10
+ "README.md",
11
+ "LICENSE"
12
+ ],
13
+ "main": "./dist/index.js",
14
+ "types": "./dist/index.d.ts",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "import": "./dist/index.js"
19
+ }
20
+ },
21
+ "keywords": [
22
+ "markdy",
23
+ "animation",
24
+ "web-animations-api",
25
+ "renderer",
26
+ "dom"
27
+ ],
28
+ "author": "Hoang Yell <hoangyell@gmail.com> (https://hoangyell.com)",
29
+ "homepage": "https://markdy.com",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "https://github.com/HoangYell/markdy-com.git",
33
+ "directory": "packages/renderer-dom"
34
+ },
35
+ "bugs": {
36
+ "url": "https://github.com/HoangYell/markdy-com/issues"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "dependencies": {
42
+ "@markdy/core": "0.1.0"
43
+ },
44
+ "devDependencies": {
45
+ "tsup": "^8.3.0",
46
+ "typescript": "^5.4.0"
47
+ },
48
+ "scripts": {
49
+ "build": "tsup",
50
+ "typecheck": "tsc --noEmit",
51
+ "lint": "tsc --noEmit"
52
+ }
53
+ }