sketchmark 1.1.2 → 1.1.4

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);
@@ -9248,11 +9269,13 @@ class AnimationController {
9248
9269
  this._config = _config;
9249
9270
  this._step = -1;
9250
9271
  this._pendingStepTimers = new Set();
9272
+ this._pendingNarrationTimers = new Set();
9251
9273
  this._transforms = new Map();
9252
9274
  this._listeners = [];
9253
9275
  // ── Narration caption ──
9254
9276
  this._captionEl = null;
9255
9277
  this._captionTextEl = null;
9278
+ this._narrationRunId = 0;
9256
9279
  // ── Annotations ──
9257
9280
  this._annotationLayer = null;
9258
9281
  this._annotations = [];
@@ -9515,20 +9538,30 @@ class AnimationController {
9515
9538
  }
9516
9539
  this.emit("step-change");
9517
9540
  }
9541
+ _clearTimerBucket(bucket) {
9542
+ bucket.forEach((id) => window.clearTimeout(id));
9543
+ bucket.clear();
9544
+ }
9518
9545
  _clearPendingStepTimers() {
9519
- this._pendingStepTimers.forEach((id) => window.clearTimeout(id));
9520
- this._pendingStepTimers.clear();
9546
+ this._clearTimerBucket(this._pendingStepTimers);
9521
9547
  }
