sketchmark 1.1.3 → 1.1.5

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.

Potentially problematic release.


This version of sketchmark might be problematic. Click here for more details.

package/dist/index.cjs CHANGED
@@ -8807,6 +8807,7 @@ function clearDashOverridesAfter(el, delayMs) {
8807
8807
  }, delayMs);
8808
8808
  }
8809
8809
  const NODE_DRAW_GUIDE_ATTR = "data-node-draw-guide";
8810
+ const TEXT_REVEAL_CLIP_ATTR = "data-text-reveal-clip-id";
8810
8811
  const GUIDED_NODE_SHAPES = new Set([
8811
8812
  "box",
8812
8813
  "circle",
@@ -8935,6 +8936,7 @@ function clearNodeDrawStyles(el) {
8935
8936
  });
8936
8937
  const text = nodeText(el);
8937
8938
  if (text) {
8939
+ clearTextReveal(text);
8938
8940
  text.style.opacity = text.style.transition = "";
8939
8941
  }
8940
8942
  }
@@ -8980,55 +8982,74 @@ function revealNodeInstant(el) {
8980
8982
  clearNodeDrawStyles(el);
8981
8983
  }
8982
8984
  // ── Text writing reveal (clipPath) ───────────────────────
8985
+ function clearTextReveal(textEl, clipId) {
8986
+ const activeClipId = textEl.getAttribute(TEXT_REVEAL_CLIP_ATTR);
8987
+ const shouldClearCurrentClip = !clipId || activeClipId === clipId;
8988
+ if (shouldClearCurrentClip) {
8989
+ textEl.removeAttribute("clip-path");
8990
+ textEl.removeAttribute(TEXT_REVEAL_CLIP_ATTR);
8991
+ }
8992
+ const clipIdToRemove = clipId ?? activeClipId;
8993
+ if (clipIdToRemove) {
8994
+ textEl.ownerSVGElement?.querySelector(`#${clipIdToRemove}`)?.remove();
8995
+ }
8996
+ }
8983
8997
  function animateTextReveal(textEl, delayMs, durationMs = ANIMATION.textRevealMs) {
8984
8998
  const ownerSvg = textEl.ownerSVGElement;
8999
+ clearTextReveal(textEl);
8985
9000
  if (!ownerSvg) {
8986
9001
  // fallback: just fade
8987
9002
  textEl.style.transition = `opacity ${ANIMATION.textFade}ms ease ${delayMs}ms`;
8988
9003
  textEl.style.opacity = "1";
8989
9004
  return;
8990
9005
  }
8991
- // Make text visible but clipped to zero width
9006
+ const bbox = textEl.getBBox?.();
9007
+ if (!bbox || bbox.width === 0) {
9008
+ // fallback if can't measure
9009
+ textEl.style.transition = `opacity ${ANIMATION.textFade}ms ease ${delayMs}ms`;
9010
+ textEl.style.opacity = "1";
9011
+ return;
9012
+ }
9013
+ let defs = ownerSvg.querySelector("defs");
9014
+ if (!defs) {
9015
+ defs = document.createElementNS(SVG_NS$1, "defs");
9016
+ ownerSvg.insertBefore(defs, ownerSvg.firstChild);
9017
+ }
9018
+ const clipId = `skm-clip-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
9019
+ const clipPath = document.createElementNS(SVG_NS$1, "clipPath");
9020
+ clipPath.setAttribute("id", clipId);
9021
+ const rect = document.createElementNS(SVG_NS$1, "rect");
9022
+ rect.setAttribute("x", String(bbox.x - 2));
9023
+ rect.setAttribute("y", String(bbox.y - 2));
9024
+ rect.setAttribute("width", "0");
9025
+ rect.setAttribute("height", String(bbox.height + 4));
9026
+ clipPath.appendChild(rect);
9027
+ defs.appendChild(clipPath);
9028
+ textEl.setAttribute("clip-path", `url(#${clipId})`);
9029
+ textEl.setAttribute(TEXT_REVEAL_CLIP_ATTR, clipId);
8992
9030
  textEl.style.opacity = "1";
8993
- // We need to wait for text to be visible before we can measure it
9031
+ requestAnimationFrame(() => requestAnimationFrame(() => {
9032
+ rect.style.transition = `width ${durationMs}ms cubic-bezier(.4,0,.2,1) ${delayMs}ms`;
9033
+ rect.setAttribute("width", String(bbox.width + 4));
9034
+ }));
9035
+ // Cleanup after animation
8994
9036
  setTimeout(() => {
8995
- const bbox = textEl.getBBox?.();
8996
- if (!bbox || bbox.width === 0) {
8997
- // fallback if can't measure
8998
- return;
8999
- }
9000
- let defs = ownerSvg.querySelector("defs");
9001
- if (!defs) {
9002
- defs = document.createElementNS(SVG_NS$1, "defs");
9003
- ownerSvg.insertBefore(defs, ownerSvg.firstChild);
9004
- }
9005
- const clipId = `skm-clip-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
9006
- const clipPath = document.createElementNS(SVG_NS$1, "clipPath");
9007
- clipPath.setAttribute("id", clipId);
9008
- const rect = document.createElementNS(SVG_NS$1, "rect");
9009
- rect.setAttribute("x", String(bbox.x - 2));
9010
- rect.setAttribute("y", String(bbox.y - 2));
9011
- rect.setAttribute("width", "0");
9012
- rect.setAttribute("height", String(bbox.height + 4));
9013
- clipPath.appendChild(rect);
9014
- defs.appendChild(clipPath);
9015
- textEl.setAttribute("clip-path", `url(#${clipId})`);
9016
- requestAnimationFrame(() => requestAnimationFrame(() => {
9017
- rect.style.transition = `width ${durationMs}ms cubic-bezier(.4,0,.2,1)`;
9018
- rect.setAttribute("width", String(bbox.width + 4));
9019
- }));
9020
- // Cleanup after animation
9021
- setTimeout(() => {
9022
- textEl.removeAttribute("clip-path");
9023
- clipPath.remove();
9024
- }, durationMs + 50);
9025
- }, delayMs);
9037
+ clearTextReveal(textEl, clipId);
9038
+ }, delayMs + durationMs + 50);
9026
9039
  }
