修复画布素材交互缺口
统一默认素材文件夹,避免侧栏拖拽上传重复生成图片 区分素材入库和画布拖拽上传,画布落点增加安全兜底 补齐画布 Shift 多选、框选渲染和多图层打组能力 调整生成器对话框隐藏逻辑,关闭按钮保留占位图 将缩放比例入口放入左下角面板并拦截编辑器内 Ctrl 滚轮缩放页面 补充素材上传、画布多选、图层打组和生成器隐藏回归测试
This commit is contained in:
@@ -200,7 +200,7 @@ describe('ImageCanvasEditorView', () => {
|
|||||||
|
|
||||||
const sidebar = screen.getByRole('complementary', { name: '图片资源栏' });
|
const sidebar = screen.getByRole('complementary', { name: '图片资源栏' });
|
||||||
expect(within(sidebar).getByRole('region', { name: '项目素材' })).toBeTruthy();
|
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: '重命名素材拼图素材' }));
|
await user.click(screen.getByRole('button', { name: '重命名素材拼图素材' }));
|
||||||
const renameInput = screen.getByLabelText('重命名素材拼图素材');
|
const renameInput = screen.getByLabelText('重命名素材拼图素材');
|
||||||
@@ -248,7 +248,6 @@ describe('ImageCanvasEditorView', () => {
|
|||||||
new File(['image'], '角色草图.png', { type: 'image/png' }),
|
new File(['image'], '角色草图.png', { type: 'image/png' }),
|
||||||
);
|
);
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: '打开素材' }));
|
|
||||||
const customFolder = screen.getByRole('region', { name: '角色上传' });
|
const customFolder = screen.getByRole('region', { name: '角色上传' });
|
||||||
expect(within(customFolder).getByRole('button', { name: '添加角色草图.png' })).toBeTruthy();
|
expect(within(customFolder).getByRole('button', { name: '添加角色草图.png' })).toBeTruthy();
|
||||||
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' }));
|
await user.click(within(customFolder).getByRole('button', { name: '删除素材角色草图.png' }));
|
||||||
|
|
||||||
expect(screen.queryByRole('button', { name: '添加角色草图.png' })).toBeNull();
|
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 () => {
|
it('renames and deletes asset folders through the persisted asset library API', async () => {
|
||||||
@@ -302,21 +301,21 @@ describe('ImageCanvasEditorView', () => {
|
|||||||
expect(deleteEditorAssetFolderMock).toHaveBeenCalledWith('folder-role');
|
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 />);
|
render(<ImageCanvasEditorView />);
|
||||||
const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
|
|
||||||
|
|
||||||
fireEvent.click(within(bottomToolbar).getByRole('button', { name: '上传工具' }));
|
|
||||||
await userEvent.upload(screen.getByLabelText('上传图片文件'), [
|
await userEvent.upload(screen.getByLabelText('上传图片文件'), [
|
||||||
new File(['image-a'], '第一张.png', { type: 'image/png' }),
|
new File(['image-a'], '第一张.png', { type: 'image/png' }),
|
||||||
new File(['image-b'], '第二张.png', { type: 'image/png' }),
|
new File(['image-b'], '第二张.png', { type: 'image/png' }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByAltText('画布图片:第一张.png')).toBeTruthy();
|
expect(screen.getByRole('button', { name: '添加第一张.png' })).toBeTruthy();
|
||||||
expect(screen.getByAltText('画布图片:第二张.png')).toBeTruthy();
|
expect(screen.getByRole('button', { name: '添加第二张.png' })).toBeTruthy();
|
||||||
});
|
});
|
||||||
expect(createEditorAssetMock).toHaveBeenCalledTimes(2);
|
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 () => {
|
it('supports asset selection mode and batch delete with shared toolbar', async () => {
|
||||||
@@ -534,21 +533,21 @@ describe('ImageCanvasEditorView', () => {
|
|||||||
expect(screen.getByAltText('画布图片:大鱼素材')).toBeTruthy();
|
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 />);
|
render(<ImageCanvasEditorView />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
|
expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
|
const viewport = screen.getByLabelText('画布工作区');
|
||||||
|
fireEvent.drop(viewport, {
|
||||||
expect(within(bottomToolbar).queryByRole('button', { name: '局部修改工具' })).toBeNull();
|
clientX: 430,
|
||||||
|
clientY: 260,
|
||||||
fireEvent.click(within(bottomToolbar).getByRole('button', { name: '上传工具' }));
|
dataTransfer: {
|
||||||
await userEvent.upload(
|
files: [new File(['image'], '测试上传.png', { type: 'image/png' })],
|
||||||
screen.getByLabelText('上传图片文件'),
|
types: ['Files'],
|
||||||
new File(['image'], '测试上传.png', { type: 'image/png' }),
|
},
|
||||||
);
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByAltText('画布图片:测试上传.png')).toBeTruthy();
|
expect(screen.getByAltText('画布图片:测试上传.png')).toBeTruthy();
|
||||||
@@ -562,6 +561,23 @@ describe('ImageCanvasEditorView', () => {
|
|||||||
expect(screen.getByRole('button', { name: '选择图层测试上传.png' })).toBeTruthy();
|
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', () => {
|
it('blocks the browser context menu inside the editor workspace', () => {
|
||||||
render(<ImageCanvasEditorView />);
|
render(<ImageCanvasEditorView />);
|
||||||
|
|
||||||
@@ -674,6 +690,64 @@ describe('ImageCanvasEditorView', () => {
|
|||||||
clientY: 280,
|
clientY: 280,
|
||||||
});
|
});
|
||||||
expect(screen.getByRole('button', { name: '当前缩放比例 90%' })).toBeTruthy();
|
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', () => {
|
it('drags the minimap to move the canvas viewport', () => {
|
||||||
@@ -707,10 +781,48 @@ describe('ImageCanvasEditorView', () => {
|
|||||||
render(<ImageCanvasEditorView />);
|
render(<ImageCanvasEditorView />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '打开图层' }));
|
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: '图层打组' }));
|
fireEvent.click(screen.getByRole('button', { name: '图层打组' }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText(/已打组/u)).toBeTruthy();
|
expect(screen.getAllByText(/已打组/u)).toHaveLength(2);
|
||||||
});
|
});
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
|
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
|
||||||
@@ -721,6 +833,10 @@ describe('ImageCanvasEditorView', () => {
|
|||||||
title: '拼图素材',
|
title: '拼图素材',
|
||||||
groupId: expect.stringMatching(/^layer-group-/u),
|
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);
|
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 />);
|
render(<ImageCanvasEditorView />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
|
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
|
||||||
@@ -883,8 +999,17 @@ describe('ImageCanvasEditorView', () => {
|
|||||||
clientY: 120,
|
clientY: 120,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy();
|
expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull();
|
||||||
expect(screen.getByLabelText('图像生成占位图')).toBeTruthy();
|
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', () => {
|
it('keeps the generation composer when clicking the canvas outside generation controls', () => {
|
||||||
@@ -904,6 +1029,16 @@ describe('ImageCanvasEditorView', () => {
|
|||||||
expect(screen.getByLabelText('图像生成占位图')).toBeTruthy();
|
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 () => {
|
it('shows generation errors instead of falling back to mock images', async () => {
|
||||||
generateEditorImageMock.mockRejectedValueOnce(new Error('VectorEngine 未配置'));
|
generateEditorImageMock.mockRejectedValueOnce(new Error('VectorEngine 未配置'));
|
||||||
render(<ImageCanvasEditorView />);
|
render(<ImageCanvasEditorView />);
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ type GenerateDialogState = {
|
|||||||
mode: 'generate' | 'edit';
|
mode: 'generate' | 'edit';
|
||||||
prompt: string;
|
prompt: string;
|
||||||
status: 'idle' | 'generating' | 'failed';
|
status: 'idle' | 'generating' | 'failed';
|
||||||
|
composerOpen?: boolean;
|
||||||
sourceLayerId?: string;
|
sourceLayerId?: string;
|
||||||
generatedLayerId?: string;
|
generatedLayerId?: string;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
@@ -180,6 +181,14 @@ type AssetMarqueeState = {
|
|||||||
currentY: number;
|
currentY: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CanvasMarqueeState = {
|
||||||
|
pointerId: number;
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
currentX: number;
|
||||||
|
currentY: number;
|
||||||
|
};
|
||||||
|
|
||||||
type DragState =
|
type DragState =
|
||||||
| {
|
| {
|
||||||
kind: 'pan';
|
kind: 'pan';
|
||||||
@@ -192,10 +201,12 @@ type DragState =
|
|||||||
kind: 'layer';
|
kind: 'layer';
|
||||||
pointerId: number;
|
pointerId: number;
|
||||||
layerId: string;
|
layerId: string;
|
||||||
|
layerIds: string[];
|
||||||
startClientX: number;
|
startClientX: number;
|
||||||
startClientY: number;
|
startClientY: number;
|
||||||
startLayerX: number;
|
startLayerX: number;
|
||||||
startLayerY: number;
|
startLayerY: number;
|
||||||
|
startLayers: Array<{ id: string; x: number; y: number }>;
|
||||||
startScale: number;
|
startScale: number;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
@@ -241,7 +252,7 @@ const EDITOR_ASSETS: EditorAsset[] = [
|
|||||||
src: '/creation-type-references/big-fish.webp',
|
src: '/creation-type-references/big-fish.webp',
|
||||||
width: 720,
|
width: 720,
|
||||||
height: 405,
|
height: 405,
|
||||||
folderId: 'references',
|
folderId: 'project',
|
||||||
sourceKind: 'built-in',
|
sourceKind: 'built-in',
|
||||||
sourceType: 'uploaded',
|
sourceType: 'uploaded',
|
||||||
persisted: false,
|
persisted: false,
|
||||||
@@ -252,7 +263,7 @@ const EDITOR_ASSETS: EditorAsset[] = [
|
|||||||
src: '/creation-type-references/bark-battle.webp',
|
src: '/creation-type-references/bark-battle.webp',
|
||||||
width: 640,
|
width: 640,
|
||||||
height: 900,
|
height: 900,
|
||||||
folderId: 'references',
|
folderId: 'project',
|
||||||
sourceKind: 'built-in',
|
sourceKind: 'built-in',
|
||||||
sourceType: 'uploaded',
|
sourceType: 'uploaded',
|
||||||
persisted: false,
|
persisted: false,
|
||||||
@@ -263,7 +274,7 @@ const EDITOR_ASSETS: EditorAsset[] = [
|
|||||||
src: '/creation-type-references/visual-novel.webp',
|
src: '/creation-type-references/visual-novel.webp',
|
||||||
width: 720,
|
width: 720,
|
||||||
height: 405,
|
height: 405,
|
||||||
folderId: 'references',
|
folderId: 'project',
|
||||||
sourceKind: 'built-in',
|
sourceKind: 'built-in',
|
||||||
sourceType: 'uploaded',
|
sourceType: 'uploaded',
|
||||||
persisted: false,
|
persisted: false,
|
||||||
@@ -278,20 +289,6 @@ const EDITOR_ASSET_FOLDERS: EditorAssetFolder[] = [
|
|||||||
systemDefault: true,
|
systemDefault: true,
|
||||||
persisted: false,
|
persisted: false,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'references',
|
|
||||||
label: '参考素材',
|
|
||||||
collapsed: false,
|
|
||||||
systemDefault: false,
|
|
||||||
persisted: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'uploads',
|
|
||||||
label: '上传素材',
|
|
||||||
collapsed: false,
|
|
||||||
systemDefault: false,
|
|
||||||
persisted: false,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const INITIAL_LAYERS: CanvasLayer[] = [
|
const INITIAL_LAYERS: CanvasLayer[] = [
|
||||||
@@ -606,10 +603,12 @@ function resolveImageGenerationErrorMessage(error: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ImageCanvasEditorView() {
|
export function ImageCanvasEditorView() {
|
||||||
|
const editorRootRef = useRef<HTMLElement | null>(null);
|
||||||
const canvasViewportRef = useRef<HTMLDivElement | null>(null);
|
const canvasViewportRef = useRef<HTMLDivElement | null>(null);
|
||||||
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const assetListRef = useRef<HTMLDivElement | null>(null);
|
const assetListRef = useRef<HTMLDivElement | null>(null);
|
||||||
const dragStateRef = useRef<DragState | null>(null);
|
const dragStateRef = useRef<DragState | null>(null);
|
||||||
|
const isShiftPressedRef = useRef(false);
|
||||||
const layerCounterRef = useRef(INITIAL_LAYERS.length);
|
const layerCounterRef = useRef(INITIAL_LAYERS.length);
|
||||||
const saveTimerRef = useRef<number | null>(null);
|
const saveTimerRef = useRef<number | null>(null);
|
||||||
const [projectId, setProjectId] = useState<string | null>(null);
|
const [projectId, setProjectId] = useState<string | null>(null);
|
||||||
@@ -636,7 +635,7 @@ export function ImageCanvasEditorView() {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [creatingFolder, setCreatingFolder] = useState(false);
|
const [creatingFolder, setCreatingFolder] = useState(false);
|
||||||
const [newFolderName, setNewFolderName] = useState('');
|
const [newFolderName, setNewFolderName] = useState('');
|
||||||
const [activeUploadFolderId, setActiveUploadFolderId] = useState('uploads');
|
const [activeUploadFolderId, setActiveUploadFolderId] = useState('project');
|
||||||
const [isAssetSelectionMode, setIsAssetSelectionMode] = useState(false);
|
const [isAssetSelectionMode, setIsAssetSelectionMode] = useState(false);
|
||||||
const [selectedAssetIds, setSelectedAssetIds] = useState<Set<string>>(
|
const [selectedAssetIds, setSelectedAssetIds] = useState<Set<string>>(
|
||||||
() => new Set(),
|
() => new Set(),
|
||||||
@@ -644,6 +643,9 @@ export function ImageCanvasEditorView() {
|
|||||||
const [assetMarquee, setAssetMarquee] = useState<AssetMarqueeState | null>(
|
const [assetMarquee, setAssetMarquee] = useState<AssetMarqueeState | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
const [canvasMarquee, setCanvasMarquee] = useState<CanvasMarqueeState | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const [selectedLayerId, setSelectedLayerId] = useState<string | null>(
|
const [selectedLayerId, setSelectedLayerId] = useState<string | null>(
|
||||||
INITIAL_LAYERS[0]?.id ?? null,
|
INITIAL_LAYERS[0]?.id ?? null,
|
||||||
);
|
);
|
||||||
@@ -681,7 +683,8 @@ export function ImageCanvasEditorView() {
|
|||||||
generateDialog?.mode === 'generate'
|
generateDialog?.mode === 'generate'
|
||||||
? (activeGenerationLayer ?? generateDialog.placeholder ?? null)
|
? (activeGenerationLayer ?? generateDialog.placeholder ?? null)
|
||||||
: null;
|
: null;
|
||||||
const generationComposerStyle = generationAnchor
|
const generationComposerStyle =
|
||||||
|
generateDialog?.composerOpen !== false && generationAnchor
|
||||||
? {
|
? {
|
||||||
left:
|
left:
|
||||||
viewport.x +
|
viewport.x +
|
||||||
@@ -723,6 +726,16 @@ export function ImageCanvasEditorView() {
|
|||||||
const selectSingleLayer = (layerId: string | null) => {
|
const selectSingleLayer = (layerId: string | null) => {
|
||||||
setSelectedLayerId(layerId);
|
setSelectedLayerId(layerId);
|
||||||
setSelectedLayerIds(layerId ? [layerId] : []);
|
setSelectedLayerIds(layerId ? [layerId] : []);
|
||||||
|
if (layerId && generateDialog?.mode === 'generate') {
|
||||||
|
setGenerateDialog((currentDialog) =>
|
||||||
|
currentDialog?.mode === 'generate'
|
||||||
|
? {
|
||||||
|
...currentDialog,
|
||||||
|
composerOpen: false,
|
||||||
|
}
|
||||||
|
: currentDialog,
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
const minimapModel = useMemo(() => {
|
const minimapModel = useMemo(() => {
|
||||||
const layerBounds = getLayerBounds(layers);
|
const layerBounds = getLayerBounds(layers);
|
||||||
@@ -864,12 +877,22 @@ export function ImageCanvasEditorView() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Shift') {
|
||||||
|
isShiftPressedRef.current = true;
|
||||||
|
}
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
setActiveSidebarPanel(null);
|
setActiveSidebarPanel(null);
|
||||||
setIsZoomMenuOpen(false);
|
setIsZoomMenuOpen(false);
|
||||||
setIsBackgroundMenuOpen(false);
|
setIsBackgroundMenuOpen(false);
|
||||||
setGenerateDialog((currentDialog) =>
|
setGenerateDialog((currentDialog) =>
|
||||||
currentDialog?.status === 'generating' ? currentDialog : null,
|
currentDialog?.status === 'generating'
|
||||||
|
? currentDialog
|
||||||
|
: currentDialog?.mode === 'generate'
|
||||||
|
? {
|
||||||
|
...currentDialog,
|
||||||
|
composerOpen: false,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -880,6 +903,9 @@ export function ImageCanvasEditorView() {
|
|||||||
setIsSpacePanning(true);
|
setIsSpacePanning(true);
|
||||||
};
|
};
|
||||||
const handleKeyUp = (event: KeyboardEvent) => {
|
const handleKeyUp = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Shift') {
|
||||||
|
isShiftPressedRef.current = false;
|
||||||
|
}
|
||||||
if (event.code !== 'Space') {
|
if (event.code !== 'Space') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -895,6 +921,27 @@ export function ImageCanvasEditorView() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const blockBrowserZoom = (event: WheelEvent) => {
|
||||||
|
const editorElement = editorRootRef.current;
|
||||||
|
if (
|
||||||
|
editorElement &&
|
||||||
|
event.target instanceof Node &&
|
||||||
|
editorElement.contains(event.target) &&
|
||||||
|
(event.ctrlKey || event.metaKey)
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('wheel', blockBrowserZoom, {
|
||||||
|
capture: true,
|
||||||
|
passive: false,
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('wheel', blockBrowserZoom, { capture: true });
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!projectId || !isProjectReady) {
|
if (!projectId || !isProjectReady) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -1364,6 +1411,7 @@ export function ImageCanvasEditorView() {
|
|||||||
folderId?: string;
|
folderId?: string;
|
||||||
canvasPoint?: { x: number; y: number };
|
canvasPoint?: { x: number; y: number };
|
||||||
uploadIndex?: number;
|
uploadIndex?: number;
|
||||||
|
addToCanvas?: boolean;
|
||||||
} = {},
|
} = {},
|
||||||
) => {
|
) => {
|
||||||
if (!file.type.startsWith('image/')) {
|
if (!file.type.startsWith('image/')) {
|
||||||
@@ -1379,13 +1427,22 @@ export function ImageCanvasEditorView() {
|
|||||||
const uploadFolderId =
|
const uploadFolderId =
|
||||||
assetFolders.some((folder) => folder.id === (options.folderId ?? activeUploadFolderId))
|
assetFolders.some((folder) => folder.id === (options.folderId ?? activeUploadFolderId))
|
||||||
? (options.folderId ?? activeUploadFolderId)
|
? (options.folderId ?? activeUploadFolderId)
|
||||||
: 'uploads';
|
: 'project';
|
||||||
const screenPoint = options.canvasPoint ?? {
|
const screenPoint = options.canvasPoint ?? {
|
||||||
x: canvasSize.width / 2,
|
x: canvasSize.width / 2,
|
||||||
y: canvasSize.height / 2,
|
y: canvasSize.height / 2,
|
||||||
};
|
};
|
||||||
const worldCenterX = (screenPoint.x - viewport.x) / viewport.scale;
|
const fallbackScreenPoint = {
|
||||||
const worldCenterY = (screenPoint.y - viewport.y) / viewport.scale;
|
x: canvasSize.width > 0 ? canvasSize.width / 2 : 640,
|
||||||
|
y: canvasSize.height > 0 ? canvasSize.height / 2 : 360,
|
||||||
|
};
|
||||||
|
const normalizedScreenPoint = {
|
||||||
|
x: Number.isFinite(screenPoint.x) ? screenPoint.x : fallbackScreenPoint.x,
|
||||||
|
y: Number.isFinite(screenPoint.y) ? screenPoint.y : fallbackScreenPoint.y,
|
||||||
|
};
|
||||||
|
const safeScale = viewport.scale > 0 ? viewport.scale : 1;
|
||||||
|
const worldCenterX = (normalizedScreenPoint.x - viewport.x) / safeScale;
|
||||||
|
const worldCenterY = (normalizedScreenPoint.y - viewport.y) / safeScale;
|
||||||
const nextLayer: CanvasLayer = {
|
const nextLayer: CanvasLayer = {
|
||||||
id: `layer-upload-${uploadIndex}`,
|
id: `layer-upload-${uploadIndex}`,
|
||||||
resourceId: `local-resource-upload-${uploadIndex}`,
|
resourceId: `local-resource-upload-${uploadIndex}`,
|
||||||
@@ -1412,7 +1469,9 @@ export function ImageCanvasEditorView() {
|
|||||||
persisted: false,
|
persisted: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (options.addToCanvas) {
|
||||||
setLayers((currentLayers) => [...currentLayers, nextLayer]);
|
setLayers((currentLayers) => [...currentLayers, nextLayer]);
|
||||||
|
}
|
||||||
setAssets((currentAssets) => [...currentAssets, uploadedAsset]);
|
setAssets((currentAssets) => [...currentAssets, uploadedAsset]);
|
||||||
setAssetFolders((currentFolders) =>
|
setAssetFolders((currentFolders) =>
|
||||||
currentFolders.map((folder) =>
|
currentFolders.map((folder) =>
|
||||||
@@ -1424,8 +1483,10 @@ export function ImageCanvasEditorView() {
|
|||||||
: folder,
|
: folder,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
if (options.addToCanvas) {
|
||||||
selectSingleLayer(nextLayer.id);
|
selectSingleLayer(nextLayer.id);
|
||||||
setActiveSidebarPanel('layers');
|
setActiveSidebarPanel('layers');
|
||||||
|
}
|
||||||
|
|
||||||
createEditorAsset({
|
createEditorAsset({
|
||||||
folderId: uploadFolderId,
|
folderId: uploadFolderId,
|
||||||
@@ -1457,7 +1518,9 @@ export function ImageCanvasEditorView() {
|
|||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
|
if (options.addToCanvas) {
|
||||||
createProjectResourceForLayer(nextLayer);
|
createProjectResourceForLayer(nextLayer);
|
||||||
|
}
|
||||||
|
|
||||||
if (imageSrc) {
|
if (imageSrc) {
|
||||||
const uploadedImage = new Image();
|
const uploadedImage = new Image();
|
||||||
@@ -1468,6 +1531,7 @@ export function ImageCanvasEditorView() {
|
|||||||
const sizeRatio = longestSide > 0 ? Math.min(1, 420 / longestSide) : 1;
|
const sizeRatio = longestSide > 0 ? Math.min(1, 420 / longestSide) : 1;
|
||||||
const width = Math.round(originalWidth * sizeRatio);
|
const width = Math.round(originalWidth * sizeRatio);
|
||||||
const height = Math.round(originalHeight * sizeRatio);
|
const height = Math.round(originalHeight * sizeRatio);
|
||||||
|
if (options.addToCanvas) {
|
||||||
setLayers((currentLayers) =>
|
setLayers((currentLayers) =>
|
||||||
currentLayers.map((layer) =>
|
currentLayers.map((layer) =>
|
||||||
layer.id === nextLayer.id
|
layer.id === nextLayer.id
|
||||||
@@ -1483,6 +1547,7 @@ export function ImageCanvasEditorView() {
|
|||||||
: layer,
|
: layer,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
setAssets((currentAssets) =>
|
setAssets((currentAssets) =>
|
||||||
currentAssets.map((asset) =>
|
currentAssets.map((asset) =>
|
||||||
asset.id === uploadedAsset.id
|
asset.id === uploadedAsset.id
|
||||||
@@ -1501,13 +1566,18 @@ export function ImageCanvasEditorView() {
|
|||||||
|
|
||||||
const addUploadedFiles = (
|
const addUploadedFiles = (
|
||||||
files: FileList | File[],
|
files: FileList | File[],
|
||||||
options: { folderId?: string; canvasPoint?: { x: number; y: number } } = {},
|
options: {
|
||||||
|
folderId?: string;
|
||||||
|
canvasPoint?: { x: number; y: number };
|
||||||
|
addToCanvas?: boolean;
|
||||||
|
} = {},
|
||||||
) => {
|
) => {
|
||||||
Array.from(files).forEach((file, index) => {
|
Array.from(files).forEach((file, index) => {
|
||||||
layerCounterRef.current += 1;
|
layerCounterRef.current += 1;
|
||||||
const uploadIndex = layerCounterRef.current;
|
const uploadIndex = layerCounterRef.current;
|
||||||
void addUploadedLayer(file, {
|
void addUploadedLayer(file, {
|
||||||
...options,
|
...options,
|
||||||
|
addToCanvas: options.addToCanvas ?? false,
|
||||||
uploadIndex,
|
uploadIndex,
|
||||||
canvasPoint: options.canvasPoint
|
canvasPoint: options.canvasPoint
|
||||||
? {
|
? {
|
||||||
@@ -1546,6 +1616,7 @@ export function ImageCanvasEditorView() {
|
|||||||
mode: 'generate',
|
mode: 'generate',
|
||||||
prompt: '',
|
prompt: '',
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
|
composerOpen: true,
|
||||||
placeholder: {
|
placeholder: {
|
||||||
x: worldCenterX - placeholderWidth / 2,
|
x: worldCenterX - placeholderWidth / 2,
|
||||||
y: worldCenterY - placeholderHeight / 2,
|
y: worldCenterY - placeholderHeight / 2,
|
||||||
@@ -1567,6 +1638,7 @@ export function ImageCanvasEditorView() {
|
|||||||
? `${sourceLayer.prompt},在保持主体结构的基础上优化画面细节`
|
? `${sourceLayer.prompt},在保持主体结构的基础上优化画面细节`
|
||||||
: '',
|
: '',
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
|
composerOpen: true,
|
||||||
sourceLayerId: sourceLayer.id,
|
sourceLayerId: sourceLayer.id,
|
||||||
});
|
});
|
||||||
setActiveTool('generate');
|
setActiveTool('generate');
|
||||||
@@ -1627,6 +1699,7 @@ export function ImageCanvasEditorView() {
|
|||||||
? {
|
? {
|
||||||
...currentDialog,
|
...currentDialog,
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
|
composerOpen: true,
|
||||||
generatedLayerId: nextLayer.id,
|
generatedLayerId: nextLayer.id,
|
||||||
placeholder: undefined,
|
placeholder: undefined,
|
||||||
errorMessage: undefined,
|
errorMessage: undefined,
|
||||||
@@ -1648,6 +1721,7 @@ export function ImageCanvasEditorView() {
|
|||||||
...dialog,
|
...dialog,
|
||||||
prompt: normalizedPrompt,
|
prompt: normalizedPrompt,
|
||||||
status: 'generating',
|
status: 'generating',
|
||||||
|
composerOpen: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1673,6 +1747,7 @@ export function ImageCanvasEditorView() {
|
|||||||
...dialog,
|
...dialog,
|
||||||
prompt: normalizedPrompt,
|
prompt: normalizedPrompt,
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
|
composerOpen: true,
|
||||||
errorMessage: resolveImageGenerationErrorMessage(error),
|
errorMessage: resolveImageGenerationErrorMessage(error),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1739,6 +1814,27 @@ export function ImageCanvasEditorView() {
|
|||||||
if (button !== 0) {
|
if (button !== 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (
|
||||||
|
effectiveTool === 'select' &&
|
||||||
|
(event.target === event.currentTarget ||
|
||||||
|
target.classList.contains('image-canvas-editor__world'))
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
const rect = canvasViewportRef.current?.getBoundingClientRect();
|
||||||
|
const startX = event.clientX - (rect?.left ?? 0);
|
||||||
|
const startY = event.clientY - (rect?.top ?? 0);
|
||||||
|
canvasViewportRef.current?.setPointerCapture?.(event.pointerId);
|
||||||
|
setCanvasMarquee({
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
startX,
|
||||||
|
startY,
|
||||||
|
currentX: startX,
|
||||||
|
currentY: startY,
|
||||||
|
});
|
||||||
|
selectSingleLayer(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
selectSingleLayer(null);
|
selectSingleLayer(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1770,6 +1866,7 @@ export function ImageCanvasEditorView() {
|
|||||||
addUploadedFiles(files, {
|
addUploadedFiles(files, {
|
||||||
folderId: defaultFolder?.id,
|
folderId: defaultFolder?.id,
|
||||||
canvasPoint,
|
canvasPoint,
|
||||||
|
addToCanvas: true,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1791,15 +1888,51 @@ export function ImageCanvasEditorView() {
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
const pointer = getPointerClient(event);
|
const pointer = getPointerClient(event);
|
||||||
canvasViewportRef.current?.setPointerCapture?.(event.pointerId);
|
canvasViewportRef.current?.setPointerCapture?.(event.pointerId);
|
||||||
selectSingleLayer(layer.id);
|
const isMultiSelectGesture = event.shiftKey || isShiftPressedRef.current;
|
||||||
|
const nextSelectedIds = isMultiSelectGesture
|
||||||
|
? selectedLayerIds.includes(layer.id)
|
||||||
|
? selectedLayerIds.length > 1
|
||||||
|
? selectedLayerIds.filter((layerId) => layerId !== layer.id)
|
||||||
|
: [layer.id]
|
||||||
|
: [...selectedLayerIds, layer.id]
|
||||||
|
: [layer.id];
|
||||||
|
setSelectedLayerId(layer.id);
|
||||||
|
setSelectedLayerIds(nextSelectedIds);
|
||||||
|
setGenerateDialog((currentDialog) => {
|
||||||
|
if (currentDialog?.mode !== 'generate') {
|
||||||
|
return currentDialog;
|
||||||
|
}
|
||||||
|
if (currentDialog.generatedLayerId === layer.id) {
|
||||||
|
return {
|
||||||
|
...currentDialog,
|
||||||
|
composerOpen: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...currentDialog,
|
||||||
|
composerOpen: false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const dragLayerIds = nextSelectedIds.includes(layer.id)
|
||||||
|
? nextSelectedIds
|
||||||
|
: [layer.id];
|
||||||
|
const startLayers = layers
|
||||||
|
.filter((currentLayer) => dragLayerIds.includes(currentLayer.id))
|
||||||
|
.map((currentLayer) => ({
|
||||||
|
id: currentLayer.id,
|
||||||
|
x: currentLayer.x,
|
||||||
|
y: currentLayer.y,
|
||||||
|
}));
|
||||||
dragStateRef.current = {
|
dragStateRef.current = {
|
||||||
kind: 'layer',
|
kind: 'layer',
|
||||||
pointerId: getPointerId(event),
|
pointerId: getPointerId(event),
|
||||||
layerId: layer.id,
|
layerId: layer.id,
|
||||||
|
layerIds: dragLayerIds,
|
||||||
startClientX: pointer.x,
|
startClientX: pointer.x,
|
||||||
startClientY: pointer.y,
|
startClientY: pointer.y,
|
||||||
startLayerX: layer.x,
|
startLayerX: layer.x,
|
||||||
startLayerY: layer.y,
|
startLayerY: layer.y,
|
||||||
|
startLayers,
|
||||||
startScale: viewport.scale,
|
startScale: viewport.scale,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -1824,7 +1957,16 @@ export function ImageCanvasEditorView() {
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
const pointer = getPointerClient(event);
|
const pointer = getPointerClient(event);
|
||||||
canvasViewportRef.current?.setPointerCapture?.(event.pointerId);
|
canvasViewportRef.current?.setPointerCapture?.(event.pointerId);
|
||||||
selectSingleLayer(null);
|
setSelectedLayerId(null);
|
||||||
|
setSelectedLayerIds([]);
|
||||||
|
setGenerateDialog((currentDialog) =>
|
||||||
|
currentDialog?.mode === 'generate'
|
||||||
|
? {
|
||||||
|
...currentDialog,
|
||||||
|
composerOpen: true,
|
||||||
|
}
|
||||||
|
: currentDialog,
|
||||||
|
);
|
||||||
dragStateRef.current = {
|
dragStateRef.current = {
|
||||||
kind: 'generation-frame',
|
kind: 'generation-frame',
|
||||||
pointerId: getPointerId(event),
|
pointerId: getPointerId(event),
|
||||||
@@ -1875,6 +2017,43 @@ export function ImageCanvasEditorView() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
|
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
if (canvasMarquee && canvasMarquee.pointerId === event.pointerId) {
|
||||||
|
event.preventDefault();
|
||||||
|
const rect = canvasViewportRef.current?.getBoundingClientRect();
|
||||||
|
const currentX = event.clientX - (rect?.left ?? 0);
|
||||||
|
const currentY = event.clientY - (rect?.top ?? 0);
|
||||||
|
setCanvasMarquee((currentMarquee) =>
|
||||||
|
currentMarquee
|
||||||
|
? {
|
||||||
|
...currentMarquee,
|
||||||
|
currentX,
|
||||||
|
currentY,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
const left = Math.min(canvasMarquee.startX, currentX);
|
||||||
|
const right = Math.max(canvasMarquee.startX, currentX);
|
||||||
|
const top = Math.min(canvasMarquee.startY, currentY);
|
||||||
|
const bottom = Math.max(canvasMarquee.startY, currentY);
|
||||||
|
const selectedIds = layers
|
||||||
|
.filter((layer) => {
|
||||||
|
const layerLeft = viewport.x + layer.x * viewport.scale;
|
||||||
|
const layerTop = viewport.y + layer.y * viewport.scale;
|
||||||
|
const layerRight = layerLeft + layer.width * viewport.scale;
|
||||||
|
const layerBottom = layerTop + layer.height * viewport.scale;
|
||||||
|
return (
|
||||||
|
layerLeft <= right &&
|
||||||
|
layerRight >= left &&
|
||||||
|
layerTop <= bottom &&
|
||||||
|
layerBottom >= top
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map((layer) => layer.id);
|
||||||
|
setSelectedLayerIds(selectedIds);
|
||||||
|
setSelectedLayerId(selectedIds[0] ?? null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const dragState = dragStateRef.current;
|
const dragState = dragStateRef.current;
|
||||||
const pointerId = getPointerId(event);
|
const pointerId = getPointerId(event);
|
||||||
if (
|
if (
|
||||||
@@ -1936,18 +2115,42 @@ export function ImageCanvasEditorView() {
|
|||||||
setSnapGuide(snapped.guide);
|
setSnapGuide(snapped.guide);
|
||||||
setLayers((currentLayers) =>
|
setLayers((currentLayers) =>
|
||||||
currentLayers.map((layer) =>
|
currentLayers.map((layer) =>
|
||||||
layer.id === dragState.layerId
|
dragState.layerIds.includes(layer.id)
|
||||||
? {
|
? (() => {
|
||||||
|
const startLayer = dragState.startLayers.find(
|
||||||
|
(item) => item.id === layer.id,
|
||||||
|
);
|
||||||
|
if (!startLayer) {
|
||||||
|
return layer;
|
||||||
|
}
|
||||||
|
if (layer.id === dragState.layerId) {
|
||||||
|
return {
|
||||||
...layer,
|
...layer,
|
||||||
x: snapped.x,
|
x: snapped.x,
|
||||||
y: snapped.y,
|
y: snapped.y,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
return {
|
||||||
|
...layer,
|
||||||
|
x: startLayer.x + deltaX + (snapped.x - (dragState.startLayerX + deltaX)),
|
||||||
|
y: startLayer.y + deltaY + (snapped.y - (dragState.startLayerY + deltaY)),
|
||||||
|
};
|
||||||
|
})()
|
||||||
: layer,
|
: layer,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const finishDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
|
const finishDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
if (canvasMarquee && canvasMarquee.pointerId === event.pointerId) {
|
||||||
|
event.preventDefault();
|
||||||
|
setCanvasMarquee(null);
|
||||||
|
if (canvasViewportRef.current?.hasPointerCapture?.(event.pointerId)) {
|
||||||
|
canvasViewportRef.current.releasePointerCapture?.(event.pointerId);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const dragState = dragStateRef.current;
|
const dragState = dragStateRef.current;
|
||||||
const pointerId = getPointerId(event);
|
const pointerId = getPointerId(event);
|
||||||
if (
|
if (
|
||||||
@@ -2025,6 +2228,7 @@ export function ImageCanvasEditorView() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
|
ref={editorRootRef}
|
||||||
className="image-canvas-editor"
|
className="image-canvas-editor"
|
||||||
aria-label="图片画布编辑器"
|
aria-label="图片画布编辑器"
|
||||||
onContextMenu={(event) => event.preventDefault()}
|
onContextMenu={(event) => event.preventDefault()}
|
||||||
@@ -2039,7 +2243,7 @@ export function ImageCanvasEditorView() {
|
|||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const files = event.currentTarget.files;
|
const files = event.currentTarget.files;
|
||||||
if (files?.length) {
|
if (files?.length) {
|
||||||
addUploadedFiles(files);
|
addUploadedFiles(files, { addToCanvas: activeTool === 'upload' });
|
||||||
}
|
}
|
||||||
event.currentTarget.value = '';
|
event.currentTarget.value = '';
|
||||||
}}
|
}}
|
||||||
@@ -2146,6 +2350,7 @@ export function ImageCanvasEditorView() {
|
|||||||
onDragOver={(event) => {
|
onDragOver={(event) => {
|
||||||
if (event.dataTransfer.types.includes('Files')) {
|
if (event.dataTransfer.types.includes('Files')) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
event.dataTransfer.dropEffect = 'copy';
|
event.dataTransfer.dropEffect = 'copy';
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -2154,6 +2359,7 @@ export function ImageCanvasEditorView() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
addUploadedFiles(event.dataTransfer.files, { folderId: folder.id });
|
addUploadedFiles(event.dataTransfer.files, { folderId: folder.id });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -2339,6 +2545,7 @@ export function ImageCanvasEditorView() {
|
|||||||
onDragOver={(event) => {
|
onDragOver={(event) => {
|
||||||
if (event.dataTransfer.types.includes('Files')) {
|
if (event.dataTransfer.types.includes('Files')) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
event.dataTransfer.dropEffect = 'copy';
|
event.dataTransfer.dropEffect = 'copy';
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -2347,6 +2554,7 @@ export function ImageCanvasEditorView() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
addUploadedFiles(event.dataTransfer.files, {
|
addUploadedFiles(event.dataTransfer.files, {
|
||||||
folderId: asset.folderId,
|
folderId: asset.folderId,
|
||||||
});
|
});
|
||||||
@@ -2440,60 +2648,6 @@ export function ImageCanvasEditorView() {
|
|||||||
<h1>图片编辑器</h1>
|
<h1>图片编辑器</h1>
|
||||||
<span>画布</span>
|
<span>画布</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="image-canvas-editor__zoom-menu-wrap">
|
|
||||||
<PlatformInlineOptionButton
|
|
||||||
className="image-canvas-editor__zoom-trigger"
|
|
||||||
aria-label={`当前缩放比例 ${formatPercent(viewport.scale)}`}
|
|
||||||
aria-haspopup="menu"
|
|
||||||
aria-expanded={isZoomMenuOpen}
|
|
||||||
onClick={() => setIsZoomMenuOpen((open) => !open)}
|
|
||||||
>
|
|
||||||
{formatPercent(viewport.scale)}
|
|
||||||
</PlatformInlineOptionButton>
|
|
||||||
{isZoomMenuOpen ? (
|
|
||||||
<PlatformFloatingMenu label="缩放菜单" placement="bottom-end">
|
|
||||||
<PlatformFloatingMenuItem
|
|
||||||
className="image-canvas-editor__zoom-menu-item"
|
|
||||||
onClick={() => {
|
|
||||||
updateScaleFromCenter(viewport.scale * 1.16);
|
|
||||||
setIsZoomMenuOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
放大
|
|
||||||
</PlatformFloatingMenuItem>
|
|
||||||
<PlatformFloatingMenuItem
|
|
||||||
className="image-canvas-editor__zoom-menu-item"
|
|
||||||
onClick={() => {
|
|
||||||
updateScaleFromCenter(viewport.scale * 0.86);
|
|
||||||
setIsZoomMenuOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
缩小
|
|
||||||
</PlatformFloatingMenuItem>
|
|
||||||
<PlatformFloatingMenuItem
|
|
||||||
className="image-canvas-editor__zoom-menu-item"
|
|
||||||
onClick={() => {
|
|
||||||
fitLayers();
|
|
||||||
setIsZoomMenuOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
显示画布所有元素
|
|
||||||
</PlatformFloatingMenuItem>
|
|
||||||
{[0.5, 1, 2].map((scale) => (
|
|
||||||
<PlatformFloatingMenuItem
|
|
||||||
key={scale}
|
|
||||||
className="image-canvas-editor__zoom-menu-item"
|
|
||||||
onClick={() => {
|
|
||||||
updateScaleFromCenter(scale);
|
|
||||||
setIsZoomMenuOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
缩放至{Math.round(scale * 100)}%
|
|
||||||
</PlatformFloatingMenuItem>
|
|
||||||
))}
|
|
||||||
</PlatformFloatingMenu>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -2536,7 +2690,7 @@ export function ImageCanvasEditorView() {
|
|||||||
.slice()
|
.slice()
|
||||||
.sort((left, right) => left.zIndex - right.zIndex)
|
.sort((left, right) => left.zIndex - right.zIndex)
|
||||||
.map((layer) => {
|
.map((layer) => {
|
||||||
const isSelected = selectedLayerId === layer.id;
|
const isSelected = selectedLayerIds.includes(layer.id);
|
||||||
const isHovered = hoveredLayerId === layer.id;
|
const isHovered = hoveredLayerId === layer.id;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -2587,9 +2741,23 @@ export function ImageCanvasEditorView() {
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{canvasMarquee ? (
|
||||||
|
<div
|
||||||
|
className="image-canvas-editor__canvas-marquee"
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
left: (Math.min(canvasMarquee.startX, canvasMarquee.currentX) - viewport.x) / viewport.scale,
|
||||||
|
top: (Math.min(canvasMarquee.startY, canvasMarquee.currentY) - viewport.y) / viewport.scale,
|
||||||
|
width: Math.abs(canvasMarquee.currentX - canvasMarquee.startX) / viewport.scale,
|
||||||
|
height: Math.abs(canvasMarquee.currentY - canvasMarquee.startY) / viewport.scale,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{generateDialog?.mode === 'generate' && generateDialog.placeholder ? (
|
{generateDialog?.mode === 'generate' && generateDialog.placeholder ? (
|
||||||
<div
|
<div
|
||||||
className="image-canvas-editor__generation-frame"
|
className="image-canvas-editor__generation-frame"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
style={{
|
style={{
|
||||||
left: generateDialog.placeholder.x,
|
left: generateDialog.placeholder.x,
|
||||||
top: generateDialog.placeholder.y,
|
top: generateDialog.placeholder.y,
|
||||||
@@ -2598,6 +2766,16 @@ export function ImageCanvasEditorView() {
|
|||||||
}}
|
}}
|
||||||
aria-label="图像生成占位图"
|
aria-label="图像生成占位图"
|
||||||
onPointerDown={handleGenerationFramePointerDown}
|
onPointerDown={handleGenerationFramePointerDown}
|
||||||
|
onDoubleClick={() =>
|
||||||
|
setGenerateDialog((currentDialog) =>
|
||||||
|
currentDialog?.mode === 'generate'
|
||||||
|
? {
|
||||||
|
...currentDialog,
|
||||||
|
composerOpen: true,
|
||||||
|
}
|
||||||
|
: currentDialog,
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<span className="image-canvas-editor__generation-frame-label">
|
<span className="image-canvas-editor__generation-frame-label">
|
||||||
<ImageIcon className="h-4 w-4" />
|
<ImageIcon className="h-4 w-4" />
|
||||||
@@ -2670,6 +2848,60 @@ export function ImageCanvasEditorView() {
|
|||||||
aria-label="画布面板入口"
|
aria-label="画布面板入口"
|
||||||
onPointerDown={(event) => event.stopPropagation()}
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
|
<div className="image-canvas-editor__zoom-menu-wrap">
|
||||||
|
<PlatformInlineOptionButton
|
||||||
|
className="image-canvas-editor__zoom-trigger"
|
||||||
|
aria-label={`当前缩放比例 ${formatPercent(viewport.scale)}`}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={isZoomMenuOpen}
|
||||||
|
onClick={() => setIsZoomMenuOpen((open) => !open)}
|
||||||
|
>
|
||||||
|
{formatPercent(viewport.scale)}
|
||||||
|
</PlatformInlineOptionButton>
|
||||||
|
{isZoomMenuOpen ? (
|
||||||
|
<PlatformFloatingMenu label="缩放菜单" placement="top-start">
|
||||||
|
<PlatformFloatingMenuItem
|
||||||
|
className="image-canvas-editor__zoom-menu-item"
|
||||||
|
onClick={() => {
|
||||||
|
updateScaleFromCenter(viewport.scale * 1.16);
|
||||||
|
setIsZoomMenuOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
放大
|
||||||
|
</PlatformFloatingMenuItem>
|
||||||
|
<PlatformFloatingMenuItem
|
||||||
|
className="image-canvas-editor__zoom-menu-item"
|
||||||
|
onClick={() => {
|
||||||
|
updateScaleFromCenter(viewport.scale * 0.86);
|
||||||
|
setIsZoomMenuOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
缩小
|
||||||
|
</PlatformFloatingMenuItem>
|
||||||
|
<PlatformFloatingMenuItem
|
||||||
|
className="image-canvas-editor__zoom-menu-item"
|
||||||
|
onClick={() => {
|
||||||
|
fitLayers();
|
||||||
|
setIsZoomMenuOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
显示画布所有元素
|
||||||
|
</PlatformFloatingMenuItem>
|
||||||
|
{[0.5, 1, 2].map((scale) => (
|
||||||
|
<PlatformFloatingMenuItem
|
||||||
|
key={scale}
|
||||||
|
className="image-canvas-editor__zoom-menu-item"
|
||||||
|
onClick={() => {
|
||||||
|
updateScaleFromCenter(scale);
|
||||||
|
setIsZoomMenuOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
缩放至{Math.round(scale * 100)}%
|
||||||
|
</PlatformFloatingMenuItem>
|
||||||
|
))}
|
||||||
|
</PlatformFloatingMenu>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
<div className="image-canvas-editor__background-control">
|
<div className="image-canvas-editor__background-control">
|
||||||
<PlatformIconButton
|
<PlatformIconButton
|
||||||
label="画布背景色"
|
label="画布背景色"
|
||||||
@@ -2889,7 +3121,14 @@ export function ImageCanvasEditorView() {
|
|||||||
variant="surfaceFloating"
|
variant="surfaceFloating"
|
||||||
disabled={generateDialog.status === 'generating'}
|
disabled={generateDialog.status === 'generating'}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setGenerateDialog(null);
|
setGenerateDialog((currentDialog) =>
|
||||||
|
currentDialog?.mode === 'generate'
|
||||||
|
? {
|
||||||
|
...currentDialog,
|
||||||
|
composerOpen: false,
|
||||||
|
}
|
||||||
|
: currentDialog,
|
||||||
|
);
|
||||||
setActiveTool('select');
|
setActiveTool('select');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3487,6 +3487,14 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
|||||||
pointer-events: none;
|
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 {
|
.image-canvas-editor__asset-button {
|
||||||
display: block;
|
display: block;
|
||||||
border: 0;
|
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 {
|
@keyframes baby-object-gift-lid-open {
|
||||||
0% {
|
0% {
|
||||||
transform: rotate(0deg) translateY(0);
|
transform: rotate(0deg) translateY(0);
|
||||||
|
|||||||
Reference in New Issue
Block a user