sketchmark 0.2.8 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -66,6 +66,15 @@ const KEYWORDS = new Set([
66
66
  "tree",
67
67
  "force",
68
68
  "markdown",
69
+ "narrate",
70
+ "pace",
71
+ "slow",
72
+ "fast",
73
+ "pause",
74
+ "beat",
75
+ "underline",
76
+ "crossout",
77
+ "bracket",
69
78
  ]);
70
79
  const ARROW_PATTERNS = ["<-->", "<->", "-->", "<--", "->", "<-", "---", "--"];
71
80
  // Characters that can start an arrow pattern — used to decide whether a '-'
@@ -675,7 +684,18 @@ function parse(src) {
675
684
  target = `${toks[1].value}${toks[2].value}${toks[3].value}`;
676
685
  }
677
686
  const step = { kind: "step", action, target };
678
- for (let j = 2; j < toks.length; j++) {
687
+ // narrate: text is the value, not a target
688
+ if (action === "narrate") {
689
+ step.target = "";
690
+ step.value = toks[1]?.value ?? "";
691
+ }
692
+ // bracket: needs two targets
693
+ if (action === "bracket" && toks.length >= 3) {
694
+ step.target = toks[1]?.value ?? "";
695
+ step.target2 = toks[2]?.value ?? "";
696
+ }
697
+ const kvStart = action === "bracket" ? 3 : 2;
698
+ for (let j = kvStart; j < toks.length; j++) {
679
699
  const k = toks[j]?.value;
680
700
  const eq = toks[j + 1];
681
701
  const vt = toks[j + 2];
@@ -721,6 +741,11 @@ function parse(src) {
721
741
  j += 2;
722
742
  continue;
723
743
  }
744
+ if (k === "pace") {
745
+ step.pace = vt.value;
746
+ j += 2;
747
+ continue;
748
+ }
724
749
  }
725
750
  // bare key value (legacy)
726
751
  if (k === "delay" && eq?.type === "NUMBER") {
@@ -1116,6 +1141,31 @@ function parse(src) {
1116
1141
  ast.rootOrder.push({ kind: "node", id: note.id });
1117
1142
  continue;
1118
1143
  }
1144
+ // beat { ... } — parallel steps
1145
+ if (v === "beat") {
1146
+ skip(); // 'beat'
1147
+ skipNL();
1148
+ if (cur().type === "LBRACE") {
1149
+ skip();
1150
+ skipNL();
1151
+ }
1152
+ const children = [];
1153
+ while (cur().type !== "RBRACE" && cur().value !== "end" && cur().type !== "EOF") {
1154
+ skipNL();
1155
+ if (cur().type === "RBRACE")
1156
+ break;
1157
+ if (cur().value === "step") {
1158
+ children.push(parseStep());
1159
+ }
1160
+ else {
1161
+ skip();
1162
+ }
1163
+ }
1164
+ if (cur().type === "RBRACE")
1165
+ skip();
1166
+ ast.steps.push({ kind: "beat", children });
1167
+ continue;
1168
+ }
1119
1169
  // step
1120
1170
  if (v === "step") {
1121
1171
  ast.steps.push(parseStep());
@@ -1307,6 +1357,20 @@ const ANIMATION = {
1307
1357
  fillFadeOffset: -60, // fill-opacity start relative to stroke end (ms)
1308
1358
  textDelay: 80, // extra buffer before text reveals (ms)
1309
1359
  chartFade: 500, // chart/markdown opacity transition (ms)
1360
+ // Pace
1361
+ paceSlowMul: 2.0, // slow pace duration multiplier
1362
+ paceFastMul: 0.5, // fast pace duration multiplier
1363
+ pauseHoldMs: 1500, // extra hold time for pause pace (ms)
1364
+ // Narration
1365
+ narrationFadeMs: 300, // caption fade-in/out duration (ms)
1366
+ narrationTypeMs: 30, // per-character typing speed for narration (ms)
1367
+ // Text writing reveal
1368
+ textRevealMs: 400, // text clip-reveal duration (ms)
1369
+ // Annotations
1370
+ annotationStrokeDur: 300, // annotation draw-in duration (ms)
1371
+ annotationColor: '#c85428', // default annotation color
1372
+ annotationStrokeW: 2.5, // annotation stroke width
1373
+ pointerSize: 8, // default pointer dot radius
1310
1374
  };
1311
1375
  // ── Export defaults ────────────────────────────────────────
1312
1376
  const EXPORT = {
@@ -6842,6 +6906,51 @@ function prepareNodeForDraw(el) {
6842
6906
  function revealNodeInstant(el) {
6843
6907
  clearNodeDrawStyles(el);
6844
6908
  }
6909
+ // ── Text writing reveal (clipPath) ───────────────────────
6910
+ function animateTextReveal(textEl, delayMs, durationMs = ANIMATION.textRevealMs) {
6911
+ const ownerSvg = textEl.ownerSVGElement;
6912
+ if (!ownerSvg) {
6913
+ // fallback: just fade
6914
+ textEl.style.transition = `opacity ${ANIMATION.textFade}ms ease ${delayMs}ms`;
6915
+ textEl.style.opacity = "1";
6916
+ return;
6917
+ }
6918
+ // Make text visible but clipped to zero width
6919
+ textEl.style.opacity = "1";
6920
+ // We need to wait for text to be visible before we can measure it
6921
+ setTimeout(() => {
6922
+ const bbox = textEl.getBBox?.();
6923
+ if (!bbox || bbox.width === 0) {
6924
+ // fallback if can't measure
6925
+ return;
6926
+ }
6927
+ let defs = ownerSvg.querySelector("defs");
6928
+ if (!defs) {
6929
+ defs = document.createElementNS(SVG_NS$1, "defs");
6930
+ ownerSvg.insertBefore(defs, ownerSvg.firstChild);
6931
+ }
6932
+ const clipId = `skm-clip-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
6933
+ const clipPath = document.createElementNS(SVG_NS$1, "clipPath");
6934
+ clipPath.setAttribute("id", clipId);
6935
+ const rect = document.createElementNS(SVG_NS$1, "rect");
6936
+ rect.setAttribute("x", String(bbox.x - 2));
6937
+ rect.setAttribute("y", String(bbox.y - 2));
6938
+ rect.setAttribute("width", "0");
6939
+ rect.setAttribute("height", String(bbox.height + 4));
6940
+ clipPath.appendChild(rect);
6941
+ defs.appendChild(clipPath);
6942
+ textEl.setAttribute("clip-path", `url(#${clipId})`);
6943
+ requestAnimationFrame(() => requestAnimationFrame(() => {
6944
+ rect.style.transition = `width ${durationMs}ms cubic-bezier(.4,0,.2,1)`;
6945
+ rect.setAttribute("width", String(bbox.width + 4));
6946
+ }));
6947
+ // Cleanup after animation
6948
+ setTimeout(() => {
6949
+ textEl.removeAttribute("clip-path");
6950
+ clipPath.remove();
6951
+ }, durationMs + 50);
6952
+ }, delayMs);
6953
+ }
6845
6954
  function animateNodeDraw(el, strokeDur = ANIMATION.nodeStrokeDur) {
6846
6955
  const guide = nodeGuidePathEl(el);
6847
6956
  if (!guide) {
@@ -6865,12 +6974,11 @@ function animateNodeDraw(el, strokeDur = ANIMATION.nodeStrokeDur) {
6865
6974
  p.style.opacity = "1";
6866
6975
  });
6867
6976
  if (text) {
6868
- text.style.transition = `opacity ${ANIMATION.textFade}ms ease ${textDelay}ms`;
6869
- text.style.opacity = "1";
6977
+ animateTextReveal(text, textDelay);
6870
6978
  }
6871
6979
  setTimeout(() => {
6872
6980
  clearNodeDrawStyles(el);
6873
- }, textDelay + ANIMATION.textFade + 40);
6981
+ }, textDelay + ANIMATION.textRevealMs + 80);
6874
6982
  }));
6875
6983
  }
6876
6984
  // ── Arrow connector parser ────────────────────────────────
@@ -6887,10 +6995,21 @@ function parseEdgeTarget(target) {
6887
6995
  }
6888
6996
  return null;
6889
6997
  }
