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
|
#### 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);
|
||||||
|
|||||||
Reference in New Issue
Block a user