From daffdef5d004d45eeb21cdce41b6c269f2a86d95 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 30 Jun 2026 18:13:45 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=94=BB=E5=B8=83=E5=8F=B3?= =?UTF-8?q?=E9=94=AE=E8=8F=9C=E5=8D=95=E5=87=BA=E5=B1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实测右键菜单尺寸并夹入视口 根据视口空间翻转导出二级菜单方向 补充菜单出屏回归测试 --- .../ImageCanvasContextMenusView.test.tsx | 76 ++++++++- .../ImageCanvasContextMenusView.tsx | 160 +++++++++++++++++- src/index.css | 19 +++ 3 files changed, 246 insertions(+), 9 deletions(-) diff --git a/src/components/image-editor/ImageCanvasContextMenusView.test.tsx b/src/components/image-editor/ImageCanvasContextMenusView.test.tsx index 2b1eda018..d56551386 100644 --- a/src/components/image-editor/ImageCanvasContextMenusView.test.tsx +++ b/src/components/image-editor/ImageCanvasContextMenusView.test.tsx @@ -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 { + 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({ diff --git a/src/components/image-editor/ImageCanvasContextMenusView.tsx b/src/components/image-editor/ImageCanvasContextMenusView.tsx index 293a74738..2e0e6de85 100644 --- a/src/components/image-editor/ImageCanvasContextMenusView.tsx +++ b/src/components/image-editor/ImageCanvasContextMenusView.tsx @@ -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(null); + const [measuredMenuLayout, setMeasuredMenuLayout] = + useState(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( + '.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 ? (
event.preventDefault()} onPointerDown={(event) => event.stopPropagation()} @@ -370,10 +514,10 @@ export function ImageCanvasContextMenusView({ {imageContextMenu && imageContextMenuLayer && !contextMenu ? (
event.stopPropagation()} > diff --git a/src/index.css b/src/index.css index 98f16bee6..ea28bd9d4 100644 --- a/src/index.css +++ b/src/index.css @@ -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; }