From 2ca93673ec6089df7887cc8d4d1673a917aa0290 Mon Sep 17 00:00:00 2001 From: hugolytics Date: Thu, 12 Mar 2026 13:08:05 +0100 Subject: [PATCH] Preserve viewer viewport across resize transitions --- .../public/examples/vllm/deployment.html | 115 ++++++++++--- examples/vLLM/deployment.html | 115 ++++++++++--- package-lock.json | 4 +- package.json | 2 +- src/viewer/viewer.ts | 130 +++++++++++++-- tests/viewer.test.ts | 157 +++++++++++++++++- 6 files changed, 456 insertions(+), 67 deletions(-) diff --git a/docs-site/public/examples/vllm/deployment.html b/docs-site/public/examples/vllm/deployment.html index 8d5e8b6..bb06c83 100644 --- a/docs-site/public/examples/vllm/deployment.html +++ b/docs-site/public/examples/vllm/deployment.html @@ -601,6 +601,9 @@ this.onCanvasClick = null; this.onKeyDown = null; this.narrowObserver = null; + this.canvasObserver = null; + this.viewportSyncRaf = null; + this.lastKnownViewport = null; this.doc = options.document ?? document; this.steps = options.steps ?? []; this.nodeIds = options.nodeIds ?? []; @@ -694,6 +697,64 @@ getStoryShell() { return this.doc.querySelector("#story-shell"); } + captureViewportState() { + if (!this.pz) return null; + const zoom = this.pz.getZoom(); + const pan = this.pz.getPan(); + if (!Number.isFinite(zoom) || zoom <= 0) return null; + if (!Number.isFinite(pan.x) || !Number.isFinite(pan.y)) return null; + return { zoom, pan: { x: pan.x, y: pan.y } }; + } + applyCanvasSize(targetSvg) { + if (!this.canvasWrap) return false; + const width = this.canvasWrap.clientWidth; + const height = this.canvasWrap.clientHeight; + if (!Number.isFinite(width) || !Number.isFinite(height) || width < 1 || height < 1) return false; + targetSvg.setAttribute("width", String(width)); + targetSvg.setAttribute("height", String(height)); + return true; + } + restoreViewport(state) { + if (!this.pz || !state) return false; + this.pz.zoom(state.zoom); + this.pz.pan(state.pan); + const restored = this.captureViewportState(); + if (!restored) return false; + this.lastKnownViewport = restored; + return true; + } + recoverViewport() { + if (this.restoreViewport(this.lastKnownViewport)) return; + if (!this.pz) return; + this.pz.fit(); + this.pz.center(); + this.applyMinInitialZoom(); + const recovered = this.captureViewportState(); + if (recovered) { + this.lastKnownViewport = recovered; + return; + } + const nodes = this.curStep >= 0 ? this.steps[this.curStep]?.nodes ?? [] : []; + if (nodes.length) autoZoom(this, nodes); + } + queueViewportSync() { + const targetSvg = this.getDiagramSvg(); + if (!targetSvg) return; + if (this.viewportSyncRaf !== null && typeof cancelAnimationFrame === "function") { + cancelAnimationFrame(this.viewportSyncRaf); + } + const schedule = typeof requestAnimationFrame === "function" ? requestAnimationFrame : (cb) => { + cb(0); + return 0; + }; + this.viewportSyncRaf = schedule(() => { + this.viewportSyncRaf = null; + const desiredState = this.captureViewportState() ?? this.lastKnownViewport; + if (!this.applyCanvasSize(targetSvg)) return; + this.pz?.resize(); + if (!this.restoreViewport(desiredState)) this.recoverViewport(); + }); + } syncPanelToggleButton() { if (!this.panelToggleBtn) return; const shell = this.getStoryShell(); @@ -709,9 +770,7 @@ if (!storyShell) return; storyShell.classList.toggle("panel-collapsed"); this.syncPanelToggleButton(); - this.pz?.resize(); - this.pz?.fit(); - this.pz?.center(); + this.onResize?.(); } bindControls() { this.doc.querySelectorAll(this.selectors.stepButtons).forEach((btn) => { @@ -778,19 +837,10 @@ if (!this.canvasWrap || !this.svgHost || !this.svgPanZoomImpl) return; const targetSvg = this.getDiagramSvg(); if (!targetSvg) return; - const resize = () => { - if (!this.canvasWrap) return; - targetSvg.setAttribute("width", String(this.canvasWrap.clientWidth)); - targetSvg.setAttribute("height", String(this.canvasWrap.clientHeight)); - this.pz?.resize(); - }; - resize(); - this.onResize = () => { - resize(); - this.pz?.fit(); - this.pz?.center(); - }; + this.applyCanvasSize(targetSvg); + this.onResize = () => this.queueViewportSync(); window.addEventListener("resize", this.onResize); + const { onUpdatedCTM: userOnUpdatedCTM, ...panZoomOptions } = this.panZoomOptions; this.pz = this.svgPanZoomImpl(targetSvg, { zoomEnabled: true, controlIconsEnabled: false, @@ -799,9 +849,20 @@ minZoom: this.panZoomMin, maxZoom: this.panZoomMax, zoomScaleSensitivity: 0.15, - ...this.panZoomOptions + ...panZoomOptions, + onUpdatedCTM: (ctm) => { + if (Number.isFinite(ctm.a) && ctm.a > 0 && Number.isFinite(ctm.e) && Number.isFinite(ctm.f)) { + this.lastKnownViewport = { zoom: ctm.a, pan: { x: ctm.e, y: ctm.f } }; + } + userOnUpdatedCTM?.(ctm); + } }); this.applyMinInitialZoom(); + this.lastKnownViewport = this.captureViewportState(); + if (typeof ResizeObserver !== "undefined") { + this.canvasObserver = new ResizeObserver(() => this.onResize?.()); + this.canvasObserver.observe(this.canvasWrap); + } tagNodes(this); tagEdges(this); setupEdgeTooltips(this); @@ -834,7 +895,7 @@ shell.classList.toggle("narrow", e.contentRect.width < this.narrowBreakpoint); const isNarrow = shell.classList.contains("narrow"); this.syncPanelToggleButton(); - if (wasNarrow !== isNarrow) this.onResize?.(); + if (wasNarrow !== isNarrow && !this.canvasObserver) this.onResize?.(); }); this.narrowObserver.observe(shell); } @@ -860,11 +921,23 @@ btn.style.display = "flex"; btn.style.alignItems = "center"; btn.style.justifyContent = "center"; - btn.addEventListener("click", () => { + btn.addEventListener("click", async () => { if (this.doc.fullscreenElement) { void this.doc.exitFullscreen(); - } else { - void fullscreenTarget.requestFullscreen?.(); + return; + } + try { + if (fullscreenTarget.requestFullscreen) { + await fullscreenTarget.requestFullscreen(); + return; + } + } catch { + } + const win = this.doc.defaultView; + if (win?.open) { + const url = new URL(win.location.href); + url.searchParams.delete("expandable"); + win.open(url.toString(), "_blank", "noopener,noreferrer"); } }); this.doc.addEventListener("fullscreenchange", () => { @@ -878,7 +951,9 @@ if (this.onCanvasClick && this.canvasWrap) this.canvasWrap.removeEventListener("click", this.onCanvasClick); if (this.onKeyDown) this.doc.removeEventListener("keydown", this.onKeyDown); if (this.zoomRaf) cancelAnimationFrame(this.zoomRaf); + if (this.viewportSyncRaf !== null && typeof cancelAnimationFrame === "function") cancelAnimationFrame(this.viewportSyncRaf); this.narrowObserver?.disconnect(); + this.canvasObserver?.disconnect(); this.pz?.destroy?.(); } }; diff --git a/examples/vLLM/deployment.html b/examples/vLLM/deployment.html index 8d5e8b6..bb06c83 100644 --- a/examples/vLLM/deployment.html +++ b/examples/vLLM/deployment.html @@ -601,6 +601,9 @@ this.onCanvasClick = null; this.onKeyDown = null; this.narrowObserver = null; + this.canvasObserver = null; + this.viewportSyncRaf = null; + this.lastKnownViewport = null; this.doc = options.document ?? document; this.steps = options.steps ?? []; this.nodeIds = options.nodeIds ?? []; @@ -694,6 +697,64 @@ getStoryShell() { return this.doc.querySelector("#story-shell"); } + captureViewportState() { + if (!this.pz) return null; + const zoom = this.pz.getZoom(); + const pan = this.pz.getPan(); + if (!Number.isFinite(zoom) || zoom <= 0) return null; + if (!Number.isFinite(pan.x) || !Number.isFinite(pan.y)) return null; + return { zoom, pan: { x: pan.x, y: pan.y } }; + } + applyCanvasSize(targetSvg) { + if (!this.canvasWrap) return false; + const width = this.canvasWrap.clientWidth; + const height = this.canvasWrap.clientHeight; + if (!Number.isFinite(width) || !Number.isFinite(height) || width < 1 || height < 1) return false; + targetSvg.setAttribute("width", String(width)); + targetSvg.setAttribute("height", String(height)); + return true; + } + restoreViewport(state) { + if (!this.pz || !state) return false; + this.pz.zoom(state.zoom); + this.pz.pan(state.pan); + const restored = this.captureViewportState(); + if (!restored) return false; + this.lastKnownViewport = restored; + return true; + } + recoverViewport() { + if (this.restoreViewport(this.lastKnownViewport)) return; + if (!this.pz) return; + this.pz.fit(); + this.pz.center(); + this.applyMinInitialZoom(); + const recovered = this.captureViewportState(); + if (recovered) { + this.lastKnownViewport = recovered; + return; + } + const nodes = this.curStep >= 0 ? this.steps[this.curStep]?.nodes ?? [] : []; + if (nodes.length) autoZoom(this, nodes); + } + queueViewportSync() { + const targetSvg = this.getDiagramSvg(); + if (!targetSvg) return; + if (this.viewportSyncRaf !== null && typeof cancelAnimationFrame === "function") { + cancelAnimationFrame(this.viewportSyncRaf); + } + const schedule = typeof requestAnimationFrame === "function" ? requestAnimationFrame : (cb) => { + cb(0); + return 0; + }; + this.viewportSyncRaf = schedule(() => { + this.viewportSyncRaf = null; + const desiredState = this.captureViewportState() ?? this.lastKnownViewport; + if (!this.applyCanvasSize(targetSvg)) return; + this.pz?.resize(); + if (!this.restoreViewport(desiredState)) this.recoverViewport(); + }); + } syncPanelToggleButton() { if (!this.panelToggleBtn) return; const shell = this.getStoryShell(); @@ -709,9 +770,7 @@ if (!storyShell) return; storyShell.classList.toggle("panel-collapsed"); this.syncPanelToggleButton(); - this.pz?.resize(); - this.pz?.fit(); - this.pz?.center(); + this.onResize?.(); } bindControls() { this.doc.querySelectorAll(this.selectors.stepButtons).forEach((btn) => { @@ -778,19 +837,10 @@ if (!this.canvasWrap || !this.svgHost || !this.svgPanZoomImpl) return; const targetSvg = this.getDiagramSvg(); if (!targetSvg) return; - const resize = () => { - if (!this.canvasWrap) return; - targetSvg.setAttribute("width", String(this.canvasWrap.clientWidth)); - targetSvg.setAttribute("height", String(this.canvasWrap.clientHeight)); - this.pz?.resize(); - }; - resize(); - this.onResize = () => { - resize(); - this.pz?.fit(); - this.pz?.center(); - }; + this.applyCanvasSize(targetSvg); + this.onResize = () => this.queueViewportSync(); window.addEventListener("resize", this.onResize); + const { onUpdatedCTM: userOnUpdatedCTM, ...panZoomOptions } = this.panZoomOptions; this.pz = this.svgPanZoomImpl(targetSvg, { zoomEnabled: true, controlIconsEnabled: false, @@ -799,9 +849,20 @@ minZoom: this.panZoomMin, maxZoom: this.panZoomMax, zoomScaleSensitivity: 0.15, - ...this.panZoomOptions + ...panZoomOptions, + onUpdatedCTM: (ctm) => { + if (Number.isFinite(ctm.a) && ctm.a > 0 && Number.isFinite(ctm.e) && Number.isFinite(ctm.f)) { + this.lastKnownViewport = { zoom: ctm.a, pan: { x: ctm.e, y: ctm.f } }; + } + userOnUpdatedCTM?.(ctm); + } }); this.applyMinInitialZoom(); + this.lastKnownViewport = this.captureViewportState(); + if (typeof ResizeObserver !== "undefined") { + this.canvasObserver = new ResizeObserver(() => this.onResize?.()); + this.canvasObserver.observe(this.canvasWrap); + } tagNodes(this); tagEdges(this); setupEdgeTooltips(this); @@ -834,7 +895,7 @@ shell.classList.toggle("narrow", e.contentRect.width < this.narrowBreakpoint); const isNarrow = shell.classList.contains("narrow"); this.syncPanelToggleButton(); - if (wasNarrow !== isNarrow) this.onResize?.(); + if (wasNarrow !== isNarrow && !this.canvasObserver) this.onResize?.(); }); this.narrowObserver.observe(shell); } @@ -860,11 +921,23 @@ btn.style.display = "flex"; btn.style.alignItems = "center"; btn.style.justifyContent = "center"; - btn.addEventListener("click", () => { + btn.addEventListener("click", async () => { if (this.doc.fullscreenElement) { void this.doc.exitFullscreen(); - } else { - void fullscreenTarget.requestFullscreen?.(); + return; + } + try { + if (fullscreenTarget.requestFullscreen) { + await fullscreenTarget.requestFullscreen(); + return; + } + } catch { + } + const win = this.doc.defaultView; + if (win?.open) { + const url = new URL(win.location.href); + url.searchParams.delete("expandable"); + win.open(url.toString(), "_blank", "noopener,noreferrer"); } }); this.doc.addEventListener("fullscreenchange", () => { @@ -878,7 +951,9 @@ if (this.onCanvasClick && this.canvasWrap) this.canvasWrap.removeEventListener("click", this.onCanvasClick); if (this.onKeyDown) this.doc.removeEventListener("keydown", this.onKeyDown); if (this.zoomRaf) cancelAnimationFrame(this.zoomRaf); + if (this.viewportSyncRaf !== null && typeof cancelAnimationFrame === "function") cancelAnimationFrame(this.viewportSyncRaf); this.narrowObserver?.disconnect(); + this.canvasObserver?.disconnect(); this.pz?.destroy?.(); } }; diff --git a/package-lock.json b/package-lock.json index f12e02e..e83adff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@biolytics.ai/diascope", - "version": "0.1.1", + "version": "0.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@biolytics.ai/diascope", - "version": "0.1.1", + "version": "0.1.2", "dependencies": { "commander": "^12.0.0", "js-yaml": "^4.1.0", diff --git a/package.json b/package.json index 80c6f07..e32e438 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@biolytics.ai/diascope", - "version": "0.1.1", + "version": "0.1.2", "description": "Turn D2 diagrams into narrated interactive stories", "homepage": "https://diascope.biolytics.ai", "repository": { diff --git a/src/viewer/viewer.ts b/src/viewer/viewer.ts index d8273a5..ce75b38 100644 --- a/src/viewer/viewer.ts +++ b/src/viewer/viewer.ts @@ -1,4 +1,4 @@ -import { applyHighlight } from "./highlight.js"; +import { applyHighlight, autoZoom } from "./highlight.js"; import { goStep, toggleFocus, resetOverview, bindKeyboard, renderBody } from "./navigation.js"; import { tagNodes, tagEdges, setupEdgeTooltips } from "./tagging.js"; import type { Step, ViewerOptions, ViewerSelectors, SvgPanZoom } from "./types.js"; @@ -70,6 +70,9 @@ export class DiaScopeViewer { readonly narrowBreakpoint: number; readonly overview: ViewerOptions['overview']; private narrowObserver: ResizeObserver | null = null; + private canvasObserver: ResizeObserver | null = null; + private viewportSyncRaf: number | null = null; + private lastKnownViewport: { zoom: number; pan: { x: number; y: number } } | null = null; private readonly svgPanZoomImpl: ((svg: SVGElement, opts: Record) => SvgPanZoom) | undefined; constructor(options: ViewerOptions) { @@ -188,6 +191,74 @@ export class DiaScopeViewer { return this.doc.querySelector("#story-shell"); } + private captureViewportState(): { zoom: number; pan: { x: number; y: number } } | null { + if (!this.pz) return null; + const zoom = this.pz.getZoom(); + const pan = this.pz.getPan(); + if (!Number.isFinite(zoom) || zoom <= 0) return null; + if (!Number.isFinite(pan.x) || !Number.isFinite(pan.y)) return null; + return { zoom, pan: { x: pan.x, y: pan.y } }; + } + + private applyCanvasSize(targetSvg: SVGElement): boolean { + if (!this.canvasWrap) return false; + const width = this.canvasWrap.clientWidth; + const height = this.canvasWrap.clientHeight; + if (!Number.isFinite(width) || !Number.isFinite(height) || width < 1 || height < 1) return false; + targetSvg.setAttribute("width", String(width)); + targetSvg.setAttribute("height", String(height)); + return true; + } + + private restoreViewport(state: { zoom: number; pan: { x: number; y: number } } | null): boolean { + if (!this.pz || !state) return false; + this.pz.zoom(state.zoom); + this.pz.pan(state.pan); + const restored = this.captureViewportState(); + if (!restored) return false; + this.lastKnownViewport = restored; + return true; + } + + private recoverViewport(): void { + if (this.restoreViewport(this.lastKnownViewport)) return; + if (!this.pz) return; + this.pz.fit(); + this.pz.center(); + this.applyMinInitialZoom(); + const recovered = this.captureViewportState(); + if (recovered) { + this.lastKnownViewport = recovered; + return; + } + const nodes = this.curStep >= 0 ? this.steps[this.curStep]?.nodes ?? [] : []; + if (nodes.length) autoZoom(this, nodes); + } + + private queueViewportSync(): void { + const targetSvg = this.getDiagramSvg(); + if (!targetSvg) return; + + if (this.viewportSyncRaf !== null && typeof cancelAnimationFrame === "function") { + cancelAnimationFrame(this.viewportSyncRaf); + } + + const schedule = typeof requestAnimationFrame === "function" + ? requestAnimationFrame + : ((cb: FrameRequestCallback) => { + cb(0); + return 0; + }); + + this.viewportSyncRaf = schedule(() => { + this.viewportSyncRaf = null; + const desiredState = this.captureViewportState() ?? this.lastKnownViewport; + if (!this.applyCanvasSize(targetSvg)) return; + this.pz?.resize(); + if (!this.restoreViewport(desiredState)) this.recoverViewport(); + }); + } + syncPanelToggleButton(): void { if (!this.panelToggleBtn) return; const shell = this.getStoryShell(); @@ -206,9 +277,7 @@ export class DiaScopeViewer { if (!storyShell) return; storyShell.classList.toggle("panel-collapsed"); this.syncPanelToggleButton(); - this.pz?.resize(); - this.pz?.fit(); - this.pz?.center(); + this.onResize?.(); } bindControls(): void { @@ -288,16 +357,14 @@ export class DiaScopeViewer { const targetSvg = this.getDiagramSvg(); if (!targetSvg) return; - const resize = () => { - if (!this.canvasWrap) return; - targetSvg.setAttribute("width", String(this.canvasWrap.clientWidth)); - targetSvg.setAttribute("height", String(this.canvasWrap.clientHeight)); - this.pz?.resize(); - }; - resize(); - this.onResize = () => { resize(); this.pz?.fit(); this.pz?.center(); }; + this.applyCanvasSize(targetSvg); + this.onResize = () => this.queueViewportSync(); window.addEventListener("resize", this.onResize); + const { onUpdatedCTM: userOnUpdatedCTM, ...panZoomOptions } = this.panZoomOptions as Record & { + onUpdatedCTM?: ((ctm: { a: number; e: number; f: number }) => void); + }; + this.pz = this.svgPanZoomImpl(targetSvg, { zoomEnabled: true, controlIconsEnabled: false, @@ -306,9 +373,21 @@ export class DiaScopeViewer { minZoom: this.panZoomMin, maxZoom: this.panZoomMax, zoomScaleSensitivity: 0.15, - ...this.panZoomOptions, + ...panZoomOptions, + onUpdatedCTM: (ctm: { a: number; e: number; f: number }) => { + if (Number.isFinite(ctm.a) && ctm.a > 0 && Number.isFinite(ctm.e) && Number.isFinite(ctm.f)) { + this.lastKnownViewport = { zoom: ctm.a, pan: { x: ctm.e, y: ctm.f } }; + } + userOnUpdatedCTM?.(ctm); + }, }); this.applyMinInitialZoom(); + this.lastKnownViewport = this.captureViewportState(); + + if (typeof ResizeObserver !== "undefined") { + this.canvasObserver = new ResizeObserver(() => this.onResize?.()); + this.canvasObserver.observe(this.canvasWrap); + } tagNodes(this); tagEdges(this); @@ -347,8 +426,8 @@ export class DiaScopeViewer { shell.classList.toggle("narrow", (e.contentRect.width) < this.narrowBreakpoint); const isNarrow = shell.classList.contains("narrow"); this.syncPanelToggleButton(); - // If layout mode changed, canvas height changed — re-sync SVG size and fit - if (wasNarrow !== isNarrow) this.onResize?.(); + // Layout mode changes alter the real canvas size; queue a re-sync if there is no canvas observer. + if (wasNarrow !== isNarrow && !this.canvasObserver) this.onResize?.(); }); this.narrowObserver.observe(shell); } @@ -378,11 +457,24 @@ export class DiaScopeViewer { btn.style.display = "flex"; btn.style.alignItems = "center"; btn.style.justifyContent = "center"; - btn.addEventListener("click", () => { + btn.addEventListener("click", async () => { if (this.doc.fullscreenElement) { void this.doc.exitFullscreen(); - } else { - void fullscreenTarget.requestFullscreen?.(); + return; + } + try { + if (fullscreenTarget.requestFullscreen) { + await fullscreenTarget.requestFullscreen(); + return; + } + } catch { + // Fallback handled below for embeds that block fullscreen. + } + const win = this.doc.defaultView; + if (win?.open) { + const url = new URL(win.location.href); + url.searchParams.delete("expandable"); + win.open(url.toString(), "_blank", "noopener,noreferrer"); } }); this.doc.addEventListener("fullscreenchange", () => { @@ -397,7 +489,9 @@ export class DiaScopeViewer { if (this.onCanvasClick && this.canvasWrap) this.canvasWrap.removeEventListener("click", this.onCanvasClick); if (this.onKeyDown) this.doc.removeEventListener("keydown", this.onKeyDown); if (this.zoomRaf) cancelAnimationFrame(this.zoomRaf); + if (this.viewportSyncRaf !== null && typeof cancelAnimationFrame === "function") cancelAnimationFrame(this.viewportSyncRaf); this.narrowObserver?.disconnect(); + this.canvasObserver?.disconnect(); this.pz?.destroy?.(); } } diff --git a/tests/viewer.test.ts b/tests/viewer.test.ts index 7f22ffb..fa940d3 100644 --- a/tests/viewer.test.ts +++ b/tests/viewer.test.ts @@ -4,11 +4,18 @@ import { DiaScopeViewer } from "../src/viewer/viewer.js"; class FakeElement { readonly style: Record = {}; readonly children: FakeElement[] = []; - readonly listeners = new Map void>>(); + readonly listeners = new Map void | Promise>>(); readonly attributes = new Map(); + readonly classList = { + add: vi.fn(), + remove: vi.fn(), + contains: vi.fn(() => false), + }; innerHTML = ""; title = ""; id = ""; + clientWidth = 0; + clientHeight = 0; requestFullscreen = vi.fn(() => Promise.resolve()); constructor(private readonly doc: FakeDocument, id = "") { @@ -33,21 +40,30 @@ class FakeElement { if (child.id) this.doc.register(child); } - addEventListener(type: string, listener: () => void): void { + addEventListener(type: string, listener: () => void | Promise): void { const current = this.listeners.get(type) ?? []; current.push(listener); this.listeners.set(type, current); } - click(): void { - for (const listener of this.listeners.get("click") ?? []) listener(); + async click(): Promise { + for (const listener of this.listeners.get("click") ?? []) await listener(); + } + + querySelectorAll(): FakeElement[] { + return []; } } class FakeDocument { readonly elements = new Map(); readonly listeners = new Map void>>(); + readonly body = new FakeElement(this, "body"); readonly documentElement = new FakeElement(this, "document-element"); + readonly defaultView = { + location: { href: "https://diascope.biolytics.ai/examples/vllm/deployment.html?expandable" }, + open: vi.fn(), + }; fullscreenElement: FakeElement | null = null; exitFullscreen = vi.fn(() => { this.fullscreenElement = null; @@ -69,10 +85,15 @@ class FakeDocument { } querySelector(selector: string): FakeElement | null { + if (selector === "#svg-host > svg") return this.getElementById("diagram-svg"); if (!selector.startsWith("#")) return null; return this.getElementById(selector.slice(1)); } + querySelectorAll(): FakeElement[] { + return []; + } + addEventListener(type: string, listener: () => void): void { const current = this.listeners.get(type) ?? []; current.push(listener); @@ -84,8 +105,47 @@ class FakeDocument { } } +function createViewerHarness() { + const doc = new FakeDocument(); + const canvasWrap = doc.register(new FakeElement(doc, "canvas-wrap")); + canvasWrap.clientWidth = 556; + canvasWrap.clientHeight = 291; + const svgHost = doc.register(new FakeElement(doc, "svg-host")); + const svg = doc.register(new FakeElement(doc, "diagram-svg")); + svgHost.appendChild(svg); + doc.register(new FakeElement(doc, "story-shell")); + + let zoom = 2; + let pan = { x: 40, y: 60 }; + + const pz = { + fit: vi.fn(), + center: vi.fn(), + resize: vi.fn(), + destroy: vi.fn(), + zoomIn: vi.fn(), + zoomOut: vi.fn(), + zoom: vi.fn((value: number) => { + zoom = value; + }), + pan: vi.fn((value: { x: number; y: number }) => { + pan = value; + }), + getZoom: vi.fn(() => zoom), + getPan: vi.fn(() => pan), + }; + + const viewer = new DiaScopeViewer({ + document: doc as unknown as Document, + svgPanZoom: (() => pz) as never, + steps: [], + }); + + return { doc, canvasWrap, svg, viewer, pz }; +} + describe("DiaScopeViewer expand button", () => { - it("fullscreens the viewer shell instead of the whole document when embedded", () => { + it("fullscreens the viewer shell instead of the whole document when embedded", async () => { const doc = new FakeDocument(); const canvasWrap = doc.register(new FakeElement(doc, "canvas-wrap")); const storyShell = doc.register(new FakeElement(doc, "story-shell")); @@ -111,10 +171,95 @@ describe("DiaScopeViewer expand button", () => { viewer.onResize = resizeSpy; viewer.setupExpandButton(); - doc.getElementById("btn-expand")?.click(); + await doc.getElementById("btn-expand")?.click(); expect(storyShell.requestFullscreen).toHaveBeenCalledTimes(1); expect(doc.documentElement.requestFullscreen).not.toHaveBeenCalled(); expect(resizeSpy).toHaveBeenCalledTimes(1); }); + + it("falls back to opening the standalone story when fullscreen is blocked", async () => { + const doc = new FakeDocument(); + const canvasWrap = doc.register(new FakeElement(doc, "canvas-wrap")); + const storyShell = doc.register(new FakeElement(doc, "story-shell")); + + storyShell.requestFullscreen.mockRejectedValue(new Error("Permission denied")); + + const viewer = new DiaScopeViewer({ + document: doc as unknown as Document, + svgPanZoom: (() => null) as never, + }); + viewer.canvasWrap = canvasWrap as unknown as HTMLElement; + + viewer.setupExpandButton(); + await doc.getElementById("btn-expand")?.click(); + + expect(doc.defaultView.open).toHaveBeenCalledWith( + "https://diascope.biolytics.ai/examples/vllm/deployment.html", + "_blank", + "noopener,noreferrer", + ); + }); +}); + +describe("DiaScopeViewer resize handling", () => { + it("preserves the current viewport state when the canvas size changes", () => { + const addEventListener = vi.fn(); + const removeEventListener = vi.fn(); + vi.stubGlobal("window", { addEventListener, removeEventListener }); + vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => { + cb(0); + return 1; + }); + vi.stubGlobal("cancelAnimationFrame", vi.fn()); + + const { canvasWrap, svg, viewer, pz } = createViewerHarness(); + + viewer.init(); + vi.clearAllMocks(); + + canvasWrap.clientWidth = 776; + canvasWrap.clientHeight = 560; + viewer.onResize?.(); + + expect(svg.getAttribute("width")).toBe("776"); + expect(svg.getAttribute("height")).toBe("560"); + expect(pz.resize).toHaveBeenCalledTimes(1); + expect(pz.zoom).toHaveBeenCalledWith(2); + expect(pz.pan).toHaveBeenCalledWith({ x: 40, y: 60 }); + expect(pz.fit).not.toHaveBeenCalled(); + expect(pz.center).not.toHaveBeenCalled(); + + vi.unstubAllGlobals(); + }); + + it("restores the last known good viewport when the current one is invalid", () => { + const addEventListener = vi.fn(); + const removeEventListener = vi.fn(); + vi.stubGlobal("window", { addEventListener, removeEventListener }); + vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => { + cb(0); + return 1; + }); + vi.stubGlobal("cancelAnimationFrame", vi.fn()); + + const { canvasWrap, viewer, pz } = createViewerHarness(); + + viewer.init(); + pz.zoom(0); + pz.pan({ x: 0, y: 0 }); + viewer["lastKnownViewport"] = { zoom: 1.5, pan: { x: 12, y: 24 } }; + vi.clearAllMocks(); + + canvasWrap.clientWidth = 776; + canvasWrap.clientHeight = 560; + viewer.onResize?.(); + + expect(pz.zoom).toHaveBeenCalledWith(1.5); + expect(pz.pan).toHaveBeenCalledWith({ x: 12, y: 24 }); + expect(pz.fit).not.toHaveBeenCalled(); + expect(pz.center).not.toHaveBeenCalled(); + + vi.unstubAllGlobals(); + }); });