diff --git a/web-admin/src/routes/-/embed/canvas/[name]/+page.svelte b/web-admin/src/routes/-/embed/canvas/[name]/+page.svelte index f9a8bb5baa3a..aa005367b1d2 100644 --- a/web-admin/src/routes/-/embed/canvas/[name]/+page.svelte +++ b/web-admin/src/routes/-/embed/canvas/[name]/+page.svelte @@ -10,6 +10,10 @@ {#key `${instanceId}::${canvasName}`} - + {/key} diff --git a/web-common/src/features/canvas/CanvasDashboardEmbed.svelte b/web-common/src/features/canvas/CanvasDashboardEmbed.svelte index 0ccb6c09a7a8..8a03a5cdf5cd 100644 --- a/web-common/src/features/canvas/CanvasDashboardEmbed.svelte +++ b/web-common/src/features/canvas/CanvasDashboardEmbed.svelte @@ -16,6 +16,7 @@ export let canvasName: string; export let navigationEnabled: boolean = true; + export let tableOfContents: boolean = true; const runtimeClient = useRuntimeClient(); @@ -62,7 +63,13 @@ {#if canvasName} - + {#each blocks as block (block.kind === "tab-group" ? `g-${block.group.name}` : `r-${block.rowIndex}`)} {#if block.kind === "tab-group"} void = () => {}; let contentRect = new DOMRectReadOnly(0, 0, 0, 0); + let scrollContainer: HTMLElement | undefined; + let bandWidth = 0; + + // Collapse the rail's bars to dots once the viewport is too narrow for the bars to sit in the + // content's left margin (i.e. the centered content is near full width). Based on the stable + // `maxWidth` rather than the measured content width, so there's no feedback loop. + const RAIL_BAR_MARGIN_PX = 48; + $: compactRail = + showTableOfContents && + bandWidth > 0 && + bandWidth < maxWidth + RAIL_BAR_MARGIN_PX; $: ({ instanceId } = client); @@ -76,55 +89,70 @@ diff --git a/web-common/src/features/canvas/toc/CanvasTableOfContents.svelte b/web-common/src/features/canvas/toc/CanvasTableOfContents.svelte new file mode 100644 index 000000000000..0824ae8a1b1d --- /dev/null +++ b/web-common/src/features/canvas/toc/CanvasTableOfContents.svelte @@ -0,0 +1,148 @@ + + +{#if toc && toc.entries.length > 1} + + +{/if} + + diff --git a/web-common/src/features/canvas/toc/toc-controller.svelte.spec.ts b/web-common/src/features/canvas/toc/toc-controller.svelte.spec.ts new file mode 100644 index 000000000000..45b6364b5183 --- /dev/null +++ b/web-common/src/features/canvas/toc/toc-controller.svelte.spec.ts @@ -0,0 +1,146 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CanvasTocController } from "./toc-controller.svelte"; + +// Controllable IntersectionObserver mock: capture the callback so tests can drive intersections. +let ioCallback: IntersectionObserverCallback; +const observe = vi.fn(); +const disconnect = vi.fn(); + +class MockIntersectionObserver { + constructor(cb: IntersectionObserverCallback) { + ioCallback = cb; + } + observe = observe; + unobserve = vi.fn(); + disconnect = disconnect; + takeRecords = vi.fn(); +} + +function fireIntersections(states: Record) { + const records = Object.entries(states).map(([id, isIntersecting]) => ({ + target: { id } as unknown as Element, + isIntersecting, + })) as IntersectionObserverEntry[]; + ioCallback(records, {} as IntersectionObserver); +} + +/** + * Build a scroll container holding markdown headings at the given viewport tops, matching the DOM + * the controller derives from (`.row-container > .canvas-markdown > h2`). + */ +function buildContainer(headings: { id: string; top: number }[]): HTMLElement { + const container = document.createElement("div"); + container.getBoundingClientRect = () => ({ top: 0 }) as DOMRect; + + const rowContainer = document.createElement("div"); + rowContainer.className = "row-container"; + const markdown = document.createElement("div"); + markdown.className = "canvas-markdown"; + rowContainer.appendChild(markdown); + container.appendChild(rowContainer); + + for (const { id, top } of headings) { + const heading = document.createElement("h2"); + heading.id = id; + heading.textContent = id; + heading.getBoundingClientRect = () => ({ top }) as DOMRect; + markdown.appendChild(heading); + } + + return container; +} + +describe("CanvasTocController", () => { + beforeEach(() => { + vi.stubGlobal("IntersectionObserver", MockIntersectionObserver); + vi.stubGlobal( + "MutationObserver", + class { + observe = vi.fn(); + disconnect = vi.fn(); + takeRecords = vi.fn(); + }, + ); + HTMLElement.prototype.scrollIntoView = vi.fn(); + observe.mockClear(); + disconnect.mockClear(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it("derives entries from the rendered markdown headings", () => { + const toc = new CanvasTocController( + buildContainer([ + { id: "a", top: 0 }, + { id: "b", top: 100 }, + ]), + ); + expect(toc.entries.map((e) => e.id)).toEqual(["a", "b"]); + expect(observe).toHaveBeenCalledTimes(2); + }); + + it("keeps the last heading above the band when none are visible", () => { + const toc = new CanvasTocController( + buildContainer([ + { id: "a", top: -10 }, + { id: "b", top: 50 }, + { id: "c", top: 400 }, + ]), + ); + // 'a' is above the band (top <= root top); 'b' and 'c' are below → active is 'a'. + expect(toc.activeId).toBe("a"); + }); + + it("activates the topmost heading currently in the band", () => { + const toc = new CanvasTocController( + buildContainer([ + { id: "a", top: -100 }, + { id: "b", top: 20 }, + { id: "c", top: 60 }, + ]), + ); + + fireIntersections({ b: true, c: true }); + expect(toc.activeId).toBe("b"); + + fireIntersections({ b: false }); + expect(toc.activeId).toBe("c"); + }); + + it("pins the target while a click-scroll is in flight, then settles once scrolling stops", () => { + vi.useFakeTimers(); + const container = buildContainer([ + { id: "a", top: -100 }, + { id: "b", top: 20 }, + { id: "c", top: 60 }, + ]); + const toc = new CanvasTocController(container); + + fireIntersections({ b: true }); + expect(toc.activeId).toBe("b"); + + // Click 'c': it's pinned, and intermediate intersections must not move it. + const event = { + preventDefault: vi.fn(), + currentTarget: document.createElement("a"), + } as unknown as MouseEvent; + toc.jumpTo(event, toc.entries[2]); + expect(toc.activeId).toBe("c"); + fireIntersections({ b: true }); + expect(toc.activeId).toBe("c"); + + // Scrolling stops → unlock and settle on the section actually in view. + container.dispatchEvent(new Event("scroll")); + vi.advanceTimersByTime(100); + expect(toc.activeId).toBe("b"); + }); + + it("disconnects observers on destroy", () => { + const toc = new CanvasTocController(buildContainer([{ id: "a", top: 0 }])); + toc.destroy(); + expect(disconnect).toHaveBeenCalled(); + }); +}); diff --git a/web-common/src/features/canvas/toc/toc-controller.svelte.ts b/web-common/src/features/canvas/toc/toc-controller.svelte.ts new file mode 100644 index 000000000000..195e53613795 --- /dev/null +++ b/web-common/src/features/canvas/toc/toc-controller.svelte.ts @@ -0,0 +1,192 @@ +import { deriveTocEntries, type TocEntry } from "./toc"; + +// Activation band: a heading counts as "in view" once it reaches the top ~30% of the container. +const ACTIVATION_ROOT_MARGIN = "0px 0px -70% 0px"; +// Debounce for re-deriving after DOM changes (tab switch, async markdown, edits). +const REFRESH_DEBOUNCE_MS = 150; +// Idle gap that counts as "scrolling stopped", and a hard cap in case no scroll events fire. +const SCROLL_IDLE_MS = 100; +const SCROLL_MAX_WAIT_MS = 1000; + +/** + * Owns the table-of-contents state and behaviour for a canvas dashboard. + * + * Canvas has no section-header primitive, so entries are derived from the `

`–`

` rendered + * inside `markdown` components of the active tab (see `deriveTocEntries`) and kept in sync via a + * MutationObserver. The section in view is tracked with a single IntersectionObserver (no per-frame + * scroll math): the active entry is the topmost heading in the activation band, falling back to the + * last heading above it when the band is empty, so exactly one entry is active at a time. + */ +export class CanvasTocController { + /** Sections derived from the active tab's rendered headings. */ + entries = $state([]); + /** The section currently in view; exactly one at a time. */ + activeId = $state(null); + + private observer: IntersectionObserver | undefined; + private mutationObserver: MutationObserver | undefined; + // Per-id intersection state, so we can pick the topmost visible heading on each change. + private readonly intersecting = new Map(); + // While locked (during a click-to-scroll), observer updates are ignored so the active item stays + // pinned to the target instead of walking through every section the scroll passes. + private locked = false; + private deepLinked = false; + private refreshTimer: ReturnType | undefined; + private cancelScrollWatch: (() => void) | undefined; + + constructor(private readonly scrollContainer: HTMLElement) { + this.refresh(); + + // Re-derive on any content change: tab switch, async markdown resolution, edits, filters, or the + // `.row-container` appearing (e.g. after required filters are satisfied). Observing the scroll + // container rather than `.row-container` keeps this resilient to remounts. + this.mutationObserver = new MutationObserver(() => this.scheduleRefresh()); + this.mutationObserver.observe(scrollContainer, { + childList: true, + subtree: true, + characterData: true, + }); + } + + /** Smooth-scroll to a section on click, update the URL hash, and pin the highlight to it. */ + jumpTo(event: MouseEvent, entry: TocEntry) { + // Handle navigation ourselves so SvelteKit doesn't intercept the hash link, and so we can + // respect prefers-reduced-motion. + event.preventDefault(); + // Pin the highlight to the target for the duration of the scroll, so it doesn't flicker through + // every section the smooth scroll passes; release once the scroll settles. + this.locked = true; + this.activeId = entry.id; + entry.el.scrollIntoView({ + behavior: this.scrollBehavior(), + block: "start", + }); + history.replaceState(history.state, "", `#${entry.id}`); + this.watchScrollEnd(); + // Release focus so the flyout collapses once the pointer leaves; otherwise `:focus-within` + // keeps it open until the user clicks elsewhere. + (event.currentTarget as HTMLElement).blur(); + } + + destroy() { + clearTimeout(this.refreshTimer); + this.cancelScrollWatch?.(); + this.mutationObserver?.disconnect(); + this.observer?.disconnect(); + } + + private scheduleRefresh() { + clearTimeout(this.refreshTimer); + this.refreshTimer = setTimeout(() => this.refresh(), REFRESH_DEBOUNCE_MS); + } + + private refresh() { + const rowContainer = + this.scrollContainer.querySelector(".row-container"); + this.entries = deriveTocEntries(rowContainer); + this.observe(); + + // On first render, honor a deep link to a section (tab selection is handled separately via the + // existing `tabs` URL param). Use an instant jump so the page doesn't animate on load. + if (!this.deepLinked && this.entries.length > 0) { + this.deepLinked = true; + const hash = decodeURIComponent(window.location.hash.slice(1)); + const target = hash && this.entries.find((e) => e.id === hash); + if (target) { + target.el.scrollIntoView({ behavior: "auto", block: "start" }); + this.activeId = target.id; + } + } + } + + private observe() { + this.intersecting.clear(); + this.observer?.disconnect(); + + if ( + typeof IntersectionObserver === "undefined" || + this.entries.length === 0 + ) { + this.activeId = this.entries[0]?.id ?? null; + return; + } + + this.observer = new IntersectionObserver( + (records) => { + for (const record of records) { + this.intersecting.set( + (record.target as HTMLElement).id, + record.isIntersecting, + ); + } + this.recompute(); + }, + { + root: this.scrollContainer, + rootMargin: ACTIVATION_ROOT_MARGIN, + threshold: 0, + }, + ); + for (const entry of this.entries) this.observer.observe(entry.el); + this.recompute(); + } + + private recompute() { + if (this.locked) return; + + // Prefer the topmost heading currently in the activation band. + const visible = this.entries.filter((e) => this.intersecting.get(e.id)); + if (visible.length > 0) { + visible.sort( + (a, b) => + a.el.getBoundingClientRect().top - b.el.getBoundingClientRect().top, + ); + this.activeId = visible[0].id; + return; + } + + // Band is empty: keep the last heading whose top is above it (the section we're within). + const rootTop = this.scrollContainer.getBoundingClientRect().top; + let candidate: string | null = this.entries[0]?.id ?? null; + for (const entry of this.entries) { + if (entry.el.getBoundingClientRect().top - rootTop <= 1) + candidate = entry.id; + else break; + } + this.activeId = candidate; + } + + private scrollBehavior(): ScrollBehavior { + return window.matchMedia?.("(prefers-reduced-motion: reduce)").matches + ? "auto" + : "smooth"; + } + + // Unlock once the container stops scrolling (debounced), with a hard timeout in case no scroll + // events fire (e.g. the target is already in view, or reduced-motion jumps instantly). + private watchScrollEnd() { + this.cancelScrollWatch?.(); + + let idle: ReturnType; + const finish = () => { + this.cancelScrollWatch?.(); + this.locked = false; + this.recompute(); + }; + const onScroll = () => { + clearTimeout(idle); + idle = setTimeout(finish, SCROLL_IDLE_MS); + }; + const maxWait = setTimeout(finish, SCROLL_MAX_WAIT_MS); + + this.scrollContainer.addEventListener("scroll", onScroll, { + passive: true, + }); + this.cancelScrollWatch = () => { + clearTimeout(idle); + clearTimeout(maxWait); + this.scrollContainer.removeEventListener("scroll", onScroll); + this.cancelScrollWatch = undefined; + }; + } +} diff --git a/web-common/src/features/canvas/toc/toc.spec.ts b/web-common/src/features/canvas/toc/toc.spec.ts new file mode 100644 index 000000000000..93a26cd8fa50 --- /dev/null +++ b/web-common/src/features/canvas/toc/toc.spec.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; +import { deriveTocEntries, slugify } from "./toc"; + +describe("slugify", () => { + it("lowercases and replaces non-alphanumerics with hyphens", () => { + expect(slugify("Hello, World!")).toBe("hello-world"); + }); + + it("trims leading and trailing hyphens", () => { + expect(slugify(" --Overview-- ")).toBe("overview"); + }); + + it("falls back to 'section' for empty/symbol-only text", () => { + expect(slugify(" ")).toBe("section"); + expect(slugify("!!!")).toBe("section"); + }); +}); + +function buildRoot(html: string): HTMLElement { + const root = document.createElement("div"); + root.innerHTML = html; + return root; +} + +describe("deriveTocEntries", () => { + it("returns nothing when there is no content", () => { + expect(deriveTocEntries(null)).toEqual([]); + expect(deriveTocEntries(buildRoot("

no headings

"))).toEqual([]); + }); + + it("only picks up headings inside .canvas-markdown", () => { + const root = buildRoot(` +

Outside

+

Inside

+ `); + const entries = deriveTocEntries(root); + expect(entries.map((e) => e.text)).toEqual(["Inside"]); + }); + + it("assigns slugified ids and scroll-margin-top to headings without an id", () => { + const root = buildRoot( + `

Getting Started

`, + ); + const [entry] = deriveTocEntries(root); + expect(entry.id).toBe("getting-started"); + expect(entry.el.id).toBe("getting-started"); + expect(entry.el.style.scrollMarginTop).toBe("16px"); + }); + + it("preserves an existing id", () => { + const root = buildRoot( + `

Title

`, + ); + const [entry] = deriveTocEntries(root); + expect(entry.id).toBe("custom"); + }); + + it("dedupes colliding slugs with a numeric suffix", () => { + const root = buildRoot(` +
+

Overview

+

Overview

+

Overview

+
+ `); + expect(deriveTocEntries(root).map((e) => e.id)).toEqual([ + "overview", + "overview-2", + "overview-3", + ]); + }); + + it("treats the shallowest heading level as top-level and nests one level deeper", () => { + const root = buildRoot(` +
+

Section A

+

Sub A1

+

Section B

+
+ `); + expect(deriveTocEntries(root).map((e) => e.depth)).toEqual([0, 1, 0]); + }); + + it("normalizes nesting when h1 is the shallowest heading used", () => { + const root = buildRoot(` +
+

Title

+

Subsection

+
+ `); + const entries = deriveTocEntries(root); + expect(entries.map((e) => e.depth)).toEqual([0, 1]); + }); + + it("gives each heading level its own indent depth (h2 and h3 differ)", () => { + const root = buildRoot(` +
+

Title

+

Section

+

Subsection

+
+ `); + expect(deriveTocEntries(root).map((e) => e.depth)).toEqual([0, 1, 2]); + }); + + it("ignores blank headings", () => { + const root = buildRoot(` +
+

+

Real

+
+ `); + expect(deriveTocEntries(root).map((e) => e.text)).toEqual(["Real"]); + }); +}); diff --git a/web-common/src/features/canvas/toc/toc.ts b/web-common/src/features/canvas/toc/toc.ts new file mode 100644 index 000000000000..c69238046e86 --- /dev/null +++ b/web-common/src/features/canvas/toc/toc.ts @@ -0,0 +1,85 @@ +/** + * Table-of-contents derivation for canvas dashboards. + * + * Canvas has no section-header primitive: section titles are authored inside `markdown` + * components, which render `

`–`

` via `{@html}` (see components/markdown/Markdown.svelte). + * The TOC is therefore derived from the headings actually rendered in the DOM, so it stays in + * sync with dashboard content. Only the active tab is mounted, so scanning the live content + * container naturally scopes the TOC to the active tab(s). + */ + +/** Extra top offset applied when smooth-scrolling to a heading, so it doesn't sit flush against + * the top edge of the scroll container. */ +const SCROLL_MARGIN_TOP_PX = 16; + +/** Markdown heading levels the TOC is built from. */ +const HEADING_SELECTOR = ".canvas-markdown :is(h1, h2, h3)"; + +export type TocEntry = { + /** Anchor id, assigned to the heading element if it lacked one. */ + id: string; + /** Full heading text; also the accessible name (never truncated in the DOM). */ + text: string; + /** Nesting depth relative to the shallowest heading present: 0 for top-level, incrementing by + * one per heading level (so h2 and h3 indent differently). */ + depth: number; + /** The heading element itself, used as the scroll target and the IntersectionObserver target. */ + el: HTMLElement; +}; + +/** Turn arbitrary heading text into a URL-hash-safe slug. */ +export function slugify(text: string): string { + return ( + text + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") || "section" + ); +} + +/** + * Scan `root` for markdown headings in document order and build the TOC entries. + * + * Side effects on each heading element: assigns a deduped `id` if missing (so anchors and the + * URL hash are stable) and sets `scroll-margin-top` (so smooth-scroll lands below the container + * edge). Both are idempotent, so re-deriving after a content change is safe. + */ +export function deriveTocEntries( + root: HTMLElement | null | undefined, +): TocEntry[] { + if (!root) return []; + + const headings = Array.from( + root.querySelectorAll(HEADING_SELECTOR), + ) + .map((el) => ({ + el, + text: el.textContent?.trim() ?? "", + level: Number(el.tagName[1]) || 1, // "H2" -> 2 + })) + .filter((h) => h.text); + + if (headings.length === 0) return []; + + // The shallowest level present becomes the top level; anything deeper nests one indent per level. + const minLevel = Math.min(...headings.map((h) => h.level)); + + const usedIds = new Set(); + + return headings.map(({ el, text, level }) => { + let id = el.id || slugify(text); + // Dedupe collisions with a numeric suffix, e.g. "overview", "overview-2". + if (usedIds.has(id)) { + let n = 2; + while (usedIds.has(`${id}-${n}`)) n++; + id = `${id}-${n}`; + } + usedIds.add(id); + + if (el.id !== id) el.id = id; + el.style.scrollMarginTop = `${SCROLL_MARGIN_TOP_PX}px`; + + return { id, text, depth: level - minLevel, el }; + }); +}