Skip to content

Commit 5a889b6

Browse files
committed
improve behavior of CSS tooltips with mermaid-format: js
1 parent 23f7614 commit 5a889b6

File tree

6 files changed

+129704
-10
lines changed

6 files changed

+129704
-10
lines changed

src/core/handlers/mermaid.ts

Lines changed: 84 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import { pandocHtmlBlock, pandocRawStr } from "../pandoc/codegen.ts";
4141
import { LocalizedError } from "../lib/error.ts";
4242
import { warning } from "log/mod.ts";
4343
import { FormatDependency } from "../../config/types.ts";
44+
import { mappedDiff } from "../mapped-text.ts";
4445

4546
const mermaidHandler: LanguageHandler = {
4647
...baseHandler,
@@ -105,12 +106,17 @@ mermaid.initialize();
105106
}
106107
handlerContext.getState().hasSetupMermaidJsRuntime = true;
107108

109+
const jsName =
110+
handlerContext.options.context.format.metadata?.["mermaid-debug"]
111+
? "mermaid.js"
112+
: "mermaid.min.js";
113+
108114
const dep: FormatDependency = {
109115
name: "quarto-diagram",
110116
scripts: [
111117
{
112-
name: "mermaid.min.js",
113-
path: formatResourcePath("html", join("mermaid", "mermaid.min.js")),
118+
name: jsName,
119+
path: formatResourcePath("html", join("mermaid", jsName)),
114120
},
115121
{
116122
name: "mermaid-init.js",
@@ -121,6 +127,12 @@ mermaid.initialize();
121127
afterBody: true,
122128
},
123129
],
130+
stylesheets: [
131+
{
132+
name: "mermaid.css",
133+
path: formatResourcePath("html", join("mermaid", "mermaid.css")),
134+
},
135+
],
124136
};
125137
handlerContext.addHtmlDependency(dep);
126138
};
@@ -149,6 +161,8 @@ mermaid.initialize();
149161

150162
return `\n![${captionSpecifier}](${sourceName}){${widthSpecifier}${heightSpecifier}${posSpecifier}${idSpecifier}}\n`;
151163
};
164+
const responsive = handlerContext.options.context.format.metadata
165+
?.[kFigResponsive];
152166

153167
const makeSvg = async () => {
154168
let svg = asMappedString(
@@ -158,8 +172,6 @@ mermaid.initialize();
158172
resources,
159173
}))[0],
160174
);
161-
const responsive = handlerContext.options.context.format.metadata
162-
?.[kFigResponsive];
163175

164176
const fixupRevealAlignment = (svg: Element) => {
165177
if (isRevealjsOutput(handlerContext.options.context.format.pandoc)) {
@@ -168,17 +180,33 @@ mermaid.initialize();
168180
}
169181
};
170182

183+
let newId: string | undefined = undefined;
184+
const idsToPatch: string[] = [];
185+
171186
const fixupMermaidSvg = (svg: Element) => {
172187
// replace mermaid id with a consistent one.
173-
const { baseName: newId } = handlerContext.uniqueFigureName(
188+
const { baseName: newMermaidId } = handlerContext.uniqueFigureName(
174189
"mermaid-figure-",
175190
"",
176191
);
192+
newId = newMermaidId;
177193
fixupRevealAlignment(svg);
178194
const oldId = svg.getAttribute("id") as string;
179-
svg.setAttribute("id", newId);
195+
svg.setAttribute("id", newMermaidId);
180196
const style = svg.querySelector("style")!;
181-
style.innerHTML = style.innerHTML.replaceAll(oldId, newId);
197+
style.innerHTML = style.innerHTML.replaceAll(oldId, newMermaidId);
198+
199+
for (const defNode of svg.querySelectorAll("defs")) {
200+
const defEl = defNode as Element;
201+
// because this is a defs node and deno-dom doesn't like non-html elements,
202+
// we can't use the standard API
203+
const m = defEl.innerHTML.match(/id="([^\"]+)"/);
204+
if (m) {
205+
const id = m[1];
206+
console.log("Will try to patch", id);
207+
idsToPatch.push(id);
208+
}
209+
}
182210
};
183211

184212
if (
@@ -195,6 +223,26 @@ mermaid.initialize();
195223
});
196224
}
197225

