@markdy/renderer-dom 0.8.25 → 0.8.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -81,6 +81,17 @@ interface SvgExportOptions {
81
81
  }
82
82
  declare function exportDiagramAsVectorSvg(containerEl: HTMLElement, options?: SvgExportOptions): string;
83
83
 
84
+ /**
85
+ * packages/renderer-dom/src/export/png-exporter.ts
86
+ * High-DPI raster PNG export with 2x retina scaling.
87
+ * Zero external dependencies.
88
+ */
89
+
90
+ interface PngExportOptions extends SvgExportOptions {
91
+ pixelRatio?: number;
92
+ }
93
+ declare function exportDiagramAsPng(containerEl: HTMLElement, options?: PngExportOptions): Promise<Blob>;
94
+
84
95
  /**
85
96
  * packages/renderer-dom/src/presentation-controller.ts
86
97
  * Interactive Beat Navigation & Keyboard-driven presentation controller.
@@ -95,13 +106,17 @@ declare class DiagramPresentationController {
95
106
  private diagram;
96
107
  private plan;
97
108
  private currentBeatIndex;
109
+ private options;
110
+ private isDestroyed;
98
111
  constructor(diagram: Diagram, plan: RenderPlan, options?: ControllerOptions);
99
112
  nextBeat(): void;
100
113
  prevBeat(): void;
114
+ private applyCurrentBeat;
101
115
  getCurrentBeatIndex(): number;
102
116
  togglePlay(): void;
103
117
  setSpeed(rate: number): void;
104
- private attachKeyboardListener;
118
+ private onKeyDown;
119
+ destroy(): void;
105
120
  }
106
121
 
107
- export { type AnimationRecordFrame, type ControllerOptions, type Diagram, type DiagramOptions, DiagramPresentationController, type GifExportOptions, ICON_REGISTRY, type IconSpec, type SvgExportOptions, createDiagram, encodeGifSequence, exportDiagramAsVectorSvg };
122
+ export { type AnimationRecordFrame, type ControllerOptions, type Diagram, type DiagramOptions, DiagramPresentationController, type GifExportOptions, ICON_REGISTRY, type IconSpec, type PngExportOptions, type SvgExportOptions, createDiagram, encodeGifSequence, exportDiagramAsPng, exportDiagramAsVectorSvg };
package/dist/index.js CHANGED
@@ -808,13 +808,14 @@ function ensureAnnotationStyles(doc) {
808
808
  doc.head.appendChild(style);
809
809
  }
810
810
  function positionForAnnotation(position, bounds, index) {
811
- const pad = 24;
811
+ const pad = 28;
812
+ const topPad = 68;
812
813
  const p = (position ?? "").toLowerCase();
813
- if (p.includes("top") && p.includes("right")) return { x: bounds.width - pad - 200, y: pad + index * 48 };
814
- if (p.includes("top") && p.includes("left")) return { x: pad, y: pad + index * 48 };
814
+ if (p.includes("top") && p.includes("right")) return { x: bounds.width - pad - 200, y: topPad + index * 48 };
815
+ if (p.includes("top") && p.includes("left")) return { x: pad, y: topPad + index * 48 };
815
816
  if (p.includes("bottom") && p.includes("right")) return { x: bounds.width - pad - 200, y: bounds.height - pad - 40 };
816
817
  if (p.includes("bottom") && p.includes("left")) return { x: pad, y: bounds.height - pad - 40 };
817
- return { x: bounds.width - pad - 200, y: pad + index * 48 };
818
+ return { x: bounds.width - pad - 200, y: topPad + index * 48 };
818
819
  }
819
820
  function mountAnnotations(layer, annotations, nodes, theme, bounds) {
820
821
  if (annotations.length === 0) return;
@@ -2110,11 +2111,16 @@ function createDiagram(opts) {
2110
2111
  const totalDurationMs = plan.duration * 1e3;
2111
2112
  const durationSeconds = plan.duration;
2112
2113
  const viewport = document.createElement("div");
2114
+ viewport.className = "markdy-viewport";
2113
2115
  Object.assign(viewport.style, {
2114
2116
  position: "relative",
2115
2117
  width: "100%",
2118
+ height: "100%",
2119
+ maxWidth: "100%",
2120
+ maxHeight: "100%",
2116
2121
  aspectRatio: `${plan.meta.width} / ${plan.meta.height}`,
2117
- overflow: "hidden"
2122
+ overflow: "hidden",
2123
+ boxSizing: "border-box"
2118
2124
  });
2119
2125
  container.appendChild(viewport);
2120
2126
  let progressEl = null;
@@ -2269,14 +2275,85 @@ function createDiagram(opts) {
2269
2275
  nodeEls.set(node.id, el);
2270
2276
  }
2271
2277
  let fitScale = 1;
2278
+ let sceneOffsetX = 0;
2279
+ let sceneOffsetY = 0;
2280
+ function computeContentBounds() {
2281
+ if (!plan.nodes || plan.nodes.length === 0) {
2282
+ return { minX: 0, minY: 0, maxX: plan.meta.width, maxY: plan.meta.height, width: plan.meta.width, height: plan.meta.height };
2283
+ }
2284
+ let minX = Infinity;
2285
+ let minY = Infinity;
2286
+ let maxX = -Infinity;
2287
+ let maxY = -Infinity;
2288
+ for (const node of plan.nodes) {
2289
+ minX = Math.min(minX, node.x);
2290
+ minY = Math.min(minY, node.y);
2291
+ maxX = Math.max(maxX, node.x + node.width);
2292
+ maxY = Math.max(maxY, node.y + node.height);
2293
+ }
2294
+ for (const gb of plan.groupBoundaries ?? []) {
2295
+ minX = Math.min(minX, gb.x);
2296
+ minY = Math.min(minY, gb.y);
2297
+ maxX = Math.max(maxX, gb.x + gb.width);
2298
+ maxY = Math.max(maxY, gb.y + gb.height);
2299
+ }
2300
+ for (const bus of plan.treeBuses ?? []) {
2301
+ minX = Math.min(minX, bus.parentX, ...bus.childXs.length ? bus.childXs : [bus.parentX]);
2302
+ minY = Math.min(minY, bus.parentY, bus.branchY, bus.childY);
2303
+ maxX = Math.max(maxX, bus.parentX, ...bus.childXs.length ? bus.childXs : [bus.parentX]);
2304
+ maxY = Math.max(maxY, bus.parentY, bus.branchY, bus.childY);
2305
+ }
2306
+ if (plan.title) {
2307
+ minY = Math.min(minY, 20);
2308
+ }
2309
+ const pad = 36;
2310
+ minX = Math.max(0, minX - pad);
2311
+ minY = Math.max(0, minY - pad);
2312
+ maxX = Math.min(plan.meta.width, maxX + pad);
2313
+ maxY = Math.min(plan.meta.height, maxY + pad);
2314
+ const width = Math.max(maxX - minX, 200);
2315
+ const height = Math.max(maxY - minY, 140);
2316
+ return { minX, minY, maxX, maxY, width, height };
2317
+ }
2272
2318
  function scaleScene() {
2273
- const width = viewport.clientWidth || plan.meta.width;
2274
- fitScale = width / plan.meta.width;
2319
+ const isBrowserLayout = (viewport.clientWidth || container.clientWidth || 0) > 0;
2320
+ if (!isBrowserLayout) {
2321
+ fitScale = 1;
2322
+ sceneOffsetX = 0;
2323
+ sceneOffsetY = 0;
2324
+ scene.style.left = "0px";
2325
+ scene.style.top = "0px";
2326
+ scene.style.transformOrigin = "0 0";
2327
+ scene.style.transform = "scale(1)";
2328
+ return;
2329
+ }
2330
+ const vWidth = viewport.clientWidth || container.clientWidth || plan.meta.width;
2331
+ const vHeight = viewport.clientHeight || container.clientHeight || vWidth * plan.meta.height / plan.meta.width;
2332
+ const canvasScaleX = vWidth / plan.meta.width;
2333
+ const canvasScaleY = vHeight / plan.meta.height;
2334
+ const baseCanvasScale = Math.min(canvasScaleX, canvasScaleY);
2335
+ const bounds = computeContentBounds();
2336
+ const contentScaleX = vWidth * 0.94 / bounds.width;
2337
+ const contentScaleY = vHeight * 0.94 / bounds.height;
2338
+ const optimalContentScale = Math.min(contentScaleX, contentScaleY);
2339
+ const chosenScale = Math.max(
2340
+ baseCanvasScale,
2341
+ Math.min(optimalContentScale, baseCanvasScale * 1.45)
2342
+ );
2343
+ fitScale = Number.isFinite(chosenScale) && chosenScale > 0 ? chosenScale : 1;
2344
+ const scaledWidth = plan.meta.width * fitScale;
2345
+ const scaledHeight = plan.meta.height * fitScale;
2346
+ sceneOffsetX = (vWidth - scaledWidth) / 2;
2347
+ sceneOffsetY = (vHeight - scaledHeight) / 2;
2348
+ scene.style.left = `${sceneOffsetX}px`;
2349
+ scene.style.top = `${sceneOffsetY}px`;
2350
+ scene.style.transformOrigin = "0 0";
2275
2351
  scene.style.transform = `scale(${fitScale})`;
2276
2352
  }
2277
2353
  scaleScene();
2278
2354
  const resizeObserver = typeof ResizeObserver === "function" ? new ResizeObserver(scaleScene) : null;
2279
2355
  resizeObserver?.observe(viewport);
2356
+ if (container !== viewport) resizeObserver?.observe(container);
2280
2357
  const edgeRuntimes = /* @__PURE__ */ new Map();
