修复画布素材交互缺口

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

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

补齐画布 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

View File

@@ -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 />);

View File

@@ -148,6 +148,7 @@ type GenerateDialogState = {
mode: 'generate' | 'edit';
prompt: string;
status: 'idle' | 'generating' | 'failed';
composerOpen?: boolean;
sourceLayerId?: string;
generatedLayerId?: string;
errorMessage?: string;
@@ -180,6 +181,14 @@ type AssetMarqueeState = {
currentY: number;
};
type CanvasMarqueeState = {
pointerId: number;
startX: number;
startY: number;
currentX: number;
currentY: number;
};
type DragState =
| {
kind: 'pan';
@@ -192,10 +201,12 @@ type DragState =
kind: 'layer';
pointerId: number;
layerId: string;
layerIds: string[];
startClientX: number;
startClientY: number;
startLayerX: number;
startLayerY: number;
startLayers: Array<{ id: string; x: number; y: number }>;
startScale: number;
}
| {
@@ -241,7 +252,7 @@ const EDITOR_ASSETS: EditorAsset[] = [
src: '/creation-type-references/big-fish.webp',
width: 720,
height: 405,
folderId: 'references',
folderId: 'project',
sourceKind: 'built-in',
sourceType: 'uploaded',
persisted: false,
@@ -252,7 +263,7 @@ const EDITOR_ASSETS: EditorAsset[] = [
src: '/creation-type-references/bark-battle.webp',
width: 640,
height: 900,
folderId: 'references',
folderId: 'project',
sourceKind: 'built-in',
sourceType: 'uploaded',
persisted: false,
@@ -263,7 +274,7 @@ const EDITOR_ASSETS: EditorAsset[] = [
src: '/creation-type-references/visual-novel.webp',
width: 720,
height: 405,
folderId: 'references',
folderId: 'project',
sourceKind: 'built-in',
sourceType: 'uploaded',
persisted: false,
@@ -278,20 +289,6 @@ const EDITOR_ASSET_FOLDERS: EditorAssetFolder[] = [
systemDefault: true,
persisted: false,
},
{
id: 'references',
label: '参考素材',
collapsed: false,
systemDefault: false,
persisted: false,
},
{
id: 'uploads',
label: '上传素材',
collapsed: false,
systemDefault: false,
persisted: false,
},
];
const INITIAL_LAYERS: CanvasLayer[] = [
@@ -606,10 +603,12 @@ function resolveImageGenerationErrorMessage(error: unknown) {
}
export function ImageCanvasEditorView() {
const editorRootRef = useRef<HTMLElement | null>(null);
const canvasViewportRef = useRef<HTMLDivElement | null>(null);
const uploadInputRef = useRef<HTMLInputElement | null>(null);
const assetListRef = useRef<HTMLDivElement | null>(null);
const dragStateRef = useRef<DragState | null>(null);
const isShiftPressedRef = useRef(false);
const layerCounterRef = useRef(INITIAL_LAYERS.length);
const saveTimerRef = useRef<number | null>(null);
const [projectId, setProjectId] = useState<string | null>(null);
@@ -636,7 +635,7 @@ export function ImageCanvasEditorView() {
} | null>(null);
const [creatingFolder, setCreatingFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
const [activeUploadFolderId, setActiveUploadFolderId] = useState('uploads');
const [activeUploadFolderId, setActiveUploadFolderId] = useState('project');
const [isAssetSelectionMode, setIsAssetSelectionMode] = useState(false);
const [selectedAssetIds, setSelectedAssetIds] = useState<Set<string>>(
() => new Set(),
@@ -644,6 +643,9 @@ export function ImageCanvasEditorView() {
const [assetMarquee, setAssetMarquee] = useState<AssetMarqueeState | null>(
null,
);
const [canvasMarquee, setCanvasMarquee] = useState<CanvasMarqueeState | null>(
null,
);
const [selectedLayerId, setSelectedLayerId] = useState<string | null>(
INITIAL_LAYERS[0]?.id ?? null,
);
@@ -681,7 +683,8 @@ export function ImageCanvasEditorView() {
generateDialog?.mode === 'generate'
? (activeGenerationLayer ?? generateDialog.placeholder ?? null)
: null;
const generationComposerStyle = generationAnchor
const generationComposerStyle =
generateDialog?.composerOpen !== false && generationAnchor
? {
left:
viewport.x +
@@ -723,6 +726,16 @@ export function ImageCanvasEditorView() {
const selectSingleLayer = (layerId: string | null) => {
setSelectedLayerId(layerId);
setSelectedLayerIds(layerId ? [layerId] : []);
if (layerId && generateDialog?.mode === 'generate') {
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'generate'
? {
...currentDialog,
composerOpen: false,
}
: currentDialog,
);
}
};
const minimapModel = useMemo(() => {
const layerBounds = getLayerBounds(layers);
@@ -864,12 +877,22 @@ export function ImageCanvasEditorView() {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
isShiftPressedRef.current = true;
}
if (event.key === 'Escape') {
setActiveSidebarPanel(null);
setIsZoomMenuOpen(false);
setIsBackgroundMenuOpen(false);
setGenerateDialog((currentDialog) =>
currentDialog?.status === 'generating' ? currentDialog : null,
currentDialog?.status === 'generating'
? currentDialog
: currentDialog?.mode === 'generate'
? {
...currentDialog,
composerOpen: false,
}
: null,
);
return;
}
@@ -880,6 +903,9 @@ export function ImageCanvasEditorView() {
setIsSpacePanning(true);
};
const handleKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
isShiftPressedRef.current = false;
}
if (event.code !== 'Space') {
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(() => {
if (!projectId || !isProjectReady) {
return undefined;
@@ -1364,6 +1411,7 @@ export function ImageCanvasEditorView() {
folderId?: string;
canvasPoint?: { x: number; y: number };
uploadIndex?: number;
addToCanvas?: boolean;
} = {},
) => {
if (!file.type.startsWith('image/')) {
@@ -1379,13 +1427,22 @@ export function ImageCanvasEditorView() {
const uploadFolderId =
assetFolders.some((folder) => folder.id === (options.folderId ?? activeUploadFolderId))
? (options.folderId ?? activeUploadFolderId)
: 'uploads';
: 'project';
const screenPoint = options.canvasPoint ?? {
x: canvasSize.width / 2,
y: canvasSize.height / 2,
};
const worldCenterX = (screenPoint.x - viewport.x) / viewport.scale;
const worldCenterY = (screenPoint.y - viewport.y) / viewport.scale;
const fallbackScreenPoint = {
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 = {
id: `layer-upload-${uploadIndex}`,
resourceId: `local-resource-upload-${uploadIndex}`,
@@ -1412,7 +1469,9 @@ export function ImageCanvasEditorView() {
persisted: false,
};
setLayers((currentLayers) => [...currentLayers, nextLayer]);
if (options.addToCanvas) {
setLayers((currentLayers) => [...currentLayers, nextLayer]);
}
setAssets((currentAssets) => [...currentAssets, uploadedAsset]);
setAssetFolders((currentFolders) =>
currentFolders.map((folder) =>
@@ -1424,8 +1483,10 @@ export function ImageCanvasEditorView() {
: folder,
),
);
selectSingleLayer(nextLayer.id);
setActiveSidebarPanel('layers');
if (options.addToCanvas) {
selectSingleLayer(nextLayer.id);
setActiveSidebarPanel('layers');
}
createEditorAsset({
folderId: uploadFolderId,
@@ -1457,7 +1518,9 @@ export function ImageCanvasEditorView() {
})
.catch(() => {});
createProjectResourceForLayer(nextLayer);
if (options.addToCanvas) {
createProjectResourceForLayer(nextLayer);
}
if (imageSrc) {
const uploadedImage = new Image();
@@ -1468,21 +1531,23 @@ export function ImageCanvasEditorView() {
const sizeRatio = longestSide > 0 ? Math.min(1, 420 / longestSide) : 1;
const width = Math.round(originalWidth * sizeRatio);
const height = Math.round(originalHeight * sizeRatio);
setLayers((currentLayers) =>
currentLayers.map((layer) =>
layer.id === nextLayer.id
? {
...layer,
width,
height,
originalWidth,
originalHeight,
x: worldCenterX - width / 2,
y: worldCenterY - height / 2,
}
: layer,
),
);
if (options.addToCanvas) {
setLayers((currentLayers) =>
currentLayers.map((layer) =>
layer.id === nextLayer.id
? {
...layer,
width,
height,
originalWidth,
originalHeight,
x: worldCenterX - width / 2,
y: worldCenterY - height / 2,
}
: layer,
),
);
}
setAssets((currentAssets) =>
currentAssets.map((asset) =>
asset.id === uploadedAsset.id
@@ -1501,13 +1566,18 @@ export function ImageCanvasEditorView() {
const addUploadedFiles = (
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) => {
layerCounterRef.current += 1;
const uploadIndex = layerCounterRef.current;
void addUploadedLayer(file, {
...options,
addToCanvas: options.addToCanvas ?? false,
uploadIndex,
canvasPoint: options.canvasPoint
? {
@@ -1546,6 +1616,7 @@ export function ImageCanvasEditorView() {
mode: 'generate',
prompt: '',
status: 'idle',
composerOpen: true,
placeholder: {
x: worldCenterX - placeholderWidth / 2,
y: worldCenterY - placeholderHeight / 2,
@@ -1567,6 +1638,7 @@ export function ImageCanvasEditorView() {
? `${sourceLayer.prompt},在保持主体结构的基础上优化画面细节`
: '',
status: 'idle',
composerOpen: true,
sourceLayerId: sourceLayer.id,
});
setActiveTool('generate');
@@ -1625,10 +1697,11 @@ export function ImageCanvasEditorView() {
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'generate'
? {
...currentDialog,
status: 'idle',
generatedLayerId: nextLayer.id,
placeholder: undefined,
...currentDialog,
status: 'idle',
composerOpen: true,
generatedLayerId: nextLayer.id,
placeholder: undefined,
errorMessage: undefined,
}
: currentDialog,
@@ -1648,6 +1721,7 @@ export function ImageCanvasEditorView() {
...dialog,
prompt: normalizedPrompt,
status: 'generating',
composerOpen: true,
});
try {
@@ -1669,12 +1743,13 @@ export function ImageCanvasEditorView() {
addGeneratedResultLayer(generated, { frame: dialog.placeholder });
}
} catch (error) {
setGenerateDialog({
...dialog,
prompt: normalizedPrompt,
status: 'failed',
errorMessage: resolveImageGenerationErrorMessage(error),
});
setGenerateDialog({
...dialog,
prompt: normalizedPrompt,
status: 'failed',
composerOpen: true,
errorMessage: resolveImageGenerationErrorMessage(error),
});
}
};
@@ -1739,6 +1814,27 @@ export function ImageCanvasEditorView() {
if (button !== 0) {
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);
};
@@ -1770,6 +1866,7 @@ export function ImageCanvasEditorView() {
addUploadedFiles(files, {
folderId: defaultFolder?.id,
canvasPoint,
addToCanvas: true,
});
};
@@ -1791,15 +1888,51 @@ export function ImageCanvasEditorView() {
event.stopPropagation();
const pointer = getPointerClient(event);
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 = {
kind: 'layer',
pointerId: getPointerId(event),
layerId: layer.id,
layerIds: dragLayerIds,
startClientX: pointer.x,
startClientY: pointer.y,
startLayerX: layer.x,
startLayerY: layer.y,
startLayers,
startScale: viewport.scale,
};
};
@@ -1824,7 +1957,16 @@ export function ImageCanvasEditorView() {
event.stopPropagation();
const pointer = getPointerClient(event);
canvasViewportRef.current?.setPointerCapture?.(event.pointerId);
selectSingleLayer(null);
setSelectedLayerId(null);
setSelectedLayerIds([]);
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'generate'
? {
...currentDialog,
composerOpen: true,
}
: currentDialog,
);
dragStateRef.current = {
kind: 'generation-frame',
pointerId: getPointerId(event),
@@ -1875,6 +2017,43 @@ export function ImageCanvasEditorView() {
};
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 pointerId = getPointerId(event);
if (
@@ -1936,18 +2115,42 @@ export function ImageCanvasEditorView() {
setSnapGuide(snapped.guide);
setLayers((currentLayers) =>
currentLayers.map((layer) =>
layer.id === dragState.layerId
? {
...layer,
x: snapped.x,
y: snapped.y,
}
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,
x: snapped.x,
y: snapped.y,
};
}
return {
...layer,
x: startLayer.x + deltaX + (snapped.x - (dragState.startLayerX + deltaX)),
y: startLayer.y + deltaY + (snapped.y - (dragState.startLayerY + deltaY)),
};
})()
: layer,
),
);
};
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 pointerId = getPointerId(event);
if (
@@ -2025,6 +2228,7 @@ export function ImageCanvasEditorView() {
return (
<section
ref={editorRootRef}
className="image-canvas-editor"
aria-label="图片画布编辑器"
onContextMenu={(event) => event.preventDefault()}
@@ -2039,7 +2243,7 @@ export function ImageCanvasEditorView() {
onChange={(event) => {
const files = event.currentTarget.files;
if (files?.length) {
addUploadedFiles(files);
addUploadedFiles(files, { addToCanvas: activeTool === 'upload' });
}
event.currentTarget.value = '';
}}
@@ -2146,6 +2350,7 @@ export function ImageCanvasEditorView() {
onDragOver={(event) => {
if (event.dataTransfer.types.includes('Files')) {
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = 'copy';
}
}}
@@ -2154,6 +2359,7 @@ export function ImageCanvasEditorView() {
return;
}
event.preventDefault();
event.stopPropagation();
addUploadedFiles(event.dataTransfer.files, { folderId: folder.id });
}}
>
@@ -2339,6 +2545,7 @@ export function ImageCanvasEditorView() {
onDragOver={(event) => {
if (event.dataTransfer.types.includes('Files')) {
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = 'copy';
}
}}
@@ -2347,6 +2554,7 @@ export function ImageCanvasEditorView() {
return;
}
event.preventDefault();
event.stopPropagation();
addUploadedFiles(event.dataTransfer.files, {
folderId: asset.folderId,
});
@@ -2440,60 +2648,6 @@ export function ImageCanvasEditorView() {
<h1></h1>
<span></span>
</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
@@ -2536,7 +2690,7 @@ export function ImageCanvasEditorView() {
.slice()
.sort((left, right) => left.zIndex - right.zIndex)
.map((layer) => {
const isSelected = selectedLayerId === layer.id;
const isSelected = selectedLayerIds.includes(layer.id);
const isHovered = hoveredLayerId === layer.id;
return (
<button
@@ -2587,9 +2741,23 @@ export function ImageCanvasEditorView() {
</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 ? (
<div
className="image-canvas-editor__generation-frame"
role="button"
tabIndex={0}
style={{
left: generateDialog.placeholder.x,
top: generateDialog.placeholder.y,
@@ -2598,6 +2766,16 @@ export function ImageCanvasEditorView() {
}}
aria-label="图像生成占位图"
onPointerDown={handleGenerationFramePointerDown}
onDoubleClick={() =>
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'generate'
? {
...currentDialog,
composerOpen: true,
}
: currentDialog,
)
}
>
<span className="image-canvas-editor__generation-frame-label">
<ImageIcon className="h-4 w-4" />
@@ -2670,6 +2848,60 @@ export function ImageCanvasEditorView() {
aria-label="画布面板入口"
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">
<PlatformIconButton
label="画布背景色"
@@ -2889,7 +3121,14 @@ export function ImageCanvasEditorView() {
variant="surfaceFloating"
disabled={generateDialog.status === 'generating'}
onClick={() => {
setGenerateDialog(null);
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'generate'
? {
...currentDialog,
composerOpen: false,
}
: currentDialog,
);
setActiveTool('select');
}}
/>

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);