pantsdown 2.2.8 → 2.3.0

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/README.md CHANGED
@@ -148,13 +148,47 @@ const config: PartialPantsdownConfig = {
148
148
  * false
149
149
  */
150
150
  detailsTagDefaultOpen: boolean;
151
+
152
+ /**
153
+ * Interactive controls added to rendered mermaid diagrams.
154
+ */
155
+ mermaid: {
156
+ /**
157
+ * Control buttons overlaid on the diagram.
158
+ * Set individual buttons to `false` to hide them.
159
+ *
160
+ * @default
161
+ * { zoom: true, reset: true, arrows: true, popover: true }
162
+ */
163
+ buttons: {
164
+ /** Zoom in & zoom out buttons */
165
+ zoom: boolean;
166
+ /** Reset zoom & pan to the initial position */
167
+ reset: boolean;
168
+ /** Directional pan arrows */
169
+ arrows: boolean;
170
+ /** Open the diagram in a fullscreen popover */
171
+ popover: boolean;
172
+ };
173
+
174
+ /**
175
+ * Enable cmd/ctrl + drag to pan and cmd/ctrl + scroll to zoom.
176
+ * Plain click & scroll are never captured, so text selection
177
+ * and page scrolling keep working. Inside the popover, plain
178
+ * drag pans and plain scroll zooms regardless of this setting.
179
+ *
180
+ * @default
181
+ * true
182
+ */
183
+ mouseActions: boolean;
184
+ };
151
185
  };
152
186
  };
153
187
 
154
188
  const pantsdown = new Pantsdown(config);
155
- const html = pantsdown.parse(markdown);
189
+ const { html, javascript } = pantsdown.parse(markdown);
156
190
 
