@markdy/renderer-dom 0.7.24 → 0.7.26

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/dist/index.js CHANGED
@@ -21,10 +21,20 @@ var EASE_MAP = {
21
21
  linear: "linear",
22
22
  in: "ease-in",
23
23
  out: "ease-out",
24
- inout: "ease-in-out"
24
+ inout: "ease-in-out",
25
+ // Named cubic-bezier presets for authors who want a more polished feel
26
+ // than the four raw CSS keywords above without hand-writing a curve.
27
+ smooth: "cubic-bezier(0.4, 0, 0.2, 1)",
28
+ snappy: "cubic-bezier(0.16, 1, 0.3, 1)",
29
+ overshoot: "cubic-bezier(0.34, 1.56, 0.64, 1)",
30
+ sharp: "cubic-bezier(0.4, 0, 1, 1)"
25
31
  };
32
+ var CUBIC_BEZIER_RE = /^cubic-bezier\(\s*-?\d*\.?\d+\s*,\s*-?\d*\.?\d+\s*,\s*-?\d*\.?\d+\s*,\s*-?\d*\.?\d+\s*\)$/;
26
33
  function toEasing(val) {
27
- return EASE_MAP[String(val ?? "")] ?? "linear";
34
+ const str = String(val ?? "");
35
+ if (EASE_MAP[str]) return EASE_MAP[str];
36
+ if (CUBIC_BEZIER_RE.test(str)) return str;
37
+ return "linear";
28
38
  }
29
39
 
30
40
  // src/figure.ts
@@ -449,73 +459,69 @@ function createActorEl(name, def, assetDefs, assetOverrides) {
449
459
  return el;
450
460
  }
451
461
 