9522
- _scheduleStep(fn, delayMs) {
9548
+ _cancelNarrationTyping() {
9549
+ this._narrationRunId += 1;
9550
+ this._clearTimerBucket(this._pendingNarrationTimers);
9551
+ }
9552
+ _scheduleTimer(fn, delayMs, bucket = this._pendingStepTimers) {
9523
9553
  if (delayMs <= 0) {
9524
9554
  fn();
9525
9555
  return;
9526
9556
  }
9527
9557
  const id = window.setTimeout(() => {
9528
- this._pendingStepTimers.delete(id);
9558
+ bucket.delete(id);
9529
9559
  fn();
9530
9560
  }, delayMs);
9531
- this._pendingStepTimers.add(id);
9561
+ bucket.add(id);
9562
+ }
9563
+ _scheduleStep(fn, delayMs) {
9564
+ this._scheduleTimer(fn, delayMs, this._pendingStepTimers);
9532
9565
  }
9533
9566
  _stepWaitMs(step, fallbackMs) {
9534
9567
  const delay = Math.max(0, step.delay ?? 0);
@@ -9572,6 +9605,7 @@ class AnimationController {
9572
9605
  }
9573
9606
  _clearAll() {
9574
9607
  this._clearPendingStepTimers();
9608
+ this._cancelNarrationTyping();
9575
9609
  this._cancelSpeech();
9576
9610
  this._transforms.clear();
9577
9611
  // Nodes
@@ -10036,7 +10070,7 @@ class AnimationController {
10036
10070
  if (!nodeGuidePathEl(nodeEl) && !nodeEl.querySelector("path")?.style.strokeDasharray) {
10037
10071
  prepareNodeForDraw(nodeEl);
10038
10072
  }
10039
- animateNodeDraw(nodeEl, step.duration ?? ANIMATION.nodeStrokeDur);
10073
+ animateNodeDraw(nodeEl, step.duration ?? ANIMATION.nodeStrokeDur, step.duration ?? ANIMATION.textRevealMs);
10040
10074
  }
10041
10075
  }
10042
10076
  // ── erase ─────────────────────────────────────────────────
@@ -10126,6 +10160,7 @@ class AnimationController {
10126
10160
  _doNarrate(text, silent) {
10127
10161
  if (!this._captionEl || !this._captionTextEl)
10128
10162
  return;
10163
+ this._cancelNarrationTyping();
10129
10164
  this._captionEl.style.opacity = "1";
10130
10165
  if (silent || !text) {
10131
10166
  this._captionTextEl.textContent = text;
@@ -10136,12 +10171,16 @@ class AnimationController {
10136
10171
  this._speak(text);
10137
10172
  // Typing effect
10138
10173
  this._captionTextEl.textContent = "";
10174
+ const narrationRunId = this._narrationRunId;
10139
10175
  let charIdx = 0;
10140
10176
  const typeNext = () => {
10177
+ if (this._narrationRunId !== narrationRunId || !this._captionTextEl)
10178
+ return;
10141
10179
  if (charIdx < text.length) {
10142
10180
  this._captionTextEl.textContent += text[charIdx++];
10143
- const id = window.setTimeout(typeNext, ANIMATION.narrationTypeMs);
10144
- this._pendingStepTimers.add(id);
10181
+ if (charIdx < text.length) {
10182
+ this._scheduleTimer(typeNext, ANIMATION.narrationTypeMs, this._pendingNarrationTimers);
10183
+ }
10145
10184
  }
10146
10185
  };
10147
10186
  typeNext();
@@ -10251,12 +10290,11 @@ class AnimationController {
10251
10290
  requestAnimationFrame(animate);
10252
10291
  }
10253
10292
  // After guide finishes: reveal rough.js element, remove guide
10254
- const id = window.setTimeout(() => {
10293
+ this._scheduleTimer(() => {
10255
10294
  roughEl.style.transition = `opacity 120ms ease`;
10256
10295
  roughEl.style.opacity = "1";
10257
10296
  guide.remove();
10258
10297
  }, dur + 30);
10259
- this._pendingStepTimers.add(id);
10260
10298
  }));
10261
10299
  }
10262
10300
  _doAnnotationCircle(target, silent) {
@@ -11833,6 +11871,7 @@ const EMBED_CSS = `
11833
11871
 
11834
11872
  .skm-embed__controls {
11835
11873
  display: flex;
11874
+ flex-wrap: wrap;
11836
11875
  align-items: center;
11837
11876
  gap: 8px;
11838
11877
  padding: 10px 12px;
@@ -11850,6 +11889,13 @@ const EMBED_CSS = `
11850
11889
  display: none;
11851
11890
  }
11852
11891
 
11892
+ .skm-embed__controls-group {
11893
+ display: flex;
11894
+ align-items: center;
11895
+ gap: 8px;
11896
+ flex-wrap: wrap;
11897
+ }
11898
+
11853
11899
  .skm-embed__button {
11854
11900
  border: 1px solid #caba98;
11855
11901
  background: #f5eedd;
@@ -11885,6 +11931,17 @@ const EMBED_CSS = `
11885
11931
  cursor: default;
11886
11932
  }
11887
11933
 
11934
+ .skm-embed__zoom {
11935
+ min-width: 48px;
11936
+ text-align: center;
11937
+ color: #8a6040;
11938
+ font-size: 11px;
11939
+ }
11940
+
11941
+ .skm-embed--dark .skm-embed__zoom {
11942
+ color: #d0b176;
11943
+ }
11944
+
11888
11945
  .skm-embed__step {
11889
11946
  margin-left: auto;
11890
11947
  min-width: 96px;
@@ -11903,8 +11960,10 @@ class SketchmarkEmbed {
11903
11960
  this.emitter = new EventEmitter();
11904
11961
  this.animUnsub = null;
11905
11962
  this.playInFlight = false;
11963
+ this.zoom = 1;
11906
11964
  this.offsetX = 0;
11907
11965
  this.offsetY = 0;
11966
+ this.autoFitEnabled = true;
11908
11967
  this.motionFrame = null;
11909
11968
  this.resizeObserver = null;
11910
11969
  this.options = options;
@@ -11925,10 +11984,18 @@ class SketchmarkEmbed {
11925
11984
  </div>
11926
11985
  <div class="skm-embed__error"></div>
11927
11986
  <div class="skm-embed__controls">
11928
- <button type="button" class="skm-embed__button" data-action="reset">Reset</button>
11929
- <button type="button" class="skm-embed__button" data-action="prev">Prev</button>
11930
- <button type="button" class="skm-embed__button" data-action="next">Next</button>
11931
- <button type="button" class="skm-embed__button" data-action="play">Play</button>
11987
+ <div class="skm-embed__controls-group">
11988
+ <button type="button" class="skm-embed__button" data-action="zoom-out">-</button>
11989
+ <span class="skm-embed__zoom">100%</span>
11990
+ <button type="button" class="skm-embed__button" data-action="zoom-in">+</button>
11991
+ <button type="button" class="skm-embed__button" data-action="fit">Reset</button>
11992
+ </div>
11993
+ <div class="skm-embed__controls-group">
11994
+ <button type="button" class="skm-embed__button" data-action="restart">Restart</button>
11995
+ <button type="button" class="skm-embed__button" data-action="prev">Prev</button>
11996
+ <button type="button" class="skm-embed__button" data-action="next">Next</button>
11997
+ <button type="button" class="skm-embed__button" data-action="play">Play</button>
11998
+ </div>
11932
11999
  <span class="skm-embed__step">No steps</span>
11933
12000
  </div>
11934
12001
  `;
@@ -11938,12 +12005,19 @@ class SketchmarkEmbed {
11938
12005
  this.errorElement = this.root.querySelector(".skm-embed__error");
11939
12006
  this.controlsElement = this.root.querySelector(".skm-embed__controls");
11940
12007
  this.stepInfoElement = this.root.querySelector(".skm-embed__step");
11941
- this.btnReset = this.root.querySelector('[data-action="reset"]');
12008
+ this.zoomInfoElement = this.root.querySelector(".skm-embed__zoom");
12009
+ this.btnFit = this.root.querySelector('[data-action="fit"]');
12010
+ this.btnZoomIn = this.root.querySelector('[data-action="zoom-in"]');
12011
+ this.btnZoomOut = this.root.querySelector('[data-action="zoom-out"]');
12012
+ this.btnRestart = this.root.querySelector('[data-action="restart"]');
11942
12013
  this.btnPrev = this.root.querySelector('[data-action="prev"]');
11943
12014
  this.btnNext = this.root.querySelector('[data-action="next"]');
11944
12015
  this.btnPlay = this.root.querySelector('[data-action="play"]');
11945
12016
  this.controlsElement.classList.toggle("is-hidden", options.showControls === false);
11946
- this.btnReset.addEventListener("click", () => this.resetAnimation());
12017
+ this.btnFit.addEventListener("click", () => this.resetView());
12018
+ this.btnZoomIn.addEventListener("click", () => this.zoomIn());
12019
+ this.btnZoomOut.addEventListener("click", () => this.zoomOut());
12020
+ this.btnRestart.addEventListener("click", () => this.resetAnimation());
11947
12021
  this.btnPrev.addEventListener("click", () => this.prevStep());
11948
12022
  this.btnNext.addEventListener("click", () => this.nextStep());
11949
12023
  this.btnPlay.addEventListener("click", () => {
@@ -11989,7 +12063,12 @@ class SketchmarkEmbed {
11989
12063
  this.animUnsub = null;
11990
12064
  this.instance?.anim?.destroy();
11991
12065
  this.instance = null;
12066
+ this.autoFitEnabled = true;
12067
+ this.zoom = 1;
12068
+ this.offsetX = 0;
12069
+ this.offsetY = 0;
11992
12070
  this.diagramWrap.innerHTML = "";
12071
+ this.applyTransform();
11993
12072
  try {
11994
12073
  const instance = render({
11995
12074
  container: this.diagramWrap,
@@ -12070,6 +12149,21 @@ class SketchmarkEmbed {
12070
12149
  this.syncControls();
12071
12150
  this.positionViewport(true);
12072
12151
  }
12152
+ fitToViewport(animated = false) {
12153
+ if (!this.instance?.svg)
12154
+ return;
12155
+ this.autoFitEnabled = true;
12156
+ this.positionViewport(animated);
12157
+ }
12158
+ resetView(animated = false) {
12159
+ this.fitToViewport(animated);
12160
+ }
12161
+ zoomIn() {
12162
+ this.zoomAroundViewportCenter(1.2);
12163
+ }
12164
+ zoomOut() {
12165
+ this.zoomAroundViewportCenter(0.8);
12166
+ }
12073
12167
  exportSVG(filename) {
12074
12168
  this.instance?.exportSVG(filename);
12075
12169
  }
@@ -12092,10 +12186,14 @@ class SketchmarkEmbed {
12092
12186
  return typeof value === "number" ? `${value}px` : value;
12093
12187
  }
12094
12188
  syncControls() {
12189
+ this.syncAnimationControls();
12190
+ this.syncViewControls();
12191
+ }
12192
+ syncAnimationControls() {
12095
12193
  const anim = this.instance?.anim;
12096
12194
  if (!anim || !anim.total) {
12097
12195
  this.stepInfoElement.textContent = "No steps";
12098
- this.btnReset.disabled = true;
12196
+ this.btnRestart.disabled = true;
12099
12197
  this.btnPrev.disabled = true;
12100
12198
  this.btnNext.disabled = true;
12101
12199
  this.btnPlay.disabled = true;
@@ -12103,56 +12201,69 @@ class SketchmarkEmbed {
12103
12201
  }
12104
12202
  this.stepInfoElement.textContent =
12105
12203
  anim.currentStep < 0 ? `${anim.total} steps` : `${anim.currentStep + 1} / ${anim.total}`;
12106
- this.btnReset.disabled = false;
12204
+ this.btnRestart.disabled = false;
12107
12205
  this.btnPrev.disabled = !anim.canPrev;
12108
12206
  this.btnNext.disabled = !anim.canNext;
12109
12207
  this.btnPlay.disabled = this.playInFlight || !anim.canNext;
12110
12208
  }
12209
+ syncViewControls() {
12210
+ const hasView = !!this.instance?.svg;
12211
+ const zoomMin = this.getZoomMin();
12212
+ const zoomMax = this.getZoomMax();
12213
+ this.zoomInfoElement.textContent = `${Math.round(this.zoom * 100)}%`;
12214
+ this.btnFit.disabled = !hasView;
12215
+ this.btnZoomOut.disabled = !hasView || this.zoom <= zoomMin + 0.001;
12216
+ this.btnZoomIn.disabled = !hasView || this.zoom >= zoomMax - 0.001;
12217
+ }
12111
12218
  positionViewport(animated) {
12112
- if (!this.instance?.svg)
12113
- return;
12114
- const svg = this.instance.svg;
12115
- const svgWidth = parseFloat(svg.getAttribute("width") || "0");
12116
- const svgHeight = parseFloat(svg.getAttribute("height") || "0");
12117
- if (!svgWidth || !svgHeight)
12219
+ const size = this.getContentSize();
12220
+ if (!size)
12118
12221
  return;
12222
+ const { width: svgWidth, height: svgHeight } = size;
12119
12223
  const viewportRect = this.viewport.getBoundingClientRect();
12120
12224
  const viewWidth = viewportRect.width || this.viewport.clientWidth;
12121
12225
  const viewHeight = viewportRect.height || this.viewport.clientHeight;
12122
12226
  if (!viewWidth || !viewHeight)
12123
12227
  return;
12124
- const sceneIsLarge = svgWidth > viewWidth || svgHeight > viewHeight;
12228
+ if (this.autoFitEnabled) {
12229
+ this.zoom = this.getFitZoom(svgWidth, svgHeight, viewWidth, viewHeight);
12230
+ }
12231
+ this.syncViewControls();
12232
+ const scaledWidth = svgWidth * this.zoom;
12233
+ const scaledHeight = svgHeight * this.zoom;
12234
+ const focusTarget = this.getFocusTarget();
12235
+ const sceneIsLarge = scaledWidth > viewWidth || scaledHeight > viewHeight;
12125
12236
  const shouldFocus = sceneIsLarge &&
12126
12237
  this.options.autoFocus !== false &&
12127
- !!this.getFocusTarget();
12238
+ !!focusTarget;
12128
12239
  if (!shouldFocus) {
12129
- this.animateTo(svgWidth <= viewWidth ? (viewWidth - svgWidth) / 2 : 0, svgHeight <= viewHeight ? (viewHeight - svgHeight) / 2 : 0, animated);
12240
+ this.animateTo(scaledWidth <= viewWidth ? (viewWidth - scaledWidth) / 2 : 0, scaledHeight <= viewHeight ? (viewHeight - scaledHeight) / 2 : 0, animated);
12130
12241
  return;
12131
12242
  }
12132
- const target = this.findTargetElement(this.getFocusTarget());
12243
+ const target = this.findTargetElement(focusTarget);
12133
12244
  if (!target) {
12134
12245
  this.animateTo(0, 0, animated);
12135
12246
  return;
12136
12247
  }
12137
- const currentRect = target.getBoundingClientRect();
12138
- const sceneX = currentRect.left - viewportRect.left - this.offsetX;
12139
- const sceneY = currentRect.top - viewportRect.top - this.offsetY;
12140
- const targetCenterX = sceneX + currentRect.width / 2;
12141
- const targetCenterY = sceneY + currentRect.height / 2;
12142
- let nextX = viewWidth / 2 - targetCenterX;
12143
- let nextY = viewHeight / 2 - targetCenterY;
12248
+ const targetBox = this.getTargetBox(target, viewportRect);
12249
+ if (!targetBox) {
12250
+ this.animateTo(0, 0, animated);
12251
+ return;
12252
+ }
12253
+ let nextX = viewWidth / 2 - targetBox.centerX;
12254
+ let nextY = viewHeight / 2 - targetBox.centerY;
12144
12255
  const padding = this.options.focusPadding ?? 24;
12145
- if (svgWidth <= viewWidth) {
12146
- nextX = (viewWidth - svgWidth) / 2;
12256
+ if (scaledWidth <= viewWidth) {
12257
+ nextX = (viewWidth - scaledWidth) / 2;
12147
12258
  }
12148
12259
  else {
12149
- nextX = clamp(nextX, viewWidth - svgWidth - padding, padding);
12260
+ nextX = clamp(nextX, viewWidth - scaledWidth - padding, padding);
12150
12261
  }
12151
- if (svgHeight <= viewHeight) {
12152
- nextY = (viewHeight - svgHeight) / 2;
12262
+ if (scaledHeight <= viewHeight) {
12263
+ nextY = (viewHeight - scaledHeight) / 2;
12153
12264
  }
12154
12265
  else {
12155
- nextY = clamp(nextY, viewHeight - svgHeight - padding, padding);
12266
+ nextY = clamp(nextY, viewHeight - scaledHeight - padding, padding);
12156
12267
  }
12157
12268
  this.animateTo(nextX, nextY, animated);
12158
12269
  }
@@ -12184,7 +12295,78 @@ class SketchmarkEmbed {
12184
12295
  this.motionFrame = requestAnimationFrame(frame);
12185
12296
  }
12186
12297
  applyTransform() {
12187
- this.world.style.transform = `translate(${this.offsetX}px, ${this.offsetY}px)`;
12298
+ this.world.style.transform = `translate(${this.offsetX}px, ${this.offsetY}px) scale(${this.zoom})`;
12299
+ this.zoomInfoElement.textContent = `${Math.round(this.zoom * 100)}%`;
12300
+ }
12301
+ getContentSize() {
12302
+ if (!this.instance?.svg)
12303
+ return null;
12304
+ const svg = this.instance.svg;
12305
+ const width = parseFloat(svg.getAttribute("width") || "0");
12306
+ const height = parseFloat(svg.getAttribute("height") || "0");
12307
+ if (!width || !height)
12308
+ return null;
12309
+ return { width, height };
12310
+ }
12311
+ getFitZoom(svgWidth, svgHeight, viewWidth, viewHeight) {
12312
+ const padding = this.getFitPadding(viewWidth, viewHeight);
12313
+ const availableWidth = Math.max(viewWidth - padding * 2, 1);
12314
+ const availableHeight = Math.max(viewHeight - padding * 2, 1);
12315
+ const nextZoom = Math.min(availableWidth / svgWidth, availableHeight / svgHeight, 1);
12316
+ return clamp(nextZoom || 1, this.getZoomMin(), this.getZoomMax());
12317
+ }
12318
+ getFitPadding(viewWidth, viewHeight) {
12319
+ if (typeof this.options.fitPadding === "number") {
12320
+ return Math.max(0, this.options.fitPadding);
12321
+ }
12322
+ return Math.max(16, Math.min(40, Math.round(Math.min(viewWidth, viewHeight) * 0.08)));
12323
+ }
12324
+ getZoomMin() {
12325
+ return this.options.zoomMin ?? 0.08;
12326
+ }
12327
+ getZoomMax() {
12328
+ return this.options.zoomMax ?? 4;
12329
+ }
12330
+ zoomAroundViewportCenter(factor) {
12331
+ if (!this.instance?.svg)
12332
+ return;
12333
+ const pivotX = this.viewport.clientWidth / 2;
12334
+ const pivotY = this.viewport.clientHeight / 2;
12335
+ this.zoomTo(this.zoom * factor, pivotX, pivotY);
12336
+ }
12337
+ zoomTo(nextZoom, pivotX, pivotY) {
12338
+ const clampedZoom = clamp(nextZoom, this.getZoomMin(), this.getZoomMax());
12339
+ const ratio = clampedZoom / this.zoom;
12340
+ if (!Number.isFinite(ratio) || ratio === 1) {
12341
+ this.syncViewControls();
12342
+ return;
12343
+ }
12344
+ this.stopMotion();
12345
+ this.autoFitEnabled = false;
12346
+ this.offsetX = pivotX - (pivotX - this.offsetX) * ratio;
12347
+ this.offsetY = pivotY - (pivotY - this.offsetY) * ratio;
12348
+ this.zoom = clampedZoom;
12349
+ this.applyTransform();
12350
+ this.syncViewControls();
12351
+ }
12352
+ getTargetBox(target, viewportRect) {
12353
+ if (target instanceof SVGGraphicsElement) {
12354
+ try {
12355
+ const bounds = target.getBBox();
12356
+ return {
12357
+ centerX: (bounds.x + bounds.width / 2) * this.zoom,
12358
+ centerY: (bounds.y + bounds.height / 2) * this.zoom,
12359
+ };
12360
+ }
12361
+ catch {
12362
+ // Ignore and fall back to layout-based bounds below.
12363
+ }
12364
+ }
12365
+ const currentRect = target.getBoundingClientRect();
12366
+ return {
12367
+ centerX: currentRect.left - viewportRect.left - this.offsetX + currentRect.width / 2,
12368
+ centerY: currentRect.top - viewportRect.top - this.offsetY + currentRect.height / 2,
12369
+ };
12188
12370
  }
12189
12371
  getFocusTarget() {
12190
12372
  const anim = this.instance?.anim;