fix: make presentation figures inspectable

This commit is contained in:
lda
2026-07-08 22:56:38 +07:00 Verified
parent 0216f8db4e
commit 7ddca743a3
12 changed files with 138 additions and 174 deletions
+6 -6
View File
@@ -232,12 +232,12 @@ committed `lda-report-success-v1` recording. No RPC server is required.
#### Editorial Canvas
The presentation renders on an adaptive editorial canvas that derives its aspect
ratio from the browser viewport. The canvas preserves a fixed logical height of
720px and clamps the logical width between 960px (4:3) and 1280px (16:9),
scaling the result to fit the viewport with letterboxing. Viewports wider than
~1.78:1 fill the maximum 1280x720 logical region; narrower viewports receive a
proportionally narrower canvas without reflowing scene content. No URL query
parameters or local-storage settings control the ratio.
ratio from the browser viewport. The canvas fills the available viewport while
clamping its aspect ratio between 4:3 and 16:9. It intentionally avoids
`transform: scale(...)`: React Flow figures, popovers, and floating UI measure
DOM geometry, so the stage must expose real element positions instead of scaled
coordinates. No URL query parameters or local-storage settings control the
ratio.
Scene 6 uses a recursive Interactive Figure with expand/collapse and breadcrumb
navigation.
@@ -1,45 +1,29 @@
import { act, cleanup, render, screen } from "@testing-library/react";
import { cleanup, render, screen } from "@testing-library/react";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { PresentationCanvas } from "./PresentationCanvas.js";
const setViewport = (width: number, height: number) => {
Object.defineProperty(window, "innerWidth", { configurable: true, value: width });
Object.defineProperty(window, "innerHeight", { configurable: true, value: height });
};
const editorialCss = readFileSync(
join(import.meta.dirname, "styles", "editorial.css"),
"utf8",
);
afterEach(() => cleanup());
describe("PresentationCanvas", () => {
it("fills a 16:9 viewport with the maximum logical width", () => {
setViewport(1280, 720);
it("renders a normal DOM stage without transform scaling", () => {
render(<PresentationCanvas><div>Scene</div></PresentationCanvas>);
expect(screen.getByTestId("presentation-canvas")).toHaveStyle({
width: "1280px",
height: "720px",
transform: "scale(1)",
left: "0px",
top: "0px",
});
});
it("recomputes the logical width after resizing to 4:3", () => {
setViewport(1280, 720);
render(<PresentationCanvas><div>Scene</div></PresentationCanvas>);
setViewport(1024, 768);
act(() => window.dispatchEvent(new Event("resize")));
const canvas = screen.getByTestId("presentation-canvas");
expect(canvas).toHaveStyle({ width: "960px", height: "720px" });
expect(canvas.style.left).toBe("0px");
expect(canvas.style.top).toBe("0px");
expect(Number(canvas.style.transform.match(/scale\((.+)\)/)?.[1])).toBeCloseTo(1024 / 960);
expect(canvas).toHaveClass("presentation-canvas");
expect(canvas.style.transform).toBe("");
expect(canvas).toHaveTextContent("Scene");
});
it("uses an intermediate logical width instead of selecting a preset", () => {
setViewport(1200, 800);
render(<PresentationCanvas><div>Scene</div></PresentationCanvas>);
expect(screen.getByTestId("presentation-canvas")).toHaveStyle({
width: "1080px",
height: "720px",
});
it("uses CSS ratio bounds instead of JavaScript scale calculations", () => {
expect(editorialCss).toContain("width: min(100dvw, calc(100dvh * 16 / 9))");
expect(editorialCss).toContain("height: min(100dvh, calc(100dvw * 9 / 12))");
expect(editorialCss).not.toContain("transform: scale");
});
});
@@ -1,42 +1,20 @@
import { useEffect, useState, type ReactNode } from "react";
import {
fitPresentationCanvas,
type ViewportSize,
} from "./canvas-fit.js";
import type { ReactNode } from "react";
type PresentationCanvasProps = { readonly children: ReactNode };
const readViewport = (): ViewportSize => ({
width: window.innerWidth,
height: window.innerHeight,
});
// The canvas adapts continuously from a 4:3 to 16:9 logical ratio while
// preserving a fixed 720px height; scenes render inside this logical
// coordinate system and the viewport scales proportionally.
export const PresentationCanvas = ({ children }: PresentationCanvasProps) => {
const [viewport, setViewport] = useState(readViewport);
useEffect(() => {
const resize = () => setViewport(readViewport());
window.addEventListener("resize", resize);
return () => window.removeEventListener("resize", resize);
}, []);
const fit = fitPresentationCanvas(viewport);
return (
<div className="presentation-viewport">
<div
className="presentation-canvas"
data-testid="presentation-canvas"
style={{
width: fit.logicalWidth,
height: fit.logicalHeight,
left: fit.offsetX,
top: fit.offsetY,
transform: `scale(${fit.scale})`,
}}
>
{children}
</div>
export const PresentationCanvas = ({ children }: PresentationCanvasProps) => (
<div className="presentation-viewport">
{/*
Keep the presentation as normal responsive DOM instead of scaling the
whole stage with transform: scale(...). React Flow and future floating UI
measure DOM geometry; transformed ancestors make those measurements lie.
The 12:9-16:9 stage ratio is enforced by CSS on this element.
*/}
<div
className="presentation-canvas"
data-testid="presentation-canvas"
>
{children}
</div>
);
};
</div>
);
@@ -1,35 +0,0 @@
import { describe, expect, it } from "vitest";
import { fitPresentationCanvas } from "./canvas-fit.js";
describe("fitPresentationCanvas", () => {
it.each([
[{ width: 1280, height: 720 }, 1280, 1, 0, 0],
[{ width: 1024, height: 768 }, 960, 1024 / 960, 0, 0],
[{ width: 1200, height: 800 }, 1080, 1200 / 1080, 0, 0],
[{ width: 800, height: 800 }, 960, 800 / 960, 0, 100],
[{ width: 1920, height: 720 }, 1280, 1, 320, 0],
])("fits %o into the supported logical ratio range", (
viewport,
logicalWidth,
scale,
offsetX,
offsetY,
) => {
const fit = fitPresentationCanvas(viewport);
expect(fit.logicalWidth).toBe(logicalWidth);
expect(fit.logicalHeight).toBe(720);
expect(fit.scale).toBeCloseTo(scale);
expect(fit.offsetX).toBeCloseTo(offsetX);
expect(fit.offsetY).toBeCloseTo(offsetY);
});
it("returns the default logical size with zero scale before viewport measurement", () => {
expect(fitPresentationCanvas({ width: 0, height: 0 })).toEqual({
logicalWidth: 1280,
logicalHeight: 720,
scale: 0,
offsetX: 0,
offsetY: 0,
});
});
});
@@ -1,49 +0,0 @@
export const PRESENTATION_MIN_WIDTH = 960;
export const PRESENTATION_MAX_WIDTH = 1280;
export const PRESENTATION_HEIGHT = 720;
export type ViewportSize = {
readonly width: number;
readonly height: number;
};
export type CanvasFit = {
readonly logicalWidth: number;
readonly logicalHeight: number;
readonly scale: number;
readonly offsetX: number;
readonly offsetY: number;
};
const clamp = (value: number, minimum: number, maximum: number): number =>
Math.min(maximum, Math.max(minimum, value));
export const fitPresentationCanvas = (viewport: ViewportSize): CanvasFit => {
if (viewport.width <= 0 || viewport.height <= 0) {
return {
logicalWidth: PRESENTATION_MAX_WIDTH,
logicalHeight: PRESENTATION_HEIGHT,
scale: 0,
offsetX: 0,
offsetY: 0,
};
}
// Width follows the viewport ratio only inside the reviewed 4:3-16:9 range.
const logicalWidth = clamp(
PRESENTATION_HEIGHT * (viewport.width / viewport.height),
PRESENTATION_MIN_WIDTH,
PRESENTATION_MAX_WIDTH,
);
const scale = Math.min(
viewport.width / logicalWidth,
viewport.height / PRESENTATION_HEIGHT,
);
return {
logicalWidth,
logicalHeight: PRESENTATION_HEIGHT,
scale,
offsetX: (viewport.width - logicalWidth * scale) / 2,
offsetY: (viewport.height - PRESENTATION_HEIGHT * scale) / 2,
};
};
@@ -186,6 +186,24 @@ describe("InteractiveFigure", () => {
expect(figure.querySelector(".interactive-figure__canvas")).toBeInTheDocument();
});
it("keeps root stage figures in presentation mode", () => {
renderFigure({ focusPath: [], size: "stage" });
expect(screen.getByRole("group", { name: /architecture/i })).toHaveAttribute(
"data-pan-zoom",
"disabled",
);
});
it("enables pan and zoom inspection for focused stage figures", () => {
renderFigure({ focusPath: ["runtime"], size: "stage" });
expect(screen.getByRole("group", { name: /runtime detail/i })).toHaveAttribute(
"data-pan-zoom",
"enabled",
);
});
it("resets roving focus when focusPath changes", () => {
const { rerender } = render(
<InteractiveFigure
@@ -83,9 +83,8 @@ const FitViewOnLayoutChange = ({ layoutKey }: { layoutKey: string }) => {
const { fitView } = useReactFlow();
useEffect(() => {
void fitView({ padding: 0.15, duration: 0 });
// React Flow can measure before the scaled presentation canvas and
// breadcrumb row settle. Fit again on the next frame to align edges with
// the final node boxes without adding a larger measurement framework.
// React Flow can measure before the breadcrumb row and stage grid settle.
// Fit again on the next frame to align edges with the final node boxes.
const frame = window.requestAnimationFrame(() => {
void fitView({ padding: 0.15, duration: 0 });
});
@@ -111,6 +110,7 @@ const InteractiveFigureInner = ({
const initialFocusedNodeId = activeNodeId ?? focus.figure.nodes[0]?.id ?? "";
const [focusedNodeId, setFocusedNodeId] = useState(initialFocusedNodeId);
const focusedNodeIdRef = useRef(initialFocusedNodeId);
const graphInspectionEnabled = size === "stage" && focus.path.length > 0;
const fallbackFocusedNodeId = activeNodeId ?? focus.figure.nodes[0]?.id ?? "";
useEffect(() => {
@@ -230,6 +230,7 @@ const InteractiveFigureInner = ({
data-motion={motionDisabled ? "disabled" : "enabled"}
data-figure-id={focus.figure.id}
data-figure-size={size}
data-pan-zoom={graphInspectionEnabled ? "enabled" : "disabled"}
onKeyDown={handleKeyDown}
>
<FigureBreadcrumbs
@@ -248,11 +249,13 @@ const InteractiveFigureInner = ({
nodesFocusable={false}
edgesFocusable={false}
elementsSelectable={false}
panOnDrag={false}
zoomOnScroll={false}
zoomOnPinch={false}
zoomOnDoubleClick={false}
preventScrolling={false}
minZoom={0.35}
maxZoom={2.2}
panOnDrag={graphInspectionEnabled}
zoomOnScroll={graphInspectionEnabled}
zoomOnPinch={graphInspectionEnabled}
zoomOnDoubleClick={graphInspectionEnabled}
preventScrolling={graphInspectionEnabled}
onNodeClick={handleNodeClick}
>
<FitViewOnLayoutChange layoutKey={focus.figure.id} />
@@ -32,10 +32,23 @@
background: color-mix(in oklch, var(--color-editorial-surface, oklch(0.96 0.012 82)) 92%, white);
}
.interactive-figure .react-flow__pane {
/*
Presentation overview figures should not steal normal slide navigation
gestures. Focused stage figures opt into React Flow inspection, where the pane
needs pointer events for drag-pan and wheel zoom.
*/
.interactive-figure[data-pan-zoom="disabled"] .react-flow__pane {
pointer-events: none;
}
.interactive-figure[data-pan-zoom="enabled"] .react-flow__pane {
cursor: grab;
}
.interactive-figure[data-pan-zoom="enabled"] .react-flow__pane:active {
cursor: grabbing;
}
.interactive-figure .react-flow__node {
cursor: default;
}
@@ -133,8 +146,10 @@
}
.interactive-figure[data-figure-size="stage"] .interactive-figure__canvas {
min-width: 1440px;
min-height: 470px;
position: relative;
min-width: 100%;
min-height: 0;
height: 100%;
}
.interactive-figure[data-figure-size="stage"] .react-flow {
@@ -23,4 +23,16 @@ describe("interactive-figure CSS", () => {
// Only the stage variant should have gradients.
expect(totalGradientSelectors).toBe(stageGradients?.length ?? 0);
});
it("does not force stage figures wider than the visible canvas", () => {
expect(css).not.toContain("min-width: 1440px");
expect(css).toContain('data-figure-size="stage"] .interactive-figure__canvas');
expect(css).toContain("min-width: 100%");
});
it("lets stage figures shrink to the available scene height", () => {
expect(css).not.toContain("min-height: 470px");
expect(css).toContain("min-height: 0");
expect(css).toContain("height: 100%");
});
});
@@ -0,0 +1,18 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const css = readFileSync(join(import.meta.dirname, "presentation.css"), "utf8");
describe("presentation.css", () => {
it("allows hidden primary-region scrolling for browser zoom overflow", () => {
const primaryBlock = css.match(
/\.presentation-stage__primary\s*\{\n position: relative;(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
expect(primaryBlock).toContain("overflow: auto");
expect(primaryBlock).toContain("scrollbar-width: none");
expect(css).toContain(".presentation-stage__primary::-webkit-scrollbar");
expect(css).toContain("display: none");
});
});
@@ -62,7 +62,18 @@
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
overflow: hidden;
/*
Browser zoom can make otherwise valid slide compositions taller than the
stage. Keep the presentation chrome clean, but allow the presenter to scroll
hidden overflow instead of losing content.
*/
overflow: auto;
overscroll-behavior: contain;
scrollbar-width: none;
}
.presentation-stage__primary::-webkit-scrollbar {
display: none;
}
.stage-caption {
@@ -18,12 +18,21 @@
position: fixed;
inset: 0;
overflow: hidden;
display: grid;
place-items: center;
background: oklch(0.13 0.01 65);
}
.presentation-canvas {
position: absolute;
transform-origin: top left;
/*
The stage fills the viewport while clamping its aspect ratio to the reviewed
12:9-16:9 range. Avoid transform-based scaling here: React Flow, popovers,
and floating chat need real DOM geometry, not scaled getBoundingClientRect()
values.
*/
position: relative;
width: min(100dvw, calc(100dvh * 16 / 9));
height: min(100dvh, calc(100dvw * 9 / 12));
overflow: hidden;
background: var(--color-editorial-paper);
color: var(--color-editorial-ink);