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

新增画布图层资源创建后的即时布局保存
补充素材库图片加入画布的持久化回归测试
更新图片画布回归验证记录
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

View File

@@ -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 生成面板拆分浏览器回归:`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 前端拆分第五阶段:新增 `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 前端拆分第六阶段:新增 `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画布工具栏` 均保持可见。

View File

@@ -75,6 +75,7 @@
- 生成状态机模型:等生成对象归档、占位框拖拽、生成完成回写、失败恢复和 undo / redo 规则进一步稳定后,再从主视图抽出深层状态模型。 - 生成状态机模型:等生成对象归档、占位框拖拽、生成完成回写、失败恢复和 undo / redo 规则进一步稳定后,再从主视图抽出深层状态模型。
- 上传 / 素材状态模型:上传占位卡片、素材文件夹移动、账号级素材库和拖拽遮罩仍在主视图与侧栏之间协作,后续需要等上传错误恢复和批量操作规则稳定后再收口。 - 上传 / 素材状态模型:上传占位卡片、素材文件夹移动、账号级素材库和拖拽遮罩仍在主视图与侧栏之间协作,后续需要等上传错误恢复和批量操作规则稳定后再收口。
- 资源持久化稳定性:新增图层时先使用当前画布图层快照更新本地状态,再等待工程资源创建并即时保存带真实 `resourceId` 的 layout。后续如果继续拆上传或生成状态机必须保留这一时序避免 React 状态刷新和异步资源返回交错时写回空图层。
## 验证计划 ## 验证计划

View File

