修复画布右键菜单出屏

实测右键菜单尺寸并夹入视口

根据视口空间翻转导出二级菜单方向

补充菜单出屏回归测试
This commit is contained in:
2026-06-30 18:13:45 +08:00
parent dfe6bafb5e
commit daffdef5d0
3 changed files with 246 additions and 9 deletions
@@ -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()}
>
+19
View File
@@ -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;
}