From 23df17233da3f16db5166cf8a05b2089bd1f006a Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Mon, 10 Mar 2025 11:39:08 -0700 Subject: [PATCH] fix(graph): make graph non-singleton, proper cleanup, fix radial --- quartz/components/Graph.tsx | 10 +- quartz/components/scripts/graph.inline.ts | 108 ++++++++++++++------- quartz/components/scripts/search.inline.ts | 4 +- quartz/components/scripts/toc.inline.ts | 2 +- quartz/components/styles/graph.scss | 6 +- 5 files changed, 84 insertions(+), 46 deletions(-) diff --git a/quartz/components/Graph.tsx b/quartz/components/Graph.tsx index e8b462d..907372e 100644 --- a/quartz/components/Graph.tsx +++ b/quartz/components/Graph.tsx @@ -48,7 +48,7 @@ const defaultOptions: GraphOptions = { depth: -1, scale: 0.9, repelForce: 0.5, - centerForce: 0.3, + centerForce: 0.2, linkDistance: 30, fontSize: 0.6, opacityScale: 1, @@ -67,8 +67,8 @@ export default ((opts?: Partial) => {

{i18n(cfg.locale).components.graph.title}

-
-
-
-
+
+
) diff --git a/quartz/components/scripts/graph.inline.ts b/quartz/components/scripts/graph.inline.ts index 8342460..fca7bd2 100644 --- a/quartz/components/scripts/graph.inline.ts +++ b/quartz/components/scripts/graph.inline.ts @@ -68,11 +68,9 @@ type TweenNode = { stop: () => void } -async function renderGraph(container: string, fullSlug: FullSlug) { +async function renderGraph(graph: HTMLElement, fullSlug: FullSlug) { const slug = simplifySlug(fullSlug) const visited = getVisited() - const graph = document.getElementById(container) - if (!graph) return removeAllChildren(graph) let { @@ -167,16 +165,14 @@ async function renderGraph(container: string, fullSlug: FullSlug) { const height = Math.max(graph.offsetHeight, 250) // we virtualize the simulation and use pixi to actually render it - // Calculate the radius of the container circle - const radius = Math.min(width, height) / 2 - 40 // 40px padding const simulation: Simulation = forceSimulation(graphData.nodes) .force("charge", forceManyBody().strength(-100 * repelForce)) .force("center", forceCenter().strength(centerForce)) .force("link", forceLink(graphData.links).distance(linkDistance)) .force("collide", forceCollide((n) => nodeRadius(n)).iterations(3)) - if (enableRadial) - simulation.force("radial", forceRadial(radius * 0.8, width / 2, height / 2).strength(0.3)) + const radius = (Math.min(width, height) / 2) * 0.8 + if (enableRadial) simulation.force("radial", forceRadial(radius).strength(0.2)) // precompute style prop strings as pixi doesn't support css variables const cssVars = [ @@ -524,7 +520,9 @@ async function renderGraph(container: string, fullSlug: FullSlug) { ) } + let stopAnimation = false function animate(time: number) { + if (stopAnimation) return for (const n of nodeRenderData) { const { x, y } = n.simulationData if (!x || !y) continue @@ -548,61 +546,101 @@ async function renderGraph(container: string, fullSlug: FullSlug) { requestAnimationFrame(animate) } - const graphAnimationFrameHandle = requestAnimationFrame(animate) - window.addCleanup(() => cancelAnimationFrame(graphAnimationFrameHandle)) + requestAnimationFrame(animate) + return () => { + stopAnimation = true + app.destroy() + } +} + +let localGraphCleanups: (() => void)[] = [] +let globalGraphCleanups: (() => void)[] = [] + +function cleanupLocalGraphs() { + for (const cleanup of localGraphCleanups) { + cleanup() + } + localGraphCleanups = [] +} + +function cleanupGlobalGraphs() { + for (const cleanup of globalGraphCleanups) { + cleanup() + } + globalGraphCleanups = [] } document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { const slug = e.detail.url addToVisited(simplifySlug(slug)) - await renderGraph("graph-container", slug) - // Function to re-render the graph when the theme changes - const handleThemeChange = () => { - renderGraph("graph-container", slug) + async function renderLocalGraph() { + cleanupLocalGraphs() + const localGraphContainers = document.getElementsByClassName("graph-container") + for (const container of localGraphContainers) { + localGraphCleanups.push(await renderGraph(container as HTMLElement, slug)) + } } - // event listener for theme change - document.addEventListener("themechange", handleThemeChange) + await renderLocalGraph() + const handleThemeChange = () => { + void renderLocalGraph() + } - // cleanup for the event listener + document.addEventListener("themechange", handleThemeChange) window.addCleanup(() => { document.removeEventListener("themechange", handleThemeChange) }) - const container = document.getElementById("global-graph-outer") - const sidebar = container?.closest(".sidebar") as HTMLElement - - function renderGlobalGraph() { + const containers = [...document.getElementsByClassName("global-graph-outer")] as HTMLElement[] + async function renderGlobalGraph() { const slug = getFullSlug(window) - container?.classList.add("active") - if (sidebar) { - sidebar.style.zIndex = "1" - } + for (const container of containers) { + container.classList.add("active") + const sidebar = container.closest(".sidebar") as HTMLElement + if (sidebar) { + sidebar.style.zIndex = "1" + } - renderGraph("global-graph-container", slug) - registerEscapeHandler(container, hideGlobalGraph) + const graphContainer = container.querySelector(".global-graph-container") as HTMLElement + registerEscapeHandler(container, hideGlobalGraph) + if (graphContainer) { + globalGraphCleanups.push(await renderGraph(graphContainer, slug)) + } + } } function hideGlobalGraph() { - container?.classList.remove("active") - if (sidebar) { - sidebar.style.zIndex = "" + cleanupGlobalGraphs() + for (const container of containers) { + container.classList.remove("active") + const sidebar = container.closest(".sidebar") as HTMLElement + if (sidebar) { + sidebar.style.zIndex = "" + } } } async function shortcutHandler(e: HTMLElementEventMap["keydown"]) { if (e.key === "g" && (e.ctrlKey || e.metaKey) && !e.shiftKey) { e.preventDefault() - const globalGraphOpen = container?.classList.contains("active") - globalGraphOpen ? hideGlobalGraph() : renderGlobalGraph() + const anyGlobalGraphOpen = containers.some((container) => + container.classList.contains("active"), + ) + anyGlobalGraphOpen ? hideGlobalGraph() : renderGlobalGraph() } } - const containerIcon = document.getElementById("global-graph-icon") - containerIcon?.addEventListener("click", renderGlobalGraph) - window.addCleanup(() => containerIcon?.removeEventListener("click", renderGlobalGraph)) + const containerIcons = document.getElementsByClassName("global-graph-icon") + Array.from(containerIcons).forEach((icon) => { + icon.addEventListener("click", renderGlobalGraph) + window.addCleanup(() => icon.removeEventListener("click", renderGlobalGraph)) + }) document.addEventListener("keydown", shortcutHandler) - window.addCleanup(() => document.removeEventListener("keydown", shortcutHandler)) + window.addCleanup(() => { + document.removeEventListener("keydown", shortcutHandler) + cleanupLocalGraphs() + cleanupGlobalGraphs() + }) }) diff --git a/quartz/components/scripts/search.inline.ts b/quartz/components/scripts/search.inline.ts index c9bbcce..1f4c009 100644 --- a/quartz/components/scripts/search.inline.ts +++ b/quartz/components/scripts/search.inline.ts @@ -384,7 +384,7 @@ async function setupSearch(searchElement: Element, currentSlug: FullSlug, data: preview.replaceChildren(previewInner) // scroll to longest - const highlights = [...preview.querySelectorAll(".highlight")].sort( + const highlights = [...preview.getElementsByClassName("highlight")].sort( (a, b) => b.innerHTML.length - a.innerHTML.length, ) highlights[0]?.scrollIntoView({ block: "start" }) @@ -488,7 +488,7 @@ async function fillDocument(data: ContentIndex) { document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { const currentSlug = e.detail.url const data = await fetchData - const searchElement = document.querySelectorAll(".search") + const searchElement = document.getElementsByClassName("search") for (const element of searchElement) { await setupSearch(element, currentSlug, data) } diff --git a/quartz/components/scripts/toc.inline.ts b/quartz/components/scripts/toc.inline.ts index a63da8d..6c5ad1c 100644 --- a/quartz/components/scripts/toc.inline.ts +++ b/quartz/components/scripts/toc.inline.ts @@ -25,7 +25,7 @@ function toggleToc(this: HTMLElement) { } function setupToc() { - for (const toc of document.querySelectorAll(".toc")) { + for (const toc of document.getElementsByClassName("toc")) { const button = toc.querySelector(".toc-header") const content = toc.querySelector(".toc-content") if (!button || !content) return diff --git a/quartz/components/styles/graph.scss b/quartz/components/styles/graph.scss index 1b19f13..cb1b7b4 100644 --- a/quartz/components/styles/graph.scss +++ b/quartz/components/styles/graph.scss @@ -15,7 +15,7 @@ position: relative; overflow: hidden; - & > #global-graph-icon { + & > .global-graph-icon { cursor: pointer; background: none; border: none; @@ -38,7 +38,7 @@ } } - & > #global-graph-outer { + & > .global-graph-outer { position: fixed; z-index: 9999; left: 0; @@ -53,7 +53,7 @@ display: inline-block; } - & > #global-graph-container { + & > .global-graph-container { border: 1px solid var(--lightgray); background-color: var(--light); border-radius: 5px;