feat: adapt presentation canvas ratio
This commit is contained in:
@@ -1,6 +1,5 @@
|
|||||||
import { act, cleanup, render, screen } from "@testing-library/react";
|
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { PRESENTATION_HEIGHT, PRESENTATION_WIDTH } from "./canvas-fit.js";
|
|
||||||
import { PresentationCanvas } from "./PresentationCanvas.js";
|
import { PresentationCanvas } from "./PresentationCanvas.js";
|
||||||
|
|
||||||
const setViewport = (width: number, height: number) => {
|
const setViewport = (width: number, height: number) => {
|
||||||
@@ -11,28 +10,36 @@ const setViewport = (width: number, height: number) => {
|
|||||||
afterEach(() => cleanup());
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
describe("PresentationCanvas", () => {
|
describe("PresentationCanvas", () => {
|
||||||
it("renders one fixed 1280x720 audience canvas", () => {
|
it("fills a 16:9 viewport with the maximum logical width", () => {
|
||||||
setViewport(1280, 720);
|
setViewport(1280, 720);
|
||||||
render(<PresentationCanvas><div>Scene</div></PresentationCanvas>);
|
render(<PresentationCanvas><div>Scene</div></PresentationCanvas>);
|
||||||
const canvas = screen.getByTestId("presentation-canvas");
|
expect(screen.getByTestId("presentation-canvas")).toHaveStyle({
|
||||||
expect(canvas).toHaveStyle({
|
width: "1280px",
|
||||||
width: `${PRESENTATION_WIDTH}px`,
|
height: "720px",
|
||||||
height: `${PRESENTATION_HEIGHT}px`,
|
|
||||||
transform: "scale(1)",
|
transform: "scale(1)",
|
||||||
left: "0px",
|
left: "0px",
|
||||||
top: "0px",
|
top: "0px",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("recomputes letterboxing after viewport resize", () => {
|
it("recomputes the logical width after resizing to 4:3", () => {
|
||||||
setViewport(1280, 720);
|
setViewport(1280, 720);
|
||||||
render(<PresentationCanvas><div>Scene</div></PresentationCanvas>);
|
render(<PresentationCanvas><div>Scene</div></PresentationCanvas>);
|
||||||
setViewport(1024, 768);
|
setViewport(1024, 768);
|
||||||
act(() => window.dispatchEvent(new Event("resize")));
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
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({
|
expect(screen.getByTestId("presentation-canvas")).toHaveStyle({
|
||||||
transform: "scale(0.8)",
|
width: "1080px",
|
||||||
left: "0px",
|
height: "720px",
|
||||||
top: "96px",
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { useEffect, useState, type ReactNode } from "react";
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
fitPresentationCanvas,
|
fitPresentationCanvas,
|
||||||
PRESENTATION_HEIGHT,
|
|
||||||
PRESENTATION_WIDTH,
|
|
||||||
type ViewportSize,
|
type ViewportSize,
|
||||||
} from "./canvas-fit.js";
|
} from "./canvas-fit.js";
|
||||||
|
|
||||||
@@ -13,8 +11,9 @@ const readViewport = (): ViewportSize => ({
|
|||||||
height: window.innerHeight,
|
height: window.innerHeight,
|
||||||
});
|
});
|
||||||
|
|
||||||
// The fixed canvas deliberately prevents slide reflow; scenes always render
|
// The canvas adapts continuously from a 4:3 to 16:9 logical ratio while
|
||||||
// inside a 1280×720 coordinate system and the viewport scales proportionally.
|
// preserving a fixed 720px height; scenes render inside this logical
|
||||||
|
// coordinate system and the viewport scales proportionally.
|
||||||
export const PresentationCanvas = ({ children }: PresentationCanvasProps) => {
|
export const PresentationCanvas = ({ children }: PresentationCanvasProps) => {
|
||||||
const [viewport, setViewport] = useState(readViewport);
|
const [viewport, setViewport] = useState(readViewport);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -29,8 +28,8 @@ export const PresentationCanvas = ({ children }: PresentationCanvasProps) => {
|
|||||||
className="presentation-canvas"
|
className="presentation-canvas"
|
||||||
data-testid="presentation-canvas"
|
data-testid="presentation-canvas"
|
||||||
style={{
|
style={{
|
||||||
width: PRESENTATION_WIDTH,
|
width: fit.logicalWidth,
|
||||||
height: PRESENTATION_HEIGHT,
|
height: fit.logicalHeight,
|
||||||
left: fit.offsetX,
|
left: fit.offsetX,
|
||||||
top: fit.offsetY,
|
top: fit.offsetY,
|
||||||
transform: `scale(${fit.scale})`,
|
transform: `scale(${fit.scale})`,
|
||||||
|
|||||||
@@ -3,16 +3,30 @@ import { fitPresentationCanvas } from "./canvas-fit.js";
|
|||||||
|
|
||||||
describe("fitPresentationCanvas", () => {
|
describe("fitPresentationCanvas", () => {
|
||||||
it.each([
|
it.each([
|
||||||
[{ width: 1280, height: 720 }, { scale: 1, offsetX: 0, offsetY: 0 }],
|
[{ width: 1280, height: 720 }, 1280, 1, 0, 0],
|
||||||
[{ width: 1920, height: 1080 }, { scale: 1.5, offsetX: 0, offsetY: 0 }],
|
[{ width: 1024, height: 768 }, 960, 1024 / 960, 0, 0],
|
||||||
[{ width: 1024, height: 768 }, { scale: 0.8, offsetX: 0, offsetY: 96 }],
|
[{ width: 1200, height: 800 }, 1080, 1200 / 1080, 0, 0],
|
||||||
[{ width: 1600, height: 900 }, { scale: 1.25, offsetX: 0, offsetY: 0 }],
|
[{ width: 800, height: 800 }, 960, 800 / 960, 0, 100],
|
||||||
])("fits %o without reflow", (viewport, expected) => {
|
[{ width: 1920, height: 720 }, 1280, 1, 320, 0],
|
||||||
expect(fitPresentationCanvas(viewport)).toEqual(expected);
|
])("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 a bounded zero fit for an unavailable viewport", () => {
|
it("returns the default logical size with zero scale before viewport measurement", () => {
|
||||||
expect(fitPresentationCanvas({ width: 0, height: 0 })).toEqual({
|
expect(fitPresentationCanvas({ width: 0, height: 0 })).toEqual({
|
||||||
|
logicalWidth: 1280,
|
||||||
|
logicalHeight: 720,
|
||||||
scale: 0,
|
scale: 0,
|
||||||
offsetX: 0,
|
offsetX: 0,
|
||||||
offsetY: 0,
|
offsetY: 0,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const PRESENTATION_WIDTH = 1280;
|
export const PRESENTATION_MIN_WIDTH = 960;
|
||||||
|
export const PRESENTATION_MAX_WIDTH = 1280;
|
||||||
export const PRESENTATION_HEIGHT = 720;
|
export const PRESENTATION_HEIGHT = 720;
|
||||||
|
|
||||||
export type ViewportSize = {
|
export type ViewportSize = {
|
||||||
@@ -7,22 +8,42 @@ export type ViewportSize = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type CanvasFit = {
|
export type CanvasFit = {
|
||||||
|
readonly logicalWidth: number;
|
||||||
|
readonly logicalHeight: number;
|
||||||
readonly scale: number;
|
readonly scale: number;
|
||||||
readonly offsetX: number;
|
readonly offsetX: number;
|
||||||
readonly offsetY: 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 => {
|
export const fitPresentationCanvas = (viewport: ViewportSize): CanvasFit => {
|
||||||
if (viewport.width <= 0 || viewport.height <= 0) {
|
if (viewport.width <= 0 || viewport.height <= 0) {
|
||||||
return { scale: 0, offsetX: 0, offsetY: 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(
|
const scale = Math.min(
|
||||||
viewport.width / PRESENTATION_WIDTH,
|
viewport.width / logicalWidth,
|
||||||
viewport.height / PRESENTATION_HEIGHT,
|
viewport.height / PRESENTATION_HEIGHT,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
|
logicalWidth,
|
||||||
|
logicalHeight: PRESENTATION_HEIGHT,
|
||||||
scale,
|
scale,
|
||||||
offsetX: (viewport.width - PRESENTATION_WIDTH * scale) / 2,
|
offsetX: (viewport.width - logicalWidth * scale) / 2,
|
||||||
offsetY: (viewport.height - PRESENTATION_HEIGHT * scale) / 2,
|
offsetY: (viewport.height - PRESENTATION_HEIGHT * scale) / 2,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user