// The tutorial panel: a column beside the main view that shows one step of // steps.ts at a time, with a table of contents, Back and Next, the step's // text with its mathematics typeset by KaTeX, and the step's actions as // buttons that drive the application through the TutorialHost. The host // calls refresh() whenever its state changes, so that the numbers in the // text follow the design in the editor. import renderMathInElement from "katex/contrib/auto-render"; import "katex/dist/katex.min.css"; import { STEPS, type TutorialHost, type TutorialStep, chapters, stepIndex } from "./steps.ts"; const STORAGE_KEY = "cymbalsim.tutorial"; export class Tutorial { private readonly root: HTMLElement; private readonly host: TutorialHost; private readonly onToggle: (open: boolean) => void; private readonly select = document.createElement("select"); private readonly counter = document.createElement("span"); private readonly body = document.createElement("div"); private readonly backBtn = document.createElement("button"); private readonly nextBtn = document.createElement("button"); private readonly error = document.createElement("div"); private index = 0; private busy = false; constructor(root: HTMLElement, host: TutorialHost, onToggle: (open: boolean) => void) { this.root = root; this.host = host; this.onToggle = onToggle; root.classList.add("tutorial"); const head = document.createElement("div"); head.className = "tut-head"; const title = document.createElement("span"); title.className = "tut-title"; title.textContent = "Tutorial"; for (const ch of chapters()) { const group = document.createElement("optgroup"); group.label = ch.title; for (const i of ch.steps) { const o = document.createElement("option"); o.value = String(i); o.textContent = STEPS[i].title; group.append(o); } this.select.append(group); } this.select.addEventListener("change", () => this.goto(Number(this.select.value))); const close = document.createElement("button"); close.textContent = "×"; close.title = "Close the tutorial"; close.className = "tut-close"; close.addEventListener("click", () => this.close()); head.append(title, this.select, close); this.body.className = "tut-body"; this.error.className = "tut-error"; this.error.hidden = true; const foot = document.createElement("div"); foot.className = "tut-foot"; this.backBtn.textContent = "Back"; this.backBtn.addEventListener("click", () => this.goto(this.index - 1)); this.nextBtn.textContent = "Next"; this.nextBtn.className = "primary"; this.nextBtn.addEventListener("click", () => this.goto(this.index + 1)); this.counter.className = "tut-counter"; foot.append(this.backBtn, this.counter, this.nextBtn); root.append(head, this.body, this.error, foot); const saved = this.load(); if (saved) this.index = Math.max(0, Math.min(STEPS.length - 1, saved.index)); } get isOpen(): boolean { return !this.root.hidden; } /** Opens the panel, at a step given by id or index when one is passed. */ open(step?: string | number): void { if (typeof step === "string") { const i = stepIndex(step); if (i >= 0) this.index = i; } else if (typeof step === "number" && step >= 0 && step < STEPS.length) this.index = step; this.root.hidden = false; this.render(true); this.save(); this.onToggle(true); } close(): void { this.root.hidden = true; this.save(); this.onToggle(false); } toggle(): void { if (this.isOpen) this.close(); else this.open(); } goto(i: number): void { if (i < 0 || i >= STEPS.length || this.busy) return; this.index = i; this.render(true); this.save(); } get step(): TutorialStep { return STEPS[this.index]; } /** Re-renders the current step's text after a change of the application's state (keeps the scroll position). */ refresh(): void { if (this.isOpen) this.render(false); } private render(top: boolean): void { const step = STEPS[this.index]; const scroll = this.body.scrollTop; this.select.value = String(this.index); this.counter.textContent = `${this.index + 1} / ${STEPS.length}`; this.backBtn.disabled = this.index === 0 || this.busy; this.nextBtn.disabled = this.index === STEPS.length - 1 || this.busy; const ctx = this.host.ctx(); let html: string; try { html = step.body(ctx); } catch (e) { html = `

This page could not be rendered: ${e instanceof Error ? e.message : String(e)}

`; } this.body.innerHTML = `
${step.chapter}

${step.title}

${html}`; if (step.actions && step.actions.length) { const box = document.createElement("div"); box.className = "tut-actions"; const label = document.createElement("div"); label.className = "tut-actions-label"; label.textContent = this.busy ? "Working..." : "Try it"; box.append(label); for (const a of step.actions) { const b = document.createElement("button"); b.textContent = a.label; b.disabled = this.busy || (!!a.needsGpu && !ctx.gpu); if (a.needsGpu && !ctx.gpu) b.title = "Needs WebGPU"; b.addEventListener("click", () => void this.runAction(a.run)); box.append(b); } this.body.append(box); } try { renderMathInElement(this.body, { delimiters: [ { left: "$$", right: "$$", display: true }, { left: "$", right: "$", display: false }, ], throwOnError: false, }); } catch (e) { console.warn("math rendering failed", e); } this.body.scrollTop = top ? 0 : scroll; } private async runAction(run: (host: TutorialHost) => Promise | void): Promise { if (this.busy) return; this.busy = true; this.error.hidden = true; this.render(false); try { await run(this.host); } catch (e) { this.error.textContent = e instanceof Error ? e.message : String(e); this.error.hidden = false; } finally { this.busy = false; this.render(false); } } private save(): void { try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ index: this.index, open: this.isOpen })); } catch { // Storage may be unavailable; the tutorial works without it. } } private load(): { index: number; open: boolean } | null { try { const s = localStorage.getItem(STORAGE_KEY); return s ? (JSON.parse(s) as { index: number; open: boolean }) : null; } catch { return null; } } /** Whether the panel was open when the page was last left. */ wasOpen(): boolean { return this.load()?.open ?? false; } }