修复画布右键菜单出屏
实测右键菜单尺寸并夹入视口 根据视口空间翻转导出二级菜单方向 补充菜单出屏回归测试
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ImageCanvasContextMenusView } from './ImageCanvasContextMenusView';
|
||||
@@ -63,6 +63,27 @@ function renderContextMenus(
|
||||
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 };
|
||||
@@ -162,6 +183,59 @@ describe('ImageCanvasContextMenusView', () => {
|
||||
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({
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
PlatformFloatingMenu,
|
||||
PlatformFloatingMenuItem,
|
||||
} from '../common/PlatformFloatingMenu';
|
||||
import { clamp, CONTEXT_MENU_VIEWPORT_MARGIN } from './ImageCanvasEditorModel';
|
||||
import type {
|
||||
CanvasClipboard,
|
||||
CanvasContextMenuState,
|
||||
@@ -44,10 +51,18 @@ type ImageCanvasContextMenusViewProps = {
|
||||
onOpenCharacterAnimationPanel: (layer: CanvasLayer) => void;
|
||||
};
|
||||
|
||||
type MeasuredMenuLayout = {
|
||||
key: string;
|
||||
x: number;
|
||||
y: number;
|
||||
submenuDirection: 'left' | 'right';
|
||||
submenuVerticalDirection: 'down' | 'up';
|
||||
};
|
||||
|
||||
export function ImageCanvasContextMenusView({
|
||||
viewport,
|
||||
contextMenu,
|
||||
canvasClipboard,
|
||||
canvasClipboard: _canvasClipboard,
|
||||
imageContextMenu,
|
||||
imageContextMenuLayer,
|
||||
contextMenuLayer,
|
||||
@@ -75,10 +90,139 @@ export function ImageCanvasContextMenusView({
|
||||
}: ImageCanvasContextMenusViewProps) {
|
||||
const isImageSequenceLayer = contextMenuLayer?.mediaType === 'image-sequence';
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
const [measuredMenuLayout, setMeasuredMenuLayout] =
|
||||
useState<MeasuredMenuLayout | null>(null);
|
||||
const closeMenus = useCallback(() => {
|
||||
onCloseContextMenu();
|
||||
onCloseImageContextMenu();
|
||||
}, [onCloseContextMenu, onCloseImageContextMenu]);
|
||||
const activeMenuLayoutKey = contextMenu
|
||||
? [
|
||||
'context',
|
||||
contextMenu.kind,
|
||||
contextMenu.kind === 'layer' ? contextMenu.layerId : 'blank',
|
||||
contextMenu.x,
|
||||
contextMenu.y,
|
||||
isImageSequenceLayer ? 'sequence' : 'single',
|
||||
imageContextMenuLayer ? 'image-actions' : 'layer-actions',
|
||||
imageContextMenuLayer?.assetKind ?? 'asset',
|
||||
].join(':')
|
||||
: imageContextMenu
|
||||
? [
|
||||
'image',
|
||||
imageContextMenu.layerId,
|
||||
imageContextMenu.x,
|
||||
imageContextMenu.y,
|
||||
imageContextMenuLayer?.assetKind ?? 'asset',
|
||||
].join(':')
|
||||
: null;
|
||||
const activeMenuX = contextMenu?.x ?? imageContextMenu?.x ?? null;
|
||||
const activeMenuY = contextMenu?.y ?? imageContextMenu?.y ?? null;
|
||||
const currentMeasuredMenuLayout =
|
||||
measuredMenuLayout?.key === activeMenuLayoutKey ? measuredMenuLayout : null;
|
||||
const contextMenuClassName = [
|
||||
'image-canvas-editor__context-menu',
|
||||
currentMeasuredMenuLayout?.submenuDirection === 'left'
|
||||
? 'image-canvas-editor__context-menu--submenu-left'
|
||||
: null,
|
||||
currentMeasuredMenuLayout?.submenuVerticalDirection === 'up'
|
||||
? 'image-canvas-editor__context-menu--submenu-up'
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const menuElement = menuRef.current;
|
||||
if (
|
||||
typeof window === 'undefined' ||
|
||||
!activeMenuLayoutKey ||
|
||||
activeMenuX === null ||
|
||||
activeMenuY === null ||
|
||||
!menuElement
|
||||
) {
|
||||
setMeasuredMenuLayout(null);
|
||||
return;
|
||||
}
|
||||
const menuRect = menuElement.getBoundingClientRect();
|
||||
const menuWidth = menuRect.width || menuElement.offsetWidth;
|
||||
const menuHeight = menuRect.height || menuElement.offsetHeight;
|
||||
const x = clamp(
|
||||
activeMenuX,
|
||||
CONTEXT_MENU_VIEWPORT_MARGIN,
|
||||
Math.max(
|
||||
CONTEXT_MENU_VIEWPORT_MARGIN,
|
||||
window.innerWidth - menuWidth - CONTEXT_MENU_VIEWPORT_MARGIN,
|
||||
),
|
||||
);
|
||||
const y = clamp(
|
||||
activeMenuY,
|
||||
CONTEXT_MENU_VIEWPORT_MARGIN,
|
||||
Math.max(
|
||||
CONTEXT_MENU_VIEWPORT_MARGIN,
|
||||
window.innerHeight - menuHeight - CONTEXT_MENU_VIEWPORT_MARGIN,
|
||||
),
|
||||
);
|
||||
const submenuElement = menuElement.querySelector<HTMLElement>(
|
||||
'.image-canvas-editor__context-submenu-panel',
|
||||
);
|
||||
const submenuRect = submenuElement?.getBoundingClientRect();
|
||||
const submenuWidth = submenuRect?.width || submenuElement?.offsetWidth || 0;
|
||||
const submenuHeight =
|
||||
submenuRect?.height || submenuElement?.offsetHeight || 0;
|
||||
const submenuGap = 8;
|
||||
const submenuVerticalOffset = 6;
|
||||
const submenuDirection =
|
||||
submenuWidth > 0 &&
|
||||
x + menuWidth + submenuGap + submenuWidth >
|
||||
window.innerWidth - CONTEXT_MENU_VIEWPORT_MARGIN &&
|
||||
x - submenuGap - submenuWidth >= CONTEXT_MENU_VIEWPORT_MARGIN
|
||||
? 'left'
|
||||
: 'right';
|
||||
const submenuAnchorRect =
|
||||
submenuElement?.parentElement?.getBoundingClientRect();
|
||||
const submenuAnchorOffsetTop =
|
||||
submenuAnchorRect && submenuElement
|
||||
? submenuAnchorRect.top - menuRect.top
|
||||
: 0;
|
||||
const submenuAnchorHeight =
|
||||
submenuAnchorRect?.height ||
|
||||
submenuElement?.parentElement?.offsetHeight ||
|
||||
0;
|
||||
const submenuDefaultBottom =
|
||||
y + submenuAnchorOffsetTop - submenuVerticalOffset + submenuHeight;
|
||||
const submenuFlippedTop =
|
||||
y +
|
||||
submenuAnchorOffsetTop +
|
||||
submenuAnchorHeight +
|
||||
submenuVerticalOffset -
|
||||
submenuHeight;
|
||||
const submenuVerticalDirection =
|
||||
submenuHeight > 0 &&
|
||||
submenuDefaultBottom >
|
||||
window.innerHeight - CONTEXT_MENU_VIEWPORT_MARGIN &&
|
||||
submenuFlippedTop >= CONTEXT_MENU_VIEWPORT_MARGIN
|
||||
? 'up'
|
||||
: 'down';
|
||||
setMeasuredMenuLayout((previous) => {
|
||||
if (
|
||||
previous?.key === activeMenuLayoutKey &&
|
||||
previous.x === x &&
|
||||
previous.y === y &&
|
||||
previous.submenuDirection === submenuDirection &&
|
||||
previous.submenuVerticalDirection === submenuVerticalDirection
|
||||
) {
|
||||
return previous;
|
||||
}
|
||||
return {
|
||||
key: activeMenuLayoutKey,
|
||||
x,
|
||||
y,
|
||||
submenuDirection,
|
||||
submenuVerticalDirection,
|
||||
};
|
||||
});
|
||||
}, [activeMenuLayoutKey, activeMenuX, activeMenuY]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextMenu && !imageContextMenu) {
|
||||
@@ -112,14 +256,14 @@ export function ImageCanvasContextMenusView({
|
||||
{contextMenu ? (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="image-canvas-editor__context-menu"
|
||||
className={contextMenuClassName}
|
||||
role="menu"
|
||||
aria-label={
|
||||
contextMenu.kind === 'blank' ? '画布右键菜单' : '图片功能面板'
|
||||
}
|
||||
style={{
|
||||
left: contextMenu.x,
|
||||
top: contextMenu.y,
|
||||
left: currentMeasuredMenuLayout?.x ?? contextMenu.x,
|
||||
top: currentMeasuredMenuLayout?.y ?? contextMenu.y,
|
||||
}}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
@@ -370,10 +514,10 @@ export function ImageCanvasContextMenusView({
|
||||
{imageContextMenu && imageContextMenuLayer && !contextMenu ? (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="image-canvas-editor__context-menu"
|
||||
className={contextMenuClassName}
|
||||
style={{
|
||||
left: imageContextMenu.x,
|
||||
top: imageContextMenu.y,
|
||||
left: currentMeasuredMenuLayout?.x ?? imageContextMenu.x,
|
||||
top: currentMeasuredMenuLayout?.y ?? imageContextMenu.y,
|
||||
}}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
|
||||
@@ -8197,6 +8197,19 @@ button.image-canvas-editor__reference-chip:disabled {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.image-canvas-editor__context-menu--submenu-left
|
||||
.image-canvas-editor__context-submenu-panel {
|
||||
right: calc(100% + 0.42rem);
|
||||
left: auto;
|
||||
transform: translateX(0.12rem);
|
||||
}
|
||||
|
||||
.image-canvas-editor__context-menu--submenu-up
|
||||
.image-canvas-editor__context-submenu-panel {
|
||||
top: auto;
|
||||
bottom: -0.32rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__context-submenu:hover
|
||||
.image-canvas-editor__context-submenu-panel,
|
||||
.image-canvas-editor__context-submenu:focus-within
|
||||
@@ -8216,6 +8229,12 @@ button.image-canvas-editor__reference-chip:disabled {
|
||||
content: '';
|
||||
}
|
||||
|
||||
.image-canvas-editor__context-menu--submenu-left
|
||||
.image-canvas-editor__context-submenu-panel::before {
|
||||
right: -0.55rem;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
.image-canvas-editor__asset-context-menu {
|
||||
z-index: 42;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user