diff --git a/TRACKING.md b/TRACKING.md
index de01d5e2..135297ca 100644
--- a/TRACKING.md
+++ b/TRACKING.md
@@ -120,3 +120,4 @@
- 2026-06-17 生成面板拆分浏览器回归:`http://127.0.0.1:10003/editor/canvas` 清空浏览器数据后未登录刷新弹出 `账号入口`;关闭登录后 `画布背景色` 打开 `画布背景设置`,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)`;点击 `生成工具` 后画布显示 `Image Generator` 占位框和 `生成图片` 跟随对话框,`AI画布工具栏` 保持可见;使用临时开发账号密码登录后上传素材成功,点击素材可添加到画布,切换 `图层` 面板可看到对应图层。
- 2026-06-17 前端拆分第五阶段:新增 `ImageCanvasLayerCommandModel`,把右键图层目标解析、复制 / 粘贴 / 创建副本、层级移动、分组 / 解组、显隐、锁定、翻转和删除的数据规则从主视图抽出;主视图只保留历史、选中态、菜单关闭、元数据清理和导出下载副作用。验证命令:`npm run test -- src/components/image-editor/ImageCanvasLayerCommandModel.test.ts src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/image-editor/ImageCanvasEditorPrimitives.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 未登录刷新弹出 `账号入口`,背景入口打开完整 `画布背景设置` 面板;登录后上传素材成功,点击素材可加入画布,图片右键打开 `图片功能面板`,创建副本、水平翻转、锁定和隐藏均生效,`AI画布工具栏` 保持可见。
- 2026-06-17 前端拆分第六阶段:新增 `ImageCanvasInteractionModel`,把适合视图、中心缩放、普通滚轮纵向滚动、Ctrl / Cmd 滚轮缩放、坐标换算、框选命中、平移、生成占位框拖拽、图层拖拽吸附、小地图投影、小地图点击定位和小地图拖拽视图移动的纯规则从主视图抽出;主视图保留事件、pointer capture、history、生成对象回写、选中态和状态更新。验证命令:`npm run test -- src/components/image-editor/ImageCanvasInteractionModel.test.ts src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/image-editor/ImageCanvasEditorPrimitives.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 登录态刷新后素材、画布图层、小地图和 `AI画布工具栏` 保持可见,Ctrl 滚轮从 110% 缩放到 121%,普通滚轮不改变缩放,浏览器控制台无 passive wheel 错误。
+- 2026-06-17 新增素材持久化修正:素材库图片、上传到画布、生成图、修改图和图标素材加入画布时会先用当前图层快照更新本地画布,再在资源创建完成后立刻保存带真实 `resourceId` 的 layout,避免资源创建异步返回时把空 `layers` 写回工程。验证命令:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 未登录弹出 `账号入口`,登录后上传素材、点击素材加入画布并刷新,画布图片和 `AI画布工具栏` 均保持可见。
diff --git a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md
index 8ad86288..62195d3f 100644
--- a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md
+++ b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md
@@ -75,6 +75,7 @@
- 生成状态机模型:等生成对象归档、占位框拖拽、生成完成回写、失败恢复和 undo / redo 规则进一步稳定后,再从主视图抽出深层状态模型。
- 上传 / 素材状态模型:上传占位卡片、素材文件夹移动、账号级素材库和拖拽遮罩仍在主视图与侧栏之间协作,后续需要等上传错误恢复和批量操作规则稳定后再收口。
+- 资源持久化稳定性:新增图层时先使用当前画布图层快照更新本地状态,再等待工程资源创建并即时保存带真实 `resourceId` 的 layout。后续如果继续拆上传或生成状态机,必须保留这一时序,避免 React 状态刷新和异步资源返回交错时写回空图层。
## 验证计划
diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx
index 106b37cf..145051f0 100644
--- a/src/components/image-editor/ImageCanvasEditorView.test.tsx
+++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx
@@ -242,26 +242,26 @@ describe('ImageCanvasEditorView', () => {
beforeEach(() => {
loadOrCreateRecentEditorProjectMock.mockImplementation(() =>
immediateAsync({
- projectId: 'editor-project-default',
- title: '默认项目',
- viewport: { x: 0, y: 0, scale: 1 },
- layers: defaultEditorProjectLayers,
- resources: defaultEditorProjectResources,
- updatedAt: '2026-06-12T00:00:00.000Z',
+ projectId: 'editor-project-default',
+ title: '默认项目',
+ viewport: { x: 0, y: 0, scale: 1 },
+ layers: defaultEditorProjectLayers,
+ resources: defaultEditorProjectResources,
+ updatedAt: '2026-06-12T00:00:00.000Z',
}),
);
loadEditorAssetLibraryMock.mockImplementation(() =>
immediateAsync({
- folders: [
- {
- folderId: 'project',
- label: '项目素材',
- sortOrder: 0,
- collapsed: false,
- systemDefault: true,
- },
- ],
- assets: defaultEditorAssetLibraryAssets,
+ folders: [
+ {
+ folderId: 'project',
+ label: '项目素材',
+ sortOrder: 0,
+ collapsed: false,
+ systemDefault: true,
+ },
+ ],
+ assets: defaultEditorAssetLibraryAssets,
}),
);
createEditorAssetMock.mockImplementation(async (input) => ({
@@ -378,7 +378,9 @@ describe('ImageCanvasEditorView', () => {
it('shows the loaded project title and a topbar entry back to projects', async () => {
render();
- expect(await screen.findByRole('heading', { name: '默认项目' })).toBeTruthy();
+ expect(
+ await screen.findByRole('heading', { name: '默认项目' }),
+ ).toBeTruthy();
const projectLink = screen.getByRole('link', { name: '返回项目页面' });
expect(projectLink.getAttribute('href')).toBe('/project');
@@ -422,7 +424,9 @@ describe('ImageCanvasEditorView', () => {
'新画布项目',
);
});
- expect(await screen.findByRole('heading', { name: '新画布项目' })).toBeTruthy();
+ expect(
+ await screen.findByRole('heading', { name: '新画布项目' }),
+ ).toBeTruthy();
});
it('does not inject built-in mock assets when the persisted library is empty', async () => {
@@ -974,7 +978,9 @@ describe('ImageCanvasEditorView', () => {
expect(openLoginModal).toHaveBeenCalledTimes(1);
expect(createEditorAssetMock).not.toHaveBeenCalled();
- expect(screen.queryByRole('button', { name: '上传失败登录后上传.png' })).toBeNull();
+ expect(
+ screen.queryByRole('button', { name: '上传失败登录后上传.png' }),
+ ).toBeNull();
const resumeUpload = openLoginModal.mock.calls[0]?.[0];
expect(typeof resumeUpload).toBe('function');
@@ -1027,7 +1033,9 @@ describe('ImageCanvasEditorView', () => {
expect(
await screen.findByLabelText('素材素材上传进度.png上传进度'),
).toBeTruthy();
- expect(screen.getByRole('button', { name: '上传中素材上传进度.png' })).toBeTruthy();
+ expect(
+ screen.getByRole('button', { name: '上传中素材上传进度.png' }),
+ ).toBeTruthy();
deferredAsset.resolve({
assetId: 'asset-upload-progress',
@@ -1044,9 +1052,7 @@ describe('ImageCanvasEditorView', () => {
screen.getByRole('button', { name: '添加素材上传进度.png' }),
).toBeTruthy();
});
- expect(
- screen.queryByLabelText('素材素材上传进度.png上传进度'),
- ).toBeNull();
+ expect(screen.queryByLabelText('素材素材上传进度.png上传进度')).toBeNull();
});
it('opens login when asset creation returns unauthorized during upload', async () => {
@@ -1212,6 +1218,69 @@ describe('ImageCanvasEditorView', () => {
expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-b');
});
+ it('saves a library asset layer right after creating its canvas resource', async () => {
+ const user = userEvent.setup();
+ createEditorProjectResourceMock.mockResolvedValueOnce({
+ resourceId: 'resource-added-asset-a',
+ projectId: 'editor-project-default',
+ imageSrc: 'data:image/png;base64,YQ==',
+ width: 320,
+ height: 240,
+ sourceType: 'uploaded',
+ });
+ loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
+ projectId: 'editor-project-default',
+ title: '空画布项目',
+ viewport: { x: 0, y: 0, scale: 1 },
+ layers: [],
+ resources: [],
+ updatedAt: '2026-06-12T00:00:00.000Z',
+ });
+ loadEditorAssetLibraryMock.mockResolvedValueOnce({
+ folders: [
+ {
+ folderId: 'project',
+ label: '项目素材',
+ sortOrder: 0,
+ collapsed: false,
+ systemDefault: true,
+ },
+ ],
+ assets: [
+ {
+ assetId: 'asset-a',
+ folderId: 'project',
+ label: '账号素材A',
+ imageSrc: 'data:image/png;base64,YQ==',
+ width: 320,
+ height: 240,
+ sourceType: 'uploaded',
+ },
+ ],
+ });
+ render();
+
+ await user.click(
+ await screen.findByRole('button', { name: '添加账号素材A' }),
+ );
+
+ expect(await screen.findByAltText('画布图片:账号素材A')).toBeTruthy();
+ await waitFor(() => {
+ expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
+ 'editor-project-default',
+ expect.objectContaining({
+ layers: expect.arrayContaining([
+ expect.objectContaining({
+ title: '账号素材A',
+ resourceId: 'resource-added-asset-a',
+ sourceAssetId: 'asset-a',
+ }),
+ ]),
+ }),
+ );
+ });
+ });
+
it('selects multiple assets with a marquee in asset selection mode', async () => {
const user = userEvent.setup();
loadEditorAssetLibraryMock.mockResolvedValueOnce({
@@ -1601,8 +1670,11 @@ describe('ImageCanvasEditorView', () => {
const menu = screen.getByRole('menu', { name: '画布右键菜单' });
expect(
- (within(menu).getByRole('menuitem', { name: '粘贴' }) as HTMLButtonElement)
- .disabled,
+ (
+ within(menu).getByRole('menuitem', {
+ name: '粘贴',
+ }) as HTMLButtonElement
+ ).disabled,
).toBe(true);
expect(within(menu).getByRole('menuitem', { name: '放大' })).toBeTruthy();
expect(
@@ -1656,11 +1728,15 @@ describe('ImageCanvasEditorView', () => {
});
const copyPasteMenu = screen.getByRole('menu', { name: '画布右键菜单' });
expect(
- (within(copyPasteMenu).getByRole('menuitem', {
- name: '粘贴',
- }) as HTMLButtonElement).disabled,
+ (
+ within(copyPasteMenu).getByRole('menuitem', {
+ name: '粘贴',
+ }) as HTMLButtonElement
+ ).disabled,
).toBe(false);
- fireEvent.click(within(copyPasteMenu).getByRole('menuitem', { name: '粘贴' }));
+ fireEvent.click(
+ within(copyPasteMenu).getByRole('menuitem', { name: '粘贴' }),
+ );
expect(screen.getAllByAltText(/画布图片:拼图素材/u)).toHaveLength(2);
fireEvent.contextMenu(
@@ -1885,7 +1961,9 @@ describe('ImageCanvasEditorView', () => {
).toBeTruthy();
fireEvent.click(screen.getByRole('menuitem', { name: '缩放至100%' }));
- expect(screen.getByRole('button', { name: /当前缩放比例 \d+%/u })).toBeTruthy();
+ expect(
+ screen.getByRole('button', { name: /当前缩放比例 \d+%/u }),
+ ).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '当前缩放比例 100%' }));
fireEvent.click(screen.getByRole('menuitem', { name: '缩放至50%' }));
@@ -1915,25 +1993,23 @@ describe('ImageCanvasEditorView', () => {
});
expect(within(settingsPanel).getByText('画布背景')).toBeTruthy();
- fireEvent.click(within(settingsPanel).getByRole('button', { name: '暖灰' }));
+ fireEvent.click(
+ within(settingsPanel).getByRole('button', { name: '暖灰' }),
+ );
expect((viewport as HTMLElement).style.backgroundColor).toBe(
'rgb(243, 240, 234)',
);
- fireEvent.change(
- within(settingsPanel).getByLabelText('自定义画布背景色'),
- {
- target: { value: '#ffffff' },
- },
- );
+ fireEvent.change(within(settingsPanel).getByLabelText('自定义画布背景色'), {
+ target: { value: '#ffffff' },
+ });
expect((viewport as HTMLElement).style.backgroundColor).toBe(
'rgb(255, 255, 255)',
);
- const hexInput = within(settingsPanel).getByLabelText(
- '画布背景十六进制颜色',
- );
+ const hexInput =
+ within(settingsPanel).getByLabelText('画布背景十六进制颜色');
fireEvent.change(hexInput, { target: { value: '#abc' } });
expect((hexInput as HTMLInputElement).value).toBe('#aabbcc');
expect((viewport as HTMLElement).style.backgroundColor).toBe(
@@ -1954,9 +2030,7 @@ describe('ImageCanvasEditorView', () => {
);
fireEvent.keyDown(window, { key: 'Escape' });
- expect(
- screen.queryByRole('dialog', { name: '画布背景设置' }),
- ).toBeNull();
+ expect(screen.queryByRole('dialog', { name: '画布背景设置' })).toBeNull();
fireEvent.click(
within(panelToolbar).getByRole('button', { name: '切换小地图' }),
@@ -2927,7 +3001,9 @@ describe('ImageCanvasEditorView', () => {
fireEvent.change(within(characterPanel).getByLabelText('角色设定'), {
target: { value: '高个子游侠' },
});
- fireEvent.click(within(characterPanel).getByRole('button', { name: '生成' }));
+ fireEvent.click(
+ within(characterPanel).getByRole('button', { name: '生成' }),
+ );
await waitFor(() => {
expect(generateEditorImageMock).toHaveBeenCalledWith(
@@ -2937,9 +3013,7 @@ describe('ImageCanvasEditorView', () => {
}),
);
});
- expect(
- generateEditorImageMock.mock.calls[0]?.[0],
- ).not.toEqual(
+ expect(generateEditorImageMock.mock.calls[0]?.[0]).not.toEqual(
expect.objectContaining({
aspectRatio: expect.any(String),
imageSize: expect.any(String),
@@ -3019,12 +3093,16 @@ describe('ImageCanvasEditorView', () => {
const characterFrame = screen.getByLabelText('角色生成占位图');
expect(characterFrame).toBeTruthy();
- dispatchPointerEvent(screen.getByLabelText('图像生成占位图'), 'pointerdown', {
- button: 0,
- pointerId: 1702,
- clientX: 500,
- clientY: 260,
- });
+ dispatchPointerEvent(
+ screen.getByLabelText('图像生成占位图'),
+ 'pointerdown',
+ {
+ button: 0,
+ pointerId: 1702,
+ clientX: 500,
+ clientY: 260,
+ },
+ );
dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', {
pointerId: 1702,
clientX: 650,
@@ -3036,9 +3114,7 @@ describe('ImageCanvasEditorView', () => {
clientY: 390,
});
const movedFrame = screen.getByLabelText('图像生成占位图');
- const movedLeft = Number.parseFloat(
- (movedFrame as HTMLElement).style.left,
- );
+ const movedLeft = Number.parseFloat((movedFrame as HTMLElement).style.left);
const movedTop = Number.parseFloat((movedFrame as HTMLElement).style.top);
expect(movedLeft).toBeGreaterThan(originalLeft);
expect(movedTop).toBeGreaterThan(originalTop);
@@ -3077,9 +3153,13 @@ describe('ImageCanvasEditorView', () => {
.getByAltText(/画布图片:生成图片/)
.closest('button') as HTMLElement;
const expectedLayerLeft =
- movedLeft + Number.parseFloat((movedFrame as HTMLElement).style.width) / 2 - 512;
+ movedLeft +
+ Number.parseFloat((movedFrame as HTMLElement).style.width) / 2 -
+ 512;
const expectedLayerTop =
- movedTop + Number.parseFloat((movedFrame as HTMLElement).style.height) / 2 - 512;
+ movedTop +
+ Number.parseFloat((movedFrame as HTMLElement).style.height) / 2 -
+ 512;
expect(Number.parseFloat(generatedLayer.style.left)).toBeCloseTo(
expectedLayerLeft,
1,
@@ -3160,7 +3240,9 @@ describe('ImageCanvasEditorView', () => {
iconPanel.querySelector('.image-canvas-editor__icon-spec-card'),
).toBeTruthy();
- fireEvent.click(within(iconPanel).getByRole('button', { name: '添加素材描述' }));
+ fireEvent.click(
+ within(iconPanel).getByRole('button', { name: '添加素材描述' }),
+ );
expect(Number.parseFloat(iconPanel.style.width)).toBeCloseTo(61.2, 1);
expect(within(iconPanel).getAllByRole('textbox')).toHaveLength(7);
@@ -4358,12 +4440,16 @@ describe('ImageCanvasEditorView', () => {
});
expect(within(editedMetadataDialog).queryByText('Prompt')).toBeNull();
expect(within(editedMetadataDialog).getByText('修改要求')).toBeTruthy();
- expect(within(editedMetadataDialog).getByText('把画面改成黄昏光线')).toBeTruthy();
+ expect(
+ within(editedMetadataDialog).getByText('把画面改成黄昏光线'),
+ ).toBeTruthy();
expect(within(editedMetadataDialog).getByText('参考图')).toBeTruthy();
expect(
within(editedMetadataDialog).getByText(/^生成图片 \d+$/u),
).toBeTruthy();
- expect(screen.getByRole('button', { name: /当前缩放比例 \d+%/u })).toBeTruthy();
+ expect(
+ screen.getByRole('button', { name: /当前缩放比例 \d+%/u }),
+ ).toBeTruthy();
});
it('hides the edit image panel after generation starts while keeping the source preview visible', async () => {
diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx
index ff56cc3a..339af6fb 100644
--- a/src/components/image-editor/ImageCanvasEditorView.tsx
+++ b/src/components/image-editor/ImageCanvasEditorView.tsx
@@ -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(null);
@@ -373,8 +370,9 @@ export function ImageCanvasEditorView() {
useState(false);
const [imageContextMenu, setImageContextMenu] =
useState(null);
- const [contextMenu, setContextMenu] =
- useState(null);
+ const [contextMenu, setContextMenu] = useState(
+ null,
+ );
const [canvasClipboard, setCanvasClipboard] =
useState(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,
- ) => {
+ const handleCanvasContextMenu = (event: ReactMouseEvent) => {
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
+ }
/>
-
@@ -4311,7 +4363,11 @@ export function ImageCanvasEditorView() {
key={`${reference.title}-${reference.label}-${reference.src}`}
className="image-canvas-editor__metadata-reference-card"
>
-
+
{reference.title}