diff --git a/quartz/components/scripts/mermaid.inline.ts b/quartz/components/scripts/mermaid.inline.ts
new file mode 100644
index 000000000..2935fa5c5
--- /dev/null
+++ b/quartz/components/scripts/mermaid.inline.ts
@@ -0,0 +1,266 @@
+import { removeAllChildren } from "./util"
+
+const svgExpand =
+ ''
+
+interface Position {
+ x: number
+ y: number
+}
+
+class DiagramPanZoom {
+ private isDragging = false
+ private startPan: Position = { x: 0, y: 0 }
+ private currentPan: Position = { x: 0, y: 0 }
+ private scale = 1
+ private readonly MIN_SCALE = 0.5
+ private readonly MAX_SCALE = 3
+ private readonly ZOOM_SENSITIVITY = 0.001
+
+ constructor(
+ private container: HTMLElement,
+ private content: HTMLElement,
+ ) {
+ this.setupEventListeners()
+ this.setupNavigationControls()
+ }
+
+ private setupEventListeners() {
+ // Mouse drag events
+ this.container.addEventListener("mousedown", this.onMouseDown.bind(this))
+ document.addEventListener("mousemove", this.onMouseMove.bind(this))
+ document.addEventListener("mouseup", this.onMouseUp.bind(this))
+
+ // Wheel zoom events
+ this.container.addEventListener("wheel", this.onWheel.bind(this), { passive: false })
+
+ // Reset on window resize
+ window.addEventListener("resize", this.resetTransform.bind(this))
+ }
+
+ private setupNavigationControls() {
+ const controls = document.createElement("div")
+ controls.className = "mermaid-controls"
+
+ // Zoom controls
+ const zoomIn = this.createButton("+", () => this.zoom(0.1))
+ const zoomOut = this.createButton("-", () => this.zoom(-0.1))
+ const resetBtn = this.createButton("Reset", () => this.resetTransform())
+
+ controls.appendChild(zoomOut)
+ controls.appendChild(resetBtn)
+ controls.appendChild(zoomIn)
+
+ this.container.appendChild(controls)
+ }
+
+ private createButton(text: string, onClick: () => void): HTMLButtonElement {
+ const button = document.createElement("button")
+ button.textContent = text
+ button.className = "mermaid-control-button"
+ button.addEventListener("click", onClick)
+ return button
+ }
+
+ private onMouseDown(e: MouseEvent) {
+ if (e.button !== 0) return // Only handle left click
+ this.isDragging = true
+ this.startPan = { x: e.clientX - this.currentPan.x, y: e.clientY - this.currentPan.y }
+ this.container.style.cursor = "grabbing"
+ }
+
+ private onMouseMove(e: MouseEvent) {
+ if (!this.isDragging) return
+ e.preventDefault()
+
+ this.currentPan = {
+ x: e.clientX - this.startPan.x,
+ y: e.clientY - this.startPan.y,
+ }
+
+ this.updateTransform()
+ }
+
+ private onMouseUp() {
+ this.isDragging = false
+ this.container.style.cursor = "grab"
+ }
+
+ private onWheel(e: WheelEvent) {
+ e.preventDefault()
+
+ const delta = -e.deltaY * this.ZOOM_SENSITIVITY
+ const newScale = Math.min(Math.max(this.scale + delta, this.MIN_SCALE), this.MAX_SCALE)
+
+ // Calculate mouse position relative to content
+ const rect = this.content.getBoundingClientRect()
+ const mouseX = e.clientX - rect.left
+ const mouseY = e.clientY - rect.top
+
+ // Adjust pan to zoom around mouse position
+ const scaleDiff = newScale - this.scale
+ this.currentPan.x -= mouseX * scaleDiff
+ this.currentPan.y -= mouseY * scaleDiff
+
+ this.scale = newScale
+ this.updateTransform()
+ }
+
+ private zoom(delta: number) {
+ const newScale = Math.min(Math.max(this.scale + delta, this.MIN_SCALE), this.MAX_SCALE)
+
+ // Zoom around center
+ const rect = this.content.getBoundingClientRect()
+ const centerX = rect.width / 2
+ const centerY = rect.height / 2
+
+ const scaleDiff = newScale - this.scale
+ this.currentPan.x -= centerX * scaleDiff
+ this.currentPan.y -= centerY * scaleDiff
+
+ this.scale = newScale
+ this.updateTransform()
+ }
+
+ private updateTransform() {
+ this.content.style.transform = `translate(${this.currentPan.x}px, ${this.currentPan.y}px) scale(${this.scale})`
+ }
+
+ private resetTransform() {
+ this.scale = 1
+ this.currentPan = { x: 0, y: 0 }
+ this.updateTransform()
+ }
+}
+
+const cssVars = [
+ "--secondary",
+ "--tertiary",
+ "--gray",
+ "--light",
+ "--lightgray",
+ "--highlight",
+ "--dark",
+ "--darkgray",
+ "--codeFont",
+] as const
+
+let mermaidImport = undefined
+document.addEventListener("nav", async () => {
+ const nodes = document.querySelectorAll("code.mermaid")
+ if (nodes.length === 0) return
+
+ const computedStyleMap = cssVars.reduce(
+ (acc, key) => {
+ acc[key] = getComputedStyle(document.documentElement).getPropertyValue(key)
+ return acc
+ },
+ {} as Record<(typeof cssVars)[number], string>,
+ )
+
+ mermaidImport ||= await import(
+ // @ts-ignore
+ "https://cdnjs.cloudflare.com/ajax/libs/mermaid/10.7.0/mermaid.esm.min.mjs"
+ )
+ const mermaid = mermaidImport.default
+ const darkMode = document.documentElement.getAttribute("saved-theme") === "dark"
+ mermaid.initialize({
+ startOnLoad: false,
+ securityLevel: "loose",
+ theme: darkMode ? "dark" : "base",
+ themeVariables: {
+ fontFamily: computedStyleMap["--codeFont"],
+ primaryColor: computedStyleMap["--light"],
+ primaryTextColor: computedStyleMap["--darkgray"],
+ primaryBorderColor: computedStyleMap["--tertiary"],
+ lineColor: computedStyleMap["--darkgray"],
+ secondaryColor: computedStyleMap["--secondary"],
+ tertiaryColor: computedStyleMap["--tertiary"],
+ clusterBkg: computedStyleMap["--light"],
+ edgeLabelBackground: computedStyleMap["--highlight"],
+ },
+ })
+ await mermaid.run({ nodes })
+
+ for (let i = 0; i < nodes.length; i++) {
+ const codeBlock = nodes[i] as HTMLElement
+ const pre = codeBlock.parentElement as HTMLPreElement
+ const clipboardBtn = pre.querySelector(".clipboard-button") as HTMLButtonElement
+
+ const expandBtn = document.createElement("button")
+ expandBtn.className = "expand-button"
+ expandBtn.type = "button"
+ expandBtn.innerHTML = svgExpand
+ expandBtn.ariaLabel = "Expand mermaid diagram"
+ const clipboardStyle = window.getComputedStyle(clipboardBtn)
+ const clipboardWidth =
+ clipboardBtn.offsetWidth +
+ parseFloat(clipboardStyle.marginLeft || "0") +
+ parseFloat(clipboardStyle.marginRight || "0")
+
+ // Set expand button position
+ expandBtn.style.right = `calc(${clipboardWidth}px + 0.3rem)`
+ pre.prepend(expandBtn)
+
+ // Create popup container
+ const popupContainer = document.createElement("div")
+ popupContainer.id = "mermaid-container"
+ popupContainer.innerHTML = `
+
+ `
+ pre.appendChild(popupContainer)
+
+ let panZoom: DiagramPanZoom | null = null
+
+ function showMermaid() {
+ const container = popupContainer.querySelector("#mermaid-space") as HTMLElement
+ const content = popupContainer.querySelector(".mermaid-content") as HTMLElement
+ if (!content) return
+ removeAllChildren(content)
+
+ // Clone the mermaid content
+ const mermaidContent = codeBlock.querySelector("svg")!.cloneNode(true) as SVGElement
+ content.appendChild(mermaidContent)
+
+ // Show container
+ popupContainer.classList.add("active")
+ container.style.cursor = "grab"
+
+ // Initialize pan-zoom after showing the popup
+ panZoom = new DiagramPanZoom(container, content)
+ }
+
+ function hideMermaid() {
+ popupContainer.classList.remove("active")
+ panZoom = null
+ }
+
+ function handleEscape(e: any) {
+ if (e.key === "Escape") {
+ hideMermaid()
+ }
+ }
+
+ const closeBtn = popupContainer.querySelector(".close-button") as HTMLButtonElement
+
+ closeBtn.addEventListener("click", hideMermaid)
+ expandBtn.addEventListener("click", showMermaid)
+ document.addEventListener("keydown", handleEscape)
+
+ window.addCleanup(() => {
+ closeBtn.removeEventListener("click", hideMermaid)
+ expandBtn.removeEventListener("click", showMermaid)
+ document.removeEventListener("keydown", handleEscape)
+ })
+ }
+})
diff --git a/quartz/components/styles/mermaid.inline.scss b/quartz/components/styles/mermaid.inline.scss
new file mode 100644
index 000000000..79a1c8495
--- /dev/null
+++ b/quartz/components/styles/mermaid.inline.scss
@@ -0,0 +1,163 @@
+.expand-button {
+ position: absolute;
+ display: flex;
+ float: right;
+ padding: 0.4rem;
+ margin: 0.3rem;
+ right: 0; // NOTE: right will be set in mermaid.inline.ts
+ color: var(--gray);
+ border-color: var(--dark);
+ background-color: var(--light);
+ border: 1px solid;
+ border-radius: 5px;
+ opacity: 0;
+ transition: 0.2s;
+
+ & > svg {
+ fill: var(--light);
+ filter: contrast(0.3);
+ }
+
+ &:hover {
+ cursor: pointer;
+ border-color: var(--secondary);
+ }
+
+ &:focus {
+ outline: 0;
+ }
+}
+
+pre {
+ &:hover > .expand-button {
+ opacity: 1;
+ transition: 0.2s;
+ }
+}
+
+#mermaid-container {
+ position: fixed;
+ contain: layout;
+ z-index: 999;
+ left: 0;
+ top: 0;
+ width: 100vw;
+ height: 100vh;
+ overflow: hidden;
+ display: none;
+ backdrop-filter: blur(4px);
+ background: rgba(0, 0, 0, 0.5);
+
+ &.active {
+ display: inline-block;
+ }
+
+ & > #mermaid-space {
+ display: grid;
+ width: 90%;
+ height: 90vh;
+ margin: 5vh auto;
+ background: var(--light);
+ box-shadow:
+ 0 14px 50px rgba(27, 33, 48, 0.12),
+ 0 10px 30px rgba(27, 33, 48, 0.16);
+ overflow: hidden;
+ position: relative;
+
+ & > .mermaid-header {
+ display: flex;
+ justify-content: flex-end;
+ padding: 1rem;
+ border-bottom: 1px solid var(--lightgray);
+ background: var(--light);
+ z-index: 2;
+ max-height: fit-content;
+
+ & > .close-button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ padding: 0;
+ background: transparent;
+ border: none;
+ border-radius: 4px;
+ color: var(--darkgray);
+ cursor: pointer;
+ transition: all 0.2s ease;
+
+ &:hover {
+ background: var(--lightgray);
+ color: var(--dark);
+ }
+ }
+ }
+
+ & > .mermaid-content {
+ padding: 2rem;
+ position: relative;
+ transform-origin: 0 0;
+ transition: transform 0.1s ease;
+ overflow: visible;
+ min-height: 200px;
+ min-width: 200px;
+
+ pre {
+ margin: 0;
+ border: none;
+ }
+
+ svg {
+ max-width: none;
+ height: auto;
+ }
+ }
+
+ & > .mermaid-controls {
+ position: absolute;
+ bottom: 20px;
+ right: 20px;
+ display: flex;
+ gap: 8px;
+ padding: 8px;
+ background: var(--light);
+ border: 1px solid var(--lightgray);
+ border-radius: 6px;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+ z-index: 2;
+
+ .mermaid-control-button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ padding: 0;
+ border: 1px solid var(--lightgray);
+ background: var(--light);
+ color: var(--dark);
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 16px;
+ font-family: var(--bodyFont);
+ transition: all 0.2s ease;
+
+ &:hover {
+ background: var(--lightgray);
+ }
+
+ &:active {
+ transform: translateY(1px);
+ }
+
+ // Style the reset button differently
+ &:nth-child(2) {
+ width: auto;
+ padding: 0 12px;
+ font-size: 14px;
+ }
+ }
+ }
+ }
+}
diff --git a/quartz/plugins/transformers/ofm.ts b/quartz/plugins/transformers/ofm.ts
index 70dce6016..b2f2e5072 100644
--- a/quartz/plugins/transformers/ofm.ts
+++ b/quartz/plugins/transformers/ofm.ts
@@ -6,11 +6,14 @@ import rehypeRaw from "rehype-raw"
import { SKIP, visit } from "unist-util-visit"
import path from "path"
import { splitAnchor } from "../../util/path"
-import { JSResource } from "../../util/resources"
+import { JSResource, CSSResource } from "../../util/resources"
// @ts-ignore
import calloutScript from "../../components/scripts/callout.inline.ts"
// @ts-ignore
import checkboxScript from "../../components/scripts/checkbox.inline.ts"
+// @ts-ignore
+import mermaidExtensionScript from "../../components/scripts/mermaid.inline.ts"
+import mermaidStyle from "../../components/styles/mermaid.inline.scss"
import { FilePath, pathToRoot, slugTag, slugifyFilePath } from "../../util/path"
import { toHast } from "mdast-util-to-hast"
import { toHtml } from "hast-util-to-html"
@@ -663,6 +666,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin>
},
externalResources() {
const js: JSResource[] = []
+ const css: CSSResource[] = []
if (opts.enableCheckbox) {
js.push({
@@ -682,32 +686,18 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin>
if (opts.mermaid) {
js.push({
- script: `
- let mermaidImport = undefined
- document.addEventListener('nav', async () => {
- if (document.querySelector("code.mermaid")) {
- mermaidImport ||= await import('https://cdnjs.cloudflare.com/ajax/libs/mermaid/10.7.0/mermaid.esm.min.mjs')
- const mermaid = mermaidImport.default
- const darkMode = document.documentElement.getAttribute('saved-theme') === 'dark'
- mermaid.initialize({
- startOnLoad: false,
- securityLevel: 'loose',
- theme: darkMode ? 'dark' : 'default'
- })
-
- await mermaid.run({
- querySelector: '.mermaid'
- })
- }
- });
- `,
+ script: mermaidExtensionScript,
loadTime: "afterDOMReady",
moduleType: "module",
contentType: "inline",
})
+ css.push({
+ content: mermaidStyle,
+ inline: true,
+ })
}
- return { js }
+ return { js, css }
},
}
}