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.

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