修复画布右键菜单与复制粘贴

统一右键图层命令后关闭画布菜单和图片菜单

补齐快速编辑入口的菜单关闭逻辑

让 Ctrl+V 优先粘贴画布内部剪贴板

补充右键菜单、图层命令和快捷键回归测试
This commit is contained in:
2026-06-26 20:29:03 +08:00
parent ec34f85006
commit 7452e3e3e2
7 changed files with 120 additions and 36 deletions
@@ -120,6 +120,7 @@ describe('ImageCanvasContextMenusView', () => {
fireEvent.click(
screen.getByRole('menuitem', { name: 'Spine 导出(zip)' }),
);
fireEvent.click(screen.getByRole('menuitem', { name: '快速编辑' }));
fireEvent.click(screen.getByRole('menuitem', { name: '生成动画' }));
fireEvent.click(screen.getByRole('menuitem', { name: '删除' }));
@@ -138,9 +139,10 @@ describe('ImageCanvasContextMenusView', () => {
2,
expect.objectContaining({ mode: 'spine-json' }),
);
expect(props.onOpenQuickEditPanel).toHaveBeenCalledWith(layer);
expect(props.onOpenCharacterAnimationPanel).toHaveBeenCalledWith(layer);
expect(props.onCloseContextMenu).toHaveBeenCalledTimes(1);
expect(props.onCloseImageContextMenu).toHaveBeenCalledTimes(1);
expect(props.onCloseContextMenu).toHaveBeenCalledTimes(2);
expect(props.onCloseImageContextMenu).toHaveBeenCalledTimes(2);
expect(props.onDeleteContextLayers).toHaveBeenCalledTimes(1);
});
@@ -170,7 +172,7 @@ describe('ImageCanvasContextMenusView', () => {
expect(props.onOpenQuickEditPanel).toHaveBeenCalledWith(layer);
expect(props.onOpenLayerMetadata).toHaveBeenCalledWith(layer);
expect(props.onCloseImageContextMenu).toHaveBeenCalledTimes(1);
expect(props.onCloseImageContextMenu).toHaveBeenCalledTimes(2);
expect(props.onDeleteLayerById).toHaveBeenCalledWith(layer.id);
});
});
@@ -286,7 +286,11 @@ export function ImageCanvasContextMenusView({
<button
type="button"
role="menuitem"
onClick={() => onOpenQuickEditPanel(imageContextMenuLayer)}
onClick={() => {
onOpenQuickEditPanel(imageContextMenuLayer);
onCloseContextMenu();
onCloseImageContextMenu();
}}
>
快速编辑
</button>
@@ -342,7 +346,10 @@ export function ImageCanvasContextMenusView({
<PlatformFloatingMenu label="图片功能面板" placement="bottom-start">
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
onClick={() => onOpenQuickEditPanel(imageContextMenuLayer)}
onClick={() => {
onOpenQuickEditPanel(imageContextMenuLayer);
onCloseImageContextMenu();
}}
>
快速编辑
</PlatformFloatingMenuItem>
@@ -1225,6 +1225,7 @@ export function ImageCanvasEditorView() {
selectAllCanvasObjects,
clearCanvasSelection,
copySelectedCanvasLayers: copySelectedLayers,
pasteCanvasClipboard,
duplicateSelectedCanvasLayers: duplicateSelectedLayers,
moveSelectedCanvasLayers: moveSelectedLayers,
nudgeSelectedCanvasObjects,
@@ -65,6 +65,7 @@ function KeyboardShortcutsHarness({
selectAllCanvasObjects = vi.fn(),
clearCanvasSelection = vi.fn(),
copySelectedCanvasLayers = vi.fn(),
pasteCanvasClipboard = vi.fn(),
duplicateSelectedCanvasLayers = vi.fn(),
moveSelectedCanvasLayers = vi.fn(),
nudgeSelectedCanvasObjects = vi.fn(),
@@ -91,6 +92,7 @@ function KeyboardShortcutsHarness({
selectAllCanvasObjects?: () => void;
clearCanvasSelection?: () => void;
copySelectedCanvasLayers?: (options?: { cut?: boolean }) => void;
pasteCanvasClipboard?: () => boolean;
duplicateSelectedCanvasLayers?: () => void;
moveSelectedCanvasLayers?: (mode: 'up' | 'down' | 'top' | 'bottom') => void;
nudgeSelectedCanvasObjects?: (delta: { x: number; y: number }) => void;
@@ -159,6 +161,7 @@ function KeyboardShortcutsHarness({
selectAllCanvasObjects,
clearCanvasSelection,
copySelectedCanvasLayers,
pasteCanvasClipboard,
duplicateSelectedCanvasLayers,
moveSelectedCanvasLayers,
nudgeSelectedCanvasObjects,
@@ -287,15 +290,17 @@ describe('useImageCanvasKeyboardShortcuts', () => {
expect(undoCanvasChange).toHaveBeenCalledTimes(1);
});
it('routes Windows edit shortcuts to selected layer commands except paste', () => {
it('routes Windows edit shortcuts to selected layer commands and canvas paste', () => {
const selectAllCanvasObjects = vi.fn();
const copySelectedCanvasLayers = vi.fn();
const pasteCanvasClipboard = vi.fn(() => true);
const duplicateSelectedCanvasLayers = vi.fn();
const exportCanvasAssets = vi.fn();
render(
<KeyboardShortcutsHarness
selectAllCanvasObjects={selectAllCanvasObjects}
copySelectedCanvasLayers={copySelectedCanvasLayers}
pasteCanvasClipboard={pasteCanvasClipboard}
duplicateSelectedCanvasLayers={duplicateSelectedCanvasLayers}
exportCanvasAssets={exportCanvasAssets}
/>,
@@ -318,10 +323,30 @@ describe('useImageCanvasKeyboardShortcuts', () => {
expect(selectAllCanvasObjects).toHaveBeenCalledTimes(1);
expect(copySelectedCanvasLayers).toHaveBeenNthCalledWith(1);
expect(copySelectedCanvasLayers).toHaveBeenNthCalledWith(2, { cut: true });
expect(pasteCanvasClipboard).toHaveBeenCalledTimes(1);
expect(duplicateSelectedCanvasLayers).toHaveBeenCalledTimes(1);
expect(exportCanvasAssets).toHaveBeenCalledTimes(1);
});
it('leaves system paste available when the canvas clipboard is empty', () => {
const pasteCanvasClipboard = vi.fn(() => false);
render(
<KeyboardShortcutsHarness pasteCanvasClipboard={pasteCanvasClipboard} />,
);
const pasteEvent = new KeyboardEvent('keydown', {
key: 'v',
code: 'KeyV',
ctrlKey: true,
bubbles: true,
cancelable: true,
});
window.dispatchEvent(pasteEvent);
expect(pasteCanvasClipboard).toHaveBeenCalledTimes(1);
expect(pasteEvent.defaultPrevented).toBe(false);
});
it('routes Windows view and layer ordering shortcuts', () => {
const fitLayers = vi.fn();
const updateScaleFromCenter = vi.fn();
@@ -19,6 +19,7 @@ type UseImageCanvasKeyboardShortcutsOptions = {
selectAllCanvasObjects?: () => void;
clearCanvasSelection?: () => void;
copySelectedCanvasLayers?: (options?: { cut?: boolean }) => void;
pasteCanvasClipboard?: () => boolean;
duplicateSelectedCanvasLayers?: () => void;
moveSelectedCanvasLayers?: (mode: 'up' | 'down' | 'top' | 'bottom') => void;
nudgeSelectedCanvasObjects?: (delta: { x: number; y: number }) => void;
@@ -160,6 +161,7 @@ export function useImageCanvasKeyboardShortcuts({
selectAllCanvasObjects,
clearCanvasSelection,
copySelectedCanvasLayers,
pasteCanvasClipboard,
duplicateSelectedCanvasLayers,
moveSelectedCanvasLayers,
nudgeSelectedCanvasObjects,
@@ -275,6 +277,12 @@ export function useImageCanvasKeyboardShortcuts({
copySelectedCanvasLayers?.({ cut: true });
return;
}
if (event.code === 'KeyV') {
if (pasteCanvasClipboard?.()) {
event.preventDefault();
}
return;
}
if (event.code === 'KeyD') {
event.preventDefault();
duplicateSelectedCanvasLayers?.();
@@ -465,6 +473,7 @@ export function useImageCanvasKeyboardShortcuts({
closeEditorChromePanels,
clearCanvasSelection,
copySelectedCanvasLayers,
pasteCanvasClipboard,
duplicateSelectedCanvasLayers,
deleteSelectedCanvasObjects,
deleteLayerById,
@@ -251,6 +251,7 @@ describe('useImageCanvasLayerCommands', () => {
fireEvent.click(screen.getByRole('button', { name: '复制' }));
expect(screen.getByTestId('clipboard').textContent).toBe('copy:2');
expect(screen.getByTestId('context').textContent).toBe('closed');
expect(screen.getByTestId('image-context-closed').textContent).toBe('1');
fireEvent.click(screen.getByRole('button', { name: '粘贴' }));
expect(screen.getByTestId('layers').textContent).toContain(
@@ -263,6 +264,7 @@ describe('useImageCanvasLayerCommands', () => {
'layer-copy-',
);
expect(screen.getByTestId('tool').textContent).toBe('select');
expect(screen.getByTestId('image-context-closed').textContent).toBe('2');
fireEvent.click(screen.getByRole('button', { name: '右键第三层' }));
fireEvent.click(screen.getByRole('button', { name: '只选第三层' }));
@@ -271,6 +273,7 @@ describe('useImageCanvasLayerCommands', () => {
expect(screen.getByTestId('clipboard').textContent).toBe('cut:1');
expect(screen.getByTestId('layers').textContent).not.toContain('third');
expect(screen.getByTestId('selection').textContent).toBe('-:');
expect(screen.getByTestId('image-context-closed').textContent).toBe('3');
expect(Number(screen.getByTestId('history').textContent)).toBeGreaterThan(
1,
);
@@ -336,7 +339,9 @@ describe('useImageCanvasLayerCommands', () => {
expect.any(Object),
);
expect(screen.getByTestId('context').textContent).toBe('closed');
expect(screen.getByTestId('image-context-closed').textContent).toBe('1');
expect(Number(screen.getByTestId('image-context-closed').textContent)).toBe(
5,
);
});
it('deletes selected and direct layers while running delete side effects', () => {
@@ -85,6 +85,10 @@ export function useImageCanvasLayerCommands({
const [canvasClipboard, setCanvasClipboard] =
useState<CanvasClipboard | null>(null);
const canvasClipboardRef = useRef<CanvasClipboard | null>(null);
const closeContextMenus = useCallback(() => {
setContextMenu(null);
setImageContextMenu(null);
}, [setContextMenu, setImageContextMenu]);
const getContextTargetLayerIds = useCallback(
(menu: CanvasContextMenuState | null = contextMenu) =>
@@ -121,13 +125,9 @@ export function useImageCanvasLayerCommands({
if (!clipboard?.layers.length) {
return false;
}
const nextLayers = duplicateLayersToPoint(
clipboard.layers,
canvasPoint,
{
renameCopies: clipboard.mode !== 'cut',
},
);
const nextLayers = duplicateLayersToPoint(clipboard.layers, canvasPoint, {
renameCopies: clipboard.mode !== 'cut',
});
if (!nextLayers.length) {
return false;
}
@@ -136,14 +136,14 @@ export function useImageCanvasLayerCommands({
setSelectedLayerIds(nextLayers.map((layer) => layer.id));
setSelectedLayerId(nextLayers[0]?.id ?? null);
setActiveTool('select');
setContextMenu(null);
closeContextMenus();
return true;
},
[
captureCanvasHistory,
closeContextMenus,
duplicateLayersToPoint,
setActiveTool,
setContextMenu,
setLayers,
setSelectedLayerId,
setSelectedLayerIds,
@@ -175,14 +175,14 @@ export function useImageCanvasLayerCommands({
: currentLayer,
);
}
setContextMenu(null);
closeContextMenus();
},
[
captureCanvasHistory,
closeContextMenus,
getContextTargetLayerIds,
layers,
selectSingleLayer,
setContextMenu,
setLayers,
setMetadataLayer,
],
@@ -241,13 +241,13 @@ export function useImageCanvasLayerCommands({
setLayers((currentLayers) => [...currentLayers, ...nextLayers]);
setSelectedLayerIds(nextLayers.map((layer) => layer.id));
setSelectedLayerId(nextLayers[0]?.id ?? null);
setContextMenu(null);
closeContextMenus();
}, [
captureCanvasHistory,
closeContextMenus,
duplicateLayersToPoint,
getContextTargetLayerIds,
layers,
setContextMenu,
setLayers,
setSelectedLayerId,
setSelectedLayerIds,
@@ -288,9 +288,14 @@ export function useImageCanvasLayerCommands({
setLayers((currentLayers) =>
updateCanvasLayersByIds(currentLayers, targetIds, updater),
);
setContextMenu(null);
closeContextMenus();
},
[captureCanvasHistory, getContextTargetLayerIds, setContextMenu, setLayers],
[
captureCanvasHistory,
closeContextMenus,
getContextTargetLayerIds,
setLayers,
],
);
const moveContextLayers = useCallback(
@@ -303,9 +308,14 @@ export function useImageCanvasLayerCommands({
setLayers((currentLayers) =>
moveCanvasLayers(currentLayers, targetIds, mode),
);
setContextMenu(null);
closeContextMenus();
},
[captureCanvasHistory, getContextTargetLayerIds, setContextMenu, setLayers],
[
captureCanvasHistory,
closeContextMenus,
getContextTargetLayerIds,
setLayers,
],
);
const moveSelectedLayers = useCallback(
@@ -339,8 +349,13 @@ export function useImageCanvasLayerCommands({
setLayers((currentLayers) =>
groupCanvasLayers(currentLayers, targetIds, createGroupId()),
);
setContextMenu(null);
}, [captureCanvasHistory, getContextTargetLayerIds, setContextMenu, setLayers]);
closeContextMenus();
}, [
captureCanvasHistory,
closeContextMenus,
getContextTargetLayerIds,
setLayers,
]);
const ungroupContextLayers = useCallback(() => {
const targetIds = getContextTargetLayerIds();
@@ -349,8 +364,13 @@ export function useImageCanvasLayerCommands({
}
captureCanvasHistory();
setLayers((currentLayers) => ungroupCanvasLayers(currentLayers, targetIds));
setContextMenu(null);
}, [captureCanvasHistory, getContextTargetLayerIds, setContextMenu, setLayers]);
closeContextMenus();
}, [
captureCanvasHistory,
closeContextMenus,
getContextTargetLayerIds,
setLayers,
]);
const toggleContextLayerVisibility = useCallback(() => {
const targetIds = getContextTargetLayerIds();
@@ -361,8 +381,13 @@ export function useImageCanvasLayerCommands({
setLayers((currentLayers) =>
toggleCanvasLayersVisibility(currentLayers, targetIds),
);
setContextMenu(null);
}, [captureCanvasHistory, getContextTargetLayerIds, setContextMenu, setLayers]);
closeContextMenus();
}, [
captureCanvasHistory,
closeContextMenus,
getContextTargetLayerIds,
setLayers,
]);
const toggleContextLayerLock = useCallback(() => {
const targetIds = getContextTargetLayerIds();
@@ -373,8 +398,13 @@ export function useImageCanvasLayerCommands({
setLayers((currentLayers) =>
toggleCanvasLayersLock(currentLayers, targetIds),
);
setContextMenu(null);
}, [captureCanvasHistory, getContextTargetLayerIds, setContextMenu, setLayers]);
closeContextMenus();
}, [
captureCanvasHistory,
closeContextMenus,
getContextTargetLayerIds,
setLayers,
]);
const flipContextLayers = useCallback(
(axis: CanvasLayerFlipAxis) => {
@@ -386,9 +416,14 @@ export function useImageCanvasLayerCommands({
setLayers((currentLayers) =>
flipCanvasLayers(currentLayers, targetIds, axis),
);
setContextMenu(null);
closeContextMenus();
},
[captureCanvasHistory, getContextTargetLayerIds, setContextMenu, setLayers],
[
captureCanvasHistory,
closeContextMenus,
getContextTargetLayerIds,
setLayers,
],
);
const deleteContextLayers = useCallback(() => {
@@ -403,12 +438,12 @@ export function useImageCanvasLayerCommands({
setMetadataLayer((currentLayer) =>
currentLayer && targetIds.includes(currentLayer.id) ? null : currentLayer,
);
setContextMenu(null);
closeContextMenus();
}, [
captureCanvasHistory,
closeContextMenus,
getContextTargetLayerIds,
selectSingleLayer,
setContextMenu,
setHoveredLayerId,
setLayers,
setMetadataLayer,