修复图片画布新增素材持久化

新增画布图层资源创建后的即时布局保存
补充素材库图片加入画布的持久化回归测试
更新图片画布回归验证记录
This commit is contained in:
2026-06-17 04:42:09 +08:00
parent b5cbe62b47
commit f794a8dd1f
4 changed files with 276 additions and 132 deletions
@@ -1,10 +1,4 @@
import {
Check,
ChevronLeft,
Download,
Pencil,
X,
} from 'lucide-react';
import { Check, ChevronLeft, Download, Pencil, X } from 'lucide-react';
import JSZip from 'jszip';
import {
type CSSProperties,
@@ -272,7 +266,10 @@ export function ImageCanvasEditorView() {
const pendingProjectResourceLayersRef = useRef<
Array<{
layer: CanvasLayer;
options: { onCreated?: (resourceId: string) => void };
options: {
onCreated?: (resourceId: string) => void;
snapshotLayers?: CanvasLayer[];
};
}>
>([]);
const selectedLayerIdRef = useRef<string | null>(null);
@@ -373,8 +370,9 @@ export function ImageCanvasEditorView() {
useState(false);
const [imageContextMenu, setImageContextMenu] =
useState<ImageContextMenuState | null>(null);
const [contextMenu, setContextMenu] =
useState<CanvasContextMenuState | null>(null);
const [contextMenu, setContextMenu] = useState<CanvasContextMenuState | null>(
null,
);
const [canvasClipboard, setCanvasClipboard] =
useState<CanvasClipboard | null>(null);
const [historyVersion, setHistoryVersion] = useState(0);
@@ -447,12 +445,11 @@ export function ImageCanvasEditorView() {
: null,
[activeCanvasGenerationDialog, layers],
);
const generationAnchor =
activeCanvasGenerationDialog
? (activeGenerationLayer ??
activeCanvasGenerationDialog.placeholder ??
null)
: null;
const generationAnchor = activeCanvasGenerationDialog
? (activeGenerationLayer ??
activeCanvasGenerationDialog.placeholder ??
null)
: null;
const generationComposerStyle =
activeCanvasGenerationDialog?.status !== 'generating' &&
activeCanvasGenerationDialog?.composerOpen !== false &&
@@ -630,8 +627,7 @@ export function ImageCanvasEditorView() {
) => CanvasGenerationDialogState | null,
) => {
setGenerateDialog((currentDialog) =>
isCanvasGenerationDialog(currentDialog) &&
currentDialog.id === dialogId
isCanvasGenerationDialog(currentDialog) && currentDialog.id === dialogId
? updater(currentDialog)
: currentDialog,
);
@@ -707,7 +703,9 @@ export function ImageCanvasEditorView() {
inactiveGenerateDialogs: inactiveGenerateDialogsRef.current.map(
(dialog) => ({
...dialog,
placeholder: dialog.placeholder ? { ...dialog.placeholder } : undefined,
placeholder: dialog.placeholder
? { ...dialog.placeholder }
: undefined,
}),
),
selectedLayerId: selectedLayerIdRef.current,
@@ -733,7 +731,9 @@ export function ImageCanvasEditorView() {
setInactiveGenerateDialogs(
snapshot.inactiveGenerateDialogs.map((dialog) => ({
...dialog,
placeholder: dialog.placeholder ? { ...dialog.placeholder } : undefined,
placeholder: dialog.placeholder
? { ...dialog.placeholder }
: undefined,
})),
);
setSelectedLayerId(snapshot.selectedLayerId);
@@ -859,7 +859,10 @@ export function ImageCanvasEditorView() {
const createProjectResourceForLayer = useCallback(
(
layer: CanvasLayer,
options: { onCreated?: (resourceId: string) => void } = {},
options: {
onCreated?: (resourceId: string) => void;
snapshotLayers?: CanvasLayer[];
} = {},
) => {
const readyProjectId = projectIdRef.current;
if (!readyProjectId) {
@@ -882,16 +885,40 @@ export function ImageCanvasEditorView() {
})
.then((resource) => {
options.onCreated?.(resource.resourceId);
setLayers((currentLayers) =>
currentLayers.map((currentLayer) =>
currentLayer.id === layer.id
? {
...currentLayer,
resourceId: resource.resourceId,
}
: currentLayer,
),
);
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)) {
@@ -1019,8 +1046,8 @@ export function ImageCanvasEditorView() {
return () => observer.disconnect();
}, []);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
(event.ctrlKey || event.metaKey) &&
event.code === 'KeyZ' &&
@@ -1034,9 +1061,9 @@ export function ImageCanvasEditorView() {
}
return;
}
if (event.key === 'Shift') {
isShiftPressedRef.current = true;
}
if (event.key === 'Shift') {
isShiftPressedRef.current = true;
}
if (
(event.key === 'Backspace' || event.key === 'Delete') &&
!event.repeat &&
@@ -1070,9 +1097,9 @@ export function ImageCanvasEditorView() {
if (event.key === 'Escape') {
setActiveSidebarPanel(null);
setIsZoomMenuOpen(false);
setIsBackgroundSettingsOpen(false);
setIsSpecMenuOpen(false);
setImageContextMenu(null);
setIsBackgroundSettingsOpen(false);
setIsSpecMenuOpen(false);
setImageContextMenu(null);
setContextMenu(null);
setQuickEditPanel((currentPanel) =>
currentPanel?.status === 'generating' ? currentPanel : null,
@@ -1325,9 +1352,13 @@ export function ImageCanvasEditorView() {
if (!canvasClipboard?.layers.length) {
return;
}
const nextLayers = duplicateLayersToPoint(canvasClipboard.layers, canvasPoint, {
renameCopies: canvasClipboard.mode !== 'cut',
});
const nextLayers = duplicateLayersToPoint(
canvasClipboard.layers,
canvasPoint,
{
renameCopies: canvasClipboard.mode !== 'cut',
},
);
if (!nextLayers.length) {
return;
}
@@ -1352,7 +1383,9 @@ export function ImageCanvasEditorView() {
setCanvasClipboard(clipboard);
if (options.cut) {
captureCanvasHistory();
setLayers((currentLayers) => removeCanvasLayers(currentLayers, targetIds));
setLayers((currentLayers) =>
removeCanvasLayers(currentLayers, targetIds),
);
selectSingleLayer(null);
setMetadataLayer((currentLayer) =>
currentLayer && targetIds.includes(currentLayer.id)
@@ -1534,13 +1567,29 @@ export function ImageCanvasEditorView() {
const listRect = listElement?.getBoundingClientRect();
const headerRect = header?.getBoundingClientRect();
setPinnedAssetMoveFolderId(
listRect && headerRect &&
listRect &&
headerRect &&
(headerRect.bottom < listRect.top || headerRect.top > listRect.bottom)
? folderId
: null,
);
};
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 },
@@ -1557,10 +1606,9 @@ export function ImageCanvasEditorView() {
},
);
captureCanvasHistory();
setLayers((currentLayers) => [...currentLayers, nextLayer]);
appendCanvasLayersWithResources([nextLayer]);
selectSingleLayer(nextLayer.id);
setHoveredLayerId(null);
createProjectResourceForLayer(nextLayer);
};
addAssetLayerRef.current = addAssetLayer;
@@ -1609,7 +1657,10 @@ export function ImageCanvasEditorView() {
try {
const blob = await readLayerImageBlob(layer);
const extension = getImageExtensionFromTypeOrSrc(blob.type, layer.src);
const extension = getImageExtensionFromTypeOrSrc(
blob.type,
layer.src,
);
const file = `images/${indexedFileName}.${extension}`;
imageByKey.set(key, {
key,
@@ -1872,7 +1923,8 @@ export function ImageCanvasEditorView() {
setSelectedLayerIds((currentIds) =>
currentIds.filter((layerId) =>
layers.every(
(layer) => layer.id !== layerId || !isLayerLinkedToAsset(layer, asset),
(layer) =>
layer.id !== layerId || !isLayerLinkedToAsset(layer, asset),
),
),
);
@@ -1978,7 +2030,9 @@ export function ImageCanvasEditorView() {
const deleteSelectedAssets = () => {
const ids = [...selectedAssetIds];
const deletedAssets = assets.filter((asset) => selectedAssetIds.has(asset.id));
const deletedAssets = assets.filter((asset) =>
selectedAssetIds.has(asset.id),
);
setAssets((currentAssets) =>
currentAssets.filter((asset) => !selectedAssetIds.has(asset.id)),
);
@@ -2363,7 +2417,7 @@ export function ImageCanvasEditorView() {
};
if (options.addToCanvas) {
setLayers((currentLayers) => [...currentLayers, nextLayer]);
appendCanvasLayersWithResources([nextLayer]);
}
if (options.addToCanvas) {
selectSingleLayer(nextLayer.id);
@@ -2446,10 +2500,6 @@ export function ImageCanvasEditorView() {
);
});
if (options.addToCanvas) {
createProjectResourceForLayer(nextLayer);
}
if (imageSrc) {
const uploadedImage = new Image();
uploadedImage.onload = () => {
@@ -2825,7 +2875,7 @@ export function ImageCanvasEditorView() {
generationInputs: options.generationInputs,
};
setLayers((currentLayers) => [...currentLayers, nextLayer]);
appendCanvasLayersWithResources([nextLayer]);
selectSingleLayer(nextLayer.id);
setActiveSidebarPanel('layers');
if (options.sourceLayer) {
@@ -2848,7 +2898,6 @@ export function ImageCanvasEditorView() {
if (options.sourceLayer) {
fitLayers([options.sourceLayer, nextLayer]);
}
createProjectResourceForLayer(nextLayer);
};
const addQuickEditResultLayer = (
@@ -2859,7 +2908,8 @@ export function ImageCanvasEditorView() {
layerCounterRef.current += 1;
const generatedIndex = layerCounterRef.current;
const originalWidth = generated.width || sourceLayer.originalWidth || 1024;
const originalHeight = generated.height || sourceLayer.originalHeight || 1024;
const originalHeight =
generated.height || sourceLayer.originalHeight || 1024;
const { width, height } = resolveLayerResolutionSize(
originalWidth,
originalHeight,
@@ -2894,13 +2944,12 @@ export function ImageCanvasEditorView() {
generationInputs,
};
setLayers((currentLayers) => [...currentLayers, nextLayer]);
appendCanvasLayersWithResources([nextLayer]);
selectSingleLayer(nextLayer.id);
setActiveSidebarPanel('layers');
setQuickEditPanel(null);
setActiveTool('select');
fitLayers([sourceLayer, nextLayer]);
createProjectResourceForLayer(nextLayer);
};
const addIconSpritesheetResultLayers = (
@@ -2970,14 +3019,13 @@ export function ImageCanvasEditorView() {
if (!nextLayers.length) {
return;
}
setLayers((currentLayers) => [...currentLayers, ...nextLayers]);
appendCanvasLayersWithResources(nextLayers);
selectSingleLayer(nextLayers[0]?.id ?? null);
setActiveSidebarPanel('layers');
if (dialogId) {
removeCanvasGenerationDialogById(dialogId);
}
setActiveTool('select');
nextLayers.forEach((layer) => createProjectResourceForLayer(layer));
};
const updateIconDescription = (index: number, value: string) => {
@@ -3101,8 +3149,9 @@ export function ImageCanvasEditorView() {
});
try {
const referenceImageSrc =
await resolveEditorImageReferenceDataUrl(quickEditSourceLayer.src);
const referenceImageSrc = await resolveEditorImageReferenceDataUrl(
quickEditSourceLayer.src,
);
const generated = await generateEditorImage({
prompt: normalizedPrompt,
size: quickEditPanel.size,
@@ -3388,9 +3437,7 @@ export function ImageCanvasEditorView() {
});
};
const handleCanvasContextMenu = (
event: ReactMouseEvent<HTMLDivElement>,
) => {
const handleCanvasContextMenu = (event: ReactMouseEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
const position = resolveContextMenuPosition(
@@ -4241,14 +4288,18 @@ export function ImageCanvasEditorView() {
setCharacterAnimationPanel={setCharacterAnimationPanel}
setIsCharacterSpecMenuOpen={setIsCharacterSpecMenuOpen}
setIsIconSpecMenuOpen={setIsIconSpecMenuOpen}
setIsPickingCharacterSpecFromCanvas={setIsPickingCharacterSpecFromCanvas}
setIsPickingCharacterSpecFromCanvas={
setIsPickingCharacterSpecFromCanvas
}
setIsPickingIconSpecFromCanvas={setIsPickingIconSpecFromCanvas}
onOpenSpecDialog={openSpecDialog}
onRequestUpload={(target) => {
setUploadTarget(target);
uploadInputRef.current?.click();
}}
onSubmitImageGeneration={(dialog) => void submitImageGeneration(dialog)}
onSubmitImageGeneration={(dialog) =>
void submitImageGeneration(dialog)
}
onSubmitIconSpritesheetGeneration={(dialog) =>
void submitIconSpritesheetGeneration(dialog)
}
@@ -4268,9 +4319,10 @@ export function ImageCanvasEditorView() {
onUpdateSpecFormValue={updateSpecFormValue}
onUpdateIconDescription={updateIconDescription}
onAddIconDescription={addIconDescription}
onUpdateCharacterAnimationDuration={updateCharacterAnimationDuration}
onUpdateCharacterAnimationDuration={
updateCharacterAnimationDuration
}
/>
</ImageCanvasStageView>
</div>
@@ -4311,7 +4363,11 @@ export function ImageCanvasEditorView() {
key={`${reference.title}-${reference.label}-${reference.src}`}
className="image-canvas-editor__metadata-reference-card"
>
<img src={reference.src} alt="" aria-hidden="true" />
<img
src={reference.src}
alt=""
aria-hidden="true"
/>
<span className="image-canvas-editor__metadata-reference-copy">
<span className="image-canvas-editor__metadata-input-title">
{reference.title}