From 8849f4fc7f241a567dfec360278f4a529a9cb1ea Mon Sep 17 00:00:00 2001 From: rohithreddykota Date: Thu, 16 Jul 2026 12:24:39 -0400 Subject: [PATCH 1/3] feat: add table of contents with scrollspy to canvas dashboards --- .../routes/-/embed/canvas/[name]/+page.svelte | 6 +- .../canvas/CanvasDashboardEmbed.svelte | 9 +- .../canvas/CanvasDashboardWrapper.svelte | 114 +++++---- .../canvas/toc/CanvasTableOfContents.svelte | 240 ++++++++++++++++++ .../src/features/canvas/toc/scrollspy.spec.ts | 107 ++++++++ .../src/features/canvas/toc/scrollspy.ts | 105 ++++++++ .../src/features/canvas/toc/toc.spec.ts | 115 +++++++++ web-common/src/features/canvas/toc/toc.ts | 85 +++++++ 8 files changed, 736 insertions(+), 45 deletions(-) create mode 100644 web-common/src/features/canvas/toc/CanvasTableOfContents.svelte create mode 100644 web-common/src/features/canvas/toc/scrollspy.spec.ts create mode 100644 web-common/src/features/canvas/toc/scrollspy.ts create mode 100644 web-common/src/features/canvas/toc/toc.spec.ts create mode 100644 web-common/src/features/canvas/toc/toc.ts 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..fc83c883e569 --- /dev/null +++ b/web-common/src/features/canvas/toc/CanvasTableOfContents.svelte @@ -0,0 +1,240 @@ + + +{#if entries.length > 0} + + +{/if} + + diff --git a/web-common/src/features/canvas/toc/scrollspy.spec.ts b/web-common/src/features/canvas/toc/scrollspy.spec.ts new file mode 100644 index 000000000000..688c50573f5b --- /dev/null +++ b/web-common/src/features/canvas/toc/scrollspy.spec.ts @@ -0,0 +1,107 @@ +import { get } from "svelte/store"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createScrollSpy } from "./scrollspy"; +import type { TocEntry } from "./toc"; + +// 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 an entry whose element reports a fixed viewport top. */ +function entry(id: string, top: number): TocEntry { + const el = document.createElement("h2"); + el.id = id; + el.getBoundingClientRect = () => ({ top }) as DOMRect; + return { id, text: id, depth: 0, el }; +} + +describe("createScrollSpy", () => { + let root: HTMLElement; + + beforeEach(() => { + vi.stubGlobal("IntersectionObserver", MockIntersectionObserver); + root = document.createElement("div"); + root.getBoundingClientRect = () => ({ top: 0 }) as DOMRect; + observe.mockClear(); + disconnect.mockClear(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("starts with no active section when there are no entries", () => { + const spy = createScrollSpy(root); + spy.setEntries([]); + expect(get(spy.activeId)).toBeNull(); + }); + + it("observes every entry and, with none visible, keeps the last heading above the band", () => { + const spy = createScrollSpy(root); + spy.setEntries([entry("a", -10), entry("b", 50), entry("c", 400)]); + + expect(observe).toHaveBeenCalledTimes(3); + // 'a' is above the band (top <= root top); 'b' and 'c' are below → active is 'a'. + expect(get(spy.activeId)).toBe("a"); + }); + + it("activates the topmost heading currently in the band", () => { + const spy = createScrollSpy(root); + const entries = [entry("a", -100), entry("b", 20), entry("c", 60)]; + spy.setEntries(entries); + + fireIntersections({ b: true, c: true }); + expect(get(spy.activeId)).toBe("b"); + + // Scroll further: only 'c' remains in the band. + fireIntersections({ b: false }); + expect(get(spy.activeId)).toBe("c"); + }); + + it("ignores observer updates while locked, then settles on unlock", () => { + const spy = createScrollSpy(root); + spy.setEntries([entry("a", -100), entry("b", 20), entry("c", 60)]); + + // Lock to 'c' (as a click-to-scroll would): intermediate intersections must not move it. + spy.lock("c"); + fireIntersections({ b: true }); + expect(get(spy.activeId)).toBe("c"); + + // Once unlocked, it recomputes from the current intersection state. + spy.unlock(); + expect(get(spy.activeId)).toBe("b"); + }); + + it("setActive overrides the computed section immediately", () => { + const spy = createScrollSpy(root); + spy.setEntries([entry("a", -10), entry("b", 50)]); + spy.setActive("b"); + expect(get(spy.activeId)).toBe("b"); + }); + + it("disconnects the observer on destroy", () => { + const spy = createScrollSpy(root); + spy.setEntries([entry("a", 0)]); + spy.destroy(); + expect(disconnect).toHaveBeenCalled(); + }); +}); diff --git a/web-common/src/features/canvas/toc/scrollspy.ts b/web-common/src/features/canvas/toc/scrollspy.ts new file mode 100644 index 000000000000..38bb5589609b --- /dev/null +++ b/web-common/src/features/canvas/toc/scrollspy.ts @@ -0,0 +1,105 @@ +import { writable, type Readable } from "svelte/store"; +import type { TocEntry } from "./toc"; + +export type ScrollSpy = { + /** The id of the section currently in view. Exactly one entry is active at a time. */ + activeId: Readable; + /** Re-bind the observer to a new set of entries (e.g. after a tab switch re-derives the TOC). */ + setEntries: (entries: TocEntry[]) => void; + /** Force a specific id active immediately (e.g. deep-linking on mount). */ + setActive: (id: string | null) => void; + /** + * Pin the active id and ignore observer updates until `unlock()`. Used during click-to-scroll so + * the highlight doesn't flicker through every section the smooth scroll passes on its way down. + */ + lock: (id: string) => void; + /** Resume observer-driven tracking and settle on the section now in view. */ + unlock: () => void; + destroy: () => void; +}; + +/** + * Track which section is in view using a single IntersectionObserver rooted at the scroll + * container (no per-frame scroll math, per the spec). + * + * The `rootMargin` shrinks the observed viewport to a band near the top of the container, so a + * heading counts as "in view" once it reaches that band. The active entry is the topmost heading + * currently in the band; when the band is empty (scrolled between two headings), the last heading + * above the band stays active, so there is always exactly one active item. + */ +export function createScrollSpy(root: HTMLElement): ScrollSpy { + const activeId = writable(null); + + let entries: TocEntry[] = []; + let observer: IntersectionObserver | undefined; + // 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. + let locked = false; + // Tracks per-id intersection state so we can pick the topmost visible heading on each change. + const intersecting = new Map(); + + function recompute() { + if (locked) return; + + // Prefer the topmost heading currently in the activation band. + const visible = entries.filter((e) => intersecting.get(e.id)); + if (visible.length > 0) { + visible.sort( + (a, b) => + a.el.getBoundingClientRect().top - b.el.getBoundingClientRect().top, + ); + activeId.set(visible[0].id); + return; + } + + // Band is empty: keep the last heading whose top is above the band (the section we're within). + const rootTop = root.getBoundingClientRect().top; + let candidate: string | null = entries[0]?.id ?? null; + for (const e of entries) { + if (e.el.getBoundingClientRect().top - rootTop <= 1) candidate = e.id; + else break; + } + activeId.set(candidate); + } + + function setEntries(next: TocEntry[]) { + entries = next; + intersecting.clear(); + observer?.disconnect(); + + if (typeof IntersectionObserver === "undefined" || next.length === 0) { + activeId.set(next[0]?.id ?? null); + return; + } + + observer = new IntersectionObserver( + (records) => { + for (const record of records) { + const id = (record.target as HTMLElement).id; + intersecting.set(id, record.isIntersecting); + } + recompute(); + }, + // Activation band: the top ~30% of the container. + { root, rootMargin: "0px 0px -70% 0px", threshold: 0 }, + ); + + for (const e of next) observer.observe(e.el); + recompute(); + } + + return { + activeId, + setEntries, + setActive: (id) => activeId.set(id), + lock: (id) => { + locked = true; + activeId.set(id); + }, + unlock: () => { + locked = false; + recompute(); + }, + destroy: () => observer?.disconnect(), + }; +} 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 }; + }); +} From 52255cdee7f253e2d21418606d989c821483f609 Mon Sep 17 00:00:00 2001 From: rohithreddykota Date: Thu, 16 Jul 2026 13:38:21 -0400 Subject: [PATCH 2/3] Hide canvas table of contents when it has only one section --- .../src/features/canvas/toc/CanvasTableOfContents.svelte | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/web-common/src/features/canvas/toc/CanvasTableOfContents.svelte b/web-common/src/features/canvas/toc/CanvasTableOfContents.svelte index fc83c883e569..2e8883c04e3c 100644 --- a/web-common/src/features/canvas/toc/CanvasTableOfContents.svelte +++ b/web-common/src/features/canvas/toc/CanvasTableOfContents.svelte @@ -113,9 +113,10 @@ } -{#if entries.length > 0} +{#if entries.length > 1} + The same links back both states, so the accessible name is always the full heading text. + Hidden when there's only one section, where a table of contents adds no value. -->