| | |
| | | private scale = 1 |
| | | private readonly MIN_SCALE = 0.5 |
| | | private readonly MAX_SCALE = 3 |
| | | private readonly ZOOM_SENSITIVITY = 0.001 |
| | | |
| | | cleanups: (() => void)[] = [] |
| | | |
| | | constructor( |
| | | private container: HTMLElement, |
| | |
| | | ) { |
| | | this.setupEventListeners() |
| | | this.setupNavigationControls() |
| | | this.resetTransform() |
| | | } |
| | | |
| | | 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)) |
| | | const mouseDownHandler = this.onMouseDown.bind(this) |
| | | const mouseMoveHandler = this.onMouseMove.bind(this) |
| | | const mouseUpHandler = this.onMouseUp.bind(this) |
| | | const resizeHandler = this.resetTransform.bind(this) |
| | | |
| | | // Wheel zoom events |
| | | this.container.addEventListener("wheel", this.onWheel.bind(this), { passive: false }) |
| | | this.container.addEventListener("mousedown", mouseDownHandler) |
| | | document.addEventListener("mousemove", mouseMoveHandler) |
| | | document.addEventListener("mouseup", mouseUpHandler) |
| | | window.addEventListener("resize", resizeHandler) |
| | | |
| | | // Reset on window resize |
| | | window.addEventListener("resize", this.resetTransform.bind(this)) |
| | | this.cleanups.push( |
| | | () => this.container.removeEventListener("mousedown", mouseDownHandler), |
| | | () => document.removeEventListener("mousemove", mouseMoveHandler), |
| | | () => document.removeEventListener("mouseup", mouseUpHandler), |
| | | () => window.removeEventListener("resize", resizeHandler), |
| | | ) |
| | | } |
| | | |
| | | cleanup() { |
| | | for (const cleanup of this.cleanups) { |
| | | cleanup() |
| | | } |
| | | } |
| | | |
| | | private setupNavigationControls() { |
| | |
| | | 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) |
| | | |
| | |
| | | |
| | | private resetTransform() { |
| | | this.scale = 1 |
| | | this.currentPan = { x: 0, y: 0 } |
| | | const svg = this.content.querySelector("svg")! |
| | | this.currentPan = { |
| | | x: svg.getBoundingClientRect().width / 2, |
| | | y: svg.getBoundingClientRect().height / 2, |
| | | } |
| | | this.updateTransform() |
| | | } |
| | | } |
| | |
| | | const nodes = center.querySelectorAll("code.mermaid") as NodeListOf<HTMLElement> |
| | | 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/11.4.0/mermaid.esm.min.mjs" |
| | | ) |
| | | const mermaid = mermaidImport.default |
| | | |
| | | const textMapping: WeakMap<HTMLElement, string> = new WeakMap() |
| | | for (const node of nodes) { |
| | | textMapping.set(node, node.innerText) |
| | | } |
| | | |
| | | async function renderMermaid() { |
| | | // de-init any other diagrams |
| | | for (const node of nodes) { |
| | | node.removeAttribute("data-processed") |
| | | const oldText = textMapping.get(node) |
| | | if (oldText) { |
| | | node.innerHTML = oldText |
| | | } |
| | | } |
| | | |
| | | const computedStyleMap = cssVars.reduce( |
| | | (acc, key) => { |
| | | acc[key] = window.getComputedStyle(document.documentElement).getPropertyValue(key) |
| | | return acc |
| | | }, |
| | | {} as Record<(typeof cssVars)[number], string>, |
| | | ) |
| | | |
| | | const darkMode = document.documentElement.getAttribute("saved-theme") === "dark" |
| | | mermaid.initialize({ |
| | | startOnLoad: false, |
| | |
| | | edgeLabelBackground: computedStyleMap["--highlight"], |
| | | }, |
| | | }) |
| | | |
| | | await mermaid.run({ nodes }) |
| | | } |
| | | |
| | | await renderMermaid() |
| | | document.addEventListener("themechange", renderMermaid) |
| | | window.addCleanup(() => document.removeEventListener("themechange", renderMermaid)) |
| | | |
| | | for (let i = 0; i < nodes.length; i++) { |
| | | const codeBlock = nodes[i] as HTMLElement |
| | |
| | | if (!popupContainer) return |
| | | |
| | | let panZoom: DiagramPanZoom | null = null |
| | | |
| | | function showMermaid() { |
| | | const container = popupContainer.querySelector("#mermaid-space") as HTMLElement |
| | | const content = popupContainer.querySelector(".mermaid-content") as HTMLElement |
| | |
| | | |
| | | function hideMermaid() { |
| | | popupContainer.classList.remove("active") |
| | | panZoom?.cleanup() |
| | | 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) |
| | | registerEscapeHandler(popupContainer, hideMermaid) |
| | | document.addEventListener("keydown", handleEscape) |
| | | |
| | | window.addCleanup(() => { |
| | | closeBtn.removeEventListener("click", hideMermaid) |
| | | panZoom?.cleanup() |
| | | expandBtn.removeEventListener("click", showMermaid) |
| | | }) |
| | | } |