fix: 首页「最近项目」缩略图与退出前视角不一致,出现偏移或裁切

- 封面使用真实画布尺寸(clientWidth/Height)及最终 viewport.x/y/scale。
- 320×240 封面改为居中 cover 裁切,不再从左上角取景。
- 正确绘制画布背景色。
This commit is contained in:
2026-07-24 14:27:54 +08:00
parent a2ee879fc8
commit 23e2d099ff
7 changed files with 184 additions and 39 deletions
@@ -1116,6 +1116,7 @@ export function ImageCanvasEditorView({
() => ({
layersRef,
viewportRef,
canvasViewportRef,
canvasGenerationDialogsRef,
canvasBackgroundColorRef,
}),
@@ -4,6 +4,7 @@ import type { CanvasLayer } from './ImageCanvasEditorTypes';
import {
buildProjectCoverSnapshotSignature,
resolveProjectCoverDrawableLayers,
resolveProjectCoverSnapshotFrame,
} from './ImageCanvasProjectCoverSnapshotModel';
function layer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
@@ -28,6 +29,8 @@ describe('ImageCanvasProjectCoverSnapshotModel', () => {
it('builds a stable cover signature from visible drawable layers', () => {
const signature = buildProjectCoverSnapshotSignature({
viewport: { x: 12, y: -8, scale: 0.75 },
viewportSize: { width: 900, height: 640 },
backgroundColor: '#f8fafc',
layers: [
layer({ id: 'hidden', hidden: true }),
layer({
@@ -49,12 +52,36 @@ describe('ImageCanvasProjectCoverSnapshotModel', () => {
});
expect(signature).toContain('"scale":0.75');
expect(signature).toContain('"width":900');
expect(signature).toContain('"backgroundColor":"#f8fafc"');
expect(signature).toContain('"id":"back"');
expect(signature).toContain('"id":"front"');
expect(signature).not.toContain('"id":"hidden"');
expect(signature).not.toContain('"id":"audio"');
});
it('center-crops the live canvas viewport to fill the 4:3 cover', () => {
const landscapeFrame = resolveProjectCoverSnapshotFrame({
viewport: { x: -260, y: 70, scale: 0.5 },
viewportSize: { width: 900, height: 640 },
});
expect(landscapeFrame.renderScale).toBe(0.5);
expect(landscapeFrame.frameScale).toBeCloseTo(0.375);
expect(landscapeFrame.offsetX).toBeCloseTo(-8.75);
expect(landscapeFrame.offsetY).toBeCloseTo(0);
const portraitFrame = resolveProjectCoverSnapshotFrame({
viewport: { x: 30, y: -40, scale: 1.25 },
viewportSize: { width: 640, height: 900 },
});
expect(portraitFrame.renderScale).toBe(1.25);
expect(portraitFrame.frameScale).toBeCloseTo(0.5);
expect(portraitFrame.offsetX).toBeCloseTo(0);
expect(portraitFrame.offsetY).toBeCloseTo(-105);
});
it('uses the first image-sequence frame as the drawable source', () => {
expect(
resolveProjectCoverDrawableLayers([
@@ -1,14 +1,19 @@
import { getSignedAssetReadUrl } from '../../services/assetReadUrlService';
import {
DEFAULT_CANVAS_BACKGROUND_COLOR,
normalizeCanvasBackgroundHex,
} from './ImageCanvasEditorModel';
import type { CanvasLayer, CanvasViewport } from './ImageCanvasEditorTypes';
export const PROJECT_COVER_SNAPSHOT_ASSET_KIND = 'project-cover-snapshot';
export const PROJECT_COVER_SNAPSHOT_OBJECT_ASSET_KIND =
'editor_project_cover_snapshot';
export const PROJECT_COVER_SNAPSHOT_SIZE = { width: 320, height: 240 } as const;
export const PROJECT_COVER_SNAPSHOT_VIEWPORT_SIZE = {
width: 900,
height: 640,
} as const;
export type ProjectCoverSnapshotViewportSize = {
width: number;
height: number;
};
type ProjectCoverDrawableLayer = {
id: string;
@@ -25,6 +30,32 @@ function numberOrFallback(value: number, fallback: number) {
return Number.isFinite(value) ? value : fallback;
}
function positiveNumberOrFallback(value: number, fallback: number) {
return Number.isFinite(value) && value > 0 ? value : fallback;
}
function normalizeProjectCoverViewportSize(
viewportSize: ProjectCoverSnapshotViewportSize,
) {
return {
width: positiveNumberOrFallback(
viewportSize.width,
PROJECT_COVER_SNAPSHOT_SIZE.width,
),
height: positiveNumberOrFallback(
viewportSize.height,
PROJECT_COVER_SNAPSHOT_SIZE.height,
),
};
}
function normalizeProjectCoverBackgroundColor(backgroundColor: string) {
return (
normalizeCanvasBackgroundHex(backgroundColor) ??
DEFAULT_CANVAS_BACKGROUND_COLOR
);
}
function normalizeObjectKey(value: string | null | undefined) {
const normalized = value?.trim().replace(/^\/+/u, '') ?? '';
return normalized || null;
@@ -83,51 +114,79 @@ export function resolveProjectCoverDrawableLayers(
export function buildProjectCoverSnapshotSignature({
layers,
viewport,
viewportSize,
backgroundColor,
}: {
layers: readonly CanvasLayer[];
// we may call it camera
viewport: CanvasViewport;
viewportSize: ProjectCoverSnapshotViewportSize;
backgroundColor: string;
}) {
const coverLayers = resolveProjectCoverDrawableLayers(layers);
if (coverLayers.length === 0) {
return null;
}
const normalizedViewportSize =
normalizeProjectCoverViewportSize(viewportSize);
return JSON.stringify({
viewport: {
x: numberOrFallback(viewport.x, 0),
y: numberOrFallback(viewport.y, 0),
scale: numberOrFallback(viewport.scale, 1),
},
viewportSize: normalizedViewportSize,
backgroundColor: normalizeProjectCoverBackgroundColor(backgroundColor),
layers: coverLayers,
});
}
export function resolveProjectCoverSnapshotFrame({
viewport,
viewportSize,
}: {
viewport: CanvasViewport;
viewportSize: ProjectCoverSnapshotViewportSize;
}) {
const normalizedViewportSize =
normalizeProjectCoverViewportSize(viewportSize);
const renderScale =
viewport.scale > 0 && Number.isFinite(viewport.scale) ? viewport.scale : 1;
const frameScale = Math.max(
PROJECT_COVER_SNAPSHOT_SIZE.width / normalizedViewportSize.width,
PROJECT_COVER_SNAPSHOT_SIZE.height / normalizedViewportSize.height,
);
return {
viewportX: numberOrFallback(viewport.x, 0),
viewportY: numberOrFallback(viewport.y, 0),
renderScale,
frameScale,
offsetX:
(PROJECT_COVER_SNAPSHOT_SIZE.width -
normalizedViewportSize.width * frameScale) /
2,
offsetY:
(PROJECT_COVER_SNAPSHOT_SIZE.height -
normalizedViewportSize.height * frameScale) /
2,
};
}
function resolveProjectCoverPlacement(
viewport: CanvasViewport,
viewportSize: ProjectCoverSnapshotViewportSize,
layer: ProjectCoverDrawableLayer,
) {
const safeScale =
viewport.scale > 0 && Number.isFinite(viewport.scale) ? viewport.scale : 1;
const viewportCenterX =
(PROJECT_COVER_SNAPSHOT_VIEWPORT_SIZE.width / 2 - viewport.x) / safeScale;
const viewportCenterY =
(PROJECT_COVER_SNAPSHOT_VIEWPORT_SIZE.height / 2 - viewport.y) / safeScale;
const worldPreviewWidth =
PROJECT_COVER_SNAPSHOT_VIEWPORT_SIZE.width / safeScale;
const worldPreviewHeight =
PROJECT_COVER_SNAPSHOT_VIEWPORT_SIZE.height / safeScale;
const previewMinX = viewportCenterX - worldPreviewWidth / 2;
const previewMinY = viewportCenterY - worldPreviewHeight / 2;
const scale = Math.min(
PROJECT_COVER_SNAPSHOT_SIZE.width / worldPreviewWidth,
PROJECT_COVER_SNAPSHOT_SIZE.height / worldPreviewHeight,
);
const offsetX = -previewMinX * scale;
const offsetY = -previewMinY * scale;
const frame = resolveProjectCoverSnapshotFrame({ viewport, viewportSize });
return {
x: offsetX + layer.x * scale,
y: offsetY + layer.y * scale,
width: layer.width * scale,
height: layer.height * scale,
x:
frame.offsetX +
(frame.viewportX + layer.x * frame.renderScale) * frame.frameScale,
y:
frame.offsetY +
(frame.viewportY + layer.y * frame.renderScale) * frame.frameScale,
width: layer.width * frame.renderScale * frame.frameScale,
height: layer.height * frame.renderScale * frame.frameScale,
};
}
@@ -153,9 +212,13 @@ function canvasToBlob(canvas: HTMLCanvasElement) {
export async function createProjectCoverSnapshotBlob({
layers,
viewport,
viewportSize,
backgroundColor,
}: {
layers: readonly CanvasLayer[];
viewport: CanvasViewport;
viewportSize: ProjectCoverSnapshotViewportSize;
backgroundColor: string;
}) {
if (typeof document === 'undefined') {
return null;
@@ -171,13 +234,18 @@ export async function createProjectCoverSnapshotBlob({
if (!context || typeof canvas.toBlob !== 'function') {
return null;
}
context.clearRect(0, 0, canvas.width, canvas.height);
context.fillStyle = normalizeProjectCoverBackgroundColor(backgroundColor);
context.fillRect(0, 0, canvas.width, canvas.height);
let drawnLayerCount = 0;
for (const layer of coverLayers) {
try {
const image = await loadProjectCoverImage(layer);
const placement = resolveProjectCoverPlacement(viewport, layer);
const placement = resolveProjectCoverPlacement(
viewport,
viewportSize,
layer,
);
context.drawImage(
image,
placement.x,
@@ -118,6 +118,7 @@ function ProjectPersistenceHarness({
const [projectRenameValue, setProjectRenameValue] = useState('');
const layersRef = useRef(layers);
const viewportRef = useRef(viewport);
const canvasViewportRef = useRef<HTMLDivElement | null>(null);
const canvasGenerationDialogsRef = useRef(generationDialogs);
const canvasBackgroundColorRef = useRef(canvasBackgroundColor);
const selectedLayerRef = useRef<string | null>(null);
@@ -146,6 +147,7 @@ function ProjectPersistenceHarness({
() => ({
layersRef,
viewportRef,
canvasViewportRef,
canvasGenerationDialogsRef,
canvasBackgroundColorRef,
}),
@@ -949,6 +951,15 @@ describe('useImageCanvasProjectPersistence', () => {
render(<ProjectPersistenceHarness />);
expect(await screen.findByText('editor-project-default')).toBeTruthy();
await waitFor(() => {
expect(createProjectCoverSnapshotBlobMock).toHaveBeenCalledWith(
expect.objectContaining({
viewport: { x: 12, y: -8, scale: 0.5 },
viewportSize: { width: 900, height: 640 },
backgroundColor: DEFAULT_CANVAS_BACKGROUND_COLOR,
}),
);
});
await waitFor(() => {
expect(putEditorProjectCoverCacheMock).toHaveBeenCalledWith(
expect.objectContaining({
@@ -1073,14 +1084,12 @@ describe('useImageCanvasProjectPersistence', () => {
});
it('does not serialize or write session cache while viewport interactions are active', async () => {
const sessionSetItemSpy = vi.spyOn(
Storage.prototype,
'setItem',
);
render(<ProjectPersistenceHarness />);
expect(await screen.findByText('editor-project-default')).toBeTruthy();
sessionSetItemSpy.mockClear();
const cachedBeforeInteraction = globalThis.sessionStorage.getItem(
EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY,
);
saveEditorProjectLayoutMock.mockClear();
vi.useFakeTimers();
@@ -1097,7 +1106,11 @@ describe('useImageCanvasProjectPersistence', () => {
});
expect(saveEditorProjectLayoutMock).not.toHaveBeenCalled();
expect(sessionSetItemSpy).not.toHaveBeenCalled();
expect(
globalThis.sessionStorage.getItem(
EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY,
),
).toBe(cachedBeforeInteraction);
act(() => {
screen.getByRole('button', { name: 'end viewport interaction' }).click();
@@ -1115,9 +1128,17 @@ describe('useImageCanvasProjectPersistence', () => {
viewport: { x: 20, y: 10, scale: 1 },
}),
);
expect(sessionSetItemSpy).toHaveBeenCalled();
const cachedAfterInteraction = JSON.parse(
globalThis.sessionStorage.getItem(
EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY,
) ?? '{}',
) as { project?: EditorProjectSnapshot };
expect(cachedAfterInteraction.project?.viewport).toEqual({
x: 20,
y: 10,
scale: 1,
});
vi.useRealTimers();
sessionSetItemSpy.mockRestore();
});
it('does not create a project resource again for backend-persisted generated layers', async () => {
@@ -22,6 +22,7 @@ import {
canvasDisplayViewportToViewport,
type CanvasLayerResourceMetadata,
DEFAULT_CANVAS_BACKGROUND_COLOR,
DEFAULT_CANVAS_SIZE,
hydrateLayer,
isInlineEditorMediaSource,
serializeCanvasLayout,
@@ -65,6 +66,7 @@ type CachedEditorProjectSnapshot = {
type ImageCanvasProjectPersistenceRefs = {
layersRef: RefObject<CanvasLayer[]>;
viewportRef: RefObject<CanvasViewport>;
canvasViewportRef: RefObject<HTMLDivElement | null>;
canvasGenerationDialogsRef: RefObject<CanvasGenerationDialogState[]>;
canvasBackgroundColorRef: RefObject<string>;
};
@@ -114,6 +116,21 @@ function isEditorProjectRevisionConflict(error: unknown) {
return error instanceof ApiClientError && error.status === 409;
}
function resolveProjectCoverViewportSize(
viewportElement: HTMLDivElement | null,
) {
const width = viewportElement?.clientWidth ?? 0;
const height = viewportElement?.clientHeight ?? 0;
return {
width:
width > 0 && Number.isFinite(width) ? width : DEFAULT_CANVAS_SIZE.width,
height:
height > 0 && Number.isFinite(height)
? height
: DEFAULT_CANVAS_SIZE.height,
};
}
const EDITOR_PROJECT_LAYOUT_RETRYABLE_STATUS_CODES = new Set([
408, 425, 429, 502, 503, 504,
]);
@@ -453,15 +470,23 @@ export function useImageCanvasProjectPersistence({
const persistProjectCoverSnapshot = useCallback(
(
nextProjectId: string,
coverViewport: CanvasViewport,
coverDisplayViewport: CanvasViewport,
coverLayers: readonly CanvasLayer[],
) => {
if (!canAccessProtectedData) {
return;
}
const coverViewport =
canvasDisplayViewportToViewport(coverDisplayViewport);
const viewportSize = resolveProjectCoverViewportSize(
refs.canvasViewportRef.current,
);
const backgroundColor = refs.canvasBackgroundColorRef.current;
const signature = buildProjectCoverSnapshotSignature({
layers: coverLayers,
viewport: coverViewport,
viewportSize,
backgroundColor,
});
if (!signature || coverSnapshotSignatureRef.current === signature) {
return;
@@ -473,6 +498,8 @@ export function useImageCanvasProjectPersistence({
void createProjectCoverSnapshotBlob({
layers: coverLayers,
viewport: coverViewport,
viewportSize,
backgroundColor,
})
.then(async (blob) => {
if (!blob) {
@@ -530,7 +557,7 @@ export function useImageCanvasProjectPersistence({
}
});
},
[canAccessProtectedData, openEditorLoginModal],
[canAccessProtectedData, openEditorLoginModal, refs],
);
const queueProjectLayoutSave = useCallback(