fix: make presentation figures inspectable
This commit is contained in:
+6
-6
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user