226+
// This is a preposterously ugly fix to a mermaid issue where
227+
// duplicate definition ids are emitted, which causes diagrams to step
228+
// on one another's toes.
229+
if (idsToPatch.length) {
230+
let oldSvgSrc = svg.value;
231+
for (const idToPatch of idsToPatch) {
232+
const to = `${newId}-${idToPatch}`;
233+
// this string substitution is fraught, but I don't know how else to fix the problem.
234+
oldSvgSrc = oldSvgSrc.replaceAll(
235+
`"${idToPatch}"`,
236+
`"${to}"`,
237+
);
238+
oldSvgSrc = oldSvgSrc.replaceAll(
239+
`#${idToPatch}`,
240+
`#${to}`,
241+
);
242+
}
243+
svg = mappedDiff(svg, oldSvgSrc);
244+
}
245+
198246
if (isMarkdownOutput(handlerContext.options.format.pandoc, ["gfm"])) {
199247
const { sourceName, fullName } = handlerContext
200248
.uniqueFigureName(
@@ -267,17 +315,32 @@ mermaid.initialize();
267315
// deno-lint-ignore require-await
268316
const makeJs = async () => {
269317
setupMermaidJsRuntime();
318+
const { baseName: tooltipName } = handlerContext
319+
.uniqueFigureName(
320+
"mermaid-tooltip-",
321+
"",
322+
);
270323
const preEl = pandocHtmlBlock("pre")({
271324
classes: ["mermaid"],
325+
attrs: [`tooltip-selector="#${tooltipName}"`],
272326
});
273327
preEl.push(pandocRawStr(cell.source));
274328

329+
const attrs: Record<string, unknown> = {};
330+
if (isRevealjsOutput(handlerContext.options.context.format.pandoc)) {
331+
console.log("Setting reveal to true");
332+
attrs.reveal = true;
333+
}
334+
275335
return this.build(
276336
handlerContext,
277337
cell,
278-
preEl.mappedString(),
338+
mappedConcat([
339+
preEl.mappedString(),
340+
`\n<div id="${tooltipName}" class="mermaidTooltip"></div>`,
341+
]),
279342
options,
280-
undefined,
343+
attrs,
281344
new Set(["mermaid-format"]),
282345
);
283346
};
@@ -315,6 +378,18 @@ mermaid.initialize();
315378
console.log("");
316379
return await makeDefault();
317380
} else {
381+
if (isRevealjsOutput(handlerContext.options.context.format.pandoc)) {
382+
const error = new LocalizedError(
383+
"NotRecommended",
384+
`\`mermaid-format: js\` not recommended in format ${
385+
handlerContext.options.format.pandoc.to ?? ""
386+
}`,
387+
cell.sourceVerbatim,
388+
0,
389+
);
390+
warning(error.message);
391+
console.log("");
392+
}
318393
return await makeJs();
319394
}
320395
} else {

src/core/svg.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ import { EitherString, MappedString } from "./lib/text-types.ts";
1111
import { asMappedString, mappedDiff } from "./mapped-text.ts";
1212
import { inInches } from "./units.ts";
1313

14+
// NB: there's effectively a copy of this function
15+
// in our mermaid runtime in `formats/html/mermaid/mermaid-runtime.js`.
16+
// if you change something here, you must keep it consistent there as well.
1417
export async function resolveSize(
1518
svg: string,
1619
options: Record<string, unknown>,
@@ -85,6 +88,9 @@ export async function resolveSize(
8588
};
8689
}
8790

91+
// NB: there's effectively a copy of this function
92+
// in our mermaid runtime in `formats/html/mermaid/mermaid-runtime.js`.
93+
// if you change something here, you must keep it consistent there as well.
8894
export const fixupAlignment = (svg: Element, align: string) => {
8995
let style = svg.getAttribute("style") ?? "";
9096

@@ -102,6 +108,9 @@ export const fixupAlignment = (svg: Element, align: string) => {
102108
svg.setAttribute("style", style);
103109
};
104110

111+
// NB: there's effectively a copy of this function
112+
// in our mermaid runtime in `formats/html/mermaid/mermaid-runtime.js`.
113+
// if you change something here, you must keep it consistent there as well.
105114
export async function setSvgSize(
106115
svgSrc: EitherString,
107116
options: Record<string, unknown>,
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,172 @@
11
mermaid.initialize({ startOnLoad: false });
2+
3+
const _quartoMermaid = {
4+
// NB: there's effectively a copy of this function
5+
// in `core/svg.ts`.
6+
// if you change something here, you must keep it consistent there as well.
7+
setSvgSize(svg) {
8+
const { widthInPoints, heightInPoints } = this.resolveSize(svg);
9+
10+
svg.setAttribute("width", widthInPoints);
11+
svg.setAttribute("height", heightInPoints);
12+
svg.style.maxWidth = null; // clear preset mermaid value.
13+
},
14+
15+
// NB: there's effectively a copy of this function
16+
// in `core/svg.ts`.
17+
// if you change something here, you must keep it consistent there as well.
18+
makeResponsive(svg) {
19+
const width = svg.getAttribute("width");
20+
if (width === null) {
21+
throw new Error("Couldn't find SVG width");
22+
}
23+
const numWidth = Number(width.slice(0, -2));
24+
25+
if (numWidth > 650) {
26+
changed = true;
27+
svg.setAttribute("width", "100%");
28+
svg.removeAttribute("height");
29+
}
30+
},
31+
32+
// NB: there's effectively a copy of this function
33+
// in `core/svg.ts`.
34+
// if you change something here, you must keep it consistent there as well.
35+
fixupAlignment(svg, align) {
36+
let style = svg.getAttribute("style") ?? "";
37+
38+
switch (align) {
39+
case "left":
40+
style = `${style} display: block; margin: auto auto auto 0`;
41+
break;
42+
case "right":
43+
style = `${style} display: block; margin: auto 0 auto auto`;
44+
break;
45+
case "center":
46+
style = `${style} display: block; margin: auto auto auto auto`;
47+
break;
48+
}
49+
svg.setAttribute("style", style);
50+
},
51+
52+
resolveOptions(svgEl) {
53+
return svgEl.parentElement.parentElement.parentElement.parentElement
54+
.dataset;
55+
},
56+
57+
// NB: there's effectively a copy of this function
58+
// in our mermaid runtime in `core/svg.ts`.
59+
// if you change something here, you must keep it consistent there as well.
60+
resolveSize(svgEl) {
61+
const inInches = (size) => {
62+
if (size.endsWith("in")) {
63+
return Number(size.slice(0, -2));
64+
}
65+
if (size.endsWith("pt") || size.endsWith("px")) {
66+
// assume 96 dpi for now
67+
return Number(size.slice(0, -2)) / 96;
68+
}
69+
return Number(size);
70+
};
71+
72+
// these are figWidth and figHeight on purpose,
73+
// because data attributes are translated to camelCase by the DOM API
74+
const kFigWidth = "figWidth",
75+
kFigHeight = "figHeight";
76+
const options = this.resolveOptions(svgEl);
77+
const width = svgEl.getAttribute("width");
78+
const height = svgEl.getAttribute("height");
79+
if (!width || !height) {
80+
// attempt to resolve figure dimensions via viewBox
81+
throw new Error("Internal error: couldn't find figure dimensions");
82+
}
83+
const getViewBox = () => {
84+
const vb = svgEl.attributes.getNamedItem("viewBox").value; // do it the roundabout way so that viewBox isn't dropped by deno_dom and text/html
85+
if (!vb) return undefined;
86+
const lst = vb.trim().split(" ").map(Number);
87+
if (lst.length !== 4) return undefined;
88+
if (lst.some(isNaN)) return undefined;
89+
return lst;
90+
};
91+
92+
let svgWidthInInches, svgHeightInInches;
93+
94+
if (
95+
(width.slice(0, -2) === "pt" && height.slice(0, -2) === "pt") ||
96+
(width.slice(0, -2) === "px" && height.slice(0, -2) === "px") ||
97+
(!isNaN(Number(width)) && !isNaN(Number(height)))
98+
) {
99+
// we assume 96 dpi which is generally what seems to be used.
100+
svgWidthInInches = Number(width.slice(0, -2)) / 96;
101+
svgHeightInInches = Number(height.slice(0, -2)) / 96;
102+
}
103+
const viewBox = getViewBox();
104+
if (viewBox !== undefined) {
105+
// assume width and height come from viewbox.
106+
const [_mx, _my, vbWidth, vbHeight] = viewBox;
107+
svgWidthInInches = vbWidth / 96;
108+
svgHeightInInches = vbHeight / 96;
109+
} else {
110+
throw new Error(
111+
"Internal Error: Couldn't resolve width and height of SVG"
112+
);
113+
}
114+
const svgWidthOverHeight = svgWidthInInches / svgHeightInInches;
115+
let widthInInches, heightInInches;
116+
117+
if (options[kFigWidth] && options[kFigHeight]) {
118+
// both were prescribed, so just go with them
119+
widthInInches = inInches(String(options[kFigWidth]));
120+
heightInInches = inInches(String(options[kFigHeight]));
121+
} else if (options[kFigWidth]) {
122+
// we were only given width, use that and adjust height based on aspect ratio;
123+
widthInInches = inInches(String(options[kFigWidth]));
124+
heightInInches = widthInInches / svgWidthOverHeight;
125+
} else if (options[kFigHeight]) {
126+
// we were only given height, use that and adjust width based on aspect ratio;
127+
heightInInches = inInches(String(options[kFigHeight]));
128+
widthInInches = heightInInches * svgWidthOverHeight;
129+
} else {
130+
// we were not given either, use svg's prescribed height
131+
heightInInches = svgHeightInInches;
132+
widthInInches = svgWidthInInches;
133+
}
134+
135+
return {
136+
widthInInches,
137+
heightInInches,
138+
widthInPoints: Math.round(widthInInches * 96),
139+
heightInPoints: Math.round(heightInInches * 96),
140+
};
141+
},
142+
143+
postProcess(svg) {
144+
const options = this.resolveOptions(svg);
145+
if (
146+
options.responsive &&
147+
options["figWidth"] === undefined &&
148+
options["figHeight"] === undefined
149+
) {
150+
this.makeResponsive(svg);
151+
} else {
152+
this.setSvgSize(svg);
153+
}
154+
if (options["reveal"]) {
155+
this.fixupAlignment(svg, options["figAlign"] || "center");
156+
}
157+
},
158+
};
159+
2160
// deno-lint-ignore no-window-prefix
3161
window.addEventListener(
4162
"load",
5163
function () {
6164
mermaid.init("div.cell-output-display pre.mermaid");
165+
for (const svgEl of Array.from(
166+
document.querySelectorAll("div.cell-output-display pre.mermaid svg")
167+
)) {
168+
_quartoMermaid.postProcess(svgEl);
169+
}
7170
},
8171
false
9172
);
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
.mermaidTooltip {
2+
position: absolute;
3+
text-align: center;
4+
max-width: 200px;
5+
padding: 2px;
6+
font-family: "trebuchet ms", verdana, arial;
7+
font-size: 12px;
8+
background: #ffffde;
9+
border: 1px solid #aaaa33;
10+
border-radius: 2px;
11+
pointer-events: none;
12+
z-index: 1000;
13+
}

0 commit comments

Comments
 (0)