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 #### Editorial Canvas
The presentation renders on an adaptive editorial canvas that derives its aspect 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 ratio from the browser viewport. The canvas fills the available viewport while
720px and clamps the logical width between 960px (4:3) and 1280px (16:9), clamping its aspect ratio between 4:3 and 16:9. It intentionally avoids
scaling the result to fit the viewport with letterboxing. Viewports wider than `transform: scale(...)`: React Flow figures, popovers, and floating UI measure
~1.78:1 fill the maximum 1280x720 logical region; narrower viewports receive a DOM geometry, so the stage must expose real element positions instead of scaled
proportionally narrower canvas without reflowing scene content. No URL query coordinates. No URL query parameters or local-storage settings control the
parameters or local-storage settings control the ratio. ratio.
Scene 6 uses a recursive Interactive Figure with expand/collapse and breadcrumb Scene 6 uses a recursive Interactive Figure with expand/collapse and breadcrumb
navigation. 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 { afterEach, describe, expect, it } from "vitest";
import { PresentationCanvas } from "./PresentationCanvas.js"; import { PresentationCanvas } from "./PresentationCanvas.js";
const setViewport = (width: number, height: number) => { const editorialCss = readFileSync(
Object.defineProperty(window, "innerWidth", { configurable: true, value: width }); join(import.meta.dirname, "styles", "editorial.css"),
Object.defineProperty(window, "innerHeight", { configurable: true, value: height }); "utf8",
}; );
afterEach(() => cleanup()); afterEach(() => cleanup());
describe("PresentationCanvas", () => { describe("PresentationCanvas", () => {
it("fills a 16:9 viewport with the maximum logical width", () => { it("renders a normal DOM stage without transform scaling", () => {
setViewport(1280, 720);
render(<PresentationCanvas><div>Scene</div></PresentationCanvas>); 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"); const canvas = screen.getByTestId("presentation-canvas");
expect(canvas).toHaveStyle({ width: "960px", height: "720px" });
expect(canvas.style.left).toBe("0px"); expect(canvas).toHaveClass("presentation-canvas");
expect(canvas.style.top).toBe("0px"); expect(canvas.style.transform).toBe("");
expect(Number(canvas.style.transform.match(/scale\((.+)\)/)?.[1])).toBeCloseTo(1024 / 960); expect(canvas).toHaveTextContent("Scene");
}); });
it("uses an intermediate logical width instead of selecting a preset", () => { it("uses CSS ratio bounds instead of JavaScript scale calculations", () => {
setViewport(1200, 800); expect(editorialCss).toContain("width: min(100dvw, calc(100dvh * 16 / 9))");
render(<PresentationCanvas><div>Scene</div></PresentationCanvas>); expect(editorialCss).toContain("height: min(100dvh, calc(100dvw * 9 / 12))");
expect(screen.getByTestId("presentation-canvas")).toHaveStyle({ expect(editorialCss).not.toContain("transform: scale");
width: "1080px",
height: "720px",
});
}); });
}); });
@@ -1,42 +1,20 @@
import { useEffect, useState, type ReactNode } from "react"; import type { ReactNode } from "react";
import {
fitPresentationCanvas,
type ViewportSize,
} from "./canvas-fit.js";
type PresentationCanvasProps = { readonly children: ReactNode }; type PresentationCanvasProps = { readonly children: ReactNode };
const readViewport = (): ViewportSize => ({ export const PresentationCanvas = ({ children }: PresentationCanvasProps) => (
width: window.innerWidth, <div className="presentation-viewport">
height: window.innerHeight, {/*
}); Keep the presentation as normal responsive DOM instead of scaling the
whole stage with transform: scale(...). React Flow and future floating UI
// The canvas adapts continuously from a 4:3 to 16:9 logical ratio while measure DOM geometry; transformed ancestors make those measurements lie.
// preserving a fixed 720px height; scenes render inside this logical The 12:9-16:9 stage ratio is enforced by CSS on this element.
// coordinate system and the viewport scales proportionally. */}
export const PresentationCanvas = ({ children }: PresentationCanvasProps) => { <div
const [viewport, setViewport] = useState(readViewport); className="presentation-canvas"
useEffect(() => { data-testid="presentation-canvas"
const resize = () => setViewport(readViewport()); >
window.addEventListener("resize", resize); {children}
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>
</div> </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(); 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", () => { it("resets roving focus when focusPath changes", () => {
const { rerender } = render( const { rerender } = render(
<InteractiveFigure <InteractiveFigure
@@ -83,9 +83,8 @@ const FitViewOnLayoutChange = ({ layoutKey }: { layoutKey: string }) => {
const { fitView } = useReactFlow(); const { fitView } = useReactFlow();
useEffect(() => { useEffect(() => {
void fitView({ padding: 0.15, duration: 0 }); void fitView({ padding: 0.15, duration: 0 });
// React Flow can measure before the scaled presentation canvas and // React Flow can measure before the breadcrumb row and stage grid settle.
// breadcrumb row settle. Fit again on the next frame to align edges with // Fit again on the next frame to align edges with the final node boxes.
// the final node boxes without adding a larger measurement framework.
const frame = window.requestAnimationFrame(() => { const frame = window.requestAnimationFrame(() => {
void fitView({ padding: 0.15, duration: 0 }); void fitView({ padding: 0.15, duration: 0 });
}); });
@@ -111,6 +110,7 @@ const InteractiveFigureInner = ({
const initialFocusedNodeId = activeNodeId ?? focus.figure.nodes[0]?.id ?? ""; const initialFocusedNodeId = activeNodeId ?? focus.figure.nodes[0]?.id ?? "";
const [focusedNodeId, setFocusedNodeId] = useState(initialFocusedNodeId); const [focusedNodeId, setFocusedNodeId] = useState(initialFocusedNodeId);
const focusedNodeIdRef = useRef(initialFocusedNodeId); const focusedNodeIdRef = useRef(initialFocusedNodeId);
const graphInspectionEnabled = size === "stage" && focus.path.length > 0;
const fallbackFocusedNodeId = activeNodeId ?? focus.figure.nodes[0]?.id ?? ""; const fallbackFocusedNodeId = activeNodeId ?? focus.figure.nodes[0]?.id ?? "";
useEffect(() => { useEffect(() => {
@@ -230,6 +230,7 @@ const InteractiveFigureInner = ({
data-motion={motionDisabled ? "disabled" : "enabled"} data-motion={motionDisabled ? "disabled" : "enabled"}
data-figure-id={focus.figure.id} data-figure-id={focus.figure.id}
data-figure-size={size} data-figure-size={size}
data-pan-zoom={graphInspectionEnabled ? "enabled" : "disabled"}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
> >
<FigureBreadcrumbs <FigureBreadcrumbs
@@ -248,11 +249,13 @@ const InteractiveFigureInner = ({
nodesFocusable={false} nodesFocusable={false}
edgesFocusable={false} edgesFocusable={false}
elementsSelectable={false} elementsSelectable={false}
panOnDrag={false} minZoom={0.35}
zoomOnScroll={false} maxZoom={2.2}
zoomOnPinch={false} panOnDrag={graphInspectionEnabled}
zoomOnDoubleClick={false} zoomOnScroll={graphInspectionEnabled}
preventScrolling={false} zoomOnPinch={graphInspectionEnabled}
zoomOnDoubleClick={graphInspectionEnabled}
preventScrolling={graphInspectionEnabled}
onNodeClick={handleNodeClick} onNodeClick={handleNodeClick}
> >
<FitViewOnLayoutChange layoutKey={focus.figure.id} /> <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); 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; 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 { .interactive-figure .react-flow__node {
cursor: default; cursor: default;
} }
@@ -133,8 +146,10 @@
} }
.interactive-figure[data-figure-size="stage"] .interactive-figure__canvas { .interactive-figure[data-figure-size="stage"] .interactive-figure__canvas {
min-width: 1440px; position: relative;
min-height: 470px; min-width: 100%;
min-height: 0;
height: 100%;
} }
.interactive-figure[data-figure-size="stage"] .react-flow { .interactive-figure[data-figure-size="stage"] .react-flow {
@@ -23,4 +23,16 @@ describe("interactive-figure CSS", () => {
// Only the stage variant should have gradients. // Only the stage variant should have gradients.
expect(totalGradientSelectors).toBe(stageGradients?.length ?? 0); 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; flex-direction: column;
gap: 0.75rem; gap: 0.75rem;
padding: 1rem; 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 { .stage-caption {
@@ -18,12 +18,21 @@
position: fixed; position: fixed;
inset: 0; inset: 0;
overflow: hidden; overflow: hidden;
display: grid;
place-items: center;
background: oklch(0.13 0.01 65); background: oklch(0.13 0.01 65);
} }
.presentation-canvas { .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; overflow: hidden;
background: var(--color-editorial-paper); background: var(--color-editorial-paper);
color: var(--color-editorial-ink); color: var(--color-editorial-ink);