fix: 首页「最近项目」缩略图与退出前视角不一致,出现偏移或裁切
- 封面使用真实画布尺寸(clientWidth/Height)及最终 viewport.x/y/scale。 - 320×240 封面改为居中 cover 裁切,不再从左上角取景。 - 正确绘制画布背景色。
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user