249 lines
		
	
	
		
			7.4 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			249 lines
		
	
	
		
			7.4 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
import { removeAllChildren } from "./util"
 | 
						|
 | 
						|
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)
 | 
						|
    window.addCleanup(() => button.removeEventListener("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 center = document.querySelector(".center") as HTMLElement
 | 
						|
  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 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 = pre.querySelector(".expand-button") as HTMLButtonElement
 | 
						|
 | 
						|
    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)
 | 
						|
 | 
						|
    // query popup container
 | 
						|
    const popupContainer = pre.querySelector("#mermaid-container") 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
 | 
						|
      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)
 | 
						|
    })
 | 
						|
  }
 | 
						|
})
 |