mirror of
https://github.com/THU-MAIC/OpenMAIC.git
synced 2026-10-28 08:25:57 +08:00
fix(whiteboard): correct the inverted viewportRatio so boards render 16:9 landscape (#1257)
This commit is contained in:
9 files changed
+146
-5
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Reference in new issue
Block a user