157
- console.log(html);
191
+ console.log(html, javascript);
158
192
  ```
159
193
 
160
194
  ## 🤝 Acknowledgements
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pantsdown",
3
- "version": "2.2.8",
3
+ "version": "2.3.0",
4
4
  "description": "Markdown to 'GitHub HTML' parser",
5
5
  "license": "MIT",
6
6
  "author": "wallpants",
@@ -38,21 +38,21 @@
38
38
  "dependencies": {
39
39
  "github-slugger": "^2.0.0",
40
40
  "highlight.js": "^11.11.1",
41
- "katex": "^0.18.0"
41
+ "katex": "^0.18.1"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@commitlint/cli": "^21.2.1",
45
45
  "@commitlint/config-conventional": "^21.2.0",
46
46
  "@commitlint/cz-commitlint": "^21.2.0",
47
- "@happy-dom/global-registrator": "^20.11.0",
47
+ "@happy-dom/global-registrator": "^20.11.1",
48
48
  "@types/bun": "^1.3.14",
49
49
  "@types/katex": "^0.16.8",
50
50
  "commitizen": "^4.3.2",
51
51
  "husky": "^9.1.7",
52
52
  "inquirer": "^12.11.1",
53
- "oxfmt": "^0.59.0",
54
- "oxlint": "^1.73.0",
55
- "oxlint-tsgolint": "^0.25.0",
53
+ "oxfmt": "^0.62.0",
54
+ "oxlint": "^1.77.0",
55
+ "oxlint-tsgolint": "^7.0.2001",
56
56
  "semantic-release": "^25.0.8",
57
57
  "typescript": "7.0.2"
58
58
  },
@@ -203,6 +203,85 @@
203
203
  margin-bottom: 16px;
204
204
  }
205
205
 
206
+ .pantsdown .mermaid-viewport {
207
+ overflow: hidden;
208
+ }
209
+ /* swallow the pre's padding and restore it as the viewport's own: the resting
210
+ layout is unchanged, but overflow clips at the padding box, so panned
211
+ content reaches the container edge (like github) instead of dying 16px short */
212
+ .pantsdown pre .mermaid-viewport {
213
+ margin: -16px;
214
+ padding: 16px;
215
+ }
216
+ .pantsdown .mermaid-canvas {
217
+ transform-origin: 0 0;
218
+ }
219
+ /* second selector covers consumers where <html> itself is the .pantsdown root */
220
+ html.pantsdown-mermaid-mod .pantsdown .mermaid-viewport,
221
+ html.pantsdown-mermaid-mod.pantsdown .mermaid-viewport {
222
+ cursor: grab;
223
+ }
224
+ .pantsdown .mermaid-viewport.dragging {
225
+ cursor: grabbing;
226
+ user-select: none;
227
+ }
228
+
229
+ .pantsdown .mermaid-button {
230
+ cursor: pointer;
231
+ height: 30px;
232
+ width: 30px;
233
+ padding: 0;
234
+ color: var(--color-fg-default);
235
+ background: var(--color-btn-bg);
236
+ border: 1px solid var(--color-btn-border);
237
+ border-radius: 4px;
238
+ display: flex;
239
+ align-items: center;
240
+ justify-content: center;
241
+ }
242
+ .pantsdown .mermaid-button:hover {
243
+ background: var(--color-btn-hover-bg);
244
+ }
245
+
246
+ .pantsdown .mermaid-controls {
247
+ position: absolute;
248
+ right: 8px;
249
+ bottom: 8px;
250
+ display: grid;
251
+ gap: 4px;
252
+ }
253
+
254
+ .pantsdown .mermaid-popover-button {
255
+ position: absolute;
256
+ right: 8px;
257
+ top: 8px;
258
+ }
259
+
260
+ .pantsdown.mermaid-overlay {
261
+ position: fixed;
262
+ inset: 0;
263
+ z-index: 100;
264
+ background: rgba(0, 0, 0, 0.5);
265
+ padding: 24px;
266
+ }
267
+ .pantsdown.mermaid-overlay .mermaid-panel {
268
+ position: relative;
269
+ width: 100%;
270
+ height: 100%;
271
+ background: var(--color-canvas-default);
272
+ border: 1px solid var(--color-border-default);
273
+ border-radius: 6px;
274
+ overflow: hidden;
275
+ }
276
+ .pantsdown.mermaid-overlay .mermaid-viewport {
277
+ width: 100%;
278
+ height: 100%;
279
+ cursor: grab;
280
+ }
281
+ .pantsdown.mermaid-overlay .mermaid-viewport.dragging {
282
+ cursor: grabbing;
283
+ }
284
+
206
285
  .pantsdown .octicon {
207
286
  display: inline-block;
208
287
  fill: currentColor;
package/src/javascript.ts CHANGED
@@ -1,4 +1,6 @@
1
- export const javascript = `
1
+ import { type PantsdownConfig } from "./types.ts";
2
+
3
+ const copyButtonScript = `
2
4
  /** code copy button */
3
5
  document.querySelectorAll("pre").forEach((pre) => {
4
6
  const firstElement = pre.firstElementChild;
@@ -33,3 +35,356 @@ export const javascript = `
33
35
  });
34
36
  });