9027
- function animateNodeDraw(el, strokeDur = ANIMATION.nodeStrokeDur) {
9040
+ function animateNodeDraw(el, strokeDur = ANIMATION.nodeStrokeDur, textOnlyDur = ANIMATION.textRevealMs) {
9028
9041
  showDrawEl(el);
9029
9042
  const guide = nodeGuidePathEl(el);
9030
9043
  if (!guide) {
9031
9044
  const firstPath = el.querySelector("path");
9045
+ const text = nodeText(el);
9046
+ if (!firstPath && el.dataset.nodeShape === "text" && text) {
9047
+ animateTextReveal(text, 0, textOnlyDur);
9048
+ setTimeout(() => {
9049
+ clearNodeDrawStyles(el);
9050
+ }, textOnlyDur + 80);
9051
+ return;
9052
+ }
9032
9053
  if (!firstPath?.style.strokeDasharray)
9033
9054
  prepareForDraw(el);
9034
9055
  animateShapeDraw(el, strokeDur, ANIMATION.nodeStagger);
@@ -9422,8 +9443,17 @@ class AnimationController {
9422
9443
  }
9423
9444
  /** Enable/disable browser text-to-speech for narrate steps */
9424
9445
  get tts() { return this._tts; }
9425
- set tts(on) { this._tts = on; if (!on)
9426
- this._cancelSpeech(); }
9446
+ set tts(on) {
9447
+ const next = !!on;
9448
+ const changed = next !== this._tts;
9449
+ this._tts = next;
9450
+ if (!next) {
9451
+ this._cancelSpeech();
9452
+ return;
9453
+ }
9454
+ if (changed)
9455
+ this._warmUpSpeech();
9456
+ }
9427
9457
  get currentStep() {
9428
9458
  return this._step;
9429
9459
  }
@@ -10049,7 +10079,7 @@ class AnimationController {
10049
10079
  if (!nodeGuidePathEl(nodeEl) && !nodeEl.querySelector("path")?.style.strokeDasharray) {
10050
10080
  prepareNodeForDraw(nodeEl);
10051
10081
  }
10052
- animateNodeDraw(nodeEl, step.duration ?? ANIMATION.nodeStrokeDur);
10082
+ animateNodeDraw(nodeEl, step.duration ?? ANIMATION.nodeStrokeDur, step.duration ?? ANIMATION.textRevealMs);
10053
10083
  }
10054
10084
  }
10055
10085
  // ── erase ─────────────────────────────────────────────────
@@ -10702,7 +10732,7 @@ class EventEmitter {
10702
10732
  }
10703
10733
 
10704
10734
  function render(options) {
10705
- const { container: rawContainer, dsl, renderer = "svg", injectCSS = true, svgOptions = {}, canvasOptions = {}, onNodeClick, onReady, } = options;
10735
+ const { container: rawContainer, dsl, renderer = "svg", injectCSS = true, tts, svgOptions = {}, canvasOptions = {}, onNodeClick, onReady, } = options;
10706
10736
  if (injectCSS && !document.getElementById("ai-diagram-css")) {
10707
10737
  const style = document.createElement("style");
10708
10738
  style.id = "ai-diagram-css";
@@ -10751,6 +10781,9 @@ function render(options) {
10751
10781
  const containerEl = el instanceof SVGSVGElement ? undefined : el;
10752
10782
  anim = new AnimationController(svg, ast.steps, containerEl, rc, ast.config);
10753
10783
  }
10784
+ if (typeof tts === "boolean") {
10785
+ anim.tts = tts;
10786
+ }
10754
10787
  onReady?.(anim, svg);
10755
10788
  return {
10756
10789
  scene,
@@ -10802,13 +10835,14 @@ function toError(error) {
10802
10835
  const CANVAS_STYLE_ID = "sketchmark-canvas-ui";
10803
10836
  const CANVAS_CSS = `
10804
10837
  .skm-canvas{display:flex;flex-direction:column;width:100%;height:100%;min-height:320px;overflow:hidden;border:1px solid #caba98;border-radius:10px;background:#f8f4ea;color:#3a2010;font-family:"Courier New",monospace}
10805
- .skm-canvas__animbar{display:flex;align-items:center;gap:6px;padding:6px 10px;background:#eee7d8;border-bottom:1px solid #caba98;flex-shrink:0}
10838
+ .skm-canvas__animbar{display:flex;align-items:center;gap:6px;padding:6px 10px;background:#eee7d8;border-bottom:1px solid #caba98;flex-shrink:0;flex-wrap:wrap}
10806
10839
  .skm-canvas__status{min-width:96px;text-align:center;color:#6a4820;font-size:11px}
10807
10840
  .skm-canvas__label{color:#8a6040;font-size:11px;font-style:italic}
10808
10841
  .skm-canvas__spacer{flex:1}
10809
10842
  .skm-canvas__stats{color:#9a7848;font-size:10px}
10810
10843
  .skm-canvas__button{border:1px solid #caba98;background:#f5eedd;color:#3a2010;border-radius:6px;padding:4px 9px;font:inherit;font-size:11px;cursor:pointer;transition:background .12s ease,border-color .12s ease,color .12s ease}
10811
10844
  .skm-canvas__button:hover:not(:disabled){background:#c8a060;border-color:#c8a060;color:#fff}
10845
+ .skm-canvas__button.is-active{background:#c8a060;border-color:#c8a060;color:#fff}
10812
10846
  .skm-canvas__button:disabled{opacity:.45;cursor:default}
10813
10847
  .skm-canvas__error{display:none;padding:8px 12px;background:#280a0a;border-bottom:1px solid #5a1818;color:#f07070;font-size:11px;line-height:1.4;white-space:pre-wrap;flex-shrink:0}
10814
10848
  .skm-canvas__error.is-visible{display:block}
@@ -10830,6 +10864,8 @@ class SketchmarkCanvas {
10830
10864
  this.instance = null;
10831
10865
  this.emitter = new EventEmitter();
10832
10866
  this.dsl = "";
10867
+ this.showCaption = true;
10868
+ this.ttsOverride = null;
10833
10869
  this.panX = 60;
10834
10870
  this.panY = 60;
10835
10871
  this.zoom = 1;
@@ -10917,6 +10953,8 @@ class SketchmarkCanvas {
10917
10953
  this.options = options;
10918
10954
  this.renderer = options.renderer ?? "svg";
10919
10955
  this.theme = options.theme ?? "light";
10956
+ this.showCaption = options.showCaption !== false;
10957
+ this.ttsOverride = typeof options.tts === "boolean" ? options.tts : null;
10920
10958
  this.dsl = normalizeNewlines(options.dsl ?? "");
10921
10959
  injectStyleOnce(CANVAS_STYLE_ID, CANVAS_CSS);
10922
10960
  const host = resolveContainer(options.container);
@@ -10935,6 +10973,8 @@ class SketchmarkCanvas {
10935
10973
  <span class="skm-canvas__status">No steps</span>
10936
10974
  <button type="button" class="skm-canvas__button" data-action="next">Next</button>
10937
10975
  <button type="button" class="skm-canvas__button" data-action="play">Play</button>
10976
+ <button type="button" class="skm-canvas__button" data-action="toggle-caption">Caption On</button>
10977
+ <button type="button" class="skm-canvas__button" data-action="toggle-tts">TTS Off</button>
10938
10978
  <span class="skm-canvas__label"></span>
10939
10979
  <span class="skm-canvas__spacer"></span>
10940
10980
  <span class="skm-canvas__stats"></span>
@@ -10969,6 +11009,8 @@ class SketchmarkCanvas {
10969
11009
  this.prevButton = this.root.querySelector('[data-action="prev"]');
10970
11010
  this.nextButton = this.root.querySelector('[data-action="next"]');
10971
11011
  this.resetButton = this.root.querySelector('[data-action="reset"]');
11012
+ this.captionButton = this.root.querySelector('[data-action="toggle-caption"]');
11013
+ this.ttsButton = this.root.querySelector('[data-action="toggle-tts"]');
10972
11014
  this.gridPattern = this.root.querySelector(`#${patternId}`);
10973
11015
  this.gridDot = this.gridPattern.querySelector("circle");
10974
11016
  this.root.querySelector('[data-action="fit"]')?.addEventListener("click", () => this.fitContent());
@@ -10979,6 +11021,8 @@ class SketchmarkCanvas {
10979
11021
  this.prevButton.addEventListener("click", () => this.prevStep());
10980
11022
  this.nextButton.addEventListener("click", () => this.nextStep());
10981
11023
  this.playButton.addEventListener("click", () => void this.play());
11024
+ this.captionButton.addEventListener("click", () => this.setCaptionVisible(!this.showCaption));
11025
+ this.ttsButton.addEventListener("click", () => this.setTtsEnabled(!this.getTtsEnabled()));
10982
11026
  this.viewport.addEventListener("pointerdown", this.onPointerDown);
10983
11027
  this.viewport.addEventListener("pointermove", this.onPointerMove);
10984
11028
  this.viewport.addEventListener("pointerup", this.onStopPanning);
@@ -11000,6 +11044,16 @@ class SketchmarkCanvas {
11000
11044
  if (renderNow)
11001
11045
  this.render();
11002
11046
  }
11047
+ setCaptionVisible(visible) {
11048
+ this.showCaption = visible;
11049
+ this.applyCaptionVisibility(this.instance);
11050
+ this.syncToggleUi();
11051
+ }
11052
+ setTtsEnabled(enabled) {
11053
+ this.ttsOverride = enabled;
11054
+ this.applyTtsSetting(this.instance);
11055
+ this.syncToggleUi();
11056
+ }
11003
11057
  bindEditor(editor, options = {}) {
11004
11058
  this.editorCleanup?.();
11005
11059
  const renderOnRun = options.renderOnRun !== false;
@@ -11044,6 +11098,8 @@ class SketchmarkCanvas {
11044
11098
  onNodeClick: this.options.onNodeClick,
11045
11099
  });
11046
11100
  this.instance = instance;
11101
+ this.applyCaptionVisibility(instance);
11102
+ this.applyTtsSetting(instance);
11047
11103
  this.statsLabel.textContent = `${instance.scene.nodes.length}n / ${instance.scene.edges.length}e / ${instance.scene.groups.length}g`;
11048
11104
  if (this.renderer === "svg") {
11049
11105
  this.animUnsub = instance.anim.on((event) => {
@@ -11176,6 +11232,37 @@ class SketchmarkCanvas {
11176
11232
  this.zoom = clampedZoom;
11177
11233
  this.applyTransform();
11178
11234
  }
11235
+ applyCaptionVisibility(instance) {
11236
+ const caption = instance?.anim.captionElement;
11237
+ if (!caption)
11238
+ return;
11239
+ caption.style.display = this.showCaption ? "" : "none";
11240
+ caption.setAttribute("aria-hidden", this.showCaption ? "false" : "true");
11241
+ }
11242
+ applyTtsSetting(instance) {
11243
+ if (!instance || this.ttsOverride === null)
11244
+ return;
11245
+ instance.anim.tts = this.ttsOverride;
11246
+ }
11247
+ getTtsEnabled() {
11248
+ if (this.ttsOverride !== null)
11249
+ return this.ttsOverride;
11250
+ return !!this.instance?.anim.tts;
11251
+ }
11252
+ syncToggleUi() {
11253
+ const canToggleCaption = this.renderer === "svg" && !!this.instance;
11254
+ const canToggleTts = canToggleCaption &&
11255
+ typeof speechSynthesis !== "undefined";
11256
+ const ttsEnabled = this.getTtsEnabled();
11257
+ this.captionButton.textContent = this.showCaption ? "Caption On" : "Caption Off";
11258
+ this.captionButton.classList.toggle("is-active", this.showCaption);
11259
+ this.captionButton.setAttribute("aria-pressed", this.showCaption ? "true" : "false");
11260
+ this.captionButton.disabled = !canToggleCaption;
11261
+ this.ttsButton.textContent = ttsEnabled ? "TTS On" : "TTS Off";
11262
+ this.ttsButton.classList.toggle("is-active", ttsEnabled);
11263
+ this.ttsButton.setAttribute("aria-pressed", ttsEnabled ? "true" : "false");
11264
+ this.ttsButton.disabled = !canToggleTts;
11265
+ }
11179
11266
  syncAnimationUi() {
11180
11267
  const anim = this.instance?.anim;
11181
11268
  const canAnimate = this.renderer === "svg" && !!anim && anim.total > 0;
@@ -11186,6 +11273,7 @@ class SketchmarkCanvas {
11186
11273
  this.nextButton.disabled = true;
11187
11274
  this.resetButton.disabled = true;
11188
11275
  this.playButton.disabled = true;
11276
+ this.syncToggleUi();
11189
11277
  return;
11190
11278
  }
11191
11279
  this.stepDisplay.textContent = anim.currentStep < 0 ? `${anim.total} steps` : `${anim.currentStep + 1} / ${anim.total}`;
@@ -11194,6 +11282,7 @@ class SketchmarkCanvas {
11194
11282
  this.nextButton.disabled = !anim.canNext;
11195
11283
  this.resetButton.disabled = false;
11196
11284
  this.playButton.disabled = this.playInFlight || !anim.canNext;
11285
+ this.syncToggleUi();
11197
11286
  }
11198
11287
  getStepTarget(stepItem) {
11199
11288
  if (!stepItem)
@@ -11850,6 +11939,7 @@ const EMBED_CSS = `
11850
11939
 
11851
11940
  .skm-embed__controls {
11852
11941
  display: flex;
11942
+ flex-wrap: wrap;
11853
11943
  align-items: center;
11854
11944
  gap: 8px;
11855
11945
  padding: 10px 12px;
@@ -11867,6 +11957,13 @@ const EMBED_CSS = `
11867
11957
  display: none;
11868
11958
  }
11869
11959
 
11960
+ .skm-embed__controls-group {
11961
+ display: flex;
11962
+ align-items: center;
11963
+ gap: 8px;
11964
+ flex-wrap: wrap;
11965
+ }
11966
+
11870
11967
  .skm-embed__button {
11871
11968
  border: 1px solid #caba98;
11872
11969
  background: #f5eedd;
@@ -11885,6 +11982,12 @@ const EMBED_CSS = `
11885
11982
  color: #fff;
11886
11983
  }
11887
11984
 
11985
+ .skm-embed__button.is-active {
11986
+ background: #c8a060;
11987
+ border-color: #c8a060;
11988
+ color: #fff;
11989
+ }
11990
+
11888
11991
  .skm-embed--dark .skm-embed__button {
11889
11992
  border-color: #4a3520;
11890
11993
  background: #22190e;
@@ -11902,6 +12005,17 @@ const EMBED_CSS = `
11902
12005
  cursor: default;
11903
12006
  }
11904
12007
 
12008
+ .skm-embed__zoom {
12009
+ min-width: 48px;
12010
+ text-align: center;
12011
+ color: #8a6040;
12012
+ font-size: 11px;
12013
+ }
12014
+
12015
+ .skm-embed--dark .skm-embed__zoom {
12016
+ color: #d0b176;
12017
+ }
12018
+
11905
12019
  .skm-embed__step {
11906
12020
  margin-left: auto;
11907
12021
  min-width: 96px;
@@ -11920,13 +12034,19 @@ class SketchmarkEmbed {
11920
12034
  this.emitter = new EventEmitter();
11921
12035
  this.animUnsub = null;
11922
12036
  this.playInFlight = false;
12037
+ this.showCaption = true;
12038
+ this.ttsOverride = null;
12039
+ this.zoom = 1;
11923
12040
  this.offsetX = 0;
11924
12041
  this.offsetY = 0;
12042
+ this.autoFitEnabled = true;
11925
12043
  this.motionFrame = null;
11926
12044
  this.resizeObserver = null;
11927
12045
  this.options = options;
11928
12046
  this.dsl = normalizeNewlines(options.dsl);
11929
12047
  this.theme = options.theme ?? "light";
12048
+ this.showCaption = options.showCaption !== false;
12049
+ this.ttsOverride = typeof options.tts === "boolean" ? options.tts : null;
11930
12050
  injectStyleOnce(EMBED_STYLE_ID, EMBED_CSS);
11931
12051
  const host = resolveContainer(options.container);
11932
12052
  host.innerHTML = "";
@@ -11942,10 +12062,22 @@ class SketchmarkEmbed {
11942
12062
  </div>
11943
12063
  <div class="skm-embed__error"></div>
11944
12064
  <div class="skm-embed__controls">
11945
- <button type="button" class="skm-embed__button" data-action="reset">Reset</button>
11946
- <button type="button" class="skm-embed__button" data-action="prev">Prev</button>
11947
- <button type="button" class="skm-embed__button" data-action="next">Next</button>
11948
- <button type="button" class="skm-embed__button" data-action="play">Play</button>
12065
+ <div class="skm-embed__controls-group">
12066
+ <button type="button" class="skm-embed__button" data-action="zoom-out">-</button>
12067
+ <span class="skm-embed__zoom">100%</span>
12068
+ <button type="button" class="skm-embed__button" data-action="zoom-in">+</button>
12069
+ <button type="button" class="skm-embed__button" data-action="fit">Reset</button>
12070
+ </div>
12071
+ <div class="skm-embed__controls-group">
12072
+ <button type="button" class="skm-embed__button" data-action="restart">Restart</button>
12073
+ <button type="button" class="skm-embed__button" data-action="prev">Prev</button>
12074
+ <button type="button" class="skm-embed__button" data-action="next">Next</button>
12075
+ <button type="button" class="skm-embed__button" data-action="play">Play</button>
12076
+ </div>
12077
+ <div class="skm-embed__controls-group">
12078
+ <button type="button" class="skm-embed__button" data-action="toggle-caption">Caption On</button>
12079
+ <button type="button" class="skm-embed__button" data-action="toggle-tts">TTS Off</button>
12080
+ </div>
11949
12081
  <span class="skm-embed__step">No steps</span>
11950
12082
  </div>
11951
12083
  `;
@@ -11955,17 +12087,28 @@ class SketchmarkEmbed {
11955
12087
  this.errorElement = this.root.querySelector(".skm-embed__error");
11956
12088
  this.controlsElement = this.root.querySelector(".skm-embed__controls");
11957
12089
  this.stepInfoElement = this.root.querySelector(".skm-embed__step");
11958
- this.btnReset = this.root.querySelector('[data-action="reset"]');
12090
+ this.zoomInfoElement = this.root.querySelector(".skm-embed__zoom");
12091
+ this.btnFit = this.root.querySelector('[data-action="fit"]');
12092
+ this.btnZoomIn = this.root.querySelector('[data-action="zoom-in"]');
12093
+ this.btnZoomOut = this.root.querySelector('[data-action="zoom-out"]');
12094
+ this.btnRestart = this.root.querySelector('[data-action="restart"]');
11959
12095
  this.btnPrev = this.root.querySelector('[data-action="prev"]');
11960
12096
  this.btnNext = this.root.querySelector('[data-action="next"]');
11961
12097
  this.btnPlay = this.root.querySelector('[data-action="play"]');
12098
+ this.btnCaption = this.root.querySelector('[data-action="toggle-caption"]');
12099
+ this.btnTts = this.root.querySelector('[data-action="toggle-tts"]');
11962
12100
  this.controlsElement.classList.toggle("is-hidden", options.showControls === false);
11963
- this.btnReset.addEventListener("click", () => this.resetAnimation());
12101
+ this.btnFit.addEventListener("click", () => this.resetView());
12102
+ this.btnZoomIn.addEventListener("click", () => this.zoomIn());
12103
+ this.btnZoomOut.addEventListener("click", () => this.zoomOut());
12104
+ this.btnRestart.addEventListener("click", () => this.resetAnimation());
11964
12105
  this.btnPrev.addEventListener("click", () => this.prevStep());
11965
12106
  this.btnNext.addEventListener("click", () => this.nextStep());
11966
12107
  this.btnPlay.addEventListener("click", () => {
11967
12108
  void this.play();
11968
12109
  });
12110
+ this.btnCaption.addEventListener("click", () => this.setCaptionVisible(!this.showCaption));
12111
+ this.btnTts.addEventListener("click", () => this.setTtsEnabled(!this.getTtsEnabled()));
11969
12112
  if (typeof ResizeObserver !== "undefined") {
11970
12113
  this.resizeObserver = new ResizeObserver(() => {
11971
12114
  this.positionViewport(false);
@@ -11983,6 +12126,16 @@ class SketchmarkEmbed {
11983
12126
  if (renderNow)
11984
12127
  this.render();
11985
12128
  }
12129
+ setCaptionVisible(visible) {
12130
+ this.showCaption = visible;
12131
+ this.applyCaptionVisibility(this.instance);
12132
+ this.syncToggleControls();
12133
+ }
12134
+ setTtsEnabled(enabled) {
12135
+ this.ttsOverride = enabled;
12136
+ this.applyTtsSetting(this.instance);
12137
+ this.syncToggleControls();
12138
+ }
11986
12139
  setSize(width, height) {
11987
12140
  this.applySize(width, height);
11988
12141
  this.positionViewport(false);
@@ -12006,7 +12159,12 @@ class SketchmarkEmbed {
12006
12159
  this.animUnsub = null;
12007
12160
  this.instance?.anim?.destroy();
12008
12161
  this.instance = null;
12162
+ this.autoFitEnabled = true;
12163
+ this.zoom = 1;
12164
+ this.offsetX = 0;
12165
+ this.offsetY = 0;
12009
12166
  this.diagramWrap.innerHTML = "";
12167
+ this.applyTransform();
12010
12168
  try {
12011
12169
  const instance = render({
12012
12170
  container: this.diagramWrap,
@@ -12022,6 +12180,8 @@ class SketchmarkEmbed {
12022
12180
  onNodeClick: this.options.onNodeClick,
12023
12181
  });
12024
12182
  this.instance = instance;
12183
+ this.applyCaptionVisibility(instance);
12184
+ this.applyTtsSetting(instance);
12025
12185
  this.animUnsub = instance.anim.on((event) => {
12026
12186
  this.syncControls();
12027
12187
  if (event.type === "step-change") {
@@ -12087,6 +12247,21 @@ class SketchmarkEmbed {
12087
12247
  this.syncControls();
12088
12248
  this.positionViewport(true);
12089
12249
  }
12250
+ fitToViewport(animated = false) {
12251
+ if (!this.instance?.svg)
12252
+ return;
12253
+ this.autoFitEnabled = true;
12254
+ this.positionViewport(animated);
12255
+ }
12256
+ resetView(animated = false) {
12257
+ this.fitToViewport(animated);
12258
+ }
12259
+ zoomIn() {
12260
+ this.zoomAroundViewportCenter(1.2);
12261
+ }
12262
+ zoomOut() {
12263
+ this.zoomAroundViewportCenter(0.8);
12264
+ }
12090
12265
  exportSVG(filename) {
12091
12266
  this.instance?.exportSVG(filename);
12092
12267
  }
@@ -12109,10 +12284,15 @@ class SketchmarkEmbed {
12109
12284
  return typeof value === "number" ? `${value}px` : value;
12110
12285
  }
12111
12286
  syncControls() {
12287
+ this.syncAnimationControls();
12288
+ this.syncViewControls();
12289
+ this.syncToggleControls();
12290
+ }
12291
+ syncAnimationControls() {
12112
12292
  const anim = this.instance?.anim;
12113
12293
  if (!anim || !anim.total) {
12114
12294
  this.stepInfoElement.textContent = "No steps";
12115
- this.btnReset.disabled = true;
12295
+ this.btnRestart.disabled = true;
12116
12296
  this.btnPrev.disabled = true;
12117
12297
  this.btnNext.disabled = true;
12118
12298
  this.btnPlay.disabled = true;
@@ -12120,56 +12300,69 @@ class SketchmarkEmbed {
12120
12300
  }
12121
12301
  this.stepInfoElement.textContent =
12122
12302
  anim.currentStep < 0 ? `${anim.total} steps` : `${anim.currentStep + 1} / ${anim.total}`;
12123
- this.btnReset.disabled = false;
12303
+ this.btnRestart.disabled = false;
12124
12304
  this.btnPrev.disabled = !anim.canPrev;
12125
12305
  this.btnNext.disabled = !anim.canNext;
12126
12306
  this.btnPlay.disabled = this.playInFlight || !anim.canNext;
12127
12307
  }
12308
+ syncViewControls() {
12309
+ const hasView = !!this.instance?.svg;
12310
+ const zoomMin = this.getZoomMin();
12311
+ const zoomMax = this.getZoomMax();
12312
+ this.zoomInfoElement.textContent = `${Math.round(this.zoom * 100)}%`;
12313
+ this.btnFit.disabled = !hasView;
12314
+ this.btnZoomOut.disabled = !hasView || this.zoom <= zoomMin + 0.001;
12315
+ this.btnZoomIn.disabled = !hasView || this.zoom >= zoomMax - 0.001;
12316
+ }
12128
12317
  positionViewport(animated) {
12129
- if (!this.instance?.svg)
12130
- return;
12131
- const svg = this.instance.svg;
12132
- const svgWidth = parseFloat(svg.getAttribute("width") || "0");
12133
- const svgHeight = parseFloat(svg.getAttribute("height") || "0");
12134
- if (!svgWidth || !svgHeight)
12318
+ const size = this.getContentSize();
12319
+ if (!size)
12135
12320
  return;
12321
+ const { width: svgWidth, height: svgHeight } = size;
12136
12322
  const viewportRect = this.viewport.getBoundingClientRect();
12137
12323
  const viewWidth = viewportRect.width || this.viewport.clientWidth;
12138
12324
  const viewHeight = viewportRect.height || this.viewport.clientHeight;
12139
12325
  if (!viewWidth || !viewHeight)
12140
12326
  return;
12141
- const sceneIsLarge = svgWidth > viewWidth || svgHeight > viewHeight;
12327
+ if (this.autoFitEnabled) {
12328
+ this.zoom = this.getFitZoom(svgWidth, svgHeight, viewWidth, viewHeight);
12329
+ }
12330
+ this.syncViewControls();
12331
+ const scaledWidth = svgWidth * this.zoom;
12332
+ const scaledHeight = svgHeight * this.zoom;
12333
+ const focusTarget = this.getFocusTarget();
12334
+ const sceneIsLarge = scaledWidth > viewWidth || scaledHeight > viewHeight;
12142
12335
  const shouldFocus = sceneIsLarge &&
12143
12336
  this.options.autoFocus !== false &&
12144
- !!this.getFocusTarget();
12337
+ !!focusTarget;
12145
12338
  if (!shouldFocus) {
12146
- this.animateTo(svgWidth <= viewWidth ? (viewWidth - svgWidth) / 2 : 0, svgHeight <= viewHeight ? (viewHeight - svgHeight) / 2 : 0, animated);
12339
+ this.animateTo(scaledWidth <= viewWidth ? (viewWidth - scaledWidth) / 2 : 0, scaledHeight <= viewHeight ? (viewHeight - scaledHeight) / 2 : 0, animated);
12147
12340
  return;
12148
12341
  }
12149
- const target = this.findTargetElement(this.getFocusTarget());
12342
+ const target = this.findTargetElement(focusTarget);
12150
12343
  if (!target) {
12151
12344
  this.animateTo(0, 0, animated);
12152
12345
  return;
12153
12346
  }
12154
- const currentRect = target.getBoundingClientRect();
12155
- const sceneX = currentRect.left - viewportRect.left - this.offsetX;
12156
- const sceneY = currentRect.top - viewportRect.top - this.offsetY;
12157
- const targetCenterX = sceneX + currentRect.width / 2;
12158
- const targetCenterY = sceneY + currentRect.height / 2;
12159
- let nextX = viewWidth / 2 - targetCenterX;
12160
- let nextY = viewHeight / 2 - targetCenterY;
12347
+ const targetBox = this.getTargetBox(target, viewportRect);
12348
+ if (!targetBox) {
12349
+ this.animateTo(0, 0, animated);
12350
+ return;
12351
+ }
12352
+ let nextX = viewWidth / 2 - targetBox.centerX;
12353
+ let nextY = viewHeight / 2 - targetBox.centerY;
12161
12354
  const padding = this.options.focusPadding ?? 24;
12162
- if (svgWidth <= viewWidth) {
12163
- nextX = (viewWidth - svgWidth) / 2;
12355
+ if (scaledWidth <= viewWidth) {
12356
+ nextX = (viewWidth - scaledWidth) / 2;
12164
12357
  }
12165
12358
  else {
12166
- nextX = clamp(nextX, viewWidth - svgWidth - padding, padding);
12359
+ nextX = clamp(nextX, viewWidth - scaledWidth - padding, padding);
12167
12360
  }
12168
- if (svgHeight <= viewHeight) {
12169
- nextY = (viewHeight - svgHeight) / 2;
12361
+ if (scaledHeight <= viewHeight) {
12362
+ nextY = (viewHeight - scaledHeight) / 2;
12170
12363
  }
12171
12364
  else {
12172
- nextY = clamp(nextY, viewHeight - svgHeight - padding, padding);
12365
+ nextY = clamp(nextY, viewHeight - scaledHeight - padding, padding);
12173
12366
  }
12174
12367
  this.animateTo(nextX, nextY, animated);
12175
12368
  }
@@ -12201,7 +12394,109 @@ class SketchmarkEmbed {
12201
12394
  this.motionFrame = requestAnimationFrame(frame);
12202
12395
  }
12203
12396
  applyTransform() {
12204
- this.world.style.transform = `translate(${this.offsetX}px, ${this.offsetY}px)`;
12397
+ this.world.style.transform = `translate(${this.offsetX}px, ${this.offsetY}px) scale(${this.zoom})`;
12398
+ this.zoomInfoElement.textContent = `${Math.round(this.zoom * 100)}%`;
12399
+ }
12400
+ getContentSize() {
12401
+ if (!this.instance?.svg)
12402
+ return null;
12403
+ const svg = this.instance.svg;
12404
+ const width = parseFloat(svg.getAttribute("width") || "0");
12405
+ const height = parseFloat(svg.getAttribute("height") || "0");
12406
+ if (!width || !height)
12407
+ return null;
12408
+ return { width, height };
12409
+ }
12410
+ getFitZoom(svgWidth, svgHeight, viewWidth, viewHeight) {
12411
+ const padding = this.getFitPadding(viewWidth, viewHeight);
12412
+ const availableWidth = Math.max(viewWidth - padding * 2, 1);
12413
+ const availableHeight = Math.max(viewHeight - padding * 2, 1);
12414
+ const nextZoom = Math.min(availableWidth / svgWidth, availableHeight / svgHeight, 1);
12415
+ return clamp(nextZoom || 1, this.getZoomMin(), this.getZoomMax());
12416
+ }
12417
+ getFitPadding(viewWidth, viewHeight) {
12418
+ if (typeof this.options.fitPadding === "number") {
12419
+ return Math.max(0, this.options.fitPadding);
12420
+ }
12421
+ return Math.max(16, Math.min(40, Math.round(Math.min(viewWidth, viewHeight) * 0.08)));
12422
+ }
12423
+ getZoomMin() {
12424
+ return this.options.zoomMin ?? 0.08;
12425
+ }
12426
+ getZoomMax() {
12427
+ return this.options.zoomMax ?? 4;
12428
+ }
12429
+ zoomAroundViewportCenter(factor) {
12430
+ if (!this.instance?.svg)
12431
+ return;
12432
+ const pivotX = this.viewport.clientWidth / 2;
12433
+ const pivotY = this.viewport.clientHeight / 2;
12434
+ this.zoomTo(this.zoom * factor, pivotX, pivotY);
12435
+ }
12436
+ zoomTo(nextZoom, pivotX, pivotY) {
12437
+ const clampedZoom = clamp(nextZoom, this.getZoomMin(), this.getZoomMax());
12438
+ const ratio = clampedZoom / this.zoom;
12439
+ if (!Number.isFinite(ratio) || ratio === 1) {
12440
+ this.syncViewControls();
12441
+ return;
12442
+ }
12443
+ this.stopMotion();
12444
+ this.autoFitEnabled = false;
12445
+ this.offsetX = pivotX - (pivotX - this.offsetX) * ratio;
12446
+ this.offsetY = pivotY - (pivotY - this.offsetY) * ratio;
12447
+ this.zoom = clampedZoom;
12448
+ this.applyTransform();
12449
+ this.syncViewControls();
12450
+ }
12451
+ applyCaptionVisibility(instance) {
12452
+ const caption = instance?.anim.captionElement;
12453
+ if (!caption)
12454
+ return;
12455
+ caption.style.display = this.showCaption ? "" : "none";
12456
+ caption.setAttribute("aria-hidden", this.showCaption ? "false" : "true");
12457
+ }
12458
+ applyTtsSetting(instance) {
12459
+ if (!instance || this.ttsOverride === null)
12460
+ return;
12461
+ instance.anim.tts = this.ttsOverride;
12462
+ }
12463
+ getTtsEnabled() {
12464
+ if (this.ttsOverride !== null)
12465
+ return this.ttsOverride;
12466
+ return !!this.instance?.anim.tts;
12467
+ }
12468
+ syncToggleControls() {
12469
+ const hasView = !!this.instance?.svg;
12470
+ const canToggleTts = hasView &&
12471
+ typeof speechSynthesis !== "undefined";
12472
+ const ttsEnabled = this.getTtsEnabled();
12473
+ this.btnCaption.textContent = this.showCaption ? "Caption On" : "Caption Off";
12474
+ this.btnCaption.classList.toggle("is-active", this.showCaption);
12475
+ this.btnCaption.setAttribute("aria-pressed", this.showCaption ? "true" : "false");
12476
+ this.btnCaption.disabled = !hasView;
12477
+ this.btnTts.textContent = ttsEnabled ? "TTS On" : "TTS Off";
12478
+ this.btnTts.classList.toggle("is-active", ttsEnabled);
12479
+ this.btnTts.setAttribute("aria-pressed", ttsEnabled ? "true" : "false");
12480
+ this.btnTts.disabled = !canToggleTts;
12481
+ }
12482
+ getTargetBox(target, viewportRect) {
12483
+ if (target instanceof SVGGraphicsElement) {
12484
+ try {
12485
+ const bounds = target.getBBox();
12486
+ return {
12487
+ centerX: (bounds.x + bounds.width / 2) * this.zoom,
12488
+ centerY: (bounds.y + bounds.height / 2) * this.zoom,
12489
+ };
12490
+ }
12491
+ catch {
12492
+ // Ignore and fall back to layout-based bounds below.
12493
+ }
12494
+ }
12495
+ const currentRect = target.getBoundingClientRect();
12496
+ return {
12497
+ centerX: currentRect.left - viewportRect.left - this.offsetX + currentRect.width / 2,
12498
+ centerY: currentRect.top - viewportRect.top - this.offsetY + currentRect.height / 2,
12499
+ };
12205
12500
  }
12206
12501
  getFocusTarget() {
12207
12502
  const anim = this.instance?.anim;