拆分图片画布历史与持久化协调器
新增画布历史 hook 承接撤销重做快照逻辑 新增项目持久化 hook 承接加载资源创建与自动保存时序 补充 hook 单测并更新图片画布拆分跟踪文档
This commit is contained in:
@@ -17,7 +17,6 @@ import { resolveEditorImageReferenceDataUrl } from '../../services/image-editor/
|
||||
import {
|
||||
createEditorAsset,
|
||||
createEditorAssetFolder,
|
||||
createEditorProjectResource,
|
||||
deleteEditorAsset,
|
||||
deleteEditorAssetFolder,
|
||||
editEditorImage,
|
||||
@@ -28,10 +27,7 @@ import {
|
||||
generateEditorIconSpritesheet,
|
||||
generateEditorImage,
|
||||
loadEditorAssetLibrary,
|
||||
loadEditorProject,
|
||||
loadOrCreateRecentEditorProject,
|
||||
renameEditorProject,
|
||||
saveEditorProjectLayout,
|
||||
updateEditorAsset,
|
||||
updateEditorAssetFolder,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
@@ -80,7 +76,6 @@ import {
|
||||
DEFAULT_CANVAS_BACKGROUND_COLOR,
|
||||
DEFAULT_CANVAS_SIZE,
|
||||
EDITOR_ASSET_FOLDERS,
|
||||
MAX_HISTORY_STEPS,
|
||||
TOOLBAR_HALF_WIDTH,
|
||||
clamp,
|
||||
createLayerFromAsset,
|
||||
@@ -88,7 +83,6 @@ import {
|
||||
formatImageSizeValue,
|
||||
getDraggedAssetId,
|
||||
hasDataTransferType,
|
||||
hydrateLayer,
|
||||
isGeneratedLayer,
|
||||
isLayerLinkedToAsset,
|
||||
normalizeAssetLibrary,
|
||||
@@ -151,7 +145,6 @@ import type {
|
||||
CanvasContextMenuState,
|
||||
CanvasGenerationDialogState,
|
||||
CanvasGenerationInputs,
|
||||
CanvasHistorySnapshot,
|
||||
CanvasLayer,
|
||||
CanvasMarqueeState,
|
||||
CanvasTool,
|
||||
@@ -169,6 +162,8 @@ import type {
|
||||
SpecGenerationType,
|
||||
UploadTarget,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { useCanvasHistory } from './useCanvasHistory';
|
||||
import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence';
|
||||
|
||||
function isImageFile(file: File) {
|
||||
return file.type.startsWith('image/');
|
||||
@@ -250,28 +245,15 @@ export function ImageCanvasEditorView() {
|
||||
const isShiftPressedRef = useRef(false);
|
||||
const layerCounterRef = useRef(0);
|
||||
const generationDialogCounterRef = useRef(0);
|
||||
const saveTimerRef = useRef<number | null>(null);
|
||||
const undoStackRef = useRef<CanvasHistorySnapshot[]>([]);
|
||||
const redoStackRef = useRef<CanvasHistorySnapshot[]>([]);
|
||||
const layersRef = useRef<CanvasLayer[]>([]);
|
||||
const viewportRef = useRef<CanvasViewport>({
|
||||
x: -260,
|
||||
y: 70,
|
||||
scale: 0.82,
|
||||
});
|
||||
const projectIdRef = useRef<string | null>(null);
|
||||
const specToolWrapRef = useRef<HTMLSpanElement | null>(null);
|
||||
const characterSpecButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const iconSpecButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const pendingProjectResourceLayersRef = useRef<
|
||||
Array<{
|
||||
layer: CanvasLayer;
|
||||
options: {
|
||||
onCreated?: (resourceId: string) => void;
|
||||
snapshotLayers?: CanvasLayer[];
|
||||
};
|
||||
}>
|
||||
>([]);
|
||||
const selectedLayerIdRef = useRef<string | null>(null);
|
||||
const selectedLayerIdsRef = useRef<string[]>([]);
|
||||
const generateDialogRef = useRef<GenerateDialogState | null>(null);
|
||||
@@ -280,7 +262,6 @@ export function ImageCanvasEditorView() {
|
||||
() => {},
|
||||
);
|
||||
const suppressAssetClickRef = useRef(false);
|
||||
const [projectId, setProjectId] = useState<string | null>(null);
|
||||
const [projectTitle, setProjectTitle] = useState('未命名画布');
|
||||
const [projectRenameValue, setProjectRenameValue] = useState('未命名画布');
|
||||
const [isRenamingProject, setIsRenamingProject] = useState(false);
|
||||
@@ -288,7 +269,6 @@ export function ImageCanvasEditorView() {
|
||||
const [projectRenameError, setProjectRenameError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [isProjectReady, setIsProjectReady] = useState(false);
|
||||
const [assetExportStatus, setAssetExportStatus] = useState<{
|
||||
tone: 'info' | 'success' | 'error';
|
||||
message: string;
|
||||
@@ -375,7 +355,6 @@ export function ImageCanvasEditorView() {
|
||||
);
|
||||
const [canvasClipboard, setCanvasClipboard] =
|
||||
useState<CanvasClipboard | null>(null);
|
||||
const [historyVersion, setHistoryVersion] = useState(0);
|
||||
const [quickEditPanel, setQuickEditPanel] =
|
||||
useState<QuickEditPanelState | null>(null);
|
||||
const [characterAnimationPanel, setCharacterAnimationPanel] =
|
||||
@@ -568,9 +547,55 @@ export function ImageCanvasEditorView() {
|
||||
const contextShouldUnlockLayer = contextTargetLayers.some(
|
||||
(layer) => layer.locked,
|
||||
);
|
||||
const canUndo = undoStackRef.current.length > 0;
|
||||
const canRedo = redoStackRef.current.length > 0;
|
||||
void historyVersion;
|
||||
const canvasHistoryRefs = useMemo(
|
||||
() => ({
|
||||
layersRef,
|
||||
viewportRef,
|
||||
generateDialogRef,
|
||||
inactiveGenerateDialogsRef,
|
||||
selectedLayerIdRef,
|
||||
selectedLayerIdsRef,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const canvasHistorySetters = useMemo(
|
||||
() => ({
|
||||
setLayers,
|
||||
setViewport,
|
||||
setGenerateDialog,
|
||||
setInactiveGenerateDialogs,
|
||||
setSelectedLayerId,
|
||||
setSelectedLayerIds,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const clearHistoryDragState = useCallback(() => {
|
||||
dragStateRef.current = null;
|
||||
}, []);
|
||||
const canvasHistoryResetters = useMemo(
|
||||
() => ({
|
||||
setHoveredLayerId,
|
||||
setMetadataLayer,
|
||||
setCanvasMarquee,
|
||||
setSnapGuide,
|
||||
setImageContextMenu,
|
||||
setContextMenu,
|
||||
setIsPanning,
|
||||
clearDragState: clearHistoryDragState,
|
||||
}),
|
||||
[clearHistoryDragState],
|
||||
);
|
||||
const {
|
||||
canUndo,
|
||||
canRedo,
|
||||
captureCanvasHistory,
|
||||
undoCanvasChange,
|
||||
redoCanvasChange,
|
||||
} = useCanvasHistory({
|
||||
refs: canvasHistoryRefs,
|
||||
setters: canvasHistorySetters,
|
||||
resetters: canvasHistoryResetters,
|
||||
});
|
||||
const groupedAssets = useMemo(
|
||||
() =>
|
||||
assetFolders.map((folder) => ({
|
||||
@@ -688,110 +713,6 @@ export function ImageCanvasEditorView() {
|
||||
);
|
||||
};
|
||||
|
||||
const getCanvasHistorySnapshot = useCallback(
|
||||
(): CanvasHistorySnapshot => ({
|
||||
layers: layersRef.current.map((layer) => ({ ...layer })),
|
||||
viewport: { ...viewportRef.current },
|
||||
generateDialog: generateDialogRef.current
|
||||
? {
|
||||
...generateDialogRef.current,
|
||||
placeholder: generateDialogRef.current.placeholder
|
||||
? { ...generateDialogRef.current.placeholder }
|
||||
: undefined,
|
||||
}
|
||||
: null,
|
||||
inactiveGenerateDialogs: inactiveGenerateDialogsRef.current.map(
|
||||
(dialog) => ({
|
||||
...dialog,
|
||||
placeholder: dialog.placeholder
|
||||
? { ...dialog.placeholder }
|
||||
: undefined,
|
||||
}),
|
||||
),
|
||||
selectedLayerId: selectedLayerIdRef.current,
|
||||
selectedLayerIds: [...selectedLayerIdsRef.current],
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const restoreCanvasHistorySnapshot = useCallback(
|
||||
(snapshot: CanvasHistorySnapshot) => {
|
||||
setLayers(snapshot.layers.map((layer) => ({ ...layer })));
|
||||
setViewport({ ...snapshot.viewport });
|
||||
setGenerateDialog(
|
||||
snapshot.generateDialog
|
||||
? {
|
||||
...snapshot.generateDialog,
|
||||
placeholder: snapshot.generateDialog.placeholder
|
||||
? { ...snapshot.generateDialog.placeholder }
|
||||
: undefined,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
setInactiveGenerateDialogs(
|
||||
snapshot.inactiveGenerateDialogs.map((dialog) => ({
|
||||
...dialog,
|
||||
placeholder: dialog.placeholder
|
||||
? { ...dialog.placeholder }
|
||||
: undefined,
|
||||
})),
|
||||
);
|
||||
setSelectedLayerId(snapshot.selectedLayerId);
|
||||
setSelectedLayerIds([...snapshot.selectedLayerIds]);
|
||||
setHoveredLayerId(null);
|
||||
setMetadataLayer(null);
|
||||
setCanvasMarquee(null);
|
||||
setSnapGuide(null);
|
||||
setImageContextMenu(null);
|
||||
setContextMenu(null);
|
||||
setIsPanning(false);
|
||||
dragStateRef.current = null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const captureCanvasHistory = useCallback(
|
||||
(options: { clearRedo?: boolean } = {}) => {
|
||||
undoStackRef.current = [
|
||||
...undoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)),
|
||||
getCanvasHistorySnapshot(),
|
||||
];
|
||||
if (options.clearRedo !== false) {
|
||||
redoStackRef.current = [];
|
||||
}
|
||||
setHistoryVersion((version) => version + 1);
|
||||
},
|
||||
[getCanvasHistorySnapshot],
|
||||
);
|
||||
|
||||
const undoCanvasChange = useCallback(() => {
|
||||
const previousSnapshot = undoStackRef.current.at(-1);
|
||||
if (!previousSnapshot) {
|
||||
return;
|
||||
}
|
||||
undoStackRef.current = undoStackRef.current.slice(0, -1);
|
||||
redoStackRef.current = [
|
||||
...redoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)),
|
||||
getCanvasHistorySnapshot(),
|
||||
];
|
||||
restoreCanvasHistorySnapshot(previousSnapshot);
|
||||
setHistoryVersion((version) => version + 1);
|
||||
}, [getCanvasHistorySnapshot, restoreCanvasHistorySnapshot]);
|
||||
|
||||
const redoCanvasChange = useCallback(() => {
|
||||
const nextSnapshot = redoStackRef.current.at(-1);
|
||||
if (!nextSnapshot) {
|
||||
return;
|
||||
}
|
||||
redoStackRef.current = redoStackRef.current.slice(0, -1);
|
||||
undoStackRef.current = [
|
||||
...undoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)),
|
||||
getCanvasHistorySnapshot(),
|
||||
];
|
||||
restoreCanvasHistorySnapshot(nextSnapshot);
|
||||
setHistoryVersion((version) => version + 1);
|
||||
}, [getCanvasHistorySnapshot, restoreCanvasHistorySnapshot]);
|
||||
|
||||
const selectSingleLayer = useCallback((layerId: string | null) => {
|
||||
setSelectedLayerId(layerId);
|
||||
setSelectedLayerIds(layerId ? [layerId] : []);
|
||||
@@ -809,6 +730,34 @@ export function ImageCanvasEditorView() {
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
const projectPersistenceRefs = useMemo(
|
||||
() => ({
|
||||
layersRef,
|
||||
viewportRef,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const projectPersistenceSetters = useMemo(
|
||||
() => ({
|
||||
setProjectTitle,
|
||||
setProjectRenameValue,
|
||||
setViewport,
|
||||
setLayers,
|
||||
selectSingleLayer,
|
||||
setLayerCounter: (value: number) => {
|
||||
layerCounterRef.current = value;
|
||||
},
|
||||
}),
|
||||
[selectSingleLayer],
|
||||
);
|
||||
const { projectId, appendCanvasLayersWithResources } =
|
||||
useImageCanvasProjectPersistence({
|
||||
refs: projectPersistenceRefs,
|
||||
setters: projectPersistenceSetters,
|
||||
layers,
|
||||
viewport,
|
||||
openEditorLoginModal,
|
||||
});
|
||||
|
||||
const hideGeneratedLayerPanelAfterBlur = useCallback(() => {
|
||||
setGenerateDialog((currentDialog) =>
|
||||
@@ -856,144 +805,11 @@ export function ImageCanvasEditorView() {
|
||||
[],
|
||||
);
|
||||
|
||||
const createProjectResourceForLayer = useCallback(
|
||||
(
|
||||
layer: CanvasLayer,
|
||||
options: {
|
||||
onCreated?: (resourceId: string) => void;
|
||||
snapshotLayers?: CanvasLayer[];
|
||||
} = {},
|
||||
) => {
|
||||
const readyProjectId = projectIdRef.current;
|
||||
if (!readyProjectId) {
|
||||
pendingProjectResourceLayersRef.current.push({ layer, options });
|
||||
return;
|
||||
}
|
||||
createEditorProjectResource(readyProjectId, {
|
||||
imageSrc: layer.src,
|
||||
objectKey: layer.objectKey,
|
||||
assetObjectId: layer.assetObjectId,
|
||||
width: layer.originalWidth,
|
||||
height: layer.originalHeight,
|
||||
sourceType: layer.sourceType,
|
||||
prompt: layer.prompt,
|
||||
actualPrompt: layer.actualPrompt,
|
||||
model: layer.model,
|
||||
provider: layer.provider,
|
||||
taskId: layer.taskId,
|
||||
sourceResourceId: layer.sourceResourceId,
|
||||
})
|
||||
.then((resource) => {
|
||||
options.onCreated?.(resource.resourceId);
|
||||
const layerWithResourceId = {
|
||||
...layer,
|
||||
resourceId: resource.resourceId,
|
||||
};
|
||||
const currentLayers = layersRef.current;
|
||||
const nextLayers = currentLayers.some(
|
||||
(currentLayer) => currentLayer.id === layer.id,
|
||||
)
|
||||
? currentLayers.map((currentLayer) =>
|
||||
currentLayer.id === layer.id
|
||||
? layerWithResourceId
|
||||
: currentLayer,
|
||||
)
|
||||
: options.snapshotLayers?.some(
|
||||
(snapshotLayer) => snapshotLayer.id === layer.id,
|
||||
)
|
||||
? options.snapshotLayers.map((snapshotLayer) =>
|
||||
snapshotLayer.id === layer.id
|
||||
? layerWithResourceId
|
||||
: snapshotLayer,
|
||||
)
|
||||
: currentLayers;
|
||||
layersRef.current = nextLayers;
|
||||
setLayers(nextLayers);
|
||||
if (nextLayers.length) {
|
||||
void saveEditorProjectLayout(readyProjectId, {
|
||||
viewport: viewportRef.current,
|
||||
layers: nextLayers.map(serializeLayer),
|
||||
}).catch((error: unknown) => {
|
||||
if (isEditorAuthError(error)) {
|
||||
openEditorLoginModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (isEditorAuthError(error)) {
|
||||
openEditorLoginModal();
|
||||
}
|
||||
});
|
||||
},
|
||||
[openEditorLoginModal],
|
||||
);
|
||||
|
||||
const minimapModel = useMemo(
|
||||
() => createMinimapModel({ layers, viewport, canvasSize }),
|
||||
[canvasSize, layers, viewport],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const projectIdFromQuery =
|
||||
typeof window === 'undefined'
|
||||
? null
|
||||
: new URLSearchParams(window.location.search)
|
||||
.get('projectid')
|
||||
?.trim() || null;
|
||||
const loadProject = projectIdFromQuery
|
||||
? loadEditorProject(projectIdFromQuery)
|
||||
: loadOrCreateRecentEditorProject();
|
||||
|
||||
loadProject
|
||||
.then((project) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
projectIdRef.current = project.projectId;
|
||||
setProjectId(project.projectId);
|
||||
const nextProjectTitle = project.title?.trim() || '未命名画布';
|
||||
setProjectTitle(nextProjectTitle);
|
||||
setProjectRenameValue(nextProjectTitle);
|
||||
const pendingLayers = pendingProjectResourceLayersRef.current.splice(0);
|
||||
pendingLayers.forEach(({ layer, options }) => {
|
||||
createProjectResourceForLayer(layer, options);
|
||||
});
|
||||
setViewport(project.viewport);
|
||||
const resourcesById = new Map(
|
||||
project.resources.map((resource) => [
|
||||
resource.resourceId,
|
||||
{ imageSrc: resource.imageSrc },
|
||||
]),
|
||||
);
|
||||
const hydratedLayers = project.layers
|
||||
.map((layer) => hydrateLayer(layer, resourcesById))
|
||||
.filter((layer): layer is CanvasLayer => Boolean(layer));
|
||||
if (hydratedLayers.length > 0) {
|
||||
layerCounterRef.current = hydratedLayers.length;
|
||||
setLayers(hydratedLayers);
|
||||
selectSingleLayer(hydratedLayers[0]?.id ?? null);
|
||||
}
|
||||
setIsProjectReady(true);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setIsProjectReady(false);
|
||||
if (isEditorAuthError(error)) {
|
||||
openEditorLoginModal(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [createProjectResourceForLayer, openEditorLoginModal, selectSingleLayer]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
loadEditorAssetLibrary()
|
||||
@@ -1245,32 +1061,6 @@ export function ImageCanvasEditorView() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId || !isProjectReady) {
|
||||
return undefined;
|
||||
}
|
||||
if (saveTimerRef.current) {
|
||||
window.clearTimeout(saveTimerRef.current);
|
||||
}
|
||||
|
||||
saveTimerRef.current = window.setTimeout(() => {
|
||||
saveEditorProjectLayout(projectId, {
|
||||
viewport,
|
||||
layers: layers.map(serializeLayer),
|
||||
}).catch((error: unknown) => {
|
||||
if (isEditorAuthError(error)) {
|
||||
openEditorLoginModal();
|
||||
}
|
||||
});
|
||||
}, 450);
|
||||
|
||||
return () => {
|
||||
if (saveTimerRef.current) {
|
||||
window.clearTimeout(saveTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [isProjectReady, layers, openEditorLoginModal, projectId, viewport]);
|
||||
|
||||
const fitLayers = useCallback(
|
||||
(targetLayers: CanvasLayer[] = layers) => {
|
||||
const nextViewport = fitViewportToLayers({
|
||||
@@ -1575,21 +1365,6 @@ export function ImageCanvasEditorView() {
|
||||
);
|
||||
};
|
||||
|
||||
const appendCanvasLayersWithResources = useCallback(
|
||||
(nextLayers: CanvasLayer[]) => {
|
||||
if (!nextLayers.length) {
|
||||
return;
|
||||
}
|
||||
const snapshotLayers = [...layersRef.current, ...nextLayers];
|
||||
layersRef.current = snapshotLayers;
|
||||
setLayers(snapshotLayers);
|
||||
nextLayers.forEach((layer) =>
|
||||
createProjectResourceForLayer(layer, { snapshotLayers }),
|
||||
);
|
||||
},
|
||||
[createProjectResourceForLayer],
|
||||
);
|
||||
|
||||
const addAssetLayer = (
|
||||
asset: EditorAsset,
|
||||
position?: { x: number; y: number },
|
||||
|
||||
218
src/components/image-editor/useCanvasHistory.test.tsx
Normal file
218
src/components/image-editor/useCanvasHistory.test.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
CanvasGenerationDialogState,
|
||||
CanvasLayer,
|
||||
CanvasViewport,
|
||||
GenerateDialogState,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { useCanvasHistory } from './useCanvasHistory';
|
||||
|
||||
function createLayer(id: string, x: number): CanvasLayer {
|
||||
return {
|
||||
id,
|
||||
resourceId: `resource-${id}`,
|
||||
title: id,
|
||||
src: `data:image/png;base64,${id}`,
|
||||
x,
|
||||
y: 20,
|
||||
width: 100,
|
||||
height: 80,
|
||||
originalWidth: 100,
|
||||
originalHeight: 80,
|
||||
zIndex: 1,
|
||||
sourceType: 'uploaded',
|
||||
};
|
||||
}
|
||||
|
||||
function HistoryHarness({ onClearDrag }: { onClearDrag: () => void }) {
|
||||
const [layers, setLayers] = useState<CanvasLayer[]>([
|
||||
createLayer('first', 10),
|
||||
]);
|
||||
const [viewport, setViewport] = useState<CanvasViewport>({
|
||||
x: 1,
|
||||
y: 2,
|
||||
scale: 1,
|
||||
});
|
||||
const [generateDialog, setGenerateDialog] =
|
||||
useState<GenerateDialogState | null>({
|
||||
id: 'dialog-active',
|
||||
mode: 'generate',
|
||||
prompt: 'active prompt',
|
||||
status: 'idle',
|
||||
placeholder: {
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 320,
|
||||
height: 240,
|
||||
originalWidth: 320,
|
||||
originalHeight: 240,
|
||||
},
|
||||
});
|
||||
const [inactiveGenerateDialogs, setInactiveGenerateDialogs] = useState<
|
||||
CanvasGenerationDialogState[]
|
||||
>([
|
||||
{
|
||||
id: 'dialog-inactive',
|
||||
mode: 'character',
|
||||
prompt: 'archived prompt',
|
||||
status: 'idle',
|
||||
placeholder: {
|
||||
x: 30,
|
||||
y: 40,
|
||||
width: 512,
|
||||
height: 512,
|
||||
originalWidth: 512,
|
||||
originalHeight: 512,
|
||||
},
|
||||
},
|
||||
]);
|
||||
const [selectedLayerId, setSelectedLayerId] = useState<string | null>('first');
|
||||
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>(['first']);
|
||||
|
||||
const layersRef = useRef(layers);
|
||||
const viewportRef = useRef(viewport);
|
||||
const generateDialogRef = useRef(generateDialog);
|
||||
const inactiveGenerateDialogsRef = useRef(inactiveGenerateDialogs);
|
||||
const selectedLayerIdRef = useRef(selectedLayerId);
|
||||
const selectedLayerIdsRef = useRef(selectedLayerIds);
|
||||
|
||||
layersRef.current = layers;
|
||||
viewportRef.current = viewport;
|
||||
generateDialogRef.current = generateDialog;
|
||||
inactiveGenerateDialogsRef.current = inactiveGenerateDialogs;
|
||||
selectedLayerIdRef.current = selectedLayerId;
|
||||
selectedLayerIdsRef.current = selectedLayerIds;
|
||||
|
||||
const history = useCanvasHistory({
|
||||
refs: {
|
||||
layersRef,
|
||||
viewportRef,
|
||||
generateDialogRef,
|
||||
inactiveGenerateDialogsRef,
|
||||
selectedLayerIdRef,
|
||||
selectedLayerIdsRef,
|
||||
},
|
||||
setters: {
|
||||
setLayers,
|
||||
setViewport,
|
||||
setGenerateDialog,
|
||||
setInactiveGenerateDialogs,
|
||||
setSelectedLayerId,
|
||||
setSelectedLayerIds,
|
||||
},
|
||||
resetters: {
|
||||
setHoveredLayerId: () => {},
|
||||
setMetadataLayer: () => {},
|
||||
setCanvasMarquee: () => {},
|
||||
setSnapGuide: () => {},
|
||||
setImageContextMenu: () => {},
|
||||
setContextMenu: () => {},
|
||||
setIsPanning: () => {},
|
||||
clearDragState: onClearDrag,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="layers">
|
||||
{layers.map((layer) => `${layer.id}:${layer.x}`).join(',')}
|
||||
</span>
|
||||
<span data-testid="viewport">
|
||||
{viewport.x},{viewport.y},{viewport.scale}
|
||||
</span>
|
||||
<span data-testid="dialog">{generateDialog?.prompt ?? '-'}</span>
|
||||
<span data-testid="inactive">
|
||||
{inactiveGenerateDialogs.map((dialog) => dialog.prompt).join(',')}
|
||||
</span>
|
||||
<span data-testid="selection">{selectedLayerIds.join(',')}</span>
|
||||
<span data-testid="can-undo">{String(history.canUndo)}</span>
|
||||
<span data-testid="can-redo">{String(history.canRedo)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
history.captureCanvasHistory();
|
||||
}}
|
||||
>
|
||||
capture
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLayers([createLayer('second', 90)]);
|
||||
setViewport({ x: 9, y: 8, scale: 2 });
|
||||
setGenerateDialog({
|
||||
id: 'dialog-next',
|
||||
mode: 'spec',
|
||||
prompt: 'next prompt',
|
||||
status: 'idle',
|
||||
});
|
||||
setInactiveGenerateDialogs([]);
|
||||
setSelectedLayerId('second');
|
||||
setSelectedLayerIds(['second']);
|
||||
}}
|
||||
>
|
||||
mutate
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
history.undoCanvasChange();
|
||||
}}
|
||||
>
|
||||
undo
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
history.redoCanvasChange();
|
||||
}}
|
||||
>
|
||||
redo
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('useCanvasHistory', () => {
|
||||
it('captures, restores, and replays canvas history snapshots', () => {
|
||||
const clearDragState = vi.fn();
|
||||
render(<HistoryHarness onClearDrag={clearDragState} />);
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'capture' }).click();
|
||||
});
|
||||
expect(screen.getByTestId('can-undo').textContent).toBe('true');
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'mutate' }).click();
|
||||
});
|
||||
expect(screen.getByTestId('layers').textContent).toBe('second:90');
|
||||
expect(screen.getByTestId('viewport').textContent).toBe('9,8,2');
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'undo' }).click();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('layers').textContent).toBe('first:10');
|
||||
expect(screen.getByTestId('viewport').textContent).toBe('1,2,1');
|
||||
expect(screen.getByTestId('dialog').textContent).toBe('active prompt');
|
||||
expect(screen.getByTestId('inactive').textContent).toBe('archived prompt');
|
||||
expect(screen.getByTestId('selection').textContent).toBe('first');
|
||||
expect(screen.getByTestId('can-redo').textContent).toBe('true');
|
||||
expect(clearDragState).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'redo' }).click();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('layers').textContent).toBe('second:90');
|
||||
expect(screen.getByTestId('viewport').textContent).toBe('9,8,2');
|
||||
expect(screen.getByTestId('dialog').textContent).toBe('next prompt');
|
||||
expect(screen.getByTestId('selection').textContent).toBe('second');
|
||||
});
|
||||
});
|
||||
169
src/components/image-editor/useCanvasHistory.ts
Normal file
169
src/components/image-editor/useCanvasHistory.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { type RefObject, useCallback, useRef, useState } from 'react';
|
||||
|
||||
import { MAX_HISTORY_STEPS } from './ImageCanvasEditorModel';
|
||||
import type {
|
||||
CanvasGenerationDialogState,
|
||||
CanvasHistorySnapshot,
|
||||
CanvasLayer,
|
||||
CanvasMarqueeState,
|
||||
CanvasContextMenuState,
|
||||
CanvasViewport,
|
||||
GenerateDialogState,
|
||||
ImageContextMenuState,
|
||||
SnapGuide,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
|
||||
type CanvasHistoryRefs = {
|
||||
layersRef: RefObject<CanvasLayer[]>;
|
||||
viewportRef: RefObject<CanvasViewport>;
|
||||
generateDialogRef: RefObject<GenerateDialogState | null>;
|
||||
inactiveGenerateDialogsRef: RefObject<CanvasGenerationDialogState[]>;
|
||||
selectedLayerIdRef: RefObject<string | null>;
|
||||
selectedLayerIdsRef: RefObject<string[]>;
|
||||
};
|
||||
|
||||
type CanvasHistorySetters = {
|
||||
setLayers: (layers: CanvasLayer[]) => void;
|
||||
setViewport: (viewport: CanvasViewport) => void;
|
||||
setGenerateDialog: (dialog: GenerateDialogState | null) => void;
|
||||
setInactiveGenerateDialogs: (
|
||||
dialogs: CanvasGenerationDialogState[],
|
||||
) => void;
|
||||
setSelectedLayerId: (layerId: string | null) => void;
|
||||
setSelectedLayerIds: (layerIds: string[]) => void;
|
||||
};
|
||||
|
||||
type CanvasHistoryResetters = {
|
||||
setHoveredLayerId: (layerId: string | null) => void;
|
||||
setMetadataLayer: (layer: CanvasLayer | null) => void;
|
||||
setCanvasMarquee: (marquee: CanvasMarqueeState | null) => void;
|
||||
setSnapGuide: (guide: SnapGuide | null) => void;
|
||||
setImageContextMenu: (menu: ImageContextMenuState | null) => void;
|
||||
setContextMenu: (menu: CanvasContextMenuState | null) => void;
|
||||
setIsPanning: (isPanning: boolean) => void;
|
||||
clearDragState: () => void;
|
||||
};
|
||||
|
||||
function cloneGenerateDialog(dialog: GenerateDialogState): GenerateDialogState {
|
||||
return {
|
||||
...dialog,
|
||||
placeholder: dialog.placeholder ? { ...dialog.placeholder } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function cloneCanvasGenerationDialog(
|
||||
dialog: CanvasGenerationDialogState,
|
||||
): CanvasGenerationDialogState {
|
||||
return {
|
||||
...dialog,
|
||||
placeholder: dialog.placeholder ? { ...dialog.placeholder } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function useCanvasHistory({
|
||||
refs,
|
||||
setters,
|
||||
resetters,
|
||||
}: {
|
||||
refs: CanvasHistoryRefs;
|
||||
setters: CanvasHistorySetters;
|
||||
resetters: CanvasHistoryResetters;
|
||||
}) {
|
||||
const undoStackRef = useRef<CanvasHistorySnapshot[]>([]);
|
||||
const redoStackRef = useRef<CanvasHistorySnapshot[]>([]);
|
||||
const [historyVersion, setHistoryVersion] = useState(0);
|
||||
|
||||
const getCanvasHistorySnapshot = useCallback(
|
||||
(): CanvasHistorySnapshot => ({
|
||||
layers: refs.layersRef.current.map((layer) => ({ ...layer })),
|
||||
viewport: { ...refs.viewportRef.current },
|
||||
generateDialog: refs.generateDialogRef.current
|
||||
? cloneGenerateDialog(refs.generateDialogRef.current)
|
||||
: null,
|
||||
inactiveGenerateDialogs:
|
||||
refs.inactiveGenerateDialogsRef.current.map(cloneCanvasGenerationDialog),
|
||||
selectedLayerId: refs.selectedLayerIdRef.current,
|
||||
selectedLayerIds: [...refs.selectedLayerIdsRef.current],
|
||||
}),
|
||||
[refs],
|
||||
);
|
||||
|
||||
const restoreCanvasHistorySnapshot = useCallback(
|
||||
(snapshot: CanvasHistorySnapshot) => {
|
||||
setters.setLayers(snapshot.layers.map((layer) => ({ ...layer })));
|
||||
setters.setViewport({ ...snapshot.viewport });
|
||||
setters.setGenerateDialog(
|
||||
snapshot.generateDialog
|
||||
? cloneGenerateDialog(snapshot.generateDialog)
|
||||
: null,
|
||||
);
|
||||
setters.setInactiveGenerateDialogs(
|
||||
snapshot.inactiveGenerateDialogs.map(cloneCanvasGenerationDialog),
|
||||
);
|
||||
setters.setSelectedLayerId(snapshot.selectedLayerId);
|
||||
setters.setSelectedLayerIds([...snapshot.selectedLayerIds]);
|
||||
resetters.setHoveredLayerId(null);
|
||||
resetters.setMetadataLayer(null);
|
||||
resetters.setCanvasMarquee(null);
|
||||
resetters.setSnapGuide(null);
|
||||
resetters.setImageContextMenu(null);
|
||||
resetters.setContextMenu(null);
|
||||
resetters.setIsPanning(false);
|
||||
resetters.clearDragState();
|
||||
},
|
||||
[resetters, setters],
|
||||
);
|
||||
|
||||
const captureCanvasHistory = useCallback(
|
||||
(options: { clearRedo?: boolean } = {}) => {
|
||||
undoStackRef.current = [
|
||||
...undoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)),
|
||||
getCanvasHistorySnapshot(),
|
||||
];
|
||||
if (options.clearRedo !== false) {
|
||||
redoStackRef.current = [];
|
||||
}
|
||||
setHistoryVersion((version) => version + 1);
|
||||
},
|
||||
[getCanvasHistorySnapshot],
|
||||
);
|
||||
|
||||
const undoCanvasChange = useCallback(() => {
|
||||
const previousSnapshot = undoStackRef.current.at(-1);
|
||||
if (!previousSnapshot) {
|
||||
return;
|
||||
}
|
||||
undoStackRef.current = undoStackRef.current.slice(0, -1);
|
||||
redoStackRef.current = [
|
||||
...redoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)),
|
||||
getCanvasHistorySnapshot(),
|
||||
];
|
||||
restoreCanvasHistorySnapshot(previousSnapshot);
|
||||
setHistoryVersion((version) => version + 1);
|
||||
}, [getCanvasHistorySnapshot, restoreCanvasHistorySnapshot]);
|
||||
|
||||
const redoCanvasChange = useCallback(() => {
|
||||
const nextSnapshot = redoStackRef.current.at(-1);
|
||||
if (!nextSnapshot) {
|
||||
return;
|
||||
}
|
||||
redoStackRef.current = redoStackRef.current.slice(0, -1);
|
||||
undoStackRef.current = [
|
||||
...undoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)),
|
||||
getCanvasHistorySnapshot(),
|
||||
];
|
||||
restoreCanvasHistorySnapshot(nextSnapshot);
|
||||
setHistoryVersion((version) => version + 1);
|
||||
}, [getCanvasHistorySnapshot, restoreCanvasHistorySnapshot]);
|
||||
|
||||
return {
|
||||
canUndo: undoStackRef.current.length > 0,
|
||||
canRedo: redoStackRef.current.length > 0,
|
||||
historyVersion,
|
||||
getCanvasHistorySnapshot,
|
||||
restoreCanvasHistorySnapshot,
|
||||
captureCanvasHistory,
|
||||
undoCanvasChange,
|
||||
redoCanvasChange,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { CanvasLayer, CanvasViewport } from './ImageCanvasEditorTypes';
|
||||
import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence';
|
||||
|
||||
const createEditorProjectResourceMock = vi.hoisted(() => vi.fn());
|
||||
const loadEditorProjectMock = vi.hoisted(() => vi.fn());
|
||||
const loadOrCreateRecentEditorProjectMock = vi.hoisted(() => vi.fn());
|
||||
const saveEditorProjectLayoutMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import('../../services/image-editor/editorProjectClient')
|
||||
>('../../services/image-editor/editorProjectClient');
|
||||
return {
|
||||
...actual,
|
||||
createEditorProjectResource: createEditorProjectResourceMock,
|
||||
loadEditorProject: loadEditorProjectMock,
|
||||
loadOrCreateRecentEditorProject: loadOrCreateRecentEditorProjectMock,
|
||||
saveEditorProjectLayout: saveEditorProjectLayoutMock,
|
||||
};
|
||||
});
|
||||
|
||||
function createLayer(id: string): CanvasLayer {
|
||||
return {
|
||||
id,
|
||||
resourceId: `local-${id}`,
|
||||
title: '账号素材A',
|
||||
src: 'data:image/png;base64,YQ==',
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 320,
|
||||
height: 240,
|
||||
originalWidth: 320,
|
||||
originalHeight: 240,
|
||||
zIndex: 3,
|
||||
sourceType: 'uploaded',
|
||||
sourceAssetId: 'asset-a',
|
||||
};
|
||||
}
|
||||
|
||||
function ProjectPersistenceHarness() {
|
||||
const [layers, setLayers] = useState<CanvasLayer[]>([]);
|
||||
const [viewport, setViewport] = useState<CanvasViewport>({
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
});
|
||||
const [projectTitle, setProjectTitle] = useState('');
|
||||
const [projectRenameValue, setProjectRenameValue] = useState('');
|
||||
const layersRef = useRef(layers);
|
||||
const viewportRef = useRef(viewport);
|
||||
const selectedLayerRef = useRef<string | null>(null);
|
||||
const layerCounterRef = useRef(0);
|
||||
|
||||
layersRef.current = layers;
|
||||
viewportRef.current = viewport;
|
||||
|
||||
const persistence = useImageCanvasProjectPersistence({
|
||||
refs: {
|
||||
layersRef,
|
||||
viewportRef,
|
||||
},
|
||||
setters: {
|
||||
setProjectTitle,
|
||||
setProjectRenameValue,
|
||||
setViewport,
|
||||
setLayers,
|
||||
selectSingleLayer: (layerId) => {
|
||||
selectedLayerRef.current = layerId;
|
||||
},
|
||||
setLayerCounter: (value) => {
|
||||
layerCounterRef.current = value;
|
||||
},
|
||||
},
|
||||
layers,
|
||||
viewport,
|
||||
openEditorLoginModal: vi.fn(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="project-id">{persistence.projectId ?? '-'}</span>
|
||||
<span data-testid="project-title">{projectTitle}</span>
|
||||
<span data-testid="project-rename">{projectRenameValue}</span>
|
||||
<span data-testid="layers">
|
||||
{layers.map((layer) => `${layer.id}:${layer.resourceId}`).join(',')}
|
||||
</span>
|
||||
<span data-testid="selected">{selectedLayerRef.current ?? '-'}</span>
|
||||
<span data-testid="counter">{layerCounterRef.current}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
persistence.appendCanvasLayersWithResources([createLayer('layer-a')]);
|
||||
}}
|
||||
>
|
||||
append
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('useImageCanvasProjectPersistence', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValue({
|
||||
projectId: 'editor-project-default',
|
||||
title: '空画布项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
});
|
||||
createEditorProjectResourceMock.mockResolvedValue({
|
||||
resourceId: 'resource-added-asset-a',
|
||||
projectId: 'editor-project-default',
|
||||
imageSrc: 'data:image/png;base64,YQ==',
|
||||
width: 320,
|
||||
height: 240,
|
||||
sourceType: 'uploaded',
|
||||
});
|
||||
saveEditorProjectLayoutMock.mockResolvedValue({
|
||||
projectId: 'editor-project-default',
|
||||
title: '空画布项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('saves appended layers with the server resource id immediately after resource creation', async () => {
|
||||
render(<ProjectPersistenceHarness />);
|
||||
|
||||
expect(await screen.findByText('editor-project-default')).toBeTruthy();
|
||||
expect(screen.getByTestId('project-title').textContent).toBe('空画布项目');
|
||||
expect(screen.getByTestId('project-rename').textContent).toBe(
|
||||
'空画布项目',
|
||||
);
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'append' }).click();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('layers').textContent).toBe(
|
||||
'layer-a:local-layer-a',
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('layers').textContent).toBe(
|
||||
'layer-a:resource-added-asset-a',
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
|
||||
'editor-project-default',
|
||||
expect.objectContaining({
|
||||
layers: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
layerId: 'layer-a',
|
||||
resourceId: 'resource-added-asset-a',
|
||||
sourceAssetId: 'asset-a',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
240
src/components/image-editor/useImageCanvasProjectPersistence.ts
Normal file
240
src/components/image-editor/useImageCanvasProjectPersistence.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { type RefObject, useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { ApiClientError } from '../../services/apiClient';
|
||||
import {
|
||||
createEditorProjectResource,
|
||||
loadEditorProject,
|
||||
loadOrCreateRecentEditorProject,
|
||||
saveEditorProjectLayout,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import { hydrateLayer, serializeLayer } from './ImageCanvasEditorModel';
|
||||
import type { CanvasLayer, CanvasViewport } from './ImageCanvasEditorTypes';
|
||||
|
||||
type ProjectResourceOptions = {
|
||||
onCreated?: (resourceId: string) => void;
|
||||
snapshotLayers?: CanvasLayer[];
|
||||
};
|
||||
|
||||
type PendingProjectResourceLayer = {
|
||||
layer: CanvasLayer;
|
||||
options: ProjectResourceOptions;
|
||||
};
|
||||
|
||||
type ImageCanvasProjectPersistenceRefs = {
|
||||
layersRef: RefObject<CanvasLayer[]>;
|
||||
viewportRef: RefObject<CanvasViewport>;
|
||||
};
|
||||
|
||||
type ImageCanvasProjectPersistenceSetters = {
|
||||
setProjectTitle: (title: string) => void;
|
||||
setProjectRenameValue: (title: string) => void;
|
||||
setViewport: (viewport: CanvasViewport) => void;
|
||||
setLayers: (layers: CanvasLayer[]) => void;
|
||||
selectSingleLayer: (layerId: string | null) => void;
|
||||
setLayerCounter: (value: number) => void;
|
||||
};
|
||||
|
||||
function isEditorAuthError(error: unknown) {
|
||||
return (
|
||||
error instanceof ApiClientError &&
|
||||
(error.status === 401 || error.status === 403)
|
||||
);
|
||||
}
|
||||
|
||||
export function useImageCanvasProjectPersistence({
|
||||
refs,
|
||||
setters,
|
||||
layers,
|
||||
viewport,
|
||||
openEditorLoginModal,
|
||||
}: {
|
||||
refs: ImageCanvasProjectPersistenceRefs;
|
||||
setters: ImageCanvasProjectPersistenceSetters;
|
||||
layers: CanvasLayer[];
|
||||
viewport: CanvasViewport;
|
||||
openEditorLoginModal: (postLoginAction?: (() => void) | null) => void;
|
||||
}) {
|
||||
const projectIdRef = useRef<string | null>(null);
|
||||
const pendingProjectResourceLayersRef = useRef<PendingProjectResourceLayer[]>(
|
||||
[],
|
||||
);
|
||||
const saveTimerRef = useRef<number | null>(null);
|
||||
const [projectId, setProjectId] = useState<string | null>(null);
|
||||
const [isProjectReady, setIsProjectReady] = useState(false);
|
||||
|
||||
const createProjectResourceForLayer = useCallback(
|
||||
(layer: CanvasLayer, options: ProjectResourceOptions = {}) => {
|
||||
const readyProjectId = projectIdRef.current;
|
||||
if (!readyProjectId) {
|
||||
pendingProjectResourceLayersRef.current.push({ layer, options });
|
||||
return;
|
||||
}
|
||||
createEditorProjectResource(readyProjectId, {
|
||||
imageSrc: layer.src,
|
||||
objectKey: layer.objectKey,
|
||||
assetObjectId: layer.assetObjectId,
|
||||
width: layer.originalWidth,
|
||||
height: layer.originalHeight,
|
||||
sourceType: layer.sourceType,
|
||||
prompt: layer.prompt,
|
||||
actualPrompt: layer.actualPrompt,
|
||||
model: layer.model,
|
||||
provider: layer.provider,
|
||||
taskId: layer.taskId,
|
||||
sourceResourceId: layer.sourceResourceId,
|
||||
})
|
||||
.then((resource) => {
|
||||
options.onCreated?.(resource.resourceId);
|
||||
const layerWithResourceId = {
|
||||
...layer,
|
||||
resourceId: resource.resourceId,
|
||||
};
|
||||
const currentLayers = refs.layersRef.current;
|
||||
const nextLayers = currentLayers.some(
|
||||
(currentLayer) => currentLayer.id === layer.id,
|
||||
)
|
||||
? currentLayers.map((currentLayer) =>
|
||||
currentLayer.id === layer.id
|
||||
? layerWithResourceId
|
||||
: currentLayer,
|
||||
)
|
||||
: options.snapshotLayers?.some(
|
||||
(snapshotLayer) => snapshotLayer.id === layer.id,
|
||||
)
|
||||
? options.snapshotLayers.map((snapshotLayer) =>
|
||||
snapshotLayer.id === layer.id
|
||||
? layerWithResourceId
|
||||
: snapshotLayer,
|
||||
)
|
||||
: currentLayers;
|
||||
refs.layersRef.current = nextLayers;
|
||||
setters.setLayers(nextLayers);
|
||||
if (nextLayers.length) {
|
||||
void saveEditorProjectLayout(readyProjectId, {
|
||||
viewport: refs.viewportRef.current,
|
||||
layers: nextLayers.map(serializeLayer),
|
||||
}).catch((error: unknown) => {
|
||||
if (isEditorAuthError(error)) {
|
||||
openEditorLoginModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (isEditorAuthError(error)) {
|
||||
openEditorLoginModal();
|
||||
}
|
||||
});
|
||||
},
|
||||
[openEditorLoginModal, refs, setters],
|
||||
);
|
||||
|
||||
const appendCanvasLayersWithResources = useCallback(
|
||||
(nextLayers: CanvasLayer[]) => {
|
||||
if (!nextLayers.length) {
|
||||
return;
|
||||
}
|
||||
const snapshotLayers = [...refs.layersRef.current, ...nextLayers];
|
||||
refs.layersRef.current = snapshotLayers;
|
||||
setters.setLayers(snapshotLayers);
|
||||
nextLayers.forEach((layer) =>
|
||||
createProjectResourceForLayer(layer, { snapshotLayers }),
|
||||
);
|
||||
},
|
||||
[createProjectResourceForLayer, refs, setters],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const projectIdFromQuery =
|
||||
typeof window === 'undefined'
|
||||
? null
|
||||
: new URLSearchParams(window.location.search)
|
||||
.get('projectid')
|
||||
?.trim() || null;
|
||||
const loadProject = projectIdFromQuery
|
||||
? loadEditorProject(projectIdFromQuery)
|
||||
: loadOrCreateRecentEditorProject();
|
||||
|
||||
loadProject
|
||||
.then((project) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
projectIdRef.current = project.projectId;
|
||||
setProjectId(project.projectId);
|
||||
const nextProjectTitle = project.title?.trim() || '未命名画布';
|
||||
setters.setProjectTitle(nextProjectTitle);
|
||||
setters.setProjectRenameValue(nextProjectTitle);
|
||||
const pendingLayers = pendingProjectResourceLayersRef.current.splice(0);
|
||||
pendingLayers.forEach(({ layer, options }) => {
|
||||
createProjectResourceForLayer(layer, options);
|
||||
});
|
||||
setters.setViewport(project.viewport);
|
||||
const resourcesById = new Map(
|
||||
project.resources.map((resource) => [
|
||||
resource.resourceId,
|
||||
{ imageSrc: resource.imageSrc },
|
||||
]),
|
||||
);
|
||||
const hydratedLayers = project.layers
|
||||
.map((layer) => hydrateLayer(layer, resourcesById))
|
||||
.filter((layer): layer is CanvasLayer => Boolean(layer));
|
||||
if (hydratedLayers.length > 0) {
|
||||
setters.setLayerCounter(hydratedLayers.length);
|
||||
setters.setLayers(hydratedLayers);
|
||||
setters.selectSingleLayer(hydratedLayers[0]?.id ?? null);
|
||||
}
|
||||
setIsProjectReady(true);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setIsProjectReady(false);
|
||||
if (isEditorAuthError(error)) {
|
||||
openEditorLoginModal(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [createProjectResourceForLayer, openEditorLoginModal, setters]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId || !isProjectReady) {
|
||||
return undefined;
|
||||
}
|
||||
if (saveTimerRef.current) {
|
||||
window.clearTimeout(saveTimerRef.current);
|
||||
}
|
||||
|
||||
saveTimerRef.current = window.setTimeout(() => {
|
||||
saveEditorProjectLayout(projectId, {
|
||||
viewport,
|
||||
layers: layers.map(serializeLayer),
|
||||
}).catch((error: unknown) => {
|
||||
if (isEditorAuthError(error)) {
|
||||
openEditorLoginModal();
|
||||
}
|
||||
});
|
||||
}, 450);
|
||||
|
||||
return () => {
|
||||
if (saveTimerRef.current) {
|
||||
window.clearTimeout(saveTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [isProjectReady, layers, openEditorLoginModal, projectId, viewport]);
|
||||
|
||||
return {
|
||||
projectId,
|
||||
isProjectReady,
|
||||
projectIdRef,
|
||||
createProjectResourceForLayer,
|
||||
appendCanvasLayersWithResources,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user