diff --git a/docs-site/integrations/remark-mermaid.mjs b/docs-site/integrations/remark-mermaid.mjs index cd1e98b..cb2c2c2 100644 --- a/docs-site/integrations/remark-mermaid.mjs +++ b/docs-site/integrations/remark-mermaid.mjs @@ -14,6 +14,14 @@ * The source is escaped rather than interpolated. A label containing `<` or `&` is * ordinary in these diagrams (`callers --> services`, `A & B`), and unescaped it * would be parsed as markup and silently deleted before mermaid ever saw it. + * + * The wrapper is emitted **here, at build time**, and it is what the magnifier button + * hangs off. Two reasons it cannot be the `
` itself, and neither is cosmetic:
+ * `Head.astro`'s redraw assigns `node.textContent`, which destroys every child of the
+ * `
` on each theme toggle; and `pre.mermaid` scrolls horizontally, so a button
+ * positioned inside it would slide out of view on exactly the wide diagrams that need
+ * it most. A wrapper written by the transform is outside both problems and needs no
+ * DOM surgery in the browser.
  */
 
 import { visit } from "unist-util-visit";
@@ -30,7 +38,10 @@ export default function remarkMermaid() {
       if (node.lang !== "mermaid" || parent === undefined || index === undefined) return;
       parent.children[index] = {
         type: "html",
-        value: `
${escape(node.value)}
`, + value: + `
` + + `
${escape(node.value)}
` + + `
`, }; }); }; diff --git a/docs-site/src/components/Head.astro b/docs-site/src/components/Head.astro index b7d4255..fd899a8 100644 --- a/docs-site/src/components/Head.astro +++ b/docs-site/src/components/Head.astro @@ -11,6 +11,12 @@ * The import is dynamic and guarded on there being a diagram, so mermaid (which is * megabytes) is fetched on the fifteen architecture pages and on none of the other * twenty-seven. + * + * The second half of the script is the magnifier: a diagram set at the width of a + * prose column is unreadable, and these are the pages where the diagram *is* the + * point. Every drawn diagram gets a button, and the button opens the SVG in a + * `` that fills the viewport, with wheel-zoom and drag-pan for the large + * architecture graphs that do not fit even there. */ import Default from "@astrojs/starlight/components/Head.astro"; --- @@ -44,6 +50,181 @@ import Default from "@astrojs/starlight/components/Head.astro"; theme: document.documentElement.dataset.theme === "dark" ? "dark" : "default", }); await mermaid.run({ nodes }); + addMagnifiers(nodes); + } + + // Two icons, inline, because one `` literal each is smaller than any way of + // getting at an icon set from here. + const MAGNIFIER = ``; + + const CROSS = ``; + + // The wrapper comes from the remark transform, so it survives the redraw above — + // which reassigns the `
`'s text content and would take any button inside it
+  // with it. The button is therefore added once and never again, and the guard is
+  // what makes calling this on every draw free.
+  function addMagnifiers(nodes: HTMLElement[]) {
+    for (const node of nodes) {
+      const figure = node.closest(".mermaid-figure");
+      if (figure === null || figure.querySelector(".mermaid-zoom") !== null) continue;
+
+      const button = document.createElement("button");
+      button.type = "button";
+      button.className = "mermaid-zoom";
+      button.setAttribute("aria-label", "Expand diagram");
+      button.innerHTML = MAGNIFIER;
+      button.addEventListener("click", () => expand(figure));
+      figure.append(button);
+    }
+  }
+
+  // One dialog for the whole page, reused. Per-diagram instances would each need
+  // their own copy of the pan and zoom state below, for no gain: only one can be
+  // open at a time.
+  let lightbox: { dialog: HTMLDialogElement; stage: HTMLElement; canvas: HTMLElement } | undefined;
+
+  let scale = 1;
+  let panX = 0;
+  let panY = 0;
+
+  const MAX_SCALE = 8;
+
+  function applyTransform(stage: HTMLElement, canvas: HTMLElement) {
+    canvas.style.transform = `translate(${panX}px, ${panY}px) scale(${scale})`;
+    stage.style.cursor = scale > 1 ? "grab" : "";
+  }
+
+  function lightboxElements() {
+    // `isConnected` rather than a bare `undefined` check: were Starlight's view
+    // transitions ever switched on, a navigation would swap the body out from under
+    // this and leave a reference to a detached dialog, which `showModal` accepts and
+    // nobody ever sees.
+    if (lightbox !== undefined && lightbox.dialog.isConnected) return lightbox;
+
+    const dialog = document.createElement("dialog");
+    dialog.className = "mermaid-lightbox";
+
+    const close = document.createElement("button");
+    close.type = "button";
+    close.className = "mermaid-lightbox-close";
+    close.setAttribute("aria-label", "Close");
+    close.innerHTML = CROSS;
+
+    const stage = document.createElement("div");
+    stage.className = "mermaid-lightbox-stage";
+    const canvas = document.createElement("div");
+    canvas.className = "mermaid-lightbox-canvas";
+    stage.append(canvas);
+    dialog.append(close, stage);
+    document.body.append(dialog);
+
+    close.addEventListener("click", () => dialog.close());
+    // A click whose target is the dialog *itself* is a click on the backdrop —
+    // anything on the content hits one of the children above. Esc is native, and so
+    // is returning focus to the button the dialog was opened from.
+    dialog.addEventListener("click", (event) => {
+      if (event.target === dialog) dialog.close();
+    });
+
+    // Non-passive, deliberately: a wheel listener registered the default way cannot
+    // `preventDefault`, and the page scrolls behind the dialog instead of zooming.
+    stage.addEventListener(
+      "wheel",
+      (event) => {
+        event.preventDefault();
+        const rect = stage.getBoundingClientRect();
+        // The canvas is centred in the stage and scales about its own centre, so
+        // offsets from the stage centre are the frame the transform is written in.
+        const x = event.clientX - rect.left - rect.width / 2;
+        const y = event.clientY - rect.top - rect.height / 2;
+
+        const next = Math.min(MAX_SCALE, Math.max(1, scale * Math.exp(-event.deltaY / 400)));
+        const ratio = next / scale;
+        // Hold whatever is under the cursor still while the scale changes.
+        panX = x - ratio * (x - panX);
+        panY = y - ratio * (y - panY);
+        scale = next;
+        // At fit there is nowhere to pan to, and a diagram left off-centre by an
+        // earlier drag would otherwise stay there.
+        if (scale === 1) {
+          panX = 0;
+          panY = 0;
+        }
+        applyTransform(stage, canvas);
+      },
+      { passive: false },
+    );
+
+    let panning = false;
+    let lastX = 0;
+    let lastY = 0;
+
+    stage.addEventListener("pointerdown", (event) => {
+      if (scale === 1) return;
+      panning = true;
+      lastX = event.clientX;
+      lastY = event.clientY;
+      stage.setPointerCapture(event.pointerId);
+      stage.style.cursor = "grabbing";
+    });
+
+    stage.addEventListener("pointermove", (event) => {
+      if (!panning) return;
+      panX += event.clientX - lastX;
+      panY += event.clientY - lastY;
+      lastX = event.clientX;
+      lastY = event.clientY;
+      applyTransform(stage, canvas);
+    });
+
+    for (const type of ["pointerup", "pointercancel"]) {
+      stage.addEventListener(type, () => {
+        panning = false;
+        applyTransform(stage, canvas);
+      });
+    }
+
+    lightbox = { dialog, stage, canvas };
+    return lightbox;
+  }
+
+  function expand(figure: HTMLElement) {
+    // Scoped to the `
` rather than the figure, because the figure also contains
+    // the magnifier — whose icon is itself an ``. A bare `figure svg` picks the
+    // diagram only because the button happens to be appended after it, which is not
+    // a property worth depending on.
+    const source = figure.querySelector(
+      "pre.mermaid svg, pre.mermaid img",
+    );
+    if (source === null) return;
+
+    const { dialog, stage, canvas } = lightboxElements();
+    const clone = source.cloneNode(true) as SVGSVGElement | HTMLImageElement;
+
+    // The `id` is deliberately kept. mermaid writes a `
` above; it exists only to be the button's positioning context. It carries
+ * no box of its own, so the `
`'s margins collapse straight through it and the
+ * two elements occupy exactly the same rectangle.
+ */
+.mermaid-figure {
+  position: relative;
+}
+
+/*
+ * Both buttons, one appearance. They sit in the same corner of their respective
+ * boxes and do the same kind of job, and a second set of values here would be one
+ * more thing to keep in step for no reader's benefit.
+ */
+.mermaid-zoom,
+.mermaid-lightbox-close {
+  position: absolute;
+  top: 0.5rem;
+  right: 0.5rem;
+  z-index: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 2rem;
+  height: 2rem;
+  padding: 0;
+  color: var(--sl-color-gray-2);
+  background: var(--sl-color-bg);
+  border: 1px solid var(--sl-color-gray-5);
+  border-radius: 0.375rem;
+  cursor: pointer;
+}
+
+.mermaid-zoom svg,
+.mermaid-lightbox-close svg {
+  width: 1.125rem;
+  height: 1.125rem;
+}
+
+.mermaid-zoom:hover,
+.mermaid-lightbox-close:hover {
+  color: var(--sl-color-white);
+  border-color: var(--sl-color-gray-4);
+}
+
+/*
+ * Hidden until wanted, so a page of diagrams is not a page of buttons. Revealed by
+ * hovering the diagram *or* by focusing the button, which is the whole of its
+ * keyboard reachability — `opacity` rather than `display` precisely so that it stays
+ * in the tab order while invisible.
+ */
+.mermaid-zoom {
+  opacity: 0;
+  transition: opacity 120ms ease-out;
+}
+
+.mermaid-figure:hover .mermaid-zoom,
+.mermaid-zoom:focus-visible {
+  opacity: 1;
+}
+
+/* A touch device has no hover to reveal it with, so it is simply always there. */
+@media (hover: none) {
+  .mermaid-zoom {
+    opacity: 1;
+  }
+}
+
+.mermaid-lightbox {
+  width: 92vw;
+  height: 88vh;
+  max-width: 92vw;
+  max-height: 88vh;
+
+  /*
+   * Explicit, and not redundant: the browser's own stylesheet centres a modal
+   * dialog with `margin: auto`, and Starlight's reset zeroes the margin on
+   * everything. Without this the dialog opens hard against the top-left corner —
+   * measured, not guessed.
+   */
+  margin: auto;
+  padding: 0;
+  overflow: hidden;
+  color: var(--sl-color-text);
+  background: var(--sl-color-bg);
+  border: 1px solid var(--sl-color-gray-5);
+  border-radius: 0.5rem;
+}
+
+.mermaid-lightbox::backdrop {
+  background: rgb(0 0 0 / 60%);
+}
+
+/*
+ * The stage clips; the canvas is what moves. Keeping the transform on an inner
+ * element is what lets the diagram be panned beyond the edges without the dialog
+ * growing a scrollbar, and `touch-action: none` is what stops a drag on a phone
+ * being read as a page scroll.
+ */
+.mermaid-lightbox-stage {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+  touch-action: none;
+}
+
+.mermaid-lightbox-canvas {
+  box-sizing: border-box;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 100%;
+  height: 100%;
+  padding: 1.5rem;
+  /*
+   * Load-bearing, and it is the assumption the zoom arithmetic in `Head.astro` is
+   * written against: the canvas fills the stage, so its centre is the stage's
+   * centre, and scaling about that point is what lets "keep what is under the
+   * cursor still" be two lines rather than a matrix.
+   */
+  transform-origin: center;
+}
+
+@keyframes mermaid-lightbox-in {
+  from {
+    opacity: 0;
+  }
+
+  to {
+    opacity: 1;
+  }
+}
+
+.mermaid-lightbox[open],
+.mermaid-lightbox[open]::backdrop {
+  animation: mermaid-lightbox-in 120ms ease-out;
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .mermaid-lightbox[open],
+  .mermaid-lightbox[open]::backdrop {
+    animation: none;
+  }
+
+  .mermaid-zoom {
+    transition: none;
+  }
+}