@@ -378,7 +378,9 @@ describe('ImageCanvasEditorView', () => {
it('shows the loaded project title and a topbar entry back to projects', async () => { it('shows the loaded project title and a topbar entry back to projects', async () => {
render(<ImageCanvasEditorView />); render(<ImageCanvasEditorView />);
expect(await screen.findByRole('heading', { name: '默认项目' })).toBeTruthy(); expect(
await screen.findByRole('heading', { name: '默认项目' }),
).toBeTruthy();
const projectLink = screen.getByRole('link', { name: '返回项目页面' }); const projectLink = screen.getByRole('link', { name: '返回项目页面' });
expect(projectLink.getAttribute('href')).toBe('/project'); 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 () => { 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(openLoginModal).toHaveBeenCalledTimes(1);
expect(createEditorAssetMock).not.toHaveBeenCalled(); 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]; const resumeUpload = openLoginModal.mock.calls[0]?.[0];
expect(typeof resumeUpload).toBe('function'); expect(typeof resumeUpload).toBe('function');
@@ -1027,7 +1033,9 @@ describe('ImageCanvasEditorView', () => {
expect( expect(
await screen.findByLabelText('素材素材上传进度.png上传进度'), await screen.findByLabelText('素材素材上传进度.png上传进度'),
).toBeTruthy(); ).toBeTruthy();
expect(screen.getByRole('button', { name: '上传中素材上传进度.png' })).toBeTruthy(); expect(
screen.getByRole('button', { name: '上传中素材上传进度.png' }),
).toBeTruthy();
deferredAsset.resolve({ deferredAsset.resolve({
assetId: 'asset-upload-progress', assetId: 'asset-upload-progress',
@@ -1044,9 +1052,7 @@ describe('ImageCanvasEditorView', () => {
screen.getByRole('button', { name: '添加素材上传进度.png' }), screen.getByRole('button', { name: '添加素材上传进度.png' }),
).toBeTruthy(); ).toBeTruthy();
}); });
expect( expect(screen.queryByLabelText('素材素材上传进度.png上传进度')).toBeNull();
screen.queryByLabelText('素材素材上传进度.png上传进度'),
).toBeNull();
}); });
it('opens login when asset creation returns unauthorized during upload', async () => { it('opens login when asset creation returns unauthorized during upload', async () => {
@@ -1212,6 +1218,69 @@ describe('ImageCanvasEditorView', () => {
expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-b'); 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(<ImageCanvasEditorView />);
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 () => { it('selects multiple assets with a marquee in asset selection mode', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
loadEditorAssetLibraryMock.mockResolvedValueOnce({ loadEditorAssetLibraryMock.mockResolvedValueOnce({
@@ -1601,8 +1670,11 @@ describe('ImageCanvasEditorView', () => {
const menu = screen.getByRole('menu', { name: '画布右键菜单' }); const menu = screen.getByRole('menu', { name: '画布右键菜单' });
expect( expect(
(within(menu).getByRole('menuitem', { name: '粘贴' }) as HTMLButtonElement) (
.disabled, within(menu).getByRole('menuitem', {
name: '粘贴',
}) as HTMLButtonElement
).disabled,
).toBe(true); ).toBe(true);
expect(within(menu).getByRole('menuitem', { name: '放大' })).toBeTruthy(); expect(within(menu).getByRole('menuitem', { name: '放大' })).toBeTruthy();
expect( expect(
@@ -1656,11 +1728,15 @@ describe('ImageCanvasEditorView', () => {
}); });
const copyPasteMenu = screen.getByRole('menu', { name: '画布右键菜单' }); const copyPasteMenu = screen.getByRole('menu', { name: '画布右键菜单' });
expect( expect(
(within(copyPasteMenu).getByRole('menuitem', { (
within(copyPasteMenu).getByRole('menuitem', {
name: '粘贴', name: '粘贴',
}) as HTMLButtonElement).disabled, }) as HTMLButtonElement
).disabled,
).toBe(false); ).toBe(false);
fireEvent.click(within(copyPasteMenu).getByRole('menuitem', { name: '粘贴' })); fireEvent.click(
within(copyPasteMenu).getByRole('menuitem', { name: '粘贴' }),
);
expect(screen.getAllByAltText(//u)).toHaveLength(2); expect(screen.getAllByAltText(//u)).toHaveLength(2);
fireEvent.contextMenu( fireEvent.contextMenu(
@@ -1885,7 +1961,9 @@ describe('ImageCanvasEditorView', () => {
).toBeTruthy(); ).toBeTruthy();
fireEvent.click(screen.getByRole('menuitem', { name: '缩放至100%' })); 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('button', { name: '当前缩放比例 100%' }));
fireEvent.click(screen.getByRole('menuitem', { name: '缩放至50%' })); fireEvent.click(screen.getByRole('menuitem', { name: '缩放至50%' }));
@@ -1915,25 +1993,23 @@ describe('ImageCanvasEditorView', () => {
}); });
expect(within(settingsPanel).getByText('画布背景')).toBeTruthy(); 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( expect((viewport as HTMLElement).style.backgroundColor).toBe(
'rgb(243, 240, 234)', 'rgb(243, 240, 234)',
); );
fireEvent.change( fireEvent.change(within(settingsPanel).getByLabelText('自定义画布背景色'), {
within(settingsPanel).getByLabelText('自定义画布背景色'),
{
target: { value: '#ffffff' }, target: { value: '#ffffff' },
}, });
);
expect((viewport as HTMLElement).style.backgroundColor).toBe( expect((viewport as HTMLElement).style.backgroundColor).toBe(
'rgb(255, 255, 255)', 'rgb(255, 255, 255)',
); );
const hexInput = within(settingsPanel).getByLabelText( const hexInput =
'画布背景十六进制颜色', within(settingsPanel).getByLabelText('画布背景十六进制颜色');
);
fireEvent.change(hexInput, { target: { value: '#abc' } }); fireEvent.change(hexInput, { target: { value: '#abc' } });
expect((hexInput as HTMLInputElement).value).toBe('#aabbcc'); expect((hexInput as HTMLInputElement).value).toBe('#aabbcc');
expect((viewport as HTMLElement).style.backgroundColor).toBe( expect((viewport as HTMLElement).style.backgroundColor).toBe(
@@ -1954,9 +2030,7 @@ describe('ImageCanvasEditorView', () => {
); );
fireEvent.keyDown(window, { key: 'Escape' }); fireEvent.keyDown(window, { key: 'Escape' });
expect( expect(screen.queryByRole('dialog', { name: '画布背景设置' })).toBeNull();
screen.queryByRole('dialog', { name: '画布背景设置' }),
).toBeNull();
fireEvent.click( fireEvent.click(
within(panelToolbar).getByRole('button', { name: '切换小地图' }), within(panelToolbar).getByRole('button', { name: '切换小地图' }),
@@ -2927,7 +3001,9 @@ describe('ImageCanvasEditorView', () => {
fireEvent.change(within(characterPanel).getByLabelText('角色设定'), { fireEvent.change(within(characterPanel).getByLabelText('角色设定'), {
target: { value: '高个子游侠' }, target: { value: '高个子游侠' },
}); });
fireEvent.click(within(characterPanel).getByRole('button', { name: '生成' })); fireEvent.click(
within(characterPanel).getByRole('button', { name: '生成' }),
);
await waitFor(() => { await waitFor(() => {
expect(generateEditorImageMock).toHaveBeenCalledWith( expect(generateEditorImageMock).toHaveBeenCalledWith(
@@ -2937,9 +3013,7 @@ describe('ImageCanvasEditorView', () => {
}), }),
); );
}); });
expect( expect(generateEditorImageMock.mock.calls[0]?.[0]).not.toEqual(
generateEditorImageMock.mock.calls[0]?.[0],
).not.toEqual(
expect.objectContaining({ expect.objectContaining({
aspectRatio: expect.any(String), aspectRatio: expect.any(String),
imageSize: expect.any(String), imageSize: expect.any(String),
@@ -3019,12 +3093,16 @@ describe('ImageCanvasEditorView', () => {
const characterFrame = screen.getByLabelText('角色生成占位图'); const characterFrame = screen.getByLabelText('角色生成占位图');
expect(characterFrame).toBeTruthy(); expect(characterFrame).toBeTruthy();
dispatchPointerEvent(screen.getByLabelText('图像生成占位图'), 'pointerdown', { dispatchPointerEvent(
screen.getByLabelText('图像生成占位图'),
'pointerdown',
{
button: 0, button: 0,
pointerId: 1702, pointerId: 1702,
clientX: 500, clientX: 500,
clientY: 260, clientY: 260,
}); },
);
dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', { dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', {
pointerId: 1702, pointerId: 1702,
clientX: 650, clientX: 650,
@@ -3036,9 +3114,7 @@ describe('ImageCanvasEditorView', () => {
clientY: 390, clientY: 390,
}); });
const movedFrame = screen.getByLabelText('图像生成占位图'); const movedFrame = screen.getByLabelText('图像生成占位图');
const movedLeft = Number.parseFloat( const movedLeft = Number.parseFloat((movedFrame as HTMLElement).style.left);
(movedFrame as HTMLElement).style.left,
);
const movedTop = Number.parseFloat((movedFrame as HTMLElement).style.top); const movedTop = Number.parseFloat((movedFrame as HTMLElement).style.top);
expect(movedLeft).toBeGreaterThan(originalLeft); expect(movedLeft).toBeGreaterThan(originalLeft);
expect(movedTop).toBeGreaterThan(originalTop); expect(movedTop).toBeGreaterThan(originalTop);
@@ -3077,9 +3153,13 @@ describe('ImageCanvasEditorView', () => {
.getByAltText(/画布图片:生成图片/) .getByAltText(/画布图片:生成图片/)
.closest('button') as HTMLElement; .closest('button') as HTMLElement;
const expectedLayerLeft = const expectedLayerLeft =
movedLeft + Number.parseFloat((movedFrame as HTMLElement).style.width) / 2 - 512; movedLeft +
Number.parseFloat((movedFrame as HTMLElement).style.width) / 2 -
512;
const expectedLayerTop = 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( expect(Number.parseFloat(generatedLayer.style.left)).toBeCloseTo(
expectedLayerLeft, expectedLayerLeft,
1, 1,
@@ -3160,7 +3240,9 @@ describe('ImageCanvasEditorView', () => {
iconPanel.querySelector('.image-canvas-editor__icon-spec-card'), iconPanel.querySelector('.image-canvas-editor__icon-spec-card'),
).toBeTruthy(); ).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(Number.parseFloat(iconPanel.style.width)).toBeCloseTo(61.2, 1);
expect(within(iconPanel).getAllByRole('textbox')).toHaveLength(7); expect(within(iconPanel).getAllByRole('textbox')).toHaveLength(7);
@@ -4358,12 +4440,16 @@ describe('ImageCanvasEditorView', () => {
}); });
expect(within(editedMetadataDialog).queryByText('Prompt')).toBeNull(); 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('参考图')).toBeTruthy(); expect(within(editedMetadataDialog).getByText('参考图')).toBeTruthy();
expect( expect(
within(editedMetadataDialog).getByText(/^ \d+$/u), within(editedMetadataDialog).getByText(/^ \d+$/u),
).toBeTruthy(); ).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 () => { it('hides the edit image panel after generation starts while keeping the source preview visible', async () => {

View File

@@ -1,10 +1,4 @@
import { import { Check, ChevronLeft, Download, Pencil, X } from 'lucide-react';
Check,
ChevronLeft,
Download,
Pencil,
X,
} from 'lucide-react';
import JSZip from 'jszip'; import JSZip from 'jszip';
import { import {
type CSSProperties, type CSSProperties,
@@ -272,7 +266,10 @@ export function ImageCanvasEditorView() {
const pendingProjectResourceLayersRef = useRef< const pendingProjectResourceLayersRef = useRef<
Array<{ Array<{
layer: CanvasLayer; layer: CanvasLayer;
options: { onCreated?: (resourceId: string) => void }; options: {
onCreated?: (resourceId: string) => void;
snapshotLayers?: CanvasLayer[];
};
}> }>
>([]); >([]);
const selectedLayerIdRef = useRef<string | null>(null); const selectedLayerIdRef = useRef<string | null>(null);
@@ -373,8 +370,9 @@ export function ImageCanvasEditorView() {
useState(false); useState(false);
const [imageContextMenu, setImageContextMenu] = const [imageContextMenu, setImageContextMenu] =
useState<ImageContextMenuState | null>(null); useState<ImageContextMenuState | null>(null);
const [contextMenu, setContextMenu] = const [contextMenu, setContextMenu] = useState<CanvasContextMenuState | null>(
useState<CanvasContextMenuState | null>(null); null,
);
const [canvasClipboard, setCanvasClipboard] = const [canvasClipboard, setCanvasClipboard] =
useState<CanvasClipboard | null>(null); useState<CanvasClipboard | null>(null);
const [historyVersion, setHistoryVersion] = useState(0); const [historyVersion, setHistoryVersion] = useState(0);
@@ -447,8 +445,7 @@ export function ImageCanvasEditorView() {
: null, : null,
[activeCanvasGenerationDialog, layers], [activeCanvasGenerationDialog, layers],
); );
const generationAnchor = const generationAnchor = activeCanvasGenerationDialog
activeCanvasGenerationDialog
? (activeGenerationLayer ?? ? (activeGenerationLayer ??
activeCanvasGenerationDialog.placeholder ?? activeCanvasGenerationDialog.placeholder ??
null) null)
@@ -630,8 +627,7 @@ export function ImageCanvasEditorView() {
) => CanvasGenerationDialogState | null, ) => CanvasGenerationDialogState | null,
) => { ) => {
setGenerateDialog((currentDialog) => setGenerateDialog((currentDialog) =>
isCanvasGenerationDialog(currentDialog) && isCanvasGenerationDialog(currentDialog) && currentDialog.id === dialogId
currentDialog.id === dialogId
? updater(currentDialog) ? updater(currentDialog)
: currentDialog, : currentDialog,
); );
@@ -707,7 +703,9 @@ export function ImageCanvasEditorView() {
inactiveGenerateDialogs: inactiveGenerateDialogsRef.current.map( inactiveGenerateDialogs: inactiveGenerateDialogsRef.current.map(
(dialog) => ({ (dialog) => ({
...dialog, ...dialog,
placeholder: dialog.placeholder ? { ...dialog.placeholder } : undefined, placeholder: dialog.placeholder
? { ...dialog.placeholder }
: undefined,
}), }),
), ),
selectedLayerId: selectedLayerIdRef.current, selectedLayerId: selectedLayerIdRef.current,
@@ -733,7 +731,9 @@ export function ImageCanvasEditorView() {
setInactiveGenerateDialogs( setInactiveGenerateDialogs(
snapshot.inactiveGenerateDialogs.map((dialog) => ({ snapshot.inactiveGenerateDialogs.map((dialog) => ({
...dialog, ...dialog,
placeholder: dialog.placeholder ? { ...dialog.placeholder } : undefined, placeholder: dialog.placeholder
? { ...dialog.placeholder }
: undefined,
})), })),
); );
setSelectedLayerId(snapshot.selectedLayerId); setSelectedLayerId(snapshot.selectedLayerId);
@@ -859,7 +859,10 @@ export function ImageCanvasEditorView() {
const createProjectResourceForLayer = useCallback( const createProjectResourceForLayer = useCallback(
( (
layer: CanvasLayer, layer: CanvasLayer,
options: { onCreated?: (resourceId: string) => void } = {}, options: {
onCreated?: (resourceId: string) => void;
snapshotLayers?: CanvasLayer[];
} = {},
) => { ) => {
const readyProjectId = projectIdRef.current; const readyProjectId = projectIdRef.current;
if (!readyProjectId) { if (!readyProjectId) {
@@ -882,16 +885,40 @@ export function ImageCanvasEditorView() {
}) })
.then((resource) => { .then((resource) => {
options.onCreated?.(resource.resourceId); options.onCreated?.(resource.resourceId);
setLayers((currentLayers) => const layerWithResourceId = {
currentLayers.map((currentLayer) => ...layer,
currentLayer.id === layer.id
? {
...currentLayer,
resourceId: resource.resourceId, 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, : 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) => { .catch((error: unknown) => {
if (isEditorAuthError(error)) { if (isEditorAuthError(error)) {
@@ -1325,9 +1352,13 @@ export function ImageCanvasEditorView() {
if (!canvasClipboard?.layers.length) { if (!canvasClipboard?.layers.length) {
return; return;
} }
const nextLayers = duplicateLayersToPoint(canvasClipboard.layers, canvasPoint, { const nextLayers = duplicateLayersToPoint(
canvasClipboard.layers,
canvasPoint,
{
renameCopies: canvasClipboard.mode !== 'cut', renameCopies: canvasClipboard.mode !== 'cut',
}); },
);
if (!nextLayers.length) { if (!nextLayers.length) {
return; return;
} }
@@ -1352,7 +1383,9 @@ export function ImageCanvasEditorView() {
setCanvasClipboard(clipboard); setCanvasClipboard(clipboard);
if (options.cut) { if (options.cut) {
captureCanvasHistory(); captureCanvasHistory();
setLayers((currentLayers) => removeCanvasLayers(currentLayers, targetIds)); setLayers((currentLayers) =>
removeCanvasLayers(currentLayers, targetIds),
);
selectSingleLayer(null); selectSingleLayer(null);
setMetadataLayer((currentLayer) => setMetadataLayer((currentLayer) =>
currentLayer && targetIds.includes(currentLayer.id) currentLayer && targetIds.includes(currentLayer.id)
@@ -1534,13 +1567,29 @@ export function ImageCanvasEditorView() {
const listRect = listElement?.getBoundingClientRect(); const listRect = listElement?.getBoundingClientRect();
const headerRect = header?.getBoundingClientRect(); const headerRect = header?.getBoundingClientRect();
setPinnedAssetMoveFolderId( setPinnedAssetMoveFolderId(
listRect && headerRect && listRect &&
headerRect &&
(headerRect.bottom < listRect.top || headerRect.top > listRect.bottom) (headerRect.bottom < listRect.top || headerRect.top > listRect.bottom)
? folderId ? folderId
: null, : 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 = ( const addAssetLayer = (
asset: EditorAsset, asset: EditorAsset,
position?: { x: number; y: number }, position?: { x: number; y: number },
@@ -1557,10 +1606,9 @@ export function ImageCanvasEditorView() {
}, },
); );
captureCanvasHistory(); captureCanvasHistory();
setLayers((currentLayers) => [...currentLayers, nextLayer]); appendCanvasLayersWithResources([nextLayer]);
selectSingleLayer(nextLayer.id); selectSingleLayer(nextLayer.id);
setHoveredLayerId(null); setHoveredLayerId(null);
createProjectResourceForLayer(nextLayer);
}; };
addAssetLayerRef.current = addAssetLayer; addAssetLayerRef.current = addAssetLayer;
@@ -1609,7 +1657,10 @@ export function ImageCanvasEditorView() {
try { try {
const blob = await readLayerImageBlob(layer); const blob = await readLayerImageBlob(layer);
const extension = getImageExtensionFromTypeOrSrc(blob.type, layer.src); const extension = getImageExtensionFromTypeOrSrc(
blob.type,
layer.src,
);
const file = `images/${indexedFileName}.${extension}`; const file = `images/${indexedFileName}.${extension}`;
imageByKey.set(key, { imageByKey.set(key, {
key, key,
@@ -1872,7 +1923,8 @@ export function ImageCanvasEditorView() {
setSelectedLayerIds((currentIds) => setSelectedLayerIds((currentIds) =>
currentIds.filter((layerId) => currentIds.filter((layerId) =>
layers.every( 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 deleteSelectedAssets = () => {
const ids = [...selectedAssetIds]; const ids = [...selectedAssetIds];
const deletedAssets = assets.filter((asset) => selectedAssetIds.has(asset.id)); const deletedAssets = assets.filter((asset) =>
selectedAssetIds.has(asset.id),
);
setAssets((currentAssets) => setAssets((currentAssets) =>
currentAssets.filter((asset) => !selectedAssetIds.has(asset.id)), currentAssets.filter((asset) => !selectedAssetIds.has(asset.id)),
); );
@@ -2363,7 +2417,7 @@ export function ImageCanvasEditorView() {
}; };
if (options.addToCanvas) { if (options.addToCanvas) {
setLayers((currentLayers) => [...currentLayers, nextLayer]); appendCanvasLayersWithResources([nextLayer]);
} }
if (options.addToCanvas) { if (options.addToCanvas) {
selectSingleLayer(nextLayer.id); selectSingleLayer(nextLayer.id);
@@ -2446,10 +2500,6 @@ export function ImageCanvasEditorView() {
); );
}); });
if (options.addToCanvas) {
createProjectResourceForLayer(nextLayer);
}
if (imageSrc) { if (imageSrc) {
const uploadedImage = new Image(); const uploadedImage = new Image();
uploadedImage.onload = () => { uploadedImage.onload = () => {
@@ -2825,7 +2875,7 @@ export function ImageCanvasEditorView() {
generationInputs: options.generationInputs, generationInputs: options.generationInputs,
}; };
setLayers((currentLayers) => [...currentLayers, nextLayer]); appendCanvasLayersWithResources([nextLayer]);
selectSingleLayer(nextLayer.id); selectSingleLayer(nextLayer.id);
setActiveSidebarPanel('layers'); setActiveSidebarPanel('layers');
if (options.sourceLayer) { if (options.sourceLayer) {
@@ -2848,7 +2898,6 @@ export function ImageCanvasEditorView() {
if (options.sourceLayer) { if (options.sourceLayer) {
fitLayers([options.sourceLayer, nextLayer]); fitLayers([options.sourceLayer, nextLayer]);
} }
createProjectResourceForLayer(nextLayer);
}; };
const addQuickEditResultLayer = ( const addQuickEditResultLayer = (
@@ -2859,7 +2908,8 @@ export function ImageCanvasEditorView() {
layerCounterRef.current += 1; layerCounterRef.current += 1;
const generatedIndex = layerCounterRef.current; const generatedIndex = layerCounterRef.current;
const originalWidth = generated.width || sourceLayer.originalWidth || 1024; 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( const { width, height } = resolveLayerResolutionSize(
originalWidth, originalWidth,
originalHeight, originalHeight,
@@ -2894,13 +2944,12 @@ export function ImageCanvasEditorView() {
generationInputs, generationInputs,
}; };
setLayers((currentLayers) => [...currentLayers, nextLayer]); appendCanvasLayersWithResources([nextLayer]);
selectSingleLayer(nextLayer.id); selectSingleLayer(nextLayer.id);
setActiveSidebarPanel('layers'); setActiveSidebarPanel('layers');
setQuickEditPanel(null); setQuickEditPanel(null);
setActiveTool('select'); setActiveTool('select');
fitLayers([sourceLayer, nextLayer]); fitLayers([sourceLayer, nextLayer]);
createProjectResourceForLayer(nextLayer);
}; };
const addIconSpritesheetResultLayers = ( const addIconSpritesheetResultLayers = (
@@ -2970,14 +3019,13 @@ export function ImageCanvasEditorView() {
if (!nextLayers.length) { if (!nextLayers.length) {
return; return;
} }
setLayers((currentLayers) => [...currentLayers, ...nextLayers]); appendCanvasLayersWithResources(nextLayers);
selectSingleLayer(nextLayers[0]?.id ?? null); selectSingleLayer(nextLayers[0]?.id ?? null);
setActiveSidebarPanel('layers'); setActiveSidebarPanel('layers');
if (dialogId) { if (dialogId) {
removeCanvasGenerationDialogById(dialogId); removeCanvasGenerationDialogById(dialogId);
} }
setActiveTool('select'); setActiveTool('select');
nextLayers.forEach((layer) => createProjectResourceForLayer(layer));
}; };
const updateIconDescription = (index: number, value: string) => { const updateIconDescription = (index: number, value: string) => {
@@ -3101,8 +3149,9 @@ export function ImageCanvasEditorView() {
}); });
try { try {
const referenceImageSrc = const referenceImageSrc = await resolveEditorImageReferenceDataUrl(
await resolveEditorImageReferenceDataUrl(quickEditSourceLayer.src); quickEditSourceLayer.src,
);
const generated = await generateEditorImage({ const generated = await generateEditorImage({
prompt: normalizedPrompt, prompt: normalizedPrompt,
size: quickEditPanel.size, size: quickEditPanel.size,
@@ -3388,9 +3437,7 @@ export function ImageCanvasEditorView() {
}); });
}; };
const handleCanvasContextMenu = ( const handleCanvasContextMenu = (event: ReactMouseEvent<HTMLDivElement>) => {
event: ReactMouseEvent<HTMLDivElement>,
) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
const position = resolveContextMenuPosition( const position = resolveContextMenuPosition(
@@ -4241,14 +4288,18 @@ export function ImageCanvasEditorView() {
setCharacterAnimationPanel={setCharacterAnimationPanel} setCharacterAnimationPanel={setCharacterAnimationPanel}
setIsCharacterSpecMenuOpen={setIsCharacterSpecMenuOpen} setIsCharacterSpecMenuOpen={setIsCharacterSpecMenuOpen}
setIsIconSpecMenuOpen={setIsIconSpecMenuOpen} setIsIconSpecMenuOpen={setIsIconSpecMenuOpen}
setIsPickingCharacterSpecFromCanvas={setIsPickingCharacterSpecFromCanvas} setIsPickingCharacterSpecFromCanvas={
setIsPickingCharacterSpecFromCanvas
}
setIsPickingIconSpecFromCanvas={setIsPickingIconSpecFromCanvas} setIsPickingIconSpecFromCanvas={setIsPickingIconSpecFromCanvas}
onOpenSpecDialog={openSpecDialog} onOpenSpecDialog={openSpecDialog}
onRequestUpload={(target) => { onRequestUpload={(target) => {
setUploadTarget(target); setUploadTarget(target);
uploadInputRef.current?.click(); uploadInputRef.current?.click();
}} }}
onSubmitImageGeneration={(dialog) => void submitImageGeneration(dialog)} onSubmitImageGeneration={(dialog) =>
void submitImageGeneration(dialog)
}
onSubmitIconSpritesheetGeneration={(dialog) => onSubmitIconSpritesheetGeneration={(dialog) =>
void submitIconSpritesheetGeneration(dialog) void submitIconSpritesheetGeneration(dialog)
} }
@@ -4268,9 +4319,10 @@ export function ImageCanvasEditorView() {
onUpdateSpecFormValue={updateSpecFormValue} onUpdateSpecFormValue={updateSpecFormValue}
onUpdateIconDescription={updateIconDescription} onUpdateIconDescription={updateIconDescription}
onAddIconDescription={addIconDescription} onAddIconDescription={addIconDescription}
onUpdateCharacterAnimationDuration={updateCharacterAnimationDuration} onUpdateCharacterAnimationDuration={
updateCharacterAnimationDuration
}
/> />
</ImageCanvasStageView> </ImageCanvasStageView>
</div> </div>
@@ -4311,7 +4363,11 @@ export function ImageCanvasEditorView() {
key={`${reference.title}-${reference.label}-${reference.src}`} key={`${reference.title}-${reference.label}-${reference.src}`}
className="image-canvas-editor__metadata-reference-card" 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-reference-copy">
<span className="image-canvas-editor__metadata-input-title"> <span className="image-canvas-editor__metadata-input-title">
{reference.title} {reference.title}