6998
+ // ── Step flattening helper ────────────────────────────────
6999
+ function flattenSteps(items) {
7000
+ const out = [];
7001
+ for (const item of items) {
7002
+ if (item.kind === "beat")
7003
+ out.push(...item.children);
7004
+ else
7005
+ out.push(item);
7006
+ }
7007
+ return out;
7008
+ }
6890
7009
  // ── Draw target helpers ───────────────────────────────────
6891
7010
  function getDrawTargetEdgeIds(steps) {
6892
7011
  const ids = new Set();
6893
- for (const s of steps) {
7012
+ for (const s of flattenSteps(steps)) {
6894
7013
  if (s.action !== "draw")
6895
7014
  continue;
6896
7015
  const e = parseEdgeTarget(s.target);
@@ -6901,7 +7020,7 @@ function getDrawTargetEdgeIds(steps) {
6901
7020
  }
6902
7021
  function getDrawTargetNodeIds(steps) {
6903
7022
  const ids = new Set();
6904
- for (const s of steps) {
7023
+ for (const s of flattenSteps(steps)) {
6905
7024
  if (s.action !== "draw" || parseEdgeTarget(s.target))
6906
7025
  continue;
6907
7026
  ids.add(`node-${s.target}`);
@@ -7038,29 +7157,41 @@ class AnimationController {
7038
7157
  get drawTargets() {
7039
7158
  return this.drawTargetEdges;
7040
7159
  }
7041
- constructor(svg, steps) {
7160
+ constructor(svg, steps, _container, _rc, _config) {
7042
7161
  this.svg = svg;
7043
7162
  this.steps = steps;
7163
+ this._container = _container;
7164
+ this._rc = _rc;
7165
+ this._config = _config;
7044
7166
  this._step = -1;
7045
7167
  this._pendingStepTimers = new Set();
7046
7168
  this._transforms = new Map();
7047
7169
  this._listeners = [];
7170
+ // ── Narration caption ──
7171
+ this._captionEl = null;
7172
+ this._captionTextEl = null;
7173
+ // ── Annotations ──
7174
+ this._annotationLayer = null;
7175
+ this._annotations = [];
7176
+ // ── Pointer ──
7177
+ this._pointerEl = null;
7178
+ this._pointerType = 'none';
7179
+ // ── TTS ──
7180
+ this._tts = false;
7181
+ this._speechDone = null;
7048
7182
  this.drawTargetEdges = getDrawTargetEdgeIds(steps);
7049
7183
  this.drawTargetNodes = getDrawTargetNodeIds(steps);
7050
7184
  // Groups: non-edge draw steps whose target has a #group-{id} element in the SVG.
7051
- // We detect this at construction time (after render) so we correctly distinguish
7052
- // a group ID from a node ID without needing extra metadata.
7053
7185
  this.drawTargetGroups = new Set();
7054
7186
  this.drawTargetTables = new Set();
7055
7187
  this.drawTargetNotes = new Set();
7056
7188
  this.drawTargetCharts = new Set();
7057
7189
  this.drawTargetMarkdowns = new Set();
7058
- for (const s of steps) {
7190
+ for (const s of flattenSteps(steps)) {
7059
7191
  if (s.action !== "draw" || parseEdgeTarget(s.target))
7060
7192
  continue;
7061
7193
  if (svg.querySelector(`#group-${s.target}`)) {
7062
7194
  this.drawTargetGroups.add(`group-${s.target}`);
7063
- // Remove from node targets if it was accidentally added
7064
7195
  this.drawTargetNodes.delete(`node-${s.target}`);
7065
7196
  }
7066
7197
  if (svg.querySelector(`#table-${s.target}`)) {
@@ -7081,7 +7212,31 @@ class AnimationController {
7081
7212
  }
7082
7213
  }
7083
7214
  this._clearAll();
7084
- }
7215
+ // Init narration caption
7216
+ if (this._container)
7217
+ this._initCaption();
7218
+ // Init annotation layer
7219
+ this._annotationLayer = document.createElementNS(SVG_NS$1, "g");
7220
+ this._annotationLayer.setAttribute("id", "annotation-layer");
7221
+ this._annotationLayer.style.pointerEvents = "none";
7222
+ this.svg.appendChild(this._annotationLayer);
7223
+ // Init pointer
7224
+ this._pointerType = (this._config?.pointer ?? "none");
7225
+ if (this._pointerType !== "none")
7226
+ this._initPointer();
7227
+ // Init TTS from config: `config tts=on`
7228
+ this._tts = this._config?.tts === true || this._config?.tts === "on";
7229
+ if (this._tts)
7230
+ this._warmUpSpeech();
7231
+ }
7232
+ /** The narration caption element — mount it anywhere via `yourContainer.appendChild(anim.captionElement)` */
7233
+ get captionElement() {
7234
+ return this._captionEl;
7235
+ }
7236
+ /** Enable/disable browser text-to-speech for narrate steps */
7237
+ get tts() { return this._tts; }
7238
+ set tts(on) { this._tts = on; if (!on)
7239
+ this._cancelSpeech(); }
7085
7240
  get currentStep() {
7086
7241
  return this._step;
7087
7242
  }
@@ -7118,6 +7273,17 @@ class AnimationController {
7118
7273
  this._clearAll();
7119
7274
  this.emit("animation-reset");
7120
7275
  }
7276
+ /** Remove caption and annotation layer from the DOM */
7277
+ destroy() {
7278
+ this._clearAll();
7279
+ this._captionEl?.remove();
7280
+ this._captionEl = null;
7281
+ this._captionTextEl = null;
7282
+ this._annotationLayer?.remove();
7283
+ this._annotationLayer = null;
7284
+ this._pointerEl?.remove();
7285
+ this._pointerEl = null;
7286
+ }
7121
7287
  next() {
7122
7288
  if (!this.canNext)
7123
7289
  return false;
@@ -7143,7 +7309,11 @@ class AnimationController {
7143
7309
  while (this.canNext) {
7144
7310
  const nextStep = this.steps[this._step + 1];
7145
7311
  this.next();
7146
- await new Promise((r) => setTimeout(r, this._playbackWaitMs(nextStep, msPerStep)));
7312
+ // Wait for timer AND speech to finish (whichever is longer)
7313
+ await Promise.all([
7314
+ new Promise((r) => setTimeout(r, this._playbackWaitMs(nextStep, msPerStep))),
7315
+ this._speechDone ?? Promise.resolve(),
7316
+ ]);
7147
7317
  }
7148
7318
  }
7149
7319
  goTo(index) {
@@ -7175,15 +7345,48 @@ class AnimationController {
7175
7345
  }, delayMs);
7176
7346
  this._pendingStepTimers.add(id);
7177
7347
  }
7348
+ _stepWaitMs(step, fallbackMs) {
7349
+ const delay = Math.max(0, step.delay ?? 0);
7350
+ const duration = Math.max(0, step.duration ?? 0);
7351
+ // Compute minimum time the step actually needs to finish
7352
+ let minNeeded = 0;
7353
+ if (step.action === "narrate") {
7354
+ const text = step.value ?? "";
7355
+ // Typing effect: chars × typeMs + fade buffer
7356
+ const typingMs = text.length * ANIMATION.narrationTypeMs + ANIMATION.narrationFadeMs;
7357
+ // TTS estimate: ~150ms per word + 500ms buffer for engine latency
7358
+ const wordCount = text.split(/\s+/).filter(Boolean).length;
7359
+ const ttsMs = this._tts ? wordCount * 150 + 500 : 0;
7360
+ minNeeded = Math.max(typingMs, ttsMs);
7361
+ }
7362
+ else if (step.action === "circle" || step.action === "underline" ||
7363
+ step.action === "crossout" || step.action === "bracket") {
7364
+ // Annotation guide draw + rough reveal + pointer fade
7365
+ minNeeded = ANIMATION.annotationStrokeDur + 120 + 200;
7366
+ }
7367
+ else if (step.action === "draw") {
7368
+ minNeeded = ANIMATION.nodeStrokeDur + ANIMATION.textRevealMs + 80;
7369
+ }
7370
+ let wait = delay + Math.max(fallbackMs, duration, minNeeded);
7371
+ if (step.pace === "slow")
7372
+ wait *= ANIMATION.paceSlowMul;
7373
+ else if (step.pace === "fast")
7374
+ wait *= ANIMATION.paceFastMul;
7375
+ else if (step.pace === "pause")
7376
+ wait += ANIMATION.pauseHoldMs;
7377
+ return wait;
7378
+ }
7178
7379
  _playbackWaitMs(step, fallbackMs) {
7179
7380
  if (!step)
7180
7381
  return fallbackMs;
7181
- const delay = Math.max(0, step.delay ?? 0);
7182
- const duration = Math.max(0, step.duration ?? 0);
7183
- return delay + Math.max(fallbackMs, duration);
7382
+ if (step.kind === "beat") {
7383
+ return Math.max(fallbackMs, ...step.children.map((c) => this._stepWaitMs(c, fallbackMs)));
7384
+ }
7385
+ return this._stepWaitMs(step, fallbackMs);
7184
7386
  }
7185
7387
  _clearAll() {
7186
7388
  this._clearPendingStepTimers();
7389
+ this._cancelSpeech();
7187
7390
  this._transforms.clear();
7188
7391
  // Nodes
7189
7392
  this.svg.querySelectorAll(".ng").forEach((el) => {
@@ -7294,17 +7497,52 @@ class AnimationController {
7294
7497
  el.style.opacity = "";
7295
7498
  el.classList.remove("hl", "faded");
7296
7499
  });
7500
+ // Clear narration caption
7501
+ if (this._captionEl) {
7502
+ this._captionEl.style.opacity = "0";
7503
+ if (this._captionTextEl)
7504
+ this._captionTextEl.textContent = "";
7505
+ }
7506
+ // Clear annotations
7507
+ this._annotations.forEach((a) => a.remove());
7508
+ this._annotations = [];
7509
+ // Clear pointer
7510
+ if (this._pointerEl) {
7511
+ this._pointerEl.setAttribute("opacity", "0");
7512
+ this._pointerEl.style.transition = "none";
7513
+ }
7297
7514
  }
7298
7515
  _applyStep(i, silent) {
7299
- const s = this.steps[i];
7300
- if (!s)
7516
+ const item = this.steps[i];
7517
+ if (!item)
7301
7518
  return;
7302
- const run = () => this._runStep(s, silent);
7303
7519
  if (silent) {
7304
- run();
7520
+ this._runStepItem(item, true);
7305
7521
  return;
7306
7522
  }
7307
- this._scheduleStep(run, Math.max(0, s.delay ?? 0));
7523
+ if (item.kind === "beat") {
7524
+ for (const child of item.children) {
7525
+ const run = () => this._runStep(child, false);
7526
+ this._scheduleStep(run, Math.max(0, child.delay ?? 0));
7527
+ }
7528
+ }
7529
+ else {
7530
+ let delayMs = Math.max(0, item.delay ?? 0);
7531
+ if (item.pace === "slow")
7532
+ delayMs *= ANIMATION.paceSlowMul;
7533
+ else if (item.pace === "fast")
7534
+ delayMs *= ANIMATION.paceFastMul;
7535
+ this._scheduleStep(() => this._runStep(item, false), delayMs);
7536
+ }
7537
+ }
7538
+ _runStepItem(item, silent) {
7539
+ if (item.kind === "beat") {
7540
+ for (const child of item.children)
7541
+ this._runStep(child, silent);
7542
+ }
7543
+ else {
7544
+ this._runStep(item, silent);
7545
+ }
7308
7546
  }
7309
7547
  _runStep(s, silent) {
7310
7548
  switch (s.action) {
@@ -7345,6 +7583,21 @@ class AnimationController {
7345
7583
  case "rotate":
7346
7584
  this._doRotate(s.target, s, silent);
7347
7585
  break;
7586
+ case "narrate":
7587
+ this._doNarrate(s.value ?? "", silent);
7588
+ break;
7589
+ case "circle":
7590
+ this._doAnnotationCircle(s.target, silent);
7591
+ break;
7592
+ case "underline":
7593
+ this._doAnnotationUnderline(s.target, silent);
7594
+ break;
7595
+ case "crossout":
7596
+ this._doAnnotationCrossout(s.target, silent);
7597
+ break;
7598
+ case "bracket":
7599
+ this._doAnnotationBracket(s.target, s.target2 ?? "", silent);
7600
+ break;
7348
7601
  }
7349
7602
  }
7350
7603
  // ── highlight ────────────────────────────────────────────
@@ -7647,40 +7900,333 @@ class AnimationController {
7647
7900
  });
7648
7901
  }
7649
7902
  }
7903
+ // ── narration ───────────────────────────────────────────
7904
+ _initCaption() {
7905
+ // Remove any leftover caption from a previous instance
7906
+ document.querySelector('.skm-caption')?.remove();
7907
+ const cap = document.createElement("div");
7908
+ cap.className = "skm-caption";
7909
+ cap.style.cssText = `
7910
+ position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
7911
+ z-index: 9999; max-width: 600px; width: max-content;
7912
+ padding: 10px 24px; box-sizing: border-box;
7913
+ font-family: var(--font-sans, system-ui, sans-serif);
7914
+ font-size: 15px; line-height: 1.5;
7915
+ color: #fde68a; background: #1a1208;
7916
+ border-radius: 8px;
7917
+ box-shadow: 0 4px 24px rgba(0,0,0,0.35);
7918
+ opacity: 0; transition: opacity ${ANIMATION.narrationFadeMs}ms ease;
7919
+ pointer-events: none; user-select: none;
7920
+ text-align: center;
7921
+ `;
7922
+ const span = document.createElement("span");
7923
+ cap.appendChild(span);
7924
+ document.body.appendChild(cap);
7925
+ this._captionEl = cap;
7926
+ this._captionTextEl = span;
7927
+ }
7928
+ _doNarrate(text, silent) {
7929
+ if (!this._captionEl || !this._captionTextEl)
7930
+ return;
7931
+ this._captionEl.style.opacity = "1";
7932
+ if (silent || !text) {
7933
+ this._captionTextEl.textContent = text;
7934
+ return;
7935
+ }
7936
+ // Fire TTS as full sentence — play() waits for _speechDone
7937
+ if (this._tts && text)
7938
+ this._speak(text);
7939
+ // Typing effect
7940
+ this._captionTextEl.textContent = "";
7941
+ let charIdx = 0;
7942
+ const typeNext = () => {
7943
+ if (charIdx < text.length) {
7944
+ this._captionTextEl.textContent += text[charIdx++];
7945
+ const id = window.setTimeout(typeNext, ANIMATION.narrationTypeMs);
7946
+ this._pendingStepTimers.add(id);
7947
+ }
7948
+ };
7949
+ typeNext();
7950
+ }
7951
+ _speak(text) {
7952
+ if (typeof speechSynthesis === "undefined")
7953
+ return;
7954
+ this._cancelSpeech();
7955
+ const utter = new SpeechSynthesisUtterance(text);
7956
+ utter.rate = 0.95;
7957
+ utter.pitch = 1;
7958
+ utter.lang = "en-US";
7959
+ // Track when speech actually finishes
7960
+ this._speechDone = new Promise((resolve) => {
7961
+ utter.onend = () => resolve();
7962
+ utter.onerror = () => resolve();
7963
+ });
7964
+ speechSynthesis.speak(utter);
7965
+ }
7966
+ _cancelSpeech() {
7967
+ if (typeof speechSynthesis !== "undefined")
7968
+ speechSynthesis.cancel();
7969
+ this._speechDone = null;
7970
+ }
7971
+ /** Pre-warm the speech engine with a silent utterance to eliminate cold-start delay */
7972
+ _warmUpSpeech() {
7973
+ if (typeof speechSynthesis === "undefined")
7974
+ return;
7975
+ const warm = new SpeechSynthesisUtterance("");
7976
+ warm.volume = 0;
7977
+ speechSynthesis.speak(warm);
7978
+ }
7979
+ // ── annotations ─────────────────────────────────────────
7980
+ _nodeMetrics(el) {
7981
+ const x = parseFloat(el.dataset.x ?? "");
7982
+ const y = parseFloat(el.dataset.y ?? "");
7983
+ const w = parseFloat(el.dataset.w ?? "");
7984
+ const h = parseFloat(el.dataset.h ?? "");
7985
+ if (isNaN(x) || isNaN(y) || isNaN(w) || isNaN(h))
7986
+ return null;
7987
+ return { x, y, w, h };
7988
+ }
7989
+ /**
7990
+ * Animate an annotation using the same guide-path approach as node draw:
7991
+ * 1. Hide the rough.js element (opacity=0)
7992
+ * 2. Create a clean single guide path and animate it with stroke-dashoffset
7993
+ * 3. Pointer follows the guide path
7994
+ * 4. After guide finishes → fade in rough.js element, remove guide
7995
+ */
7996
+ _animateAnnotation(roughEl, guideD, silent) {
7997
+ if (silent)
7998
+ return;
7999
+ // Hide rough.js element — will be revealed after guide draws
8000
+ roughEl.style.opacity = "0";
8001
+ roughEl.style.transition = "none";
8002
+ // Create a clean guide path
8003
+ const guide = document.createElementNS(SVG_NS$1, "path");
8004
+ guide.setAttribute("d", guideD);
8005
+ guide.setAttribute("fill", "none");
8006
+ guide.setAttribute("stroke", ANIMATION.annotationColor);
8007
+ guide.setAttribute("stroke-width", String(ANIMATION.annotationStrokeW));
8008
+ guide.setAttribute("stroke-linecap", "round");
8009
+ guide.setAttribute("stroke-linejoin", "round");
8010
+ guide.style.pointerEvents = "none";
8011
+ this._annotationLayer.appendChild(guide);
8012
+ const len = pathLength(guide);
8013
+ guide.style.strokeDasharray = `${len}`;
8014
+ guide.style.strokeDashoffset = `${len}`;
8015
+ guide.style.transition = "none";
8016
+ // Pre-position pointer at the start of the guide
8017
+ const hasPointer = !!this._pointerEl;
8018
+ if (hasPointer) {
8019
+ try {
8020
+ const startPt = guide.getPointAtLength(0);
8021
+ this._pointerEl.setAttribute("transform", `translate(${startPt.x},${startPt.y})`);
8022
+ }
8023
+ catch { /* ignore */ }
8024
+ this._pointerEl.setAttribute("opacity", "1");
8025
+ this._pointerEl.style.transition = "none";
8026
+ }
8027
+ const dur = ANIMATION.annotationStrokeDur;
8028
+ requestAnimationFrame(() => requestAnimationFrame(() => {
8029
+ // Animate guide stroke-dashoffset
8030
+ guide.style.transition = `stroke-dashoffset ${dur}ms cubic-bezier(.4,0,.2,1)`;
8031
+ guide.style.strokeDashoffset = "0";
8032
+ // Animate pointer along guide path
8033
+ if (hasPointer) {
8034
+ const startTime = performance.now();
8035
+ const pointerRef = this._pointerEl;
8036
+ const animate = () => {
8037
+ const elapsed = performance.now() - startTime;
8038
+ const t = Math.min(elapsed / dur, 1);
8039
+ const eased = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
8040
+ try {
8041
+ const pt = guide.getPointAtLength(eased * len);
8042
+ pointerRef.setAttribute("transform", `translate(${pt.x},${pt.y})`);
8043
+ }
8044
+ catch { /* ignore */ }
8045
+ if (t < 1) {
8046
+ requestAnimationFrame(animate);
8047
+ }
8048
+ else {
8049
+ pointerRef.style.transition = `opacity 200ms ease`;
8050
+ pointerRef.setAttribute("opacity", "0");
8051
+ }
8052
+ };
8053
+ requestAnimationFrame(animate);
8054
+ }
8055
+ // After guide finishes: reveal rough.js element, remove guide
8056
+ const id = window.setTimeout(() => {
8057
+ roughEl.style.transition = `opacity 120ms ease`;
8058
+ roughEl.style.opacity = "1";
8059
+ guide.remove();
8060
+ }, dur + 30);
8061
+ this._pendingStepTimers.add(id);
8062
+ }));
8063
+ }
8064
+ _doAnnotationCircle(target, silent) {
8065
+ const el = resolveEl(this.svg, target);
8066
+ if (!el || !this._rc || !this._annotationLayer)
8067
+ return;
8068
+ const m = this._nodeMetrics(el);
8069
+ if (!m)
8070
+ return;
8071
+ const cx = m.x + m.w / 2, cy = m.y + m.h / 2;
8072
+ const rx = m.w * 0.65, ry = m.h * 0.65;
8073
+ const roughEl = this._rc.ellipse(cx, cy, rx * 2, ry * 2, {
8074
+ roughness: 2.0, stroke: ANIMATION.annotationColor,
8075
+ strokeWidth: ANIMATION.annotationStrokeW, fill: "none",
8076
+ seed: Date.now(),
8077
+ });
8078
+ this._annotationLayer.appendChild(roughEl);
8079
+ this._annotations.push(roughEl);
8080
+ // Clean guide path for draw-in animation
8081
+ const guideD = ellipsePath(cx, cy, rx, ry);
8082
+ this._animateAnnotation(roughEl, guideD, silent);
8083
+ }
8084
+ _doAnnotationUnderline(target, silent) {
8085
+ const el = resolveEl(this.svg, target);
8086
+ if (!el || !this._rc || !this._annotationLayer)
8087
+ return;
8088
+ const m = this._nodeMetrics(el);
8089
+ if (!m)
8090
+ return;
8091
+ const lineY = m.y + m.h + 4;
8092
+ const roughEl = this._rc.line(m.x, lineY, m.x + m.w, lineY, {
8093
+ roughness: 1.5, stroke: ANIMATION.annotationColor,
8094
+ strokeWidth: ANIMATION.annotationStrokeW, seed: Date.now(),
8095
+ });
8096
+ this._annotationLayer.appendChild(roughEl);
8097
+ this._annotations.push(roughEl);
8098
+ // Clean guide path
8099
+ const guideD = `M ${m.x} ${lineY} L ${m.x + m.w} ${lineY}`;
8100
+ this._animateAnnotation(roughEl, guideD, silent);
8101
+ }
8102
+ _doAnnotationCrossout(target, silent) {
8103
+ const el = resolveEl(this.svg, target);
8104
+ if (!el || !this._rc || !this._annotationLayer)
8105
+ return;
8106
+ const m = this._nodeMetrics(el);
8107
+ if (!m)
8108
+ return;
8109
+ const pad = 4;
8110
+ const roughG = document.createElementNS(SVG_NS$1, "g");
8111
+ const line1 = this._rc.line(m.x - pad, m.y - pad, m.x + m.w + pad, m.y + m.h + pad, {
8112
+ roughness: 1.5, stroke: ANIMATION.annotationColor,
8113
+ strokeWidth: ANIMATION.annotationStrokeW, seed: Date.now(),
8114
+ });
8115
+ const line2 = this._rc.line(m.x + m.w + pad, m.y - pad, m.x - pad, m.y + m.h + pad, {
8116
+ roughness: 1.5, stroke: ANIMATION.annotationColor,
8117
+ strokeWidth: ANIMATION.annotationStrokeW, seed: Date.now() + 1,
8118
+ });
8119
+ roughG.appendChild(line1);
8120
+ roughG.appendChild(line2);
8121
+ this._annotationLayer.appendChild(roughG);
8122
+ this._annotations.push(roughG);
8123
+ // Clean guide: two diagonal lines in a single path (pointer draws both)
8124
+ const guideD = `M ${m.x - pad} ${m.y - pad} L ${m.x + m.w + pad} ${m.y + m.h + pad} ` +
8125
+ `M ${m.x + m.w + pad} ${m.y - pad} L ${m.x - pad} ${m.y + m.h + pad}`;
8126
+ this._animateAnnotation(roughG, guideD, silent);
8127
+ }
8128
+ _doAnnotationBracket(target1, target2, silent) {
8129
+ const el1 = resolveEl(this.svg, target1);
8130
+ const el2 = resolveEl(this.svg, target2);
8131
+ if (!el1 || !el2 || !this._rc || !this._annotationLayer)
8132
+ return;
8133
+ const m1 = this._nodeMetrics(el1);
8134
+ const m2 = this._nodeMetrics(el2);
8135
+ if (!m1 || !m2)
8136
+ return;
8137
+ // Bracket on the right side spanning both elements
8138
+ const rightX = Math.max(m1.x + m1.w, m2.x + m2.w) + 12;
8139
+ const topY = Math.min(m1.y, m2.y);
8140
+ const botY = Math.max(m1.y + m1.h, m2.y + m2.h);
8141
+ const midY = (topY + botY) / 2;
8142
+ const bulge = 16;
8143
+ // Draw a curly brace using path
8144
+ const guideD = `M ${rightX} ${topY} Q ${rightX + bulge} ${topY} ${rightX + bulge} ${midY - 4} ` +
8145
+ `L ${rightX + bulge} ${midY} L ${rightX + bulge * 1.5} ${midY} ` +
8146
+ `M ${rightX + bulge} ${midY} L ${rightX + bulge} ${midY + 4} ` +
8147
+ `Q ${rightX + bulge} ${botY} ${rightX} ${botY}`;
8148
+ const roughEl = this._rc.path(guideD, {
8149
+ roughness: 1.2, stroke: ANIMATION.annotationColor,
8150
+ strokeWidth: ANIMATION.annotationStrokeW, fill: "none",
8151
+ seed: Date.now(),
8152
+ });
8153
+ this._annotationLayer.appendChild(roughEl);
8154
+ this._annotations.push(roughEl);
8155
+ this._animateAnnotation(roughEl, guideD, silent);
8156
+ }
8157
+ // ── pointer ─────────────────────────────────────────────
8158
+ _initPointer() {
8159
+ if (this._pointerType === "dot") {
8160
+ const circle = document.createElementNS(SVG_NS$1, "circle");
8161
+ circle.setAttribute("r", String(ANIMATION.pointerSize));
8162
+ circle.setAttribute("fill", ANIMATION.annotationColor);
8163
+ circle.setAttribute("opacity", "0");
8164
+ circle.style.pointerEvents = "none";
8165
+ this.svg.appendChild(circle);
8166
+ this._pointerEl = circle;
8167
+ }
8168
+ else if (this._pointerType === "chalk") {
8169
+ const g = document.createElementNS(SVG_NS$1, "g");
8170
+ const circle = document.createElementNS(SVG_NS$1, "circle");
8171
+ circle.setAttribute("r", "5");
8172
+ circle.setAttribute("fill", "#fff");
8173
+ circle.setAttribute("stroke", "#1a1208");
8174
+ circle.setAttribute("stroke-width", "1.5");
8175
+ g.appendChild(circle);
8176
+ g.setAttribute("opacity", "0");
8177
+ g.style.pointerEvents = "none";
8178
+ this.svg.appendChild(g);
8179
+ this._pointerEl = g;
8180
+ }
8181
+ else if (this._pointerType === "hand") {
8182
+ const g = document.createElementNS(SVG_NS$1, "g");
8183
+ const path = document.createElementNS(SVG_NS$1, "path");
8184
+ path.setAttribute("d", "M5,0 L5,12 L8,9 L11,16 L13,15 L10,8 L14,8 Z");
8185
+ path.setAttribute("fill", "#1a1208");
8186
+ g.appendChild(path);
8187
+ g.setAttribute("opacity", "0");
8188
+ g.style.pointerEvents = "none";
8189
+ this.svg.appendChild(g);
8190
+ this._pointerEl = g;
8191
+ }
8192
+ }
8193
+ }
8194
+ const ANIMATION_CSS = `
8195
+ .ng, .gg, .tg, .ntg, .cg, .eg, .mdg {
8196
+ transform-box: fill-box;
8197
+ transform-origin: center;
8198
+ transition: filter 0.3s, opacity 0.35s;
8199
+ }
8200
+
8201
+ /* highlight */
8202
+ .ng.hl path, .ng.hl rect, .ng.hl ellipse, .ng.hl polygon,
8203
+ .tg.hl path, .tg.hl rect,
8204
+ .ntg.hl path, .ntg.hl polygon,
8205
+ .cg.hl path, .cg.hl rect,
8206
+ .mdg.hl text,
8207
+ .eg.hl path, .eg.hl line, .eg.hl polygon { stroke-width: 2.8 !important; }
8208
+
8209
+ .ng.hl, .tg.hl, .ntg.hl, .cg.hl, .mdg.hl, .eg.hl {
8210
+ animation: ng-pulse 1.4s ease-in-out infinite;
8211
+ }
8212
+ @keyframes ng-pulse {
8213
+ 0%, 100% { filter: drop-shadow(0 0 7px rgba(200,84,40,.6)); }
8214
+ 50% { filter: drop-shadow(0 0 14px rgba(200,84,40,.9)); }
7650
8215
  }
7651
- const ANIMATION_CSS = `
7652
- .ng, .gg, .tg, .ntg, .cg, .eg, .mdg {
7653
- transform-box: fill-box;
7654
- transform-origin: center;
7655
- transition: filter 0.3s, opacity 0.35s;
7656
- }
7657
-
7658
- /* highlight */
7659
- .ng.hl path, .ng.hl rect, .ng.hl ellipse, .ng.hl polygon,
7660
- .tg.hl path, .tg.hl rect,
7661
- .ntg.hl path, .ntg.hl polygon,
7662
- .cg.hl path, .cg.hl rect,
7663
- .mdg.hl text,
7664
- .eg.hl path, .eg.hl line, .eg.hl polygon { stroke-width: 2.8 !important; }
7665
-
7666
- .ng.hl, .tg.hl, .ntg.hl, .cg.hl, .mdg.hl, .eg.hl {
7667
- animation: ng-pulse 1.4s ease-in-out infinite;
7668
- }
7669
- @keyframes ng-pulse {
7670
- 0%, 100% { filter: drop-shadow(0 0 7px rgba(200,84,40,.6)); }
7671
- 50% { filter: drop-shadow(0 0 14px rgba(200,84,40,.9)); }
7672
- }
7673
-
7674
- /* fade */
7675
- .ng.faded, .gg.faded, .tg.faded, .ntg.faded,
8216
+
8217
+ /* fade */
8218
+ .ng.faded, .gg.faded, .tg.faded, .ntg.faded,
7676
8219
  .cg.faded, .eg.faded, .mdg.faded { opacity: 0.22; }
7677
8220
 
7678
8221
  .ng.hidden { opacity: 0; pointer-events: none; }
7679
8222
  .gg.gg-hidden { opacity: 0; }
7680
8223
  .tg.gg-hidden { opacity: 0; }
7681
8224
  .ntg.gg-hidden { opacity: 0; }
7682
- .cg.gg-hidden { opacity: 0; }
7683
- .mdg.gg-hidden { opacity: 0; }
8225
+ .cg.gg-hidden { opacity: 0; }
8226
+ .mdg.gg-hidden { opacity: 0; }
8227
+
8228
+ /* narration caption */
8229
+ .skm-caption { pointer-events: none; user-select: none; }
7684
8230
  `;
7685
8231
 
7686
8232
  // ============================================================
@@ -7927,12 +8473,19 @@ function render(options) {
7927
8473
  interactive: true,
7928
8474
  onNodeClick,
7929
8475
  });
7930
- anim = new AnimationController(svg, ast.steps);
8476
+ // Create rough.js instance for annotations (same import as SVG renderer)
8477
+ let rc = null;
8478
+ try {
8479
+ rc = rough.svg(svg);
8480
+ }
8481
+ catch { /* rough.js not available — annotations disabled */ }
8482
+ const containerEl = el instanceof SVGSVGElement ? undefined : el;
8483
+ anim = new AnimationController(svg, ast.steps, containerEl, rc, ast.config);
7931
8484
  }
7932
8485
  onReady?.(anim, svg);
7933
8486
  const instance = {
7934
8487
  scene, anim, svg, canvas,
7935
- update: (newDsl) => render({ ...options, dsl: newDsl }),
8488
+ update: (newDsl) => { anim?.destroy(); return render({ ...options, dsl: newDsl }); },
7936
8489
  exportSVG: (filename = 'diagram.svg') => {
7937
8490
  if (svg) {
7938
8491
  Promise.resolve().then(function () { return index; }).then(m => m.exportSVG(svg, { filename }));