3cbc33bd69
快速编辑继承并支持模型、比例和尺寸参数,旧模型回落当前默认模型 仅禁止图标与图标图集快速编辑,图标规范继续按普通图片处理 按用户选定分辨率替换当前图层并保持图层中心位置 补齐后端真实图层校验、移动端避让、定向测试与项目文档
295 lines
10 KiB
TypeScript
295 lines
10 KiB
TypeScript
/* @vitest-environment jsdom */
|
||
|
||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||
import { describe, expect, it, vi } from 'vitest';
|
||
|
||
import { ImageCanvasContextMenusView } from './ImageCanvasContextMenusView';
|
||
import type { CanvasLayer } from './ImageCanvasEditorTypes';
|
||
|
||
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
|
||
return {
|
||
id: 'layer-1',
|
||
resourceId: 'resource-1',
|
||
title: '角色图',
|
||
src: 'data:image/png;base64,layer',
|
||
x: 0,
|
||
y: 0,
|
||
width: 320,
|
||
height: 240,
|
||
originalWidth: 320,
|
||
originalHeight: 240,
|
||
zIndex: 1,
|
||
sourceType: 'uploaded',
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function renderContextMenus(
|
||
overrides: Partial<Parameters<typeof ImageCanvasContextMenusView>[0]> = {},
|
||
) {
|
||
const props: Parameters<typeof ImageCanvasContextMenusView>[0] = {
|
||
viewport: { x: 0, y: 0, scale: 1 },
|
||
contextMenu: null,
|
||
canvasClipboard: null,
|
||
imageContextMenu: null,
|
||
imageContextMenuLayer: null,
|
||
contextMenuLayer: null,
|
||
contextShouldShowLayer: false,
|
||
contextShouldUnlockLayer: false,
|
||
onPasteCanvasClipboard: vi.fn(),
|
||
onCopyContextLayers: vi.fn(),
|
||
onDuplicateContextLayers: vi.fn(),
|
||
onMoveContextLayers: vi.fn(),
|
||
onGroupContextLayers: vi.fn(),
|
||
onUngroupContextLayers: vi.fn(),
|
||
onToggleContextLayerVisibility: vi.fn(),
|
||
onToggleContextLayerLock: vi.fn(),
|
||
onFlipContextLayers: vi.fn(),
|
||
onExportContextLayer: vi.fn(),
|
||
onDeleteContextLayers: vi.fn(),
|
||
onDeleteLayerById: vi.fn(),
|
||
onCloseContextMenu: vi.fn(),
|
||
onCloseImageContextMenu: vi.fn(),
|
||
onUpdateScaleFromCenter: vi.fn(),
|
||
onFitLayers: vi.fn(),
|
||
onOpenQuickEditPanel: vi.fn(),
|
||
onOpenLayerMetadata: vi.fn(),
|
||
onOpenCharacterAnimationPanel: vi.fn(),
|
||
...overrides,
|
||
};
|
||
|
||
render(<ImageCanvasContextMenusView {...props} />);
|
||
|
||
return props;
|
||
}
|
||
|
||
function createDomRect(
|
||
width: number,
|
||
height: number,
|
||
overrides: Partial<DOMRect> = {},
|
||
): DOMRect {
|
||
const left = overrides.left ?? 0;
|
||
const top = overrides.top ?? 0;
|
||
return {
|
||
x: 0,
|
||
y: 0,
|
||
width,
|
||
height,
|
||
top,
|
||
left,
|
||
right: left + width,
|
||
bottom: top + height,
|
||
toJSON: () => ({}),
|
||
...overrides,
|
||
} as DOMRect;
|
||
}
|
||
|
||
describe('ImageCanvasContextMenusView', () => {
|
||
it('renders blank canvas commands and forwards the canvas point', () => {
|
||
const canvasPoint = { x: 18, y: 24 };
|
||
const layer = createLayer();
|
||
const props = renderContextMenus({
|
||
contextMenu: { kind: 'blank', x: 10, y: 12, canvasPoint },
|
||
canvasClipboard: { layers: [layer], mode: 'copy' },
|
||
});
|
||
|
||
fireEvent.click(screen.getByRole('menuitem', { name: '粘贴' }));
|
||
fireEvent.click(screen.getByRole('menuitem', { name: '放大' }));
|
||
fireEvent.click(screen.getByRole('menuitem', { name: '显示画布所有元素' }));
|
||
|
||
expect(props.onPasteCanvasClipboard).toHaveBeenCalledWith(canvasPoint);
|
||
expect(props.onUpdateScaleFromCenter).toHaveBeenCalledWith(1.16);
|
||
expect(props.onFitLayers).toHaveBeenCalledTimes(1);
|
||
expect(props.onCloseContextMenu).toHaveBeenCalledTimes(2);
|
||
});
|
||
|
||
it('closes canvas context menus when another operation starts outside', () => {
|
||
const props = renderContextMenus({
|
||
contextMenu: {
|
||
kind: 'blank',
|
||
x: 10,
|
||
y: 12,
|
||
canvasPoint: { x: 18, y: 24 },
|
||
},
|
||
});
|
||
|
||
fireEvent.pointerDown(document.body);
|
||
|
||
expect(props.onCloseContextMenu).toHaveBeenCalledTimes(1);
|
||
expect(props.onCloseImageContextMenu).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it('renders layer context commands and forwards layer operations', () => {
|
||
const layer = createLayer({
|
||
assetKind: 'character',
|
||
mediaType: 'image-sequence',
|
||
});
|
||
const props = renderContextMenus({
|
||
contextMenu: {
|
||
kind: 'layer',
|
||
x: 16,
|
||
y: 18,
|
||
layerId: layer.id,
|
||
canvasPoint: { x: 40, y: 42 },
|
||
},
|
||
canvasClipboard: { layers: [layer], mode: 'copy' },
|
||
imageContextMenuLayer: layer,
|
||
contextMenuLayer: layer,
|
||
contextShouldShowLayer: true,
|
||
contextShouldUnlockLayer: true,
|
||
});
|
||
|
||
fireEvent.click(screen.getByRole('menuitem', { name: '复制' }));
|
||
fireEvent.click(screen.getByRole('menuitem', { name: '剪切' }));
|
||
fireEvent.click(screen.getByRole('menuitem', { name: '创建副本' }));
|
||
fireEvent.click(screen.getByRole('menuitem', { name: '上移一层' }));
|
||
fireEvent.click(screen.getByRole('menuitem', { name: '显示' }));
|
||
fireEvent.click(screen.getByRole('menuitem', { name: '解锁' }));
|
||
fireEvent.click(screen.getByRole('menuitem', { name: '水平翻转' }));
|
||
expect(
|
||
screen
|
||
.getByRole('menuitem', { name: '导出为' })
|
||
.getAttribute('aria-haspopup'),
|
||
).toBe('menu');
|
||
fireEvent.click(
|
||
screen.getByRole('menuitem', { name: '序列帧导出(zip)' }),
|
||
);
|
||
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: '删除' }));
|
||
|
||
expect(props.onCopyContextLayers).toHaveBeenNthCalledWith(1);
|
||
expect(props.onCopyContextLayers).toHaveBeenNthCalledWith(2, { cut: true });
|
||
expect(props.onDuplicateContextLayers).toHaveBeenCalledTimes(1);
|
||
expect(props.onMoveContextLayers).toHaveBeenCalledWith('up');
|
||
expect(props.onToggleContextLayerVisibility).toHaveBeenCalledTimes(1);
|
||
expect(props.onToggleContextLayerLock).toHaveBeenCalledTimes(1);
|
||
expect(props.onFlipContextLayers).toHaveBeenCalledWith('x');
|
||
expect(props.onExportContextLayer).toHaveBeenNthCalledWith(
|
||
1,
|
||
expect.objectContaining({ mode: 'sequence-with-preview' }),
|
||
);
|
||
expect(props.onExportContextLayer).toHaveBeenNthCalledWith(
|
||
2,
|
||
expect.objectContaining({ mode: 'spine-json' }),
|
||
);
|
||
expect(props.onOpenQuickEditPanel).toHaveBeenCalledWith(layer);
|
||
expect(props.onOpenCharacterAnimationPanel).toHaveBeenCalledWith(layer);
|
||
expect(props.onCloseContextMenu).toHaveBeenCalledTimes(2);
|
||
expect(props.onCloseImageContextMenu).toHaveBeenCalledTimes(2);
|
||
expect(props.onDeleteContextLayers).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it('keeps measured layer menus and submenus inside the viewport', async () => {
|
||
const originalInnerWidth = window.innerWidth;
|
||
const originalInnerHeight = window.innerHeight;
|
||
window.innerWidth = 500;
|
||
window.innerHeight = 640;
|
||
const getBoundingClientRectSpy = vi
|
||
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
|
||
.mockImplementation(function getBoundingClientRect(this: HTMLElement) {
|
||
if (this.classList.contains('image-canvas-editor__context-menu')) {
|
||
return createDomRect(188, 620);
|
||
}
|
||
if (
|
||
this.classList.contains('image-canvas-editor__context-submenu-panel')
|
||
) {
|
||
return createDomRect(160, 72);
|
||
}
|
||
if (this.classList.contains('image-canvas-editor__context-submenu')) {
|
||
return createDomRect(188, 29, { top: 586 });
|
||
}
|
||
return createDomRect(0, 0);
|
||
});
|
||
|
||
try {
|
||
const layer = createLayer({ mediaType: 'image-sequence' });
|
||
renderContextMenus({
|
||
contextMenu: {
|
||
kind: 'layer',
|
||
x: 490,
|
||
y: 500,
|
||
layerId: layer.id,
|
||
canvasPoint: { x: 40, y: 42 },
|
||
},
|
||
contextMenuLayer: layer,
|
||
});
|
||
|
||
const menu = screen.getByRole('menu', { name: '图片功能面板' });
|
||
await waitFor(() => {
|
||
expect(menu.style.left).toBe('304px');
|
||
expect(menu.style.top).toBe('12px');
|
||
expect(menu.className).toContain(
|
||
'image-canvas-editor__context-menu--submenu-left',
|
||
);
|
||
expect(menu.className).toContain(
|
||
'image-canvas-editor__context-menu--submenu-up',
|
||
);
|
||
});
|
||
} finally {
|
||
getBoundingClientRectSpy.mockRestore();
|
||
window.innerWidth = originalInnerWidth;
|
||
window.innerHeight = originalInnerHeight;
|
||
}
|
||
});
|
||
|
||
it('closes the standalone image menu after opening character animation', () => {
|
||
const layer = createLayer({ assetKind: 'character' });
|
||
const props = renderContextMenus({
|
||
imageContextMenu: { layerId: layer.id, x: 20, y: 22 },
|
||
imageContextMenuLayer: layer,
|
||
});
|
||
|
||
fireEvent.click(screen.getByRole('menuitem', { name: '生成动画' }));
|
||
|
||
expect(props.onOpenCharacterAnimationPanel).toHaveBeenCalledWith(layer);
|
||
expect(props.onCloseImageContextMenu).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it('renders the image context menu when no canvas context menu is open', () => {
|
||
const layer = createLayer({ assetKind: 'character' });
|
||
const props = renderContextMenus({
|
||
imageContextMenu: { layerId: layer.id, x: 20, y: 22 },
|
||
imageContextMenuLayer: layer,
|
||
});
|
||
|
||
fireEvent.click(screen.getByRole('menuitem', { name: '快速编辑' }));
|
||
fireEvent.click(screen.getByRole('menuitem', { name: '查看图片信息' }));
|
||
fireEvent.click(screen.getByRole('menuitem', { name: '删除图片' }));
|
||
|
||
expect(props.onOpenQuickEditPanel).toHaveBeenCalledWith(layer);
|
||
expect(props.onOpenLayerMetadata).toHaveBeenCalledWith(layer);
|
||
expect(props.onCloseImageContextMenu).toHaveBeenCalledTimes(2);
|
||
expect(props.onDeleteLayerById).toHaveBeenCalledWith(layer.id);
|
||
});
|
||
|
||
it.each(['icon', 'icon-spritesheet'] as const)(
|
||
'hides quick edit from the standalone menu for %s assets',
|
||
(assetKind) => {
|
||
const layer = createLayer({ assetKind });
|
||
renderContextMenus({
|
||
imageContextMenu: { layerId: layer.id, x: 20, y: 22 },
|
||
imageContextMenuLayer: layer,
|
||
});
|
||
|
||
expect(screen.queryByRole('menuitem', { name: '快速编辑' })).toBeNull();
|
||
expect(
|
||
screen.getByRole('menuitem', { name: '查看图片信息' }),
|
||
).toBeTruthy();
|
||
},
|
||
);
|
||
|
||
it('keeps quick edit in the standalone menu for icon specs', () => {
|
||
const layer = createLayer({ assetKind: 'icon-spec' });
|
||
renderContextMenus({
|
||
imageContextMenu: { layerId: layer.id, x: 20, y: 22 },
|
||
imageContextMenuLayer: layer,
|
||
});
|
||
|
||
expect(screen.getByRole('menuitem', { name: '快速编辑' })).toBeTruthy();
|
||
});
|
||
});
|