From e9a05be5ba3b7ab3ea3e2460f23848f7cdf63fc9 Mon Sep 17 00:00:00 2001 From: Aaron Pham Date: Fri, 8 Nov 2024 13:29:09 -0500 Subject: [PATCH] feat(mermaid): custom stuff Signed-off-by: Aaron Pham --- quartz/components/scripts/mermaid.inline.ts | 266 +++++++++++++++++++ quartz/components/styles/mermaid.inline.scss | 163 ++++++++++++ quartz/plugins/transformers/ofm.ts | 32 +-- 3 files changed, 440 insertions(+), 21 deletions(-) create mode 100644 quartz/components/scripts/mermaid.inline.ts create mode 100644 quartz/components/styles/mermaid.inline.scss 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 } }, } }