452
- // src/animations.ts
453
- function isSceneDark(scene) {
454
- const bg = scene.style.background || "white";
455
- let hex = bg.trim().replace(/^#/, "");
456
- if (hex.length === 3) hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
457
- if (hex.length === 6) {
458
- const r = parseInt(hex.slice(0, 2), 16);
459
- const g = parseInt(hex.slice(2, 4), 16);
460
- const b = parseInt(hex.slice(4, 6), 16);
461
- return 0.299 * r + 0.587 * g + 0.114 * b <= 140;
462
- }
463
- const dark = { black: true, "#000": true, "#000000": true };
464
- return dark[bg.toLowerCase()] ?? false;
462
+ // src/camera.ts
463
+ function freshCameraState() {
464
+ return { x: 0, y: 0, zoom: 1 };
465
465
  }
466
- function buildAnimations(ast, actorEls, scene, assetOverrides, faceSwaps) {
467
- const anims = [];
468
- const states = /* @__PURE__ */ new Map();
469
- for (const [name, def] of Object.entries(ast.actors)) {
470
- states.set(name, stateFrom(def));
471
- }
472
- const captionActors = /* @__PURE__ */ new Set();
473
- for (const [name, def] of Object.entries(ast.actors)) {
474
- if (def.type === "caption") captionActors.add(name);
475
- }
476
- const txFor = (actorName) => captionActors.has(actorName) ? txCaption : tx;
477
- const events = [...ast.events].sort((a, b) => a.time - b.time);
478
- preInitInlineStyles(ast, actorEls, states, events, txFor);
479
- const cameraState = freshCameraState();
480
- for (const ev of events) {
481
- const delayMs = ev.time * 1e3;
482
- const durMs = Math.max(
483
- 1,
484
- (typeof ev.params.dur === "number" ? ev.params.dur : 0.5) * 1e3
485
- );
486
- const easing = toEasing(ev.params.ease);
487
- const baseOpts = {
488
- delay: delayMs,
489
- duration: durMs,
490
- fill: "forwards",
491
- easing
492
- };
493
- if (ev.actor === "camera") {
494
- buildCameraAction(ev, scene, ast, baseOpts, anims, cameraState);
495
- continue;
466
+ function cameraTx(s) {
467
+ return `translate(${-s.x}px, ${-s.y}px) scale(${s.zoom})`;
468
+ }
469
+ var DEFAULT_SHAKE_INTENSITY = 8;
470
+ function buildCameraAction(ev, scene, ast, baseOpts, anims, state) {
471
+ switch (ev.action) {
472
+ case "pan": {
473
+ const to = ev.params.to;
474
+ if (!to) break;
475
+ const next = {
476
+ ...state,
477
+ x: to[0] - ast.meta.width / 2,
478
+ y: to[1] - ast.meta.height / 2
479
+ };
480
+ anims.push(
481
+ scene.animate([{ transform: cameraTx(state) }, { transform: cameraTx(next) }], baseOpts)
482
+ );
483
+ state.x = next.x;
484
+ state.y = next.y;
485
+ break;
496
486
  }
497
- const el = actorEls.get(ev.actor);
498
- const s = states.get(ev.actor);
499
- if (!el || !s) continue;
500
- buildAction(
501
- ev,
502
- el,
503
- s,
504
- baseOpts,
505
- delayMs,
506
- durMs,
507
- ast,
508
- states,
509
- actorEls,
510
- scene,
511
- assetOverrides,
512
- faceSwaps,
513
- anims,
514
- txFor(ev.actor)
515
- );
487
+ case "zoom": {
488
+ const next = {
489
+ ...state,
490
+ zoom: typeof ev.params.to === "number" ? ev.params.to : state.zoom
491
+ };
492
+ anims.push(
493
+ scene.animate([{ transform: cameraTx(state) }, { transform: cameraTx(next) }], baseOpts)
494
+ );
495
+ state.zoom = next.zoom;
496
+ break;
497
+ }
498
+ case "shake": {
499
+ const mag = typeof ev.params.intensity === "number" ? ev.params.intensity : DEFAULT_SHAKE_INTENSITY;
500
+ const at = (dx, dy, offset) => ({
501
+ transform: cameraTx({ ...state, x: state.x + dx, y: state.y + dy }),
502
+ offset
503
+ });
504
+ anims.push(
505
+ scene.animate(
506
+ [
507
+ at(0, 0, 0),
508
+ at(-mag, -mag * 0.4, 0.15),
509
+ at(mag, mag * 0.4, 0.35),
510
+ at(-mag * 0.6, mag * 0.3, 0.55),
511
+ at(mag * 0.5, -mag * 0.3, 0.75),
512
+ at(0, 0, 1)
513
+ ],
514
+ { ...baseOpts, easing: "linear" }
515
+ )
516
+ );
517
+ break;
518
+ }
519
+ default:
520
+ break;
516
521
  }
517
- return anims;
518
522
  }
523
+
524
+ // src/stage.ts
519
525
  function offscreenState(s, direction, sceneWidth, sceneHeight) {
520
526
  const out = { ...s };
521
527
  switch (direction) {
@@ -545,114 +551,252 @@ function preInitInlineStyles(ast, actorEls, states, events, txFor) {
545
551
  const s = states.get(name);
546
552
  if (!el || !s) continue;
547
553
  const firstEv = firstEventByActor.get(name);
548
- const txFn = txFor(name);
549
- if (firstEv?.action === "enter") {
554
+ if (!firstEv) continue;
555
+ if (firstEv.action === "enter") {
550
556
  const from = String(firstEv.params.from ?? "left");
551
- const offscreen = offscreenState(s, from, ast.meta.width, ast.meta.height);
552
- el.style.transform = txFn(offscreen);
557
+ el.style.transform = txFor(name)(offscreenState(s, from, ast.meta.width, ast.meta.height));
553
558
  }
554
- if (firstEv?.action === "fade_in" && (def.opacity === void 0 || def.opacity > 0)) {
559
+ if (firstEv.action === "fade_in" && (def.opacity === void 0 || def.opacity > 0)) {
555
560
  el.style.opacity = "0";
556
561
  }
557
562
  }
558
563
  }
559
- function freshCameraState() {
560
- return { x: 0, y: 0, zoom: 1 };
564
+
565
+ // src/actions/figure.ts
566
+ function sideOf(ctx) {
567
+ return String(ctx.ev.params.side ?? "right") === "left" ? "left" : "right";
561
568
  }
562
- function cameraTx(s) {
563
- return `translate(${-s.x}px, ${-s.y}px) scale(${s.zoom})`;
569
+ function partEl(ctx, part) {
570
+ const selector = PART_SEL[part];
571
+ return selector ? ctx.el.querySelector(selector) : null;
564
572
  }
565
- function buildCameraAction(ev, scene, ast, baseOpts, anims, cameraState) {
566
- const s = cameraState;
567
- switch (ev.action) {
568
- case "pan": {
569
- const to = ev.params.to;
570
- if (!to) break;
571
- const sceneW = ast.meta.width;
572
- const sceneH = ast.meta.height;
573
- const targetX = to[0] - sceneW / 2;
574
- const targetY = to[1] - sceneH / 2;
575
- const next = { ...s, x: targetX, y: targetY };
576
- anims.push(
577
- scene.animate(
578
- [{ transform: cameraTx(s) }, { transform: cameraTx(next) }],
579
- baseOpts
580
- )
581
- );
582
- s.x = next.x;
583
- s.y = next.y;
584
- break;
585
- }
586
- case "zoom": {
587
- const to = typeof ev.params.to === "number" ? ev.params.to : s.zoom;
588
- const next = { ...s, zoom: to };
589
- anims.push(
590
- scene.animate(
591
- [{ transform: cameraTx(s) }, { transform: cameraTx(next) }],
592
- baseOpts
593
- )
594
- );
595
- s.zoom = next.zoom;
596
- break;
597
- }
598
- case "shake": {
599
- const mag = typeof ev.params.intensity === "number" ? ev.params.intensity : 8;
600
- anims.push(
601
- scene.animate(
602
- [
603
- { transform: cameraTx(s), offset: 0 },
604
- { transform: cameraTx({ ...s, x: s.x - mag, y: s.y - mag * 0.4 }), offset: 0.15 },
605
- { transform: cameraTx({ ...s, x: s.x + mag, y: s.y + mag * 0.4 }), offset: 0.35 },
606
- { transform: cameraTx({ ...s, x: s.x - mag * 0.6, y: s.y + mag * 0.3 }), offset: 0.55 },
607
- { transform: cameraTx({ ...s, x: s.x + mag * 0.5, y: s.y - mag * 0.3 }), offset: 0.75 },
608
- { transform: cameraTx(s), offset: 1 }
609
- ],
610
- { ...baseOpts, easing: "linear" }
611
- )
612
- );
613
- break;
573
+ function rotateKeyframe(deg, offset) {
574
+ return offset === void 0 ? { transform: `rotate(${deg}deg)` } : { transform: `rotate(${deg}deg)`, offset };
575
+ }
576
+ function swingLimb(ctx, part, extendDeg, peakOffset) {
577
+ const el = partEl(ctx, part);
578
+ if (!el) return;
579
+ const rest = readRotation(el);
580
+ ctx.anims.push(
581
+ el.animate([rotateKeyframe(rest), rotateKeyframe(extendDeg, peakOffset), rotateKeyframe(rest)], {
582
+ ...ctx.baseOpts,
583
+ easing: "ease-in-out",
584
+ fill: "forwards"
585
+ })
586
+ );
587
+ }
588
+ var punch = (ctx) => {
589
+ const side = sideOf(ctx);
590
+ swingLimb(ctx, side === "left" ? "arm_left" : "arm_right", side === "left" ? -75 : 75, 0.35);
591
+ };
592
+ var kick = (ctx) => {
593
+ const side = sideOf(ctx);
594
+ swingLimb(ctx, side === "left" ? "leg_left" : "leg_right", side === "left" ? -100 : 100, 0.38);
595
+ };
596
+ var wave = (ctx) => {
597
+ const side = sideOf(ctx);
598
+ const el = partEl(ctx, side === "left" ? "arm_left" : "arm_right");
599
+ if (!el) return;
600
+ const rest = readRotation(el);
601
+ const up = side === "left" ? 70 : -70;
602
+ const inward = side === "left" ? 50 : -50;
603
+ ctx.anims.push(
604
+ el.animate(
605
+ [
606
+ rotateKeyframe(rest, 0),
607
+ rotateKeyframe(up, 0.2),
608
+ rotateKeyframe(inward, 0.4),
609
+ rotateKeyframe(up, 0.55),
610
+ rotateKeyframe(inward, 0.7),
611
+ rotateKeyframe(up, 0.85),
612
+ rotateKeyframe(rest, 1)
613
+ ],
614
+ { ...ctx.baseOpts, easing: "ease-in-out", fill: "forwards" }
615
+ )
616
+ );
617
+ };
618
+ var nod = (ctx) => {
619
+ const el = partEl(ctx, "head");
620
+ if (!el) return;
621
+ const rest = readRotation(el);
622
+ const down = 15;
623
+ ctx.anims.push(
624
+ el.animate(
625
+ [
626
+ rotateKeyframe(rest, 0),
627
+ rotateKeyframe(down, 0.35),
628
+ rotateKeyframe(rest, 0.65),
629
+ rotateKeyframe(down, 0.8),
630
+ rotateKeyframe(rest, 1)
631
+ ],
632
+ { ...ctx.baseOpts, easing: "ease-in-out", fill: "forwards" }
633
+ )
634
+ );
635
+ };
636
+ function commitRotation(el, deg) {
637
+ el.style.transform = el.style.transform.replace(/rotate\([^)]*\)/, `rotate(${deg}deg)`);
638
+ }
639
+ var rotatePart = (ctx) => {
640
+ const el = partEl(ctx, String(ctx.ev.params.part ?? ""));
641
+ if (!el) return;
642
+ const from = readRotation(el);
643
+ const to = typeof ctx.ev.params.to === "number" ? ctx.ev.params.to : from;
644
+ ctx.anims.push(
645
+ el.animate([rotateKeyframe(from), rotateKeyframe(to)], { ...ctx.baseOpts, fill: "forwards" })
646
+ );
647
+ commitRotation(el, to);
648
+ };
649
+ var POSEABLE_PARTS = ["arm_left", "arm_right", "leg_left", "leg_right", "head", "body"];
650
+ var pose = (ctx) => {
651
+ for (const part of POSEABLE_PARTS) {
652
+ const to = ctx.ev.params[part];
653
+ if (typeof to !== "number") continue;
654
+ const el = partEl(ctx, part);
655
+ if (!el) continue;
656
+ ctx.anims.push(
657
+ el.animate([rotateKeyframe(readRotation(el)), rotateKeyframe(to)], {
658
+ ...ctx.baseOpts,
659
+ fill: "forwards"
660
+ })
661
+ );
662
+ commitRotation(el, to);
663
+ }
664
+ };
665
+ var face = (ctx) => {
666
+ const el = ctx.el.querySelector("[data-fig-face]");
667
+ if (!el) return;
668
+ const emoji = String(ctx.ev.params.text ?? ctx.ev.params._0 ?? "");
669
+ if (emoji) ctx.faceSwaps.push({ timeMs: ctx.ev.time * 1e3, el, emoji });
670
+ };
671
+
672
+ // src/actions/transform.ts
673
+ var move = ({ ev, el, state, baseOpts, anims, tx: tx2 }) => {
674
+ const to = ev.params.to;
675
+ const toX = to?.[0] ?? state.x;
676
+ const toY = to?.[1] ?? state.y;
677
+ anims.push(el.animate([{ transform: tx2(state) }, { transform: tx2({ ...state, x: toX, y: toY }) }], baseOpts));
678
+ state.x = toX;
679
+ state.y = toY;
680
+ };
681
+ var enter = ({ ev, el, state, baseOpts, ast, anims, tx: tx2 }) => {
682
+ const from = String(ev.params.from ?? "left");
683
+ const fromState = offscreenState(state, from, ast.meta.width, ast.meta.height);
684
+ anims.push(
685
+ el.animate(
686
+ [
687
+ { transform: tx2(fromState), opacity: state.opacity },
688
+ { transform: tx2(state), opacity: 1 }
689
+ ],
690
+ baseOpts
691
+ )
692
+ );
693
+ state.opacity = 1;
694
+ };
695
+ var exit = ({ ev, el, state, baseOpts, ast, anims, tx: tx2 }) => {
696
+ const to = String(ev.params.to ?? "right");
697
+ const toState = offscreenState(state, to, ast.meta.width, ast.meta.height);
698
+ anims.push(
699
+ el.animate(
700
+ [
701
+ { transform: tx2(state), opacity: state.opacity },
702
+ { transform: tx2(toState), opacity: 0 }
703
+ ],
704
+ baseOpts
705
+ )
706
+ );
707
+ state.x = toState.x;
708
+ state.y = toState.y;
709
+ state.opacity = 0;
710
+ };
711
+ var fadeIn = ({ el, state, baseOpts, anims }) => {
712
+ anims.push(el.animate([{ opacity: 0 }, { opacity: 1 }], baseOpts));
713
+ state.opacity = 1;
714
+ };
715
+ var fadeOut = ({ el, state, baseOpts, anims }) => {
716
+ anims.push(el.animate([{ opacity: state.opacity }, { opacity: 0 }], baseOpts));
717
+ state.opacity = 0;
718
+ };
719
+ var scale = ({ ev, el, state, baseOpts, anims, tx: tx2 }) => {
720
+ const to = typeof ev.params.to === "number" ? ev.params.to : state.scale;
721
+ anims.push(el.animate([{ transform: tx2(state) }, { transform: tx2({ ...state, scale: to }) }], baseOpts));
722
+ state.scale = to;
723
+ };
724
+ var rotate = ({ ev, el, state, baseOpts, anims, tx: tx2 }) => {
725
+ const to = typeof ev.params.to === "number" ? ev.params.to : state.rotate;
726
+ anims.push(el.animate([{ transform: tx2(state) }, { transform: tx2({ ...state, rotate: to }) }], baseOpts));
727
+ state.rotate = to;
728
+ };
729
+ var shake = ({ ev, el, state, baseOpts, anims, tx: tx2 }) => {
730
+ const mag = typeof ev.params.intensity === "number" ? ev.params.intensity : 5;
731
+ anims.push(
732
+ el.animate(
733
+ [
734
+ { transform: tx2(state), offset: 0 },
735
+ { transform: tx2({ ...state, x: state.x + mag }), offset: 0.2 },
736
+ { transform: tx2({ ...state, x: state.x - mag }), offset: 0.4 },
737
+ { transform: tx2({ ...state, x: state.x + mag }), offset: 0.6 },
738
+ { transform: tx2({ ...state, x: state.x - mag }), offset: 0.8 },
739
+ { transform: tx2(state), offset: 1 }
740
+ ],
741
+ { ...baseOpts, easing: "linear" }
742
+ )
743
+ );
744
+ };
745
+ var jump = ({ ev, el, state, baseOpts, anims, tx: tx2 }) => {
746
+ const height = typeof ev.params.height === "number" ? ev.params.height : 30;
747
+ anims.push(
748
+ el.animate(
749
+ [
750
+ { transform: tx2(state), offset: 0 },
751
+ { transform: tx2({ ...state, scale: state.scale * 0.9 }), offset: 0.1 },
752
+ { transform: tx2({ ...state, y: state.y - height, scale: state.scale * 1.1 }), offset: 0.45 },
753
+ { transform: tx2({ ...state, y: state.y - height * 0.3, scale: state.scale * 1.05 }), offset: 0.7 },
754
+ { transform: tx2({ ...state, scale: state.scale * 0.92 }), offset: 0.88 },
755
+ { transform: tx2(state), offset: 1 }
756
+ ],
757
+ { ...baseOpts, easing: "ease-in-out" }
758
+ )
759
+ );
760
+ };
761
+ var bounce = ({ ev, el, state, baseOpts, anims, tx: tx2 }) => {
762
+ const intensity = typeof ev.params.intensity === "number" ? ev.params.intensity : 15;
763
+ const count = typeof ev.params.count === "number" ? ev.params.count : 3;
764
+ const keyframes = [{ transform: tx2(state), offset: 0 }];
765
+ for (let i = 0; i < count; i++) {
766
+ const amp = intensity * Math.pow(0.55, i);
767
+ const baseOffset = (i + 0.5) / (count + 0.5);
768
+ const peakOffset = Math.min(baseOffset, 0.98);
769
+ const valleyOffset = Math.min(baseOffset + 0.25 / (count + 0.5), 0.99);
770
+ keyframes.push({ transform: tx2({ ...state, y: state.y - amp }), offset: peakOffset });
771
+ if (i < count - 1) {
772
+ keyframes.push({ transform: tx2(state), offset: valleyOffset });
614
773
  }
615
- default:
616
- break;
617
774
  }
618
- }
619
- var FLOW_STROKE_BY_ACTION = {
620
- request: "#38bdf8",
621
- response: "#a78bfa",
622
- emit: "#f59e0b"
775
+ keyframes.push({ transform: tx2(state), offset: 1 });
776
+ anims.push(el.animate(keyframes, { ...baseOpts, easing: "ease-out" }));
623
777
  };
778
+
779
+ // src/geometry/rect.ts
780
+ var ACTOR_SIZES = {
781
+ service: { width: 180, height: 84 },
782
+ client: { width: 180, height: 84 },
783
+ db: { width: 180, height: 84 },
784
+ queue: { width: 180, height: 84 },
785
+ box: { width: 100, height: 100 },
786
+ caption: { width: 260, height: 56 },
787
+ figure: { width: 120, height: 170 }
788
+ };
789
+ var DEFAULT_ACTOR_SIZE = { width: 140, height: 42 };
624
790
  function actorSizeByType(type) {
625
- switch (type) {
626
- case "service":
627
- case "client":
628
- case "db":
629
- case "queue":
630
- return { width: 180, height: 84 };
631
- case "box":
632
- return { width: 100, height: 100 };
633
- case "caption":
634
- return { width: 260, height: 56 };
635
- case "figure":
636
- return { width: 120, height: 170 };
637
- default:
638
- return { width: 140, height: 42 };
639
- }
791
+ return ACTOR_SIZES[type] ?? DEFAULT_ACTOR_SIZE;
640
792
  }
641
793
  function actorCenter(state, actorType) {
642
794
  const { width, height } = actorSizeByType(actorType);
643
- return {
644
- x: state.x + width / 2,
645
- y: state.y + height / 2
646
- };
795
+ return { x: state.x + width / 2, y: state.y + height / 2 };
647
796
  }
648
797
  function actorRect(state, actorType) {
649
798
  const { width, height } = actorSizeByType(actorType);
650
- return {
651
- x1: state.x,
652
- y1: state.y,
653
- x2: state.x + width,
654
- y2: state.y + height
655
- };
799
+ return { x1: state.x, y1: state.y, x2: state.x + width, y2: state.y + height };
656
800
  }
657
801
  function inflateRect(rect, pad) {
658
802
  return {
@@ -690,12 +834,14 @@ function countPathIntersections(points, obstacles) {
690
834
  }
691
835
  return hits;
692
836
  }
693
- function toPathD(points) {
694
- return points.map((p, i) => `${i === 0 ? "M" : "L"} ${round1(p.x)} ${round1(p.y)}`).join(" ");
695
- }
837
+
838
+ // src/geometry/path.ts
696
839
  function round1(n) {
697
840
  return Math.round(n * 10) / 10;
698
841
  }
842
+ function toPathD(points) {
843
+ return points.map((p, i) => `${i === 0 ? "M" : "L"} ${round1(p.x)} ${round1(p.y)}`).join(" ");
844
+ }
699
845
  function polylineLength(points) {
700
846
  let total = 0;
701
847
  for (let i = 0; i < points.length - 1; i++) {
@@ -704,13 +850,13 @@ function polylineLength(points) {
704
850
  return Math.max(1, total);
705
851
  }
706
852
  function pointAtDistance(points, dist) {
707
- let remain = dist;
853
+ let remain = Math.max(0, dist);
708
854
  for (let i = 0; i < points.length - 1; i++) {
709
855
  const a = points[i];
710
856
  const b = points[i + 1];
711
857
  const seg = Math.hypot(b.x - a.x, b.y - a.y);
712
858
  if (remain <= seg || i === points.length - 2) {
713
- const t = seg <= 0 ? 0 : remain / seg;
859
+ const t = seg <= 0 ? 0 : Math.min(1, remain / seg);
714
860
  return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
715
861
  }
716
862
  remain -= seg;
@@ -718,11 +864,13 @@ function pointAtDistance(points, dist) {
718
864
  return points[0];
719
865
  }
720
866
  function routeFlowPath(sourceName, targetName, sourceState, targetState, states, ast, lane) {
721
- const sourceRect = actorRect(sourceState, ast.actors[sourceName]?.type ?? "box");
722
- const targetRect = actorRect(targetState, ast.actors[targetName]?.type ?? "box");
867
+ const sourceType = ast.actors[sourceName]?.type ?? "box";
868
+ const targetType = ast.actors[targetName]?.type ?? "box";
869
+ const sourceRect = actorRect(sourceState, sourceType);
870
+ const targetRect = actorRect(targetState, targetType);
723
871
  const laneShift = lane * 18;
724
- const sourceCenter = actorCenter(sourceState, ast.actors[sourceName]?.type ?? "box");
725
- const targetCenter = actorCenter(targetState, ast.actors[targetName]?.type ?? "box");
872
+ const sourceCenter = actorCenter(sourceState, sourceType);
873
+ const targetCenter = actorCenter(targetState, targetType);
726
874
  const horizontalPrimary = Math.abs(targetCenter.x - sourceCenter.x) >= Math.abs(targetCenter.y - sourceCenter.y);
727
875
  const source = horizontalPrimary ? { x: targetCenter.x >= sourceCenter.x ? sourceRect.x2 : sourceRect.x1, y: sourceCenter.y } : { x: sourceCenter.x, y: targetCenter.y >= sourceCenter.y ? sourceRect.y2 : sourceRect.y1 };
728
876
  const target = horizontalPrimary ? { x: targetCenter.x >= sourceCenter.x ? targetRect.x1 : targetRect.x2, y: targetCenter.y } : { x: targetCenter.x, y: targetCenter.y >= sourceCenter.y ? targetRect.y1 : targetRect.y2 };
@@ -762,11 +910,22 @@ function routeFlowPath(sourceName, targetName, sourceState, targetState, states,
762
910
  }
763
911
  return best;
764
912
  }
913
+
914
+ // src/actions/flow.ts
915
+ var EDGE_LAYER_ATTR = "data-markdy-edge-layer";
916
+ var STROKE_BY_ACTION = {
917
+ request: "#38bdf8",
918
+ response: "#a78bfa",
919
+ emit: "#f59e0b"
920
+ };
921
+ var DEFAULT_STROKE = "#38bdf8";
922
+ var LABEL_MAX_CHARS = 28;
923
+ var EDGE_FADE_MS = 140;
765
924
  function ensureEdgeLayer(scene) {
766
- const existing = scene.querySelector("svg[data-markdy-edge-layer='1']");
925
+ const existing = scene.querySelector(`svg[${EDGE_LAYER_ATTR}='1']`);
767
926
  if (existing) return existing;
768
927
  const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
769
- svg.setAttribute("data-markdy-edge-layer", "1");
928
+ svg.setAttribute(EDGE_LAYER_ATTR, "1");
770
929
  Object.assign(svg.style, {
771
930
  position: "absolute",
772
931
  inset: "0",
@@ -779,23 +938,19 @@ function ensureEdgeLayer(scene) {
779
938
  scene.appendChild(svg);
780
939
  return svg;
781
940
  }
782
- function renderFlowEdge(ev, sourceName, targetName, sourceState, targetState, states, ast, lane, scene, baseOpts, anims) {
783
- const styleToken = String(ev.params.style ?? "");
784
- const isDashed = styleToken === "dashed" || styleToken === "fire_and_forget" || ev.action === "response";
785
- const stroke = FLOW_STROKE_BY_ACTION[ev.action] ?? "#38bdf8";
786
- const points = routeFlowPath(
787
- sourceName,
788
- targetName,
789
- sourceState,
790
- targetState,
791
- states,
792
- ast,
793
- lane
794
- );
941
+ function isDashed(ctx) {
942
+ const style = String(ctx.ev.params.style ?? "");
943
+ return style === "dashed" || style === "fire_and_forget" || ctx.ev.action === "response";
944
+ }
945
+ function buildEdge(ctx, targetName) {
946
+ const { ev, state, states, ast, scene, baseOpts, anims } = ctx;
947
+ const targetState = states.get(targetName);
948
+ if (!targetState) return;
949
+ const lane = ev.line % 5 - 2;
950
+ const points = routeFlowPath(ev.actor, targetName, state, targetState, states, ast, lane);
795
951
  const length = polylineLength(points);
796
- const midPoint = pointAtDistance(points, length * 0.5);
797
952
  const pathD = toPathD(points);
798
- const svg = ensureEdgeLayer(scene);
953
+ const stroke = STROKE_BY_ACTION[ev.action] ?? DEFAULT_STROKE;
799
954
  const group = document.createElementNS("http://www.w3.org/2000/svg", "g");
800
955
  group.setAttribute("data-markdy-flow-edge", "1");
801
956
  const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
@@ -804,7 +959,7 @@ function renderFlowEdge(ev, sourceName, targetName, sourceState, targetState, st
804
959
  path.setAttribute("stroke", stroke);
805
960
  path.setAttribute("stroke-width", "2.5");
806
961
  path.setAttribute("data-markdy-flow-action", ev.action);
807
- path.style.strokeDasharray = isDashed ? "8 6" : `${length}`;
962
+ path.style.strokeDasharray = isDashed(ctx) ? "8 6" : `${length}`;
808
963
  path.style.strokeDashoffset = `${length}`;
809
964
  group.appendChild(path);
810
965
  const marker = document.createElementNS("http://www.w3.org/2000/svg", "circle");
@@ -814,491 +969,285 @@ function renderFlowEdge(ev, sourceName, targetName, sourceState, targetState, st
814
969
  marker.style.offsetDistance = "0%";
815
970
  marker.style.opacity = "0";
816
971
  group.appendChild(marker);
817
- const labelRaw = String(ev.params.label ?? "");
818
- if (labelRaw) {
819
- const label = labelRaw.length > 28 ? `${labelRaw.slice(0, 27)}\u2026` : labelRaw;
820
- const labelEl = document.createElementNS("http://www.w3.org/2000/svg", "text");
821
- labelEl.setAttribute("x", `${round1(midPoint.x)}`);
822
- labelEl.setAttribute("y", `${round1(midPoint.y - 8)}`);
823
- labelEl.setAttribute("text-anchor", "middle");
824
- labelEl.setAttribute("font-size", "12");
825
- labelEl.setAttribute("fill", "#cbd5e1");
826
- labelEl.textContent = label;
827
- labelEl.setAttribute("data-full-label", labelRaw);
828
- group.appendChild(labelEl);
972
+ const labelText = String(ev.params.label ?? "");
973
+ if (labelText) {
974
+ const midPoint = pointAtDistance(points, length * 0.5);
975
+ const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
976
+ label.setAttribute("x", `${round1(midPoint.x)}`);
977
+ label.setAttribute("y", `${round1(midPoint.y - 8)}`);
978
+ label.setAttribute("text-anchor", "middle");
979
+ label.setAttribute("font-size", "12");
980
+ label.setAttribute("fill", "#cbd5e1");
981
+ label.setAttribute("data-full-label", labelText);
982
+ label.textContent = labelText.length > LABEL_MAX_CHARS ? `${labelText.slice(0, LABEL_MAX_CHARS - 1)}\u2026` : labelText;
983
+ group.appendChild(label);
829
984
  }
830
- svg.appendChild(group);
831
- anims.push(path.animate([{ strokeDashoffset: length }, { strokeDashoffset: 0 }], baseOpts));
985
+ ensureEdgeLayer(scene).appendChild(group);
832
986
  anims.push(
987
+ path.animate([{ strokeDashoffset: length }, { strokeDashoffset: 0 }], baseOpts),
833
988
  marker.animate(
834
- [{ offsetDistance: "0%", opacity: 1 }, { offsetDistance: "100%", opacity: 1 }],
989
+ [
990
+ { offsetDistance: "0%", opacity: 1 },
991
+ { offsetDistance: "100%", opacity: 1 }
992
+ ],
835
993
  baseOpts
836
- )
994
+ ),
995
+ group.animate([{ opacity: 1 }, { opacity: 0 }], {
996
+ delay: Number(baseOpts.delay ?? 0) + Number(baseOpts.duration ?? 0),
997
+ duration: EDGE_FADE_MS,
998
+ fill: "forwards"
999
+ })
837
1000
  );
838
- const fadeOutDelay = Number(baseOpts.delay ?? 0) + Number(baseOpts.duration ?? 0);
1001
+ }
1002
+ var flowEdge = (ctx) => {
1003
+ const targetName = String(ctx.ev.params.to ?? "");
1004
+ if (targetName) buildEdge(ctx, targetName);
1005
+ };
1006
+
1007
+ // src/theme.ts
1008
+ var DARK_LUMINANCE_THRESHOLD = 140;
1009
+ var NAMED_DARK = {
1010
+ black: true,
1011
+ "#000": true,
1012
+ "#000000": true
1013
+ };
1014
+ function isSceneDark(scene) {
1015
+ const bg = scene.style.background || "white";
1016
+ let hex = bg.trim().replace(/^#/, "");
1017
+ if (hex.length === 3) hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
1018
+ if (hex.length === 6) {
1019
+ const r = parseInt(hex.slice(0, 2), 16);
1020
+ const g = parseInt(hex.slice(2, 4), 16);
1021
+ const b = parseInt(hex.slice(4, 6), 16);
1022
+ return 0.299 * r + 0.587 * g + 0.114 * b <= DARK_LUMINANCE_THRESHOLD;
1023
+ }
1024
+ return NAMED_DARK[bg.toLowerCase()] ?? false;
1025
+ }
1026
+ function speechBubbleTheme(dark) {
1027
+ return dark ? {
1028
+ background: "#1e2530",
1029
+ border: "#475569",
1030
+ text: "#e2e8f0",
1031
+ shadow: "0 2px 8px rgba(0,0,0,0.35)"
1032
+ } : {
1033
+ background: "white",
1034
+ border: "#222",
1035
+ text: "#222",
1036
+ shadow: "0 2px 8px rgba(0,0,0,0.12)"
1037
+ };
1038
+ }
1039
+
1040
+ // src/actions/speech.ts
1041
+ var MAX_FADE_MS = 200;
1042
+ var FADE_FRACTION = 0.15;
1043
+ var say = ({ ev, el, state, delayMs, durMs, scene, anims }) => {
1044
+ const text = String(ev.params.text ?? "");
1045
+ const theme = speechBubbleTheme(isSceneDark(scene));
1046
+ const inverseScale = 1 / (state.scale || 1);
1047
+ const bubble = document.createElement("div");
1048
+ bubble.textContent = text;
1049
+ Object.assign(bubble.style, {
1050
+ position: "absolute",
1051
+ bottom: "calc(100% + 10px)",
1052
+ left: "50%",
1053
+ transform: `translateX(-50%) scale(${inverseScale})`,
1054
+ transformOrigin: "center bottom",
1055
+ background: theme.background,
1056
+ border: `2px solid ${theme.border}`,
1057
+ color: theme.text,
1058
+ borderRadius: "12px",
1059
+ padding: "6px 14px",
1060
+ fontFamily: "system-ui, sans-serif",
1061
+ fontSize: "15px",
1062
+ lineHeight: "1.3",
1063
+ whiteSpace: "nowrap",
1064
+ maxWidth: "220px",
1065
+ overflow: "hidden",
1066
+ textOverflow: "ellipsis",
1067
+ pointerEvents: "none",
1068
+ zIndex: "10",
1069
+ boxShadow: theme.shadow,
1070
+ opacity: "0"
1071
+ });
1072
+ const tail = document.createElement("span");
1073
+ Object.assign(tail.style, {
1074
+ position: "absolute",
1075
+ bottom: "-10px",
1076
+ left: "50%",
1077
+ transform: "translateX(-50%)",
1078
+ width: "0",
1079
+ height: "0",
1080
+ borderLeft: "7px solid transparent",
1081
+ borderRight: "7px solid transparent",
1082
+ borderTop: `10px solid ${theme.border}`
1083
+ });
1084
+ bubble.appendChild(tail);
1085
+ el.style.overflow = "visible";
1086
+ el.appendChild(bubble);
1087
+ const fadeMs = Math.min(MAX_FADE_MS, durMs * FADE_FRACTION);
839
1088
  anims.push(
840
- group.animate([{ opacity: 1 }, { opacity: 0 }], {
841
- delay: fadeOutDelay,
842
- duration: 140,
1089
+ bubble.animate([{ opacity: 0 }, { opacity: 1 }], {
1090
+ delay: delayMs,
1091
+ duration: fadeMs,
1092
+ fill: "forwards"
1093
+ }),
1094
+ bubble.animate([{ opacity: 1 }, { opacity: 0 }], {
1095
+ delay: delayMs + durMs - fadeMs,
1096
+ duration: fadeMs,
843
1097
  fill: "forwards"
844
1098
  })
845
1099
  );
1100
+ };
1101
+
1102
+ // src/actions/projectile.ts
1103
+ var PROJECTILE_SIZE_PX = 32;
1104
+ function createProjectile(assetName, assetDef, assetOverrides) {
1105
+ if (assetDef.type === "image") {
1106
+ const img = document.createElement("img");
1107
+ img.src = assetOverrides[assetName] ?? assetDef.value;
1108
+ img.alt = assetName;
1109
+ img.setAttribute("draggable", "false");
1110
+ img.style.width = `${PROJECTILE_SIZE_PX}px`;
1111
+ img.style.height = `${PROJECTILE_SIZE_PX}px`;
1112
+ return img;
1113
+ }
1114
+ const span = document.createElement("span");
1115
+ span.className = "iconify";
1116
+ span.dataset.icon = assetDef.value;
1117
+ span.style.fontSize = `${PROJECTILE_SIZE_PX}px`;
1118
+ span.style.lineHeight = "1";
1119
+ span.style.display = "inline-block";
1120
+ return span;
846
1121
  }
847
- function buildAction(ev, el, s, baseOpts, delayMs, durMs, ast, states, _actorEls, scene, assetOverrides, faceSwaps, anims, txFn) {
848
- switch (ev.action) {
849
- case "request":
850
- case "response":
851
- case "emit": {
852
- const targetActorName = String(ev.params.to ?? "");
853
- if (!targetActorName) break;
854
- const targetState = states.get(targetActorName);
855
- if (!targetState) break;
856
- const lane = ev.line % 5 - 2;
857
- renderFlowEdge(
858
- ev,
859
- ev.actor,
860
- targetActorName,
861
- s,
862
- targetState,
863
- states,
864
- ast,
865
- lane,
866
- scene,
867
- baseOpts,
868
- anims
869
- );
870
- break;
871
- }
872
- // ── move ────────────────────────────────────────────────────────────────
873
- case "move": {
874
- const toArr = ev.params.to;
875
- const toX = toArr?.[0] ?? s.x;
876
- const toY = toArr?.[1] ?? s.y;
877
- anims.push(
878
- el.animate(
879
- [{ transform: txFn(s) }, { transform: txFn({ ...s, x: toX, y: toY }) }],
880
- baseOpts
881
- )
882
- );
883
- s.x = toX;
884
- s.y = toY;
885
- break;
886
- }
887
- // ── enter ───────────────────────────────────────────────────────────────
888
- // Slides the actor from the given screen edge into its current (s)
889
- // position while also restoring opacity to 1. The opacity restore makes
890
- // `enter` symmetric with `exit` so actors can re-enter after exiting
891
- // without needing a separate fade_in.
892
- case "enter": {
893
- const from = String(ev.params.from ?? "left");
894
- const fromState = offscreenState(s, from, ast.meta.width, ast.meta.height);
895
- anims.push(
896
- el.animate(
897
- [
898
- { transform: txFn(fromState), opacity: s.opacity },
899
- { transform: txFn(s), opacity: 1 }
900
- ],
901
- baseOpts
902
- )
903
- );
904
- s.opacity = 1;
905
- break;
906
- }
907
- // ── exit ────────────────────────────────────────────────────────────────
908
- // Mirror of enter: slides off-screen in the given direction while also
909
- // fading opacity to 0. Universal — works on any actor type (caption, text,
910
- // figure, box, sprite). Leaves `s` at the off-screen state since the
911
- // actor is conceptually gone after this.
912
- case "exit": {
913
- const to = String(ev.params.to ?? "right");
914
- const toState = offscreenState(s, to, ast.meta.width, ast.meta.height);
915
- anims.push(
916
- el.animate(
917
- [
918
- { transform: txFn(s), opacity: s.opacity },
919
- { transform: txFn(toState), opacity: 0 }
920
- ],
921
- baseOpts
922
- )
923
- );
924
- s.x = toState.x;
925
- s.y = toState.y;
926
- s.opacity = 0;
927
- break;
928
- }
929
- // ── fade_in ─────────────────────────────────────────────────────────────
930
- case "fade_in": {
931
- anims.push(el.animate([{ opacity: 0 }, { opacity: 1 }], baseOpts));
932
- s.opacity = 1;
933
- break;
934
- }
935
- // ── fade_out ────────────────────────────────────────────────────────────
936
- case "fade_out": {
937
- anims.push(
938
- el.animate([{ opacity: s.opacity }, { opacity: 0 }], baseOpts)
939
- );
940
- s.opacity = 0;
941
- break;
942
- }
943
- // ── scale ───────────────────────────────────────────────────────────────
944
- case "scale": {
945
- const toScale = typeof ev.params.to === "number" ? ev.params.to : s.scale;
946
- anims.push(
947
- el.animate(
948
- [{ transform: txFn(s) }, { transform: txFn({ ...s, scale: toScale }) }],
949
- baseOpts
950
- )
951
- );
952
- s.scale = toScale;
953
- break;
954
- }
955
- // ── rotate ──────────────────────────────────────────────────────────────
956
- case "rotate": {
957
- const toDeg = typeof ev.params.to === "number" ? ev.params.to : s.rotate;
958
- anims.push(
959
- el.animate(
960
- [{ transform: txFn(s) }, { transform: txFn({ ...s, rotate: toDeg }) }],
961
- baseOpts
962
- )
963
- );
964
- s.rotate = toDeg;
965
- break;
966
- }
967
- // ── shake ───────────────────────────────────────────────────────────────
968
- case "shake": {
969
- const mag = typeof ev.params.intensity === "number" ? ev.params.intensity : 5;
970
- anims.push(
971
- el.animate(
972
- [
973
- { transform: txFn(s), offset: 0 },
974
- { transform: txFn({ ...s, x: s.x + mag }), offset: 0.2 },
975
- { transform: txFn({ ...s, x: s.x - mag }), offset: 0.4 },
976
- { transform: txFn({ ...s, x: s.x + mag }), offset: 0.6 },
977
- { transform: txFn({ ...s, x: s.x - mag }), offset: 0.8 },
978
- { transform: txFn(s), offset: 1 }
979
- ],
980
- { ...baseOpts, easing: "linear" }
981
- )
982
- );
983
- break;
984
- }
985
- // ── punch ───────────────────────────────────────────────────────────────
986
- case "punch": {
987
- const pSide = String(ev.params.side ?? "right");
988
- const pArmEl = el.querySelector(
989
- pSide === "left" ? "[data-fig-arm-l]" : "[data-fig-arm-r]"
990
- );
991
- if (!pArmEl) break;
992
- const pRest = readRotation(pArmEl);
993
- const pExtend = pSide === "left" ? -75 : 75;
994
- anims.push(
995
- pArmEl.animate(
996
- [
997
- { transform: `rotate(${pRest}deg)` },
998
- { transform: `rotate(${pExtend}deg)`, offset: 0.35 },
999
- { transform: `rotate(${pRest}deg)` }
1000
- ],
1001
- { ...baseOpts, easing: "ease-in-out", fill: "forwards" }
1002
- )
1003
- );
1004
- break;
1005
- }
1006
- // ── kick ────────────────────────────────────────────────────────────────
1007
- case "kick": {
1008
- const kSide = String(ev.params.side ?? "right");
1009
- const kLegEl = el.querySelector(
1010
- kSide === "left" ? "[data-fig-leg-l]" : "[data-fig-leg-r]"
1011
- );
1012
- if (!kLegEl) break;
1013
- const kRest = readRotation(kLegEl);
1014
- const kExtend = kSide === "left" ? -100 : 100;
1015
- anims.push(
1016
- kLegEl.animate(
1017
- [
1018
- { transform: `rotate(${kRest}deg)` },
1019
- { transform: `rotate(${kExtend}deg)`, offset: 0.38 },
1020
- { transform: `rotate(${kRest}deg)` }
1021
- ],
1022
- { ...baseOpts, easing: "ease-in-out", fill: "forwards" }
1023
- )
1024
- );
1025
- break;
1026
- }
1027
- // ── rotate_part ─────────────────────────────────────────────────────────
1028
- case "rotate_part": {
1029
- const rpName = String(ev.params.part ?? "");
1030
- const rpSel = PART_SEL[rpName];
1031
- if (!rpSel) break;
1032
- const rpEl = el.querySelector(rpSel);
1033
- if (!rpEl) break;
1034
- const rpFrom = readRotation(rpEl);
1035
- const rpTo = typeof ev.params.to === "number" ? ev.params.to : rpFrom;
1036
- anims.push(
1037
- rpEl.animate(
1038
- [
1039
- { transform: `rotate(${rpFrom}deg)` },
1040
- { transform: `rotate(${rpTo}deg)` }
1041
- ],
1042
- { ...baseOpts, fill: "forwards" }
1043
- )
1044
- );
1045
- rpEl.style.transform = rpEl.style.transform.replace(
1046
- /rotate\([^)]*\)/,
1047
- `rotate(${rpTo}deg)`
1048
- );
1049
- break;
1050
- }
1051
- // ── face ────────────────────────────────────────────────────────────────
1052
- case "face": {
1053
- const fEl = el.querySelector("[data-fig-face]");
1054
- if (!fEl) break;
1055
- const emoji = String(ev.params.text ?? ev.params._0 ?? "");
1056
- if (emoji) faceSwaps.push({ timeMs: ev.time * 1e3, el: fEl, emoji });
1057
- break;
1058
- }
1059
- // ── say ─────────────────────────────────────────────────────────────────
1060
- case "say": {
1061
- const text = String(ev.params.text ?? "");
1062
- const inverseScale = 1 / (s.scale || 1);
1063
- const sceneDark = isSceneDark(scene);
1064
- const bubbleBg = sceneDark ? "#1e2530" : "white";
1065
- const bubbleBorder = sceneDark ? "#475569" : "#222";
1066
- const bubbleText = sceneDark ? "#e2e8f0" : "#222";
1067
- const bubbleShadow = sceneDark ? "0 2px 8px rgba(0,0,0,0.35)" : "0 2px 8px rgba(0,0,0,0.12)";
1068
- const bubble = document.createElement("div");
1069
- bubble.textContent = text;
1070
- bubble.style.opacity = "0";
1071
- Object.assign(bubble.style, {
1072
- position: "absolute",
1073
- bottom: "calc(100% + 10px)",
1074
- left: "50%",
1075
- transform: `translateX(-50%) scale(${inverseScale})`,
1076
- transformOrigin: "center bottom",
1077
- background: bubbleBg,
1078
- border: `2px solid ${bubbleBorder}`,
1079
- color: bubbleText,
1080
- borderRadius: "12px",
1081
- padding: "6px 14px",
1082
- fontFamily: "system-ui, sans-serif",
1083
- fontSize: "15px",
1084
- lineHeight: "1.3",
1085
- whiteSpace: "nowrap",
1086
- maxWidth: "220px",
1087
- overflow: "hidden",
1088
- textOverflow: "ellipsis",
1089
- pointerEvents: "none",
1090
- zIndex: "10",
1091
- boxShadow: bubbleShadow
1092
- });
1093
- const tail = document.createElement("span");
1094
- Object.assign(tail.style, {
1095
- position: "absolute",
1096
- bottom: "-10px",
1097
- left: "50%",
1098
- transform: "translateX(-50%)",
1099
- width: "0",
1100
- height: "0",
1101
- borderLeft: "7px solid transparent",
1102
- borderRight: "7px solid transparent",
1103
- borderTop: `10px solid ${bubbleBorder}`
1104
- });
1105
- bubble.appendChild(tail);
1106
- el.style.overflow = "visible";
1107
- el.appendChild(bubble);
1108
- const fadeDur = Math.min(200, durMs * 0.15);
1109
- anims.push(
1110
- bubble.animate([{ opacity: 0 }, { opacity: 1 }], {
1111
- delay: delayMs,
1112
- duration: fadeDur,
1113
- fill: "forwards"
1114
- }),
1115
- bubble.animate([{ opacity: 1 }, { opacity: 0 }], {
1116
- delay: delayMs + durMs - fadeDur,
1117
- duration: fadeDur,
1118
- fill: "forwards"
1119
- })
1120
- );
1121
- break;
1122
- }
1123
- // ── pose ────────────────────────────────────────────────────────────────
1124
- // Sets multiple body parts to target angles simultaneously.
1125
- // Usage: @1.0: hero.pose(arm_left=45, arm_right=-45, leg_left=10, dur=0.4)
1126
- case "pose": {
1127
- const poseParts = ["arm_left", "arm_right", "leg_left", "leg_right", "head", "body"];
1128
- for (const partName of poseParts) {
1129
- if (typeof ev.params[partName] !== "number") continue;
1130
- const pSel = PART_SEL[partName];
1131
- if (!pSel) continue;
1132
- const pEl = el.querySelector(pSel);
1133
- if (!pEl) continue;
1134
- const fromDeg = readRotation(pEl);
1135
- const toDeg = ev.params[partName];
1136
- anims.push(
1137
- pEl.animate(
1138
- [
1139
- { transform: `rotate(${fromDeg}deg)` },
1140
- { transform: `rotate(${toDeg}deg)` }
1141
- ],
1142
- { ...baseOpts, fill: "forwards" }
1143
- )
1144
- );
1145
- pEl.style.transform = pEl.style.transform.replace(
1146
- /rotate\([^)]*\)/,
1147
- `rotate(${toDeg}deg)`
1148
- );
1149
- }
1150
- break;
1151
- }
1152
- // ── wave ────────────────────────────────────────────────────────────────
1153
- // Built-in wave gesture: raises arm, oscillates, returns.
1154
- // Usage: @2.0: hero.wave(side=right, dur=0.8)
1155
- case "wave": {
1156
- const wSide = String(ev.params.side ?? "right");
1157
- const wArmEl = el.querySelector(
1158
- wSide === "left" ? "[data-fig-arm-l]" : "[data-fig-arm-r]"
1159
- );
1160
- if (!wArmEl) break;
1161
- const wRest = readRotation(wArmEl);
1162
- const wUp = wSide === "left" ? 70 : -70;
1163
- const wMid1 = wSide === "left" ? 50 : -50;
1164
- const wMid2 = wSide === "left" ? 70 : -70;
1165
- anims.push(
1166
- wArmEl.animate(
1167
- [
1168
- { transform: `rotate(${wRest}deg)`, offset: 0 },
1169
- { transform: `rotate(${wUp}deg)`, offset: 0.2 },
1170
- { transform: `rotate(${wMid1}deg)`, offset: 0.4 },
1171
- { transform: `rotate(${wMid2}deg)`, offset: 0.55 },
1172
- { transform: `rotate(${wMid1}deg)`, offset: 0.7 },
1173
- { transform: `rotate(${wMid2}deg)`, offset: 0.85 },
1174
- { transform: `rotate(${wRest}deg)`, offset: 1 }
1175
- ],
1176
- { ...baseOpts, easing: "ease-in-out", fill: "forwards" }
1177
- )
1178
- );
1179
- break;
1180
- }
1181
- // ── jump ────────────────────────────────────────────────────────────────
1182
- // Jumps the actor up with squash/stretch effect.
1183
- // Usage: @3.0: hero.jump(height=30, dur=0.5)
1184
- case "jump": {
1185
- const jHeight = typeof ev.params.height === "number" ? ev.params.height : 30;
1186
- anims.push(
1187
- el.animate(
1188
- [
1189
- { transform: txFn(s), offset: 0 },
1190
- { transform: txFn({ ...s, scale: s.scale * 0.9 }), offset: 0.1 },
1191
- { transform: txFn({ ...s, y: s.y - jHeight, scale: s.scale * 1.1 }), offset: 0.45 },
1192
- { transform: txFn({ ...s, y: s.y - jHeight * 0.3, scale: s.scale * 1.05 }), offset: 0.7 },
1193
- { transform: txFn({ ...s, scale: s.scale * 0.92 }), offset: 0.88 },
1194
- { transform: txFn(s), offset: 1 }
1195
- ],
1196
- { ...baseOpts, easing: "ease-in-out" }
1197
- )
1198
- );
1199
- break;
1200
- }
1201
- // ── nod ─────────────────────────────────────────────────────────────────
1202
- // Nods the head down and back up. Figure actors only.
1203
- // Usage: @2.0: hero.nod(dur=0.4)
1204
- case "nod": {
1205
- const nHeadEl = el.querySelector("[data-fig-head]");
1206
- if (!nHeadEl) break;
1207
- const nRest = readRotation(nHeadEl);
1208
- const nDown = 15;
1209
- anims.push(
1210
- nHeadEl.animate(
1211
- [
1212
- { transform: `rotate(${nRest}deg)`, offset: 0 },
1213
- { transform: `rotate(${nDown}deg)`, offset: 0.35 },
1214
- { transform: `rotate(${nRest}deg)`, offset: 0.65 },
1215
- { transform: `rotate(${nDown}deg)`, offset: 0.8 },
1216
- { transform: `rotate(${nRest}deg)`, offset: 1 }
1217
- ],
1218
- { ...baseOpts, easing: "ease-in-out", fill: "forwards" }
1219
- )
1220
- );
1221
- break;
1222
- }
1223
- // ── bounce ──────────────────────────────────────────────────────────────
1224
- // Bounces the actor vertically with diminishing amplitude.
1225
- // Usage: @1.0: hero.bounce(intensity=15, count=3, dur=0.6)
1226
- case "bounce": {
1227
- const bIntensity = typeof ev.params.intensity === "number" ? ev.params.intensity : 15;
1228
- const bCount = typeof ev.params.count === "number" ? ev.params.count : 3;
1229
- const keyframes = [{ transform: txFn(s), offset: 0 }];
1230
- for (let bi = 0; bi < bCount; bi++) {
1231
- const amp = bIntensity * Math.pow(0.55, bi);
1232
- const baseOffset = (bi + 0.5) / (bCount + 0.5);
1233
- const peakOffset = Math.min(baseOffset, 0.98);
1234
- const valleyOffset = Math.min(baseOffset + 0.25 / (bCount + 0.5), 0.99);
1235
- keyframes.push({
1236
- transform: txFn({ ...s, y: s.y - amp }),
1237
- offset: peakOffset
1238
- });
1239
- if (bi < bCount - 1) {
1240
- keyframes.push({
1241
- transform: txFn(s),
1242
- offset: valleyOffset
1243
- });
1244
- }
1245
- }
1246
- keyframes.push({ transform: txFn(s), offset: 1 });
1247
- anims.push(
1248
- el.animate(keyframes, { ...baseOpts, easing: "ease-out" })
1249
- );
1250
- break;
1251
- }
1252
- // ── throw ───────────────────────────────────────────────────────────────
1253
- case "throw": {
1254
- const assetName = String(ev.params.asset ?? "");
1255
- const targetActorName = String(ev.params.to ?? "");
1256
- const targetState = states.get(targetActorName);
1257
- const assetDef = ast.assets[assetName];
1258
- if (!assetDef || !targetState) break;
1259
- let projectile;
1260
- if (assetDef.type === "image") {
1261
- const img = document.createElement("img");
1262
- img.src = assetOverrides[assetName] ?? assetDef.value;
1263
- img.alt = assetName;
1264
- img.setAttribute("draggable", "false");
1265
- img.style.width = "32px";
1266
- img.style.height = "32px";
1267
- projectile = img;
1268
- } else {
1269
- const span = document.createElement("span");
1270
- span.className = "iconify";
1271
- span.dataset.icon = assetDef.value;
1272
- span.style.fontSize = "32px";
1273
- span.style.lineHeight = "1";
1274
- span.style.display = "inline-block";
1275
- projectile = span;
1276
- }
1277
- Object.assign(projectile.style, {
1278
- position: "absolute",
1279
- left: "0",
1280
- top: "0",
1281
- pointerEvents: "none",
1282
- zIndex: "9",
1283
- opacity: "0"
1284
- });
1285
- scene.appendChild(projectile);
1286
- const throwAnim = projectile.animate(
1287
- [
1288
- { transform: tx(s), opacity: 1 },
1289
- { transform: tx(targetState), opacity: 0 }
1290
- ],
1291
- { ...baseOpts, easing: "ease-in" }
1292
- );
1293
- throwAnim.addEventListener("finish", () => {
1294
- if (projectile.parentNode === scene) scene.removeChild(projectile);
1295
- });
1296
- anims.push(throwAnim);
1297
- break;
1122
+ var throwAsset = ({
1123
+ ev,
1124
+ state,
1125
+ baseOpts,
1126
+ ast,
1127
+ states,
1128
+ scene,
1129
+ assetOverrides,
1130
+ anims
1131
+ }) => {
1132
+ const assetName = String(ev.params.asset ?? "");
1133
+ const targetState = states.get(String(ev.params.to ?? ""));
1134
+ const assetDef = ast.assets[assetName];
1135
+ if (!assetDef || !targetState) return;
1136
+ const projectile = createProjectile(assetName, assetDef, assetOverrides);
1137
+ Object.assign(projectile.style, {
1138
+ position: "absolute",
1139
+ left: "0",
1140
+ top: "0",
1141
+ pointerEvents: "none",
1142
+ zIndex: "9",
1143
+ opacity: "0"
1144
+ });
1145
+ scene.appendChild(projectile);
1146
+ const anim = projectile.animate(
1147
+ [
1148
+ { transform: tx(state), opacity: 1 },
1149
+ { transform: tx(targetState), opacity: 0 }
1150
+ ],
1151
+ { ...baseOpts, easing: "ease-in" }
1152
+ );
1153
+ anim.addEventListener("finish", () => {
1154
+ if (projectile.parentNode === scene) scene.removeChild(projectile);
1155
+ });
1156
+ anims.push(anim);
1157
+ };
1158
+
1159
+ // src/actions/registry.ts
1160
+ var ACTION_HANDLERS = Object.freeze({
1161
+ // Universal — every actor type
1162
+ move,
1163
+ enter,
1164
+ exit,
1165
+ fade_in: fadeIn,
1166
+ fade_out: fadeOut,
1167
+ scale,
1168
+ rotate,
1169
+ shake,
1170
+ jump,
1171
+ bounce,
1172
+ say,
1173
+ throw: throwAsset,
1174
+ // Figure-only the parser rejects these on other actor types
1175
+ punch,
1176
+ kick,
1177
+ wave,
1178
+ nod,
1179
+ rotate_part: rotatePart,
1180
+ pose,
1181
+ face,
1182
+ // System-diagram flow edges (@markdy/stdlib-systems)
1183
+ request: flowEdge,
1184
+ response: flowEdge,
1185
+ emit: flowEdge
1186
+ });
1187
+ function handlerFor(action) {
1188
+ return ACTION_HANDLERS[action];
1189
+ }
1190
+
1191
+ // src/animations.ts
1192
+ var DEFAULT_DURATION_S = 0.5;
1193
+ var DEFAULT_EASE_BY_ACTION = {
1194
+ enter: "out",
1195
+ fade_in: "out",
1196
+ exit: "in",
1197
+ fade_out: "in"
1198
+ };
1199
+ function buildAnimations(ast, actorEls, scene, assetOverrides, faceSwaps) {
1200
+ const anims = [];
1201
+ const states = /* @__PURE__ */ new Map();
1202
+ for (const [name, def] of Object.entries(ast.actors)) {
1203
+ states.set(name, stateFrom(def));
1204
+ }
1205
+ const captionActors = new Set(
1206
+ Object.entries(ast.actors).filter(([, def]) => def.type === "caption").map(([name]) => name)
1207
+ );
1208
+ const txFor = (actorName) => captionActors.has(actorName) ? txCaption : tx;
1209
+ const events = [...ast.events].sort((a, b) => a.time - b.time);
1210
+ preInitInlineStyles(ast, actorEls, states, events, txFor);
1211
+ const cameraState = freshCameraState();
1212
+ for (const ev of events) {
1213
+ const delayMs = ev.time * 1e3;
1214
+ const durMs = Math.max(
1215
+ 1,
1216
+ (typeof ev.params.dur === "number" ? ev.params.dur : DEFAULT_DURATION_S) * 1e3
1217
+ );
1218
+ const baseOpts = {
1219
+ delay: delayMs,
1220
+ duration: durMs,
1221
+ fill: "forwards",
1222
+ easing: toEasing(ev.params.ease ?? DEFAULT_EASE_BY_ACTION[ev.action])
1223
+ };
1224
+ if (ev.actor === "camera") {
1225
+ buildCameraAction(ev, scene, ast, baseOpts, anims, cameraState);
1226
+ continue;
1298
1227
  }
1299
- default:
1300
- break;
1228
+ const el = actorEls.get(ev.actor);
1229
+ const state = states.get(ev.actor);
1230
+ const handler = handlerFor(ev.action);
1231
+ if (!el || !state || !handler) continue;
1232
+ const ctx = {
1233
+ ev,
1234
+ el,
1235
+ state,
1236
+ baseOpts,
1237
+ delayMs,
1238
+ durMs,
1239
+ ast,
1240
+ states,
1241
+ actorEls,
1242
+ scene,
1243
+ assetOverrides,
1244
+ faceSwaps,
1245
+ anims,
1246
+ tx: txFor(ev.actor)
1247
+ };
1248
+ handler(ctx);
1301
1249
  }
1250
+ return anims;
1302
1251
  }
1303
1252
 
1304
1253
  // src/player.ts
@@ -1328,10 +1277,13 @@ function createPlayer(opts) {
1328
1277
  loop = true,
1329
1278
  copyright = true,
1330
1279
  progressBar = true,
1331
- onWarning = (w) => console.warn(`[markdy] line ${w.line}: ${w.message} (${w.kind})`)
1280
+ onWarning = (w) => console.warn(`[markdy] line ${w.line}: ${w.message} (${w.kind})`),
1281
+ onTimeUpdate,
1282
+ onPlayStateChange
1332
1283
  } = opts;
1333
1284
  const ast = parse(code, imports ? { imports } : void 0);
1334
1285
  const totalDurationMs = (ast.meta.duration ?? 0) * 1e3;
1286
+ const durationSeconds = totalDurationMs / 1e3;
1335
1287
  for (const w of ast.warnings) onWarning(w);
1336
1288
  const viewport = document.createElement("div");
1337
1289
  Object.assign(viewport.style, {
@@ -1456,6 +1408,7 @@ function createPlayer(opts) {
1456
1408
  anim.currentTime = sceneMs;
1457
1409
  }
1458
1410
  applyFaceSwaps();
1411
+ onTimeUpdate?.(sceneMs / 1e3, durationSeconds);
1459
1412
  }
1460
1413
  function applyFaceSwaps() {
1461
1414
  if (faceSwaps.length === 0) return;
@@ -1489,6 +1442,7 @@ function createPlayer(opts) {
1489
1442
  sceneMs = totalDurationMs;
1490
1443
  applyCurrentTime();
1491
1444
  isPlaying = false;
1445
+ onPlayStateChange?.(false);
1492
1446
  lastRafTs = null;
1493
1447
  rafId = null;
1494
1448
  return;
@@ -1502,12 +1456,14 @@ function createPlayer(opts) {
1502
1456
  play() {
1503
1457
  if (isPlaying) return;
1504
1458
  isPlaying = true;
1459
+ onPlayStateChange?.(true);
1505
1460
  lastRafTs = null;
1506
1461
  rafId = requestAnimationFrame(rafTick);
1507
1462
  },
1508
1463
  pause() {
1509
1464
  if (!isPlaying) return;
1510
1465
  isPlaying = false;
1466
+ onPlayStateChange?.(false);
1511
1467
  if (rafId !== null) {
1512
1468
  cancelAnimationFrame(rafId);
1513
1469
  rafId = null;
@@ -1515,10 +1471,28 @@ function createPlayer(opts) {
1515
1471
  lastRafTs = null;
1516
1472
  },
1517
1473
  seek(seconds) {
1518
- sceneMs = seconds * 1e3;
1474
+ const nextMs = seconds * 1e3;
1475
+ sceneMs = totalDurationMs > 0 ? Math.min(Math.max(nextMs, 0), totalDurationMs) : Math.max(nextMs, 0);
1519
1476
  applyCurrentTime();
1520
1477
  if (totalDurationMs > 0) updateProgressBar(sceneMs / totalDurationMs);
1521
1478
  },
1479
+ currentTime() {
1480
+ return sceneMs / 1e3;
1481
+ },
1482
+ duration() {
1483
+ return durationSeconds;
1484
+ },
1485
+ isPlaying() {
1486
+ return isPlaying;
1487
+ },
1488
+ chapters() {
1489
+ return ast.chapters;
1490
+ },
1491
+ seekToChapter(name) {
1492
+ const chapter = ast.chapters.find((c) => c.name === name);
1493
+ if (!chapter) return;
1494
+ player.seek(chapter.startTime);
1495
+ },
1522
1496
  destroy() {
1523
1497
  player.pause();
1524
1498
  for (const anim of allAnims) anim.cancel();