2281
2358
  const edgeSceneId = createEdgeSceneId();
2282
2359
  const sequenceAnims = plan.diagramType === "sequence" ? mountSequenceLayer(
@@ -2341,8 +2418,8 @@ function createDiagram(opts) {
2341
2418
  function handleViewportWheel(event) {
2342
2419
  event.preventDefault();
2343
2420
  const rect = viewport.getBoundingClientRect();
2344
- const pointerX = (event.clientX - rect.left) / fitScale;
2345
- const pointerY = (event.clientY - rect.top) / fitScale;
2421
+ const pointerX = (event.clientX - rect.left - sceneOffsetX) / fitScale;
2422
+ const pointerY = (event.clientY - rect.top - sceneOffsetY) / fitScale;
2346
2423
  const nextScale = Math.min(MAX_VIEWPORT_ZOOM, Math.max(MIN_VIEWPORT_ZOOM, viewportScale * Math.exp(-event.deltaY * VIEWPORT_ZOOM_STEP)));
2347
2424
  if (nextScale === viewportScale) return;
2348
2425
  const sceneX = (pointerX - viewportPanX) / viewportScale;
@@ -2694,7 +2771,7 @@ function lzwCompress(pixels, minCodeSize = 8) {
2694
2771
  if (nextCode === 1 << codeSize && codeSize < 12) {
2695
2772
  codeSize++;
2696
2773
  }
2697
- if (nextCode >= 4095) {
2774
+ if (nextCode >= 4096) {
2698
2775
  codes.push(clearCode);
2699
2776
  dict = /* @__PURE__ */ new Map();
2700
2777
  for (let j = 0; j < clearCode; j++) dict.set(String(j), j);
@@ -2785,31 +2862,101 @@ function encodeGifSequence(frames, options = {}) {
2785
2862
 
2786
2863
  // src/export/svg-exporter.ts
2787
2864
  function exportDiagramAsVectorSvg(containerEl, options = {}) {
2788
- const svgEl = containerEl.querySelector("svg");
2789
- if (!svgEl) throw new Error("No Markdy SVG element found in container");
2790
- const clonedSvg = svgEl.cloneNode(true);
2791
- const width = svgEl.getAttribute("width") || String(svgEl.clientWidth || 800);
2792
- const height = svgEl.getAttribute("height") || String(svgEl.clientHeight || 400);
2793
- clonedSvg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
2794
- clonedSvg.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink");
2795
- clonedSvg.setAttribute("width", width);
2796
- clonedSvg.setAttribute("height", height);
2797
- clonedSvg.setAttribute("viewBox", `0 0 ${width} ${height}`);
2865
+ const sceneEl = containerEl.classList?.contains("markdy-scene-root") ? containerEl : containerEl.querySelector(".markdy-scene-root") || containerEl.querySelector("svg") || (containerEl.tagName?.toLowerCase() === "svg" ? containerEl : null);
2866
+ if (!sceneEl) throw new Error("No Markdy scene element found in container");
2867
+ if (sceneEl.tagName?.toLowerCase() === "svg") {
2868
+ const clonedSvg = sceneEl.cloneNode(true);
2869
+ if (!clonedSvg.getAttribute("xmlns")) {
2870
+ clonedSvg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
2871
+ }
2872
+ const serializer2 = new XMLSerializer();
2873
+ return `<?xml version="1.0" encoding="UTF-8" standalone="no"?>
2874
+ ${serializer2.serializeToString(clonedSvg)}`;
2875
+ }
2876
+ const clonedScene = sceneEl.cloneNode(true);
2877
+ const widthStr = clonedScene.style.width || String(sceneEl.clientWidth || 800);
2878
+ const heightStr = clonedScene.style.height || String(sceneEl.clientHeight || 400);
2879
+ let width = parseFloat(widthStr);
2880
+ let height = parseFloat(heightStr);
2881
+ if (isNaN(width)) width = 800;
2882
+ if (isNaN(height)) height = 400;
2883
+ const scale = options.scale || 1;
2884
+ const scaledWidth = width * scale;
2885
+ const scaledHeight = height * scale;
2886
+ clonedScene.style.transform = `scale(${scale})`;
2887
+ clonedScene.style.transformOrigin = "0 0";
2888
+ clonedScene.style.position = "relative";
2889
+ clonedScene.style.left = "0px";
2890
+ clonedScene.style.top = "0px";
2891
+ clonedScene.setAttribute("xmlns", "http://www.w3.org/1999/xhtml");
2798
2892
  if (options.transparentBackground) {
2799
- const bgRect = clonedSvg.querySelector("rect");
2800
- if (bgRect) bgRect.remove();
2893
+ clonedScene.style.background = "transparent";
2801
2894
  }
2895
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
2896
+ svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
2897
+ svg.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink");
2898
+ svg.setAttribute("width", String(scaledWidth));
2899
+ svg.setAttribute("height", String(scaledHeight));
2900
+ svg.setAttribute("viewBox", `0 0 ${scaledWidth} ${scaledHeight}`);
2802
2901
  if (options.includeThemeStyles !== false) {
2803
2902
  const styleEl = document.createElementNS("http://www.w3.org/2000/svg", "style");
2804
- styleEl.textContent = `
2805
- text { font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
2903
+ let combinedStyles = `
2904
+ foreignObject { width: 100%; height: 100%; }
2806
2905
  .markdy-node { transition: opacity 0.3s ease; }
2807
2906
  `;
2808
- clonedSvg.insertBefore(styleEl, clonedSvg.firstChild);
2809
- }
2907
+ if (typeof document !== "undefined") {
2908
+ const styles = document.querySelectorAll("style[id^='markdy-']");
2909
+ for (let i = 0; i < styles.length; i++) {
2910
+ combinedStyles += styles[i].textContent + "\n";
2911
+ }
2912
+ }
2913
+ styleEl.textContent = combinedStyles;
2914
+ svg.appendChild(styleEl);
2915
+ }
2916
+ const foreignObject = document.createElementNS("http://www.w3.org/2000/svg", "foreignObject");
2917
+ foreignObject.setAttribute("width", "100%");
2918
+ foreignObject.setAttribute("height", "100%");
2919
+ foreignObject.appendChild(clonedScene);
2920
+ svg.appendChild(foreignObject);
2810
2921
  const serializer = new XMLSerializer();
2811
2922
  return `<?xml version="1.0" encoding="UTF-8" standalone="no"?>
2812
- ` + serializer.serializeToString(clonedSvg);
2923
+ ` + serializer.serializeToString(svg);
2924
+ }
2925
+
2926
+ // src/export/png-exporter.ts
2927
+ async function exportDiagramAsPng(containerEl, options = {}) {
2928
+ const svgXml = exportDiagramAsVectorSvg(containerEl, options);
2929
+ const pixelRatio = options.pixelRatio || 2;
2930
+ const blob = new Blob([svgXml], { type: "image/svg+xml;charset=utf-8" });
2931
+ const url = URL.createObjectURL(blob);
2932
+ const img = new Image();
2933
+ await new Promise((resolve, reject) => {
2934
+ img.onload = () => resolve();
2935
+ img.onerror = () => {
2936
+ URL.revokeObjectURL(url);
2937
+ reject(new Error("Failed to rasterize SVG into Image for PNG export"));
2938
+ };
2939
+ img.src = url;
2940
+ });
2941
+ const width = img.naturalWidth || 800;
2942
+ const height = img.naturalHeight || 400;
2943
+ const canvas = document.createElement("canvas");
2944
+ canvas.width = width * pixelRatio;
2945
+ canvas.height = height * pixelRatio;
2946
+ const ctx = canvas.getContext("2d");
2947
+ if (!ctx) {
2948
+ URL.revokeObjectURL(url);
2949
+ throw new Error("Could not get 2D canvas context for PNG export");
2950
+ }
2951
+ ctx.scale(pixelRatio, pixelRatio);
2952
+ ctx.drawImage(img, 0, 0, width, height);
2953
+ URL.revokeObjectURL(url);
2954
+ return new Promise((resolve, reject) => {
2955
+ canvas.toBlob((b) => {
2956
+ if (b) resolve(b);
2957
+ else reject(new Error("Canvas toBlob failed for PNG export"));
2958
+ }, "image/png");
2959
+ });
2813
2960
  }
2814
2961
 
2815
2962
  // src/presentation-controller.ts
@@ -2817,60 +2964,79 @@ var DiagramPresentationController = class {
2817
2964
  diagram;
2818
2965
  plan;
2819
2966
  currentBeatIndex = 0;
2967
+ options;
2968
+ isDestroyed = false;
2820
2969
  constructor(diagram, plan, options = {}) {
2821
2970
  this.diagram = diagram;
2822
2971
  this.plan = plan;
2972
+ this.options = options;
2823
2973
  if (options.enableKeyboard !== false && typeof window !== "undefined") {
2824
- this.attachKeyboardListener();
2974
+ window.addEventListener("keydown", this.onKeyDown);
2825
2975
  }
2826
2976
  }
2827
2977
  nextBeat() {
2828
2978
  if (this.currentBeatIndex < this.plan.beats.length - 1) {
2829
2979
  this.currentBeatIndex++;
2830
- const beat = this.plan.beats[this.currentBeatIndex];
2831
- this.diagram.seek(beat.start);
2980
+ this.applyCurrentBeat();
2832
2981
  }
2833
2982
  }
2834
2983
  prevBeat() {
2835
2984
  if (this.currentBeatIndex > 0) {
2836
2985
  this.currentBeatIndex--;
2837
- const beat = this.plan.beats[this.currentBeatIndex];
2838
- this.diagram.seek(beat.start);
2986
+ this.applyCurrentBeat();
2987
+ }
2988
+ }
2989
+ applyCurrentBeat() {
2990
+ const beat = this.plan.beats[this.currentBeatIndex];
2991
+ this.diagram.seek(beat.start);
2992
+ if (this.options.onBeatChange) {
2993
+ this.options.onBeatChange(beat.name, this.currentBeatIndex);
2839
2994
  }
2840
2995
  }
2841
2996
  getCurrentBeatIndex() {
2842
2997
  return this.currentBeatIndex;
2843
2998
  }
2844
2999
  togglePlay() {
2845
- this.diagram.play();
3000
+ if (this.diagram.isPlaying()) {
3001
+ this.diagram.pause();
3002
+ } else {
3003
+ this.diagram.play();
3004
+ }
2846
3005
  }
2847
3006
  setSpeed(rate) {
2848
3007
  this.diagram.setPlaybackRate(rate);
2849
3008
  }
2850
- attachKeyboardListener() {
2851
- window.addEventListener("keydown", (e) => {
2852
- if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
2853
- switch (e.key) {
2854
- case "ArrowRight":
2855
- case "PageDown":
2856
- this.nextBeat();
2857
- break;
2858
- case "ArrowLeft":
2859
- case "PageUp":
2860
- this.prevBeat();
2861
- break;
2862
- case " ":
2863
- e.preventDefault();
2864
- this.togglePlay();
2865
- break;
2866
- case "1":
2867
- this.setSpeed(1);
2868
- break;
2869
- case "2":
2870
- this.setSpeed(2);
2871
- break;
2872
- }
2873
- });
3009
+ onKeyDown = (e) => {
3010
+ if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement || e.target?.isContentEditable) {
3011
+ return;
3012
+ }
3013
+ switch (e.key) {
3014
+ case "ArrowRight":
3015
+ case "PageDown":
3016
+ this.nextBeat();
3017
+ break;
3018
+ case "ArrowLeft":
3019
+ case "PageUp":
3020
+ this.prevBeat();
3021
+ break;
3022
+ case " ":
3023
+ e.preventDefault();
3024
+ this.togglePlay();
3025
+ break;
3026
+ case "1":
3027
+ this.setSpeed(1);
3028
+ break;
3029
+ case "2":
3030
+ this.setSpeed(2);
3031
+ break;
3032
+ }
3033
+ };
3034
+ destroy() {
3035
+ if (this.isDestroyed) return;
3036
+ this.isDestroyed = true;
3037
+ if (typeof window !== "undefined") {
3038
+ window.removeEventListener("keydown", this.onKeyDown);
3039
+ }
2874
3040
  }
2875
3041
  };
2876
3042
  export {
@@ -2878,5 +3044,6 @@ export {
2878
3044
  ICON_REGISTRY,
2879
3045
  createDiagram,
2880
3046
  encodeGifSequence,
3047
+ exportDiagramAsPng,
2881
3048
  exportDiagramAsVectorSvg
2882
3049
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markdy/renderer-dom",
3
- "version": "0.8.25",
3
+ "version": "0.8.27",
4
4
  "description": "Browser renderer for diagram-native animated MarkdyScript architecture diagrams, built on the Web Animations API.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -44,14 +44,14 @@
44
44
  "access": "public"
45
45
  },
46
46
  "dependencies": {
47
- "@markdy/core": "0.8.25"
47
+ "@markdy/core": "0.8.27"
48
48
  },
49
49
  "devDependencies": {
50
50
  "jsdom": "^29.1.1",
51
51
  "tsup": "^8.5.1",
52
52
  "typescript": "^5.9.3",
53
53
  "vitest": "^4.1.7",
54
- "@markdy/stdlib-systems": "0.8.25"
54
+ "@markdy/stdlib-systems": "0.8.27"
55
55
  },
56
56
  "scripts": {
57
57
  "build": "tsup",