fix(whiteboard): correct the inverted viewportRatio so boards render 16:9 landscape (#1257)

This commit is contained in:
wyuc authored and GitHub committed 2026-08-27 20:39:26 +08:00
1 parent 04621578de
commit 67584fe9e8
9 files changed
+146 -5

No files matched your search

+6 -1
View File
@@ -13,6 +13,7 @@ import {
import { motion, AnimatePresence } from 'motion/react';
import { useCanvasStore } from '@/lib/store/canvas';
import { ScreenElement } from '@/components/slide-renderer/Editor/ScreenElement';
import { normalizeWhiteboardViewportRatio } from '@/lib/whiteboard/viewport';
import type { PPTElement, Whiteboard } from '@openmaic/dsl';
import { useI18n } from '@/lib/hooks/use-i18n';
@@ -400,7 +401,11 @@ export const WhiteboardCanvas = forwardRef<WhiteboardCanvasHandle, WhiteboardCan
const elements = useMemo(() => rawElements ?? [], [rawElements]);
const canvasWidth = whiteboard?.viewportSize ?? 1000;
const canvasHeight = canvasWidth * (whiteboard?.viewportRatio ?? 0.5625);
// viewportRatio is height/width; normalize into the plausible band so an
// inverted persisted ratio (16:9 written as width/height) can never render
// a sheet taller than it is wide, even if it bypassed the runtime repair.
const canvasHeight =
canvasWidth * normalizeWhiteboardViewportRatio(whiteboard?.viewportRatio ?? 0.5625);
const containerScale = useMemo(() => {
if (containerSize.width === 0 || containerSize.height === 0) return 1;
+2 -1
View File
@@ -29,7 +29,8 @@ export function createWhiteboardAPI(store: StageStore) {
const whiteboard: Whiteboard = {
id: generateId('whiteboard'),
viewportSize: 1000,
viewportRatio: 16 / 9,
// viewportRatio is height/width, so a 16:9 landscape sheet is 9/16.
viewportRatio: 9 / 16,
elements: [],
background: {
type: 'solid',
+9 -1
View File
@@ -5,6 +5,8 @@ import {
type Whiteboard,
} from '@openmaic/dsl';
import { normalizeWhiteboardViewportRatio } from '@/lib/whiteboard/viewport';
import {
WhiteboardRuntimeCodeLineIdConflictError,
WhiteboardRuntimeCodeLineNotFoundError,
@@ -54,7 +56,13 @@ export async function applyWhiteboardRuntimeOperation(
const snapshot = immutableClone(operation);
if (snapshot.kind === 'legacy_snapshot_imported') {
if (current !== null) throw new Error('WHITEBOARD_RUNTIME_IMPORT_AFTER_STATE');
return immutableClone(snapshot.whiteboard);
const whiteboard = snapshot.whiteboard;
// viewportRatio is height/width; repair an inverted persisted value
// (> 1, i.e. 16:9 written as width/height) so the board projects landscape.
return immutableClone({
...whiteboard,
viewportRatio: normalizeWhiteboardViewportRatio(whiteboard.viewportRatio),
});
}
if (snapshot.kind === 'element_added') {
+12 -2
View File
@@ -2,6 +2,7 @@ import { normalizeElement, type CodeLine, type PPTElement, type Whiteboard } fro
import sceneSchemaJson from '@openmaic/dsl/schema/scene.schema.json';
import type { RuntimePayloadValidator } from '@openmaic/storage';
import { normalizeWhiteboardViewportRatio } from '@/lib/whiteboard/viewport';
import {
LEGACY_WHITEBOARD_SOURCE_KIND,
WHITEBOARD_RUNTIME_PAYLOAD_VERSION,
@@ -391,6 +392,10 @@ export function normalizeAndValidateLegacyWhiteboard(value: unknown): Whiteboard
if (typeof board.viewportRatio !== 'number' || !Number.isFinite(board.viewportRatio)) {
throw new Error('invalid whiteboard viewportRatio');
}
// viewportRatio is height/width; normalize an inverted persisted value
// (> 1, i.e. 16:9 written as width/height) into the plausible band so an
// inverted ratio cannot be committed again.
const viewportRatio = normalizeWhiteboardViewportRatio(board.viewportRatio);
if (!Array.isArray(board.elements)) throw new Error('invalid whiteboard elements');
if (Object.hasOwn(board, 'background')) {
const error = validateSchema(
@@ -418,7 +423,7 @@ export function normalizeAndValidateLegacyWhiteboard(value: unknown): Whiteboard
ids.add(normalized.id);
return normalized;
});
return cloneCanonicalJson({ ...board, elements } as Whiteboard);
return cloneCanonicalJson({ ...board, elements, viewportRatio } as Whiteboard);
}
export function validateWhiteboardRuntimePayload(
@@ -451,7 +456,12 @@ export function validateWhiteboardRuntimePayload(
throw new Error('source fingerprint is invalid');
}
const normalized = normalizeAndValidateLegacyWhiteboard(operation.whiteboard);
if (canonicalJson(normalized) !== canonicalJson(operation.whiteboard)) {
// A persisted payload may carry the un-normalized inverted viewportRatio
// (height/width written as width/height) that the fold repairs on read;
// compare against the ratio-normalized expectation so such records replay.
const whiteboard = operation.whiteboard as Record<string, unknown>;
const expected = { ...whiteboard, viewportRatio: normalized.viewportRatio };
if (canonicalJson(normalized) !== canonicalJson(expected)) {
throw new Error('whiteboard payload is not canonical');
}
} else if (operation.kind === 'element_added') {
+26
View File
@@ -0,0 +1,26 @@
/**
* Whiteboard viewport geometry.
*
* `viewportRatio` is the sheet's height/width: the canonical 16:9 landscape
* board (1000 x 562.5) is `9 / 16` (0.5625), never `16 / 9`. Every writer in
* the repo emits height/width, so a persisted value > 1 is an inverted
* (width/height) 16:9 ratio written by the old stage API and must be repaired
* wherever the data is consumed.
*/
/** Plausible height/width band for a whiteboard sheet: landscape to square. */
export const MIN_WHITEBOARD_VIEWPORT_RATIO = 0.4;
export const MAX_WHITEBOARD_VIEWPORT_RATIO = 1;
/**
* Normalize a height/width viewport ratio into the plausible band:
* an inverted value (> 1, i.e. width/height such as 16/9) is reciprocated,
* and an implausibly small value is clamped up. Non-finite persisted values
* fall back to the canonical 16:9 ratio. Shared by the runtime importers,
* validators, and the canvas consumption site.
*/
export function normalizeWhiteboardViewportRatio(ratio: number): number {
if (!Number.isFinite(ratio)) return 9 / 16;
const repaired = ratio > 1 ? 1 / ratio : ratio;
return Math.min(Math.max(repaired, MIN_WHITEBOARD_VIEWPORT_RATIO), MAX_WHITEBOARD_VIEWPORT_RATIO);
}
@@ -133,6 +133,27 @@ describe('dormant Legacy whiteboard import seam', () => {
expect((await service.read('stage-1')).whiteboard?.id).toBe('legacy-board');
});
it('imports an inverted legacy viewportRatio as 9/16 (height/width)', async () => {
const { service } = harness();
const run = createLegacyWhiteboardImporterForTests({
service,
provenanceEligible: () => true,
loadDocument: async () =>
document([
{
id: 'legacy-board',
viewportSize: 1000,
viewportRatio: 16 / 9,
elements: [],
},
]),
});
await expect(run('stage-1')).resolves.toMatchObject({ status: 'imported' });
// The old stage API wrote the 16:9 landscape as width/height; the import
// must project the sheet at 9/16 so it renders 1000 x 562.5, not portrait.
expect((await service.read('stage-1')).whiteboard?.viewportRatio).toBe(9 / 16);
});
it('reports exact replay when commit response loss is followed by one failed recovery read', async () => {
const backing = new BrowserRuntimeStore({
indexedDB: new IDBFactory(),
@@ -513,6 +513,16 @@ describe('whiteboard RuntimeStore payload contract', () => {
});
});
it('normalizes an inverted legacy viewportRatio into the plausible band', () => {
const normalized = normalizeAndValidateLegacyWhiteboard(board({ viewportRatio: 16 / 9 }));
// viewportRatio is height/width: the 16:9 landscape board must be 9/16.
expect(normalized.viewportRatio).toBe(9 / 16);
// height = width * ratio stays below width for the 1000px sheet.
expect(normalized.viewportSize * normalized.viewportRatio).toBeLessThan(
normalized.viewportSize,
);
});
it('accepts generated-schema tuple constraints for a normalized shape', () => {
const normalized = normalizeAndValidateLegacyWhiteboard(
board({
@@ -642,6 +652,25 @@ describe('whiteboard RuntimeStore fold', () => {
expect(Object.isFrozen(extended.whiteboard?.elements)).toBe(true);
});
it('repairs an inverted legacy viewportRatio when folding an import snapshot', async () => {
const result = await foldWhiteboardRuntimeRecords('session-1', [
record(
0,
payload({
operation: {
...payload().operation,
whiteboard: board({ viewportRatio: 16 / 9 }),
},
}),
),
]);
expect(result.whiteboard?.viewportRatio).toBe(9 / 16);
// The projected 1000px sheet must stay landscape (not taller than wide).
expect(result.whiteboard!.viewportSize * result.whiteboard!.viewportRatio).toBeLessThan(
result.whiteboard!.viewportSize,
);
});
it('preserves a Legacy board for learner adds and rejects invalid ordering or duplicates', async () => {
const legacyThenAdd = await foldWhiteboardRuntimeRecords('session-1', [
record(0, payload()),
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import {
MAX_WHITEBOARD_VIEWPORT_RATIO,
MIN_WHITEBOARD_VIEWPORT_RATIO,
normalizeWhiteboardViewportRatio,
} from '@/lib/whiteboard/viewport';
describe('whiteboard viewport ratio (height/width)', () => {
it('normalizes an inverted 16:9 ratio so the sheet is never taller than wide', () => {
// Canvas math: canvasHeight = canvasWidth * ratio. An inverted persisted
// ratio (16:9 written as width/height) must never yield a portrait sheet.
const ratio = normalizeWhiteboardViewportRatio(16 / 9);
expect(ratio).toBe(9 / 16);
expect(ratio).toBeLessThan(1);
expect(1000 * ratio).toBeLessThan(1000);
});
it('keeps the canonical 9/16 landscape ratio unchanged', () => {
expect(normalizeWhiteboardViewportRatio(9 / 16)).toBe(9 / 16);
});
it('clamps implausible ratios into the [0.4, 1] landscape band', () => {
expect(normalizeWhiteboardViewportRatio(0.1)).toBe(MIN_WHITEBOARD_VIEWPORT_RATIO);
expect(normalizeWhiteboardViewportRatio(1)).toBe(MAX_WHITEBOARD_VIEWPORT_RATIO);
});
it('falls back to the canonical 16:9 ratio for non-finite persisted values', () => {
expect(normalizeWhiteboardViewportRatio(Number.NaN)).toBe(9 / 16);
expect(normalizeWhiteboardViewportRatio(Number.POSITIVE_INFINITY)).toBe(9 / 16);
});
});
@@ -88,6 +88,15 @@ describe('Stage API persistence injection', () => {
expect(incrementalSave.mock.calls[2]![1]).toEqual([{ kind: 'structure' }]);
});
it('creates a 16:9 landscape whiteboard (viewportRatio is height/width)', () => {
const api = createStageAPI(useStageStore);
const result = api.whiteboard.create();
expect(result.success).toBe(true);
// The 1000px-wide sheet must render 1000 x 562.5, never 1000 x 1778.
expect(result.data?.viewportRatio).toBe(9 / 16);
expect(result.data?.viewportRatio).toBeLessThan(1);
});
it('routes every raw-setState Stage API module through the guarded store', () => {
const apiDir = path.join(process.cwd(), 'lib/api');
const modules = [