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