35
37
  `;
38
+
39
+ function mermaidScript(config: PantsdownConfig["renderer"]["mermaid"]): string {
40
+ return `
41
+ /** mermaid pan & zoom controls */
42
+ (() => {
43
+ const config = ${JSON.stringify(config)};
44
+ const MIN_SCALE = 0.2;
45
+ const MAX_SCALE = 10;
46
+ const PAN_STEP = 60;
47
+
48
+ // pan/zoom state per diagram, keyed by data-mermaid-key. Lives on
49
+ // window so it survives consumers replacing the whole DOM on every
50
+ // update. Pruning to the keys present in the current document resets
51
+ // views on navigation and evicts stale entries.
52
+ const transforms = (window.pantsdownMermaidTransforms ??= new Map());
53
+ {
54
+ const presentKeys = new Set();
55
+ document.querySelectorAll(".mermaid[data-mermaid-key]").forEach((el) => {
56
+ presentKeys.add(el.getAttribute("data-mermaid-key"));
57
+ });
58
+ transforms.forEach((_, key) => {
59
+ if (!presentKeys.has(key)) transforms.delete(key);
60
+ });
61
+ }
62
+
63
+ const icon = (paths) =>
64
+ '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
65
+ paths +
66
+ "</svg>";
67
+
68
+ const icons = {
69
+ up: icon('<path d="m18 15-6-6-6 6"/>'),
70
+ down: icon('<path d="m6 9 6 6 6-6"/>'),
71
+ left: icon('<path d="m15 18-6-6 6-6"/>'),
72
+ right: icon('<path d="m9 18 6-6-6-6"/>'),
73
+ zoomIn: icon('<circle cx="11" cy="11" r="8"/><line x1="21" x2="16.65" y1="21" y2="16.65"/><line x1="11" x2="11" y1="8" y2="14"/><line x1="8" x2="14" y1="11" y2="11"/>'),
74
+ zoomOut: icon('<circle cx="11" cy="11" r="8"/><line x1="21" x2="16.65" y1="21" y2="16.65"/><line x1="8" x2="14" y1="11" y2="11"/>'),
75
+ reset: icon('<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/>'),
76
+ popover: icon('<polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" x2="14" y1="3" y2="10"/><line x1="3" x2="10" y1="21" y2="14"/>'),
77
+ close: icon('<path d="M18 6 6 18"/><path d="m6 6 12 12"/>'),
78
+ };
79
+
80
+ function makeButton(name, title, onClick) {
81
+ const btn = document.createElement("button");
82
+ btn.type = "button";
83
+ btn.className = "mermaid-button";
84
+ btn.innerHTML = icons[name];
85
+ btn.title = title;
86
+ btn.setAttribute("aria-label", title);
87
+ btn.addEventListener("click", onClick);
88
+ return btn;
89
+ }
90
+
91
+ function attachGestures(viewport, canvas, opts) {
92
+ const state = { x: 0, y: 0, scale: 1 };
93
+ // "reset" returns to this state; the popover overrides it with a
94
+ // fitted & centered transform via setHome
95
+ const home = { x: 0, y: 0, scale: 1 };
96
+
97
+ function apply() {
98
+ canvas.style.transform =
99
+ "translate(" + state.x + "px, " + state.y + "px) scale(" + state.scale + ")";
100
+ if (opts.storeKey) {
101
+ transforms.set(opts.storeKey, {
102
+ x: state.x,
103
+ y: state.y,
104
+ scale: state.scale,
105
+ });
106
+ }
107
+ }
108
+
109
+ function panBy(dx, dy) {
110
+ state.x += dx;
111
+ state.y += dy;
112
+ apply();
113
+ }
114
+
115
+ function zoomAt(factor, originX, originY) {
116
+ const scale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, state.scale * factor));
117
+ factor = scale / state.scale;
118
+ state.x = originX - (originX - state.x) * factor;
119
+ state.y = originY - (originY - state.y) * factor;
120
+ state.scale = scale;
121
+ apply();
122
+ }
123
+
124
+ // canvas coordinates are relative to the viewport's content box;
125
+ // the inline viewport carries the pre's padding, so gesture
126
+ // coordinates (relative to the border box) must be shifted by it
127
+ function contentOrigin() {
128
+ const style = window.getComputedStyle(viewport);
129
+ return {
130
+ x: parseFloat(style.paddingLeft) || 0,
131
+ y: parseFloat(style.paddingTop) || 0,
132
+ };
133
+ }
134
+
135
+ function zoomBy(factor) {
136
+ const rect = viewport.getBoundingClientRect();
137
+ const origin = contentOrigin();
138
+ zoomAt(factor, rect.width / 2 - origin.x, rect.height / 2 - origin.y);
139
+ }
140
+
141
+ function reset() {
142
+ state.x = home.x;
143
+ state.y = home.y;
144
+ state.scale = home.scale;
145
+ apply();
146
+ }
147
+
148
+ function setHome(x, y, scale) {
149
+ home.x = x;
150
+ home.y = y;
151
+ home.scale = scale;
152
+ reset();
153
+ }
154
+
155
+ // inline diagrams require cmd/ctrl (and can be disabled via config)
156
+ // so plain click & scroll keep selecting text / scrolling the page;
157
+ // the popover captures plain gestures
158
+ const enabled = opts.requireMod ? config.mouseActions : true;
159
+ const modOk = (e) => !opts.requireMod || e.metaKey || e.ctrlKey;
160
+
161
+ if (enabled) {
162
+ viewport.addEventListener(
163
+ "wheel",
164
+ (e) => {
165
+ if (!modOk(e)) return;
166
+ e.preventDefault();
167
+ const rect = viewport.getBoundingClientRect();
168
+ const origin = contentOrigin();
169
+ const delta = e.deltaY * (e.deltaMode === 1 ? 16 : 1);
170
+ zoomAt(
171
+ Math.exp(-delta * 0.002),
172
+ e.clientX - rect.left - origin.x,
173
+ e.clientY - rect.top - origin.y,
174
+ );
175
+ },
176
+ { passive: false },
177
+ );
178
+
179
+ viewport.addEventListener("pointerdown", (e) => {
180
+ if (e.button !== 0 || !modOk(e)) return;
181
+ e.preventDefault();
182
+ viewport.setPointerCapture(e.pointerId);
183
+ viewport.classList.add("dragging");
184
+ let lastX = e.clientX;
185
+ let lastY = e.clientY;
186
+ const onMove = (ev) => {
187
+ panBy(ev.clientX - lastX, ev.clientY - lastY);
188
+ lastX = ev.clientX;
189
+ lastY = ev.clientY;
190
+ };
191
+ const onUp = () => {
192
+ viewport.classList.remove("dragging");
193
+ viewport.removeEventListener("pointermove", onMove);
194
+ viewport.removeEventListener("pointerup", onUp);
195
+ viewport.removeEventListener("pointercancel", onUp);
196
+ };
197
+ viewport.addEventListener("pointermove", onMove);
198
+ viewport.addEventListener("pointerup", onUp);
199
+ viewport.addEventListener("pointercancel", onUp);
200
+ });
201
+ }
202
+
203
+ // restore the stashed transform so re-renders don't visually
204
+ // move the diagram; home stays identity, so reset still returns
205
+ // to the natural view
206
+ const stored = opts.storeKey ? transforms.get(opts.storeKey) : undefined;
207
+ if (stored) {
208
+ state.x = stored.x;
209
+ state.y = stored.y;
210
+ state.scale = stored.scale;
211
+ apply();
212
+ }
213
+
214
+ return { panBy, zoomBy, reset, setHome };
215
+ }
216
+
217
+ function buildCluster(controller) {
218
+ const b = config.buttons;
219
+ if (!b.arrows && !b.zoom && !b.reset) return null;
220
+
221
+ const cluster = document.createElement("div");
222
+ cluster.className = "mermaid-controls";
223
+ cluster.style.gridTemplateAreas = b.arrows
224
+ ? '". up zin" "left reset right" ". down zout"'
225
+ : '"zin" "reset" "zout"';
226
+
227
+ const place = (btn, area) => {
228
+ btn.style.gridArea = area;
229
+ cluster.appendChild(btn);
230
+ };
231
+
232
+ if (b.arrows) {
233
+ place(makeButton("up", "Pan up", () => controller.panBy(0, PAN_STEP)), "up");
234
+ place(makeButton("down", "Pan down", () => controller.panBy(0, -PAN_STEP)), "down");
235
+ place(makeButton("left", "Pan left", () => controller.panBy(PAN_STEP, 0)), "left");
236
+ place(makeButton("right", "Pan right", () => controller.panBy(-PAN_STEP, 0)), "right");
237
+ }
238
+ if (b.zoom) {
239
+ place(makeButton("zoomIn", "Zoom in", () => controller.zoomBy(1.25)), "zin");
240
+ place(makeButton("zoomOut", "Zoom out", () => controller.zoomBy(0.8)), "zout");
241
+ }
242
+ if (b.reset) place(makeButton("reset", "Reset view", () => controller.reset()), "reset");
243
+
244
+ return cluster;
245
+ }
246
+
247
+ function openPopover(el, svg) {
248
+ const root = el.closest(".pantsdown");
249
+ const overlay = document.createElement("div");
250
+ overlay.className = (root ? root.className : "pantsdown") + " mermaid-overlay";
251
+
252
+ const panel = document.createElement("div");
253
+ panel.className = "mermaid-panel";
254
+ const viewport = document.createElement("div");
255
+ viewport.className = "mermaid-viewport";
256
+ const canvas = document.createElement("div");
257
+ canvas.className = "mermaid-canvas";
258
+ const clone = svg.cloneNode(true);
259
+ canvas.appendChild(clone);
260
+ viewport.appendChild(canvas);
261
+ panel.appendChild(viewport);
262
+ overlay.appendChild(panel);
263
+
264
+ const controller = attachGestures(viewport, canvas, { requireMod: false });
265
+ const cluster = buildCluster(controller);
266
+ if (cluster) panel.appendChild(cluster);
267
+
268
+ function onKeydown(e) {
269
+ if (e.key === "Escape") close();
270
+ }
271
+ function close() {
272
+ overlay.remove();
273
+ window.removeEventListener("keydown", onKeydown);
274
+ }
275
+
276
+ const closeButton = makeButton("close", "Close", close);
277
+ closeButton.classList.add("mermaid-popover-button");
278
+ panel.appendChild(closeButton);
279
+
280
+ window.addEventListener("keydown", onKeydown);
281
+ overlay.addEventListener("pointerdown", (e) => {
282
+ if (e.target === overlay) close();
283
+ });
284
+
285
+ document.body.appendChild(overlay);
286
+
287
+ // fit the whole diagram inside the viewport (never upscaling past
288
+ // its natural size) and center it; reset returns to this state
289
+ const vp = viewport.getBoundingClientRect();
290
+ const content = clone.getBoundingClientRect();
291
+ if (vp.width > 0 && content.width > 0 && content.height > 0) {
292
+ const pad = 32;
293
+ const scale = Math.max(
294
+ MIN_SCALE,
295
+ Math.min(
296
+ 1,
297
+ (vp.width - pad) / content.width,
298
+ (vp.height - pad) / content.height,
299
+ ),
300
+ );
301
+ const x = (vp.width - content.width * scale) / 2 - (content.left - vp.left) * scale;
302
+ const y = (vp.height - content.height * scale) / 2 - (content.top - vp.top) * scale;
303
+ controller.setHome(x, y, scale);
304
+ }
305
+ }
306
+
307
+ function setup(el) {
308
+ // prefer the canvas svg: if a consumer re-injects html that was
309
+ // serialized after a previous setup, the diagram svg sits inside
310
+ // the old wrapper and stale button icons are svgs too
311
+ const svg = el.querySelector(".mermaid-canvas svg") ?? el.querySelector("svg");
312
+ if (!svg) return;
313
+
314
+ // serialized html loses event listeners, so stale wrappers and
315
+ // controls would be dead and duplicated — remove them and rebuild
316
+ el.querySelectorAll(
317
+ ".mermaid-viewport, .mermaid-controls, .mermaid-popover-button",
318
+ ).forEach((stale) => {
319
+ stale.remove();
320
+ });
321
+
322
+ const viewport = document.createElement("div");
323
+ viewport.className = "mermaid-viewport";
324
+ const canvas = document.createElement("div");
325
+ canvas.className = "mermaid-canvas";
326
+ canvas.appendChild(svg);
327
+ viewport.appendChild(canvas);
328
+ el.appendChild(viewport);
329
+
330
+ const controller = attachGestures(viewport, canvas, {
331
+ requireMod: true,
332
+ storeKey: el.getAttribute("data-mermaid-key"),
333
+ });
334
+ const cluster = buildCluster(controller);
335
+ if (cluster) el.appendChild(cluster);
336
+
337
+ if (config.buttons.popover) {
338
+ const btn = makeButton("popover", "Open in popover", () => openPopover(el, svg));
339
+ btn.classList.add("mermaid-popover-button");
340
+ el.appendChild(btn);
341
+ }
342
+ }
343
+
344
+ // mermaid renders asynchronously (the page loads mermaid.js separately),
345
+ // so wait for the svg to replace the diagram source
346
+ function whenRendered(el, cb) {
347
+ if (el.querySelector("svg")) {
348
+ cb();
349
+ return;
350
+ }
351
+ const observer = new MutationObserver(() => {
352
+ if (el.querySelector("svg")) {
353
+ observer.disconnect();
354
+ cb();
355
+ }
356
+ });
357
+ observer.observe(el, { childList: true, subtree: true });
358
+ }
359
+
360
+ document.querySelectorAll(".mermaid").forEach((el) => {
361
+ if (el.hasAttribute("data-pantsdown-mermaid")) return;
362
+ el.setAttribute("data-pantsdown-mermaid", "");
363
+ whenRendered(el, () => {
364
+ setup(el);
365
+ });
366
+ });
367
+
368
+ // grab cursor hint while cmd/ctrl is held over a diagram
369
+ if (config.mouseActions && !window.pantsdownMermaidModListener) {
370
+ window.pantsdownMermaidModListener = true;
371
+ const setMod = (on) => {
372
+ document.documentElement.classList.toggle("pantsdown-mermaid-mod", on);
373
+ };
374
+ window.addEventListener("keydown", (e) => {
375
+ if (e.key === "Meta" || e.key === "Control") setMod(true);
376
+ });
377
+ window.addEventListener("keyup", (e) => {
378
+ if (e.key === "Meta" || e.key === "Control") setMod(false);
379
+ });
380
+ window.addEventListener("blur", () => {
381
+ setMod(false);
382
+ });
383
+ }
384
+ })();
385
+ `;
386
+ }
387
+
388
+ export function getJavascript(config: PantsdownConfig): string {
389
+ return copyButtonScript + mermaidScript(config.renderer.mermaid);
390
+ }
package/src/pantsdown.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import GithubSlugger from "github-slugger";
2
- import { javascript } from "./javascript.ts";
2
+ import { getJavascript } from "./javascript.ts";
3
3
  import { Lexer } from "./lexer.ts";
4
4
  import { Parser } from "./parser.ts";
5
5
  import { type PantsdownConfig, type PartialPantsdownConfig } from "./types.ts";
@@ -9,6 +9,15 @@ const defaultConfig: PantsdownConfig = {
9
9
  relativeImageUrlPrefix: "",
10
10
  absoluteImageUrlPrefix: undefined,
11
11
  detailsTagDefaultOpen: false,
12
+ mermaid: {
13
+ buttons: {
14
+ zoom: true,
15
+ reset: true,
16
+ arrows: true,
17
+ popover: true,
18
+ },
19
+ mouseActions: true,
20
+ },
12
21
  },
13
22
  };
14
23
 
@@ -28,10 +37,19 @@ export class Pantsdown {
28
37
  * The object you provide will be deeply merged into current config.
29
38
  */
30
39
  setConfig(config: PartialPantsdownConfig) {
40
+ const { mermaid, ...renderer } = config.renderer ?? {};
31
41
  this.config = {
32
42
  renderer: {
33
43
  ...this.config.renderer,
34
- ...config.renderer,
44
+ ...renderer,
45
+ mermaid: {
46
+ ...this.config.renderer.mermaid,
47
+ ...mermaid,
48
+ buttons: {
49
+ ...this.config.renderer.mermaid.buttons,
50
+ ...mermaid?.buttons,
51
+ },
52
+ },
35
53
  },
36
54
  };
37
55
  }
@@ -43,12 +61,13 @@ export class Pantsdown {
43
61
  // re-init slugger to avoid slug count from incorrectly incrementing
44
62
  // from previosly slugged headings
45
63
  this.parser.renderer.slugger = new GithubSlugger();
64
+ this.parser.renderer.mermaidCounts = new Map();
46
65
 
47
66
  const tokens = this.lexer.lex(src);
48
67
  const html = this.parser.parse(tokens);
49
68
  return {
50
69
  html,
51
- javascript,
70
+ javascript: getJavascript(this.config),
52
71
  };
53
72
  }
54
73
  }
package/src/renderer.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  fixHtmlLocalImageHref,
14
14
  fixLocalImageHref,
15
15
  getHtmlElementText,
16
+ hashString,
16
17
  injectHtmlAttributes,
17
18
  } from "./utils.ts";
18
19
 
@@ -23,6 +24,7 @@ export class Renderer {
23
24
  private pantsdown: Pantsdown;
24
25
  parser!: Parser; // set by the parser
25
26
  slugger = new GithubSlugger();
27
+ mermaidCounts = new Map<string, number>();
26
28
 
27
29
  constructor(pantsdown: Pantsdown) {
28
30
  this.pantsdown = pantsdown;
@@ -40,6 +42,13 @@ export class Renderer {
40
42
 
41
43
  if (lang === "mermaid") {
42
44
  attrs.push(["class", "mermaid-container mermaid"]);
45
+ // stable identity across re-renders — the pan/zoom script keys
46
+ // stashed transforms on it; the occurrence index disambiguates
47
+ // byte-identical diagrams in the same document
48
+ const hash = hashString(code);
49
+ const count = this.mermaidCounts.get(hash) ?? 0;
50
+ this.mermaidCounts.set(hash, count + 1);
51
+ attrs.push(["data-mermaid-key", `${hash}-${count}`]);
43
52
  // escape so diagram source can't be parsed as HTML;
44
53
  // mermaid reads textContent, which undoes the escaping
45
54
  const result = `<pre style="position: relative;">` + escape(code, true) + "\n" + `</pre>`;
package/src/types.ts CHANGED
@@ -285,6 +285,40 @@ export type PantsdownConfig = {
285
285
  * false
286
286
  */
287
287
  detailsTagDefaultOpen: boolean;
288
+
289
+ /**
290
+ * Interactive controls added to rendered mermaid diagrams.
291
+ */
292
+ mermaid: {
293
+ /**
294
+ * Control buttons overlaid on the diagram.
295
+ * Set individual buttons to `false` to hide them.
296
+ *
297
+ * @default
298
+ * { zoom: true, reset: true, arrows: true, popover: true }
299
+ */
300
+ buttons: {
301
+ /** Zoom in & zoom out buttons */
302
+ zoom: boolean;
303
+ /** Reset zoom & pan to the initial position */
304
+ reset: boolean;
305
+ /** Directional pan arrows */
306
+ arrows: boolean;
307
+ /** Open the diagram in a fullscreen popover */
308
+ popover: boolean;
309
+ };
310
+
311
+ /**
312
+ * Enable cmd/ctrl + drag to pan and cmd/ctrl + scroll to zoom.
313
+ * Plain click & scroll are never captured, so text selection
314
+ * and page scrolling keep working. Inside the popover, plain
315
+ * drag pans and plain scroll zooms regardless of this setting.
316
+ *
317
+ * @default
318
+ * true
319
+ */
320
+ mouseActions: boolean;
321
+ };
288
322
  };
289
323
  };
290
324
 
package/src/utils.ts CHANGED
@@ -30,6 +30,15 @@ export function escape(html: string, encode?: boolean) {
30
30
  return html;
31
31
  }
32
32
 
33
+ /** djb2 — stable, collision-tolerant key for identifying content across parses */
34
+ export function hashString(str: string): string {
35
+ let hash = 5381;
36
+ for (let i = 0; i < str.length; i++) {
37
+ hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0;
38
+ }
39
+ return (hash >>> 0).toString(36);
40
+ }
41
+
33
42
  export function getHtmlElementText(html: string) {
34
43
  try {
35
44
  const parser = new DOMParser();