修复画布素材交互缺口

统一默认素材文件夹,避免侧栏拖拽上传重复生成图片

区分素材入库和画布拖拽上传,画布落点增加安全兜底

补齐画布 Shift 多选、框选渲染和多图层打组能力

调整生成器对话框隐藏逻辑,关闭按钮保留占位图

将缩放比例入口放入左下角面板并拦截编辑器内 Ctrl 滚轮缩放页面

补充素材上传、画布多选、图层打组和生成器隐藏回归测试
This commit is contained in:
2026-06-14 19:20:13 +08:00
parent 028a648d9c
commit 0fd0a06387
3 changed files with 527 additions and 139 deletions
@@ -200,7 +200,7 @@ describe('ImageCanvasEditorView', () => {
const sidebar = screen.getByRole('complementary', { name: '图片资源栏' });
expect(within(sidebar).getByRole('region', { name: '项目素材' })).toBeTruthy();
expect(within(sidebar).getByRole('region', { name: '参考素材' })).toBeTruthy();
expect(within(sidebar).queryByRole('region', { name: '参考素材' })).toBeNull();
await user.click(screen.getByRole('button', { name: '重命名素材拼图素材' }));
const renameInput = screen.getByLabelText('重命名素材拼图素材');
@@ -248,7 +248,6 @@ describe('ImageCanvasEditorView', () => {
new File(['image'], '角色草图.png', { type: 'image/png' }),
);
await user.click(screen.getByRole('button', { name: '打开素材' }));
const customFolder = screen.getByRole('region', { name: '角色上传' });
expect(within(customFolder).getByRole('button', { name: '添加角色草图.png' })).toBeTruthy();
expect(within(customFolder).getByRole('button', { name: '删除素材角色草图.png' })).toBeTruthy();
@@ -256,7 +255,7 @@ describe('ImageCanvasEditorView', () => {
await user.click(within(customFolder).getByRole('button', { name: '删除素材角色草图.png' }));
expect(screen.queryByRole('button', { name: '添加角色草图.png' })).toBeNull();
expect(screen.getByAltText('画布图片:角色草图.png')).toBeTruthy();
expect(screen.queryByAltText('画布图片:角色草图.png')).toBeNull();
});
it('renames and deletes asset folders through the persisted asset library API', async () => {
@@ -302,21 +301,21 @@ describe('ImageCanvasEditorView', () => {
expect(deleteEditorAssetFolderMock).toHaveBeenCalledWith('folder-role');
});
it('uploads multiple files and persists them as account-level assets', async () => {
it('uploads multiple files as account-level assets without adding canvas layers', async () => {
render(<ImageCanvasEditorView />);
const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
fireEvent.click(within(bottomToolbar).getByRole('button', { name: '上传工具' }));
await userEvent.upload(screen.getByLabelText('上传图片文件'), [
new File(['image-a'], '第一张.png', { type: 'image/png' }),
new File(['image-b'], '第二张.png', { type: 'image/png' }),
]);
await waitFor(() => {
expect(screen.getByAltText('画布图片:第一张.png')).toBeTruthy();
expect(screen.getByAltText('画布图片:第二张.png')).toBeTruthy();
expect(screen.getByRole('button', { name: '添加第一张.png' })).toBeTruthy();
expect(screen.getByRole('button', { name: '添加第二张.png' })).toBeTruthy();
});
expect(createEditorAssetMock).toHaveBeenCalledTimes(2);
expect(screen.queryByAltText('画布图片:第一张.png')).toBeNull();
expect(screen.queryByAltText('画布图片:第二张.png')).toBeNull();
});
it('supports asset selection mode and batch delete with shared toolbar', async () => {
@@ -534,21 +533,21 @@ describe('ImageCanvasEditorView', () => {
expect(screen.getByAltText('画布图片:大鱼素材')).toBeTruthy();
});
it('uploads an image file as a new canvas layer', async () => {
it('drops an image file on the canvas as a new canvas layer', async () => {
render(<ImageCanvasEditorView />);
await waitFor(() => {
expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
});
const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
expect(within(bottomToolbar).queryByRole('button', { name: '局部修改工具' })).toBeNull();
fireEvent.click(within(bottomToolbar).getByRole('button', { name: '上传工具' }));
await userEvent.upload(
screen.getByLabelText('上传图片文件'),
new File(['image'], '测试上传.png', { type: 'image/png' }),
);
const viewport = screen.getByLabelText('画布工作区');
fireEvent.drop(viewport, {
clientX: 430,
clientY: 260,
dataTransfer: {
files: [new File(['image'], '测试上传.png', { type: 'image/png' })],
types: ['Files'],
},
});
await waitFor(() => {
expect(screen.getByAltText('画布图片:测试上传.png')).toBeTruthy();
@@ -562,6 +561,23 @@ describe('ImageCanvasEditorView', () => {
expect(screen.getByRole('button', { name: '选择图层测试上传.png' })).toBeTruthy();
});
it('drops files into the asset panel only once without creating canvas layers', async () => {
render(<ImageCanvasEditorView />);
fireEvent.drop(screen.getByRole('region', { name: '项目素材' }), {
dataTransfer: {
files: [new File(['image'], '素材拖拽.png', { type: 'image/png' })],
types: ['Files'],
},
});
await waitFor(() => {
expect(screen.getByRole('button', { name: '添加素材拖拽.png' })).toBeTruthy();
});
expect(createEditorAssetMock).toHaveBeenCalledTimes(1);
expect(screen.queryByAltText('画布图片:素材拖拽.png')).toBeNull();
});
it('blocks the browser context menu inside the editor workspace', () => {
render(<ImageCanvasEditorView />);
@@ -674,6 +690,64 @@ describe('ImageCanvasEditorView', () => {
clientY: 280,
});
expect(screen.getByRole('button', { name: '当前缩放比例 90%' })).toBeTruthy();
const ctrlWheelEvent = new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
ctrlKey: true,
deltaY: -120,
clientX: 400,
clientY: 280,
});
viewport.dispatchEvent(ctrlWheelEvent);
expect(ctrlWheelEvent.defaultPrevented).toBe(true);
});
it('selects multiple canvas layers with shift click', async () => {
render(<ImageCanvasEditorView />);
const firstLayer = screen.getByAltText('画布图片:拼图素材').closest('button')!;
const secondLayer = screen.getByAltText('画布图片:大鱼素材').closest('button')!;
fireEvent.pointerDown(firstLayer, {
button: 0,
pointerId: 81,
clientX: 120,
clientY: 120,
});
fireEvent.pointerUp(screen.getByLabelText('画布工作区'), {
pointerId: 81,
clientX: 120,
clientY: 120,
});
await waitFor(() => {
expect(
screen.getByAltText('画布图片:拼图素材').closest('button')?.className,
).toContain('image-canvas-editor__layer--selected');
});
fireEvent.keyDown(window, { key: 'Shift', code: 'ShiftLeft' });
fireEvent.pointerDown(secondLayer, {
button: 0,
pointerId: 82,
clientX: 520,
clientY: 180,
shiftKey: true,
});
fireEvent.pointerUp(screen.getByLabelText('画布工作区'), {
pointerId: 82,
clientX: 520,
clientY: 180,
});
fireEvent.keyUp(window, { key: 'Shift', code: 'ShiftLeft' });
await waitFor(() => {
expect(
screen.getByAltText('画布图片:拼图素材').closest('button')?.className,
).toContain('image-canvas-editor__layer--selected');
expect(
screen.getByAltText('画布图片:大鱼素材').closest('button')?.className,
).toContain('image-canvas-editor__layer--selected');
});
});
it('drags the minimap to move the canvas viewport', () => {
@@ -707,10 +781,48 @@ describe('ImageCanvasEditorView', () => {
render(<ImageCanvasEditorView />);
fireEvent.click(screen.getByRole('button', { name: '打开图层' }));
fireEvent.pointerDown(screen.getByAltText('画布图片:拼图素材').closest('button')!, {
button: 0,
pointerId: 90,
clientX: 120,
clientY: 120,
});
fireEvent.pointerUp(screen.getByLabelText('画布工作区'), {
pointerId: 90,
clientX: 120,
clientY: 120,
});
await waitFor(() => {
expect(
screen.getByAltText('画布图片:拼图素材').closest('button')?.className,
).toContain('image-canvas-editor__layer--selected');
});
fireEvent.keyDown(window, { key: 'Shift', code: 'ShiftLeft' });
fireEvent.pointerDown(screen.getByAltText('画布图片:大鱼素材').closest('button')!, {
button: 0,
pointerId: 91,
clientX: 520,
clientY: 180,
shiftKey: true,
});
fireEvent.pointerUp(screen.getByLabelText('画布工作区'), {
pointerId: 91,
clientX: 520,
clientY: 180,
});
fireEvent.keyUp(window, { key: 'Shift', code: 'ShiftLeft' });
await waitFor(() => {
expect(
screen.getByAltText('画布图片:拼图素材').closest('button')?.className,
).toContain('image-canvas-editor__layer--selected');
expect(
screen.getByAltText('画布图片:大鱼素材').closest('button')?.className,
).toContain('image-canvas-editor__layer--selected');
});
fireEvent.click(screen.getByRole('button', { name: '图层打组' }));
await waitFor(() => {
expect(screen.getByText(//u)).toBeTruthy();
expect(screen.getAllByText(//u)).toHaveLength(2);
});
await waitFor(() => {
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
@@ -721,6 +833,10 @@ describe('ImageCanvasEditorView', () => {
title: '拼图素材',
groupId: expect.stringMatching(/^layer-group-/u),
}),
expect.objectContaining({
title: '大鱼素材',
groupId: expect.stringMatching(/^layer-group-/u),
}),
]),
}),
);
@@ -870,7 +986,7 @@ describe('ImageCanvasEditorView', () => {
expect(Number.parseFloat((generatedLayer as HTMLElement).style.top)).toBeGreaterThan(180);
});
it('keeps the generation composer when selecting another image', () => {
it('hides the generation composer when selecting another image but keeps the placeholder', () => {
render(<ImageCanvasEditorView />);
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
@@ -883,8 +999,17 @@ describe('ImageCanvasEditorView', () => {
clientY: 120,
});
expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy();
expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull();
expect(screen.getByLabelText('图像生成占位图')).toBeTruthy();
fireEvent.pointerDown(screen.getByLabelText('图像生成占位图'), {
button: 0,
pointerId: 64,
clientX: 300,
clientY: 180,
});
expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy();
});
it('keeps the generation composer when clicking the canvas outside generation controls', () => {
@@ -904,6 +1029,16 @@ describe('ImageCanvasEditorView', () => {
expect(screen.getByLabelText('图像生成占位图')).toBeTruthy();
});
it('closes the generation composer without removing the placeholder frame', () => {
render(<ImageCanvasEditorView />);
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
fireEvent.click(screen.getByRole('button', { name: '关闭生成图片' }));
expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull();
expect(screen.getByLabelText('图像生成占位图')).toBeTruthy();
});
it('shows generation errors instead of falling back to mock images', async () => {
generateEditorImageMock.mockRejectedValueOnce(new Error('VectorEngine 未配置'));
render(<ImageCanvasEditorView />);
File diff suppressed because it is too large Load Diff
+14
View File
@@ -3487,6 +3487,14 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
pointer-events: none;
}
.image-canvas-editor__canvas-marquee {
position: absolute;
z-index: 7;
border: 1px solid #2563eb;
background: rgb(37 99 235 / 0.12);
pointer-events: none;
}
.image-canvas-editor__asset-button {
display: block;
border: 0;
@@ -5222,6 +5230,12 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
}
}
.image-canvas-editor__panel-dock .image-canvas-editor__zoom-trigger {
width: auto;
min-width: 4.15rem;
padding: 0 0.65rem;
}
@keyframes baby-object-gift-lid-open {
0% {
transform: rotate(0deg) translateY(0);