From 9eab60e4270765dc8bfc576e031418dd7eb33401 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=89=A9?= <253518756@qq.com> Date: Thu, 18 Jun 2026 16:43:02 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=BC=96=E8=BE=91=E5=99=A8?= =?UTF-8?q?=E5=BA=95=E6=A0=8F=E9=80=89=E9=A1=B9=E9=9D=A2=E6=9D=BF=E6=82=AC?= =?UTF-8?q?=E5=81=9C=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将生成规范和生成音乐底栏入口改为悬停打开、离开收起 将生成音乐选项面板锚定到音乐按钮上方,避免错位 补充底栏 hover 行为和音乐菜单定位测试 更新生成类面板 Lovart 交互文档 --- ...】生成类面板Lovart统一改造方案-2026-06-17.md | 4 + .../ImageCanvasBottomToolbarView.test.tsx | 40 +++++++- .../ImageCanvasBottomToolbarView.tsx | 61 ++++++++---- .../ImageCanvasEditorShellView.test.tsx | 3 + .../image-editor/ImageCanvasEditorView.tsx | 5 + .../ImageCanvasGenerationComposerView.tsx | 8 ++ .../image-editor/ImageCanvasStageView.tsx | 9 ++ .../useImageCanvasGenerationSurface.test.tsx | 56 ++++++++++- .../useImageCanvasGenerationSurface.tsx | 98 +++++++++++++++++-- src/index.css | 1 + 10 files changed, 254 insertions(+), 31 deletions(-) diff --git a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md index c1a81aef0..934dda7ad 100644 --- a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md +++ b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md @@ -56,6 +56,8 @@ - 父级面板、底部选项框、弹出子面板字号保持一致。 - 底部组合值使用 `·` 分隔,例如 `16:9 · 4秒 · 480p`。 - 底部参数热区与生成按钮等高,默认不显示阴影;悬停显示轻量阴影;箭头默认向下,展开后旋转向上。 +- 底边栏中会在上方弹出二级选项的入口不再依赖点击展开。鼠标悬停到入口即可打开二级面板,鼠标离开入口和二级面板后自动收起;当前范围包括 `生成规范` 和 `生成音乐`。 +- 底边栏二级选项面板必须锚定到对应入口按钮本身,不使用屏幕居中或固定底部偏移;移动端窄屏下也应保持跟随入口位置。 - 生成图片和生成视频文本输入框紧贴参考图下方,取消旧网格预留导致的空白高度。 - 不再在底部常驻展开全部可选项。 @@ -124,6 +126,8 @@ - 多文本输入面板字段标题和边界仍清晰。 - 生成按钮内能看到 `N泥点` 文案。 - 弹出选项面板通过 `role=menu` 暴露,点击选项后保持打开,可连续修改多个字段。 +- 底边栏 `生成规范` / `生成音乐` 悬停即可看到上方选项面板;鼠标从按钮移动到选项面板时不闪退,离开按钮和选项面板后收起。 +- `生成音乐` 选项面板出现在音乐按钮上方,不再固定在底栏中间。 - 规范面板比图片生成面板更紧凑,字段间距和输入高度更小,但外层 shell、首行参考图和底部按钮区必须继续对齐生成图片 / 生成角色 / 生成视频。 - 生成视频结果以视频图层加入画布,画布媒体元素标记为 `画布视频:生成视频 N`。 - 生成器输入、参数、参考图和占位框在刷新后仍存在;已生成对象的生成器面板继续跟随成品图层。 diff --git a/src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx b/src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx index c36ca789f..0bd5ac897 100644 --- a/src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx @@ -9,11 +9,16 @@ import { ImageCanvasBottomToolbarView } from './ImageCanvasBottomToolbarView'; describe('ImageCanvasBottomToolbarView', () => { it('renders the canvas tools and forwards tool changes', () => { const switchTool = vi.fn(); + const specToolWrapRef = createRef(); + const musicToolWrapRef = createRef(); render( ()} + specToolWrapRef={specToolWrapRef} + musicToolWrapRef={musicToolWrapRef} effectiveTool="generate" onSwitchTool={switchTool} + onOpenToolOptions={vi.fn()} + onCloseToolOptions={vi.fn()} />, ); @@ -48,13 +53,18 @@ describe('ImageCanvasBottomToolbarView', () => { }); it('only keeps the select and hand tools visibly pressed', () => { + const specToolWrapRef = createRef(); + const musicToolWrapRef = createRef(); const renderToolbar = (effectiveTool: Parameters< typeof ImageCanvasBottomToolbarView >[0]['effectiveTool']) => ( ()} + specToolWrapRef={specToolWrapRef} + musicToolWrapRef={musicToolWrapRef} effectiveTool={effectiveTool} onSwitchTool={vi.fn()} + onOpenToolOptions={vi.fn()} + onCloseToolOptions={vi.fn()} /> ); @@ -98,4 +108,30 @@ describe('ImageCanvasBottomToolbarView', () => { } }); + it('opens and closes bottom option tools on hover', () => { + const openToolOptions = vi.fn(); + const closeToolOptions = vi.fn(); + const specToolWrapRef = createRef(); + const musicToolWrapRef = createRef(); + render( + , + ); + + fireEvent.pointerEnter(specToolWrapRef.current!); + fireEvent.pointerLeave(specToolWrapRef.current!); + fireEvent.pointerEnter(musicToolWrapRef.current!); + fireEvent.pointerLeave(musicToolWrapRef.current!); + + expect(openToolOptions).toHaveBeenNthCalledWith(1, 'spec'); + expect(closeToolOptions).toHaveBeenNthCalledWith(1, 'spec'); + expect(openToolOptions).toHaveBeenNthCalledWith(2, 'music'); + expect(closeToolOptions).toHaveBeenNthCalledWith(2, 'music'); + }); }); diff --git a/src/components/image-editor/ImageCanvasBottomToolbarView.tsx b/src/components/image-editor/ImageCanvasBottomToolbarView.tsx index 69d63ff61..151792de3 100644 --- a/src/components/image-editor/ImageCanvasBottomToolbarView.tsx +++ b/src/components/image-editor/ImageCanvasBottomToolbarView.tsx @@ -18,10 +18,15 @@ import type { RefObject } from 'react'; import { EditorIconButton } from './ImageCanvasEditorPrimitives'; import type { CanvasTool } from './ImageCanvasEditorTypes'; +type ToolbarOptionTool = Extract; + type ImageCanvasBottomToolbarViewProps = { specToolWrapRef: RefObject; + musicToolWrapRef: RefObject; effectiveTool: CanvasTool; onSwitchTool: (tool: CanvasTool) => void; + onOpenToolOptions: (tool: ToolbarOptionTool) => void; + onCloseToolOptions: (tool: ToolbarOptionTool) => void; }; const canvasTools: Array<{ @@ -44,14 +49,21 @@ const canvasTools: Array<{ { id: 'export', label: '导出工具', icon: Download }, ]; +function isToolbarOptionTool(tool: CanvasTool): tool is ToolbarOptionTool { + return tool === 'music' || tool === 'spec'; +} + function hasPersistentPressedState(tool: CanvasTool) { return tool === 'select' || tool === 'hand'; } export function ImageCanvasBottomToolbarView({ specToolWrapRef, + musicToolWrapRef, effectiveTool, onSwitchTool, + onOpenToolOptions, + onCloseToolOptions, }: ImageCanvasBottomToolbarViewProps) { return (
event.stopPropagation()} > - {canvasTools.map(({ id, label, icon: Icon }) => - id === 'spec' ? ( - - onSwitchTool(id)} - /> - - ) : ( + {canvasTools.map(({ id, label, icon: Icon }) => { + if (isToolbarOptionTool(id)) { + return ( + onOpenToolOptions(id)} + onPointerLeave={() => onCloseToolOptions(id)} + onFocus={() => onOpenToolOptions(id)} + onBlur={() => onCloseToolOptions(id)} + > + onSwitchTool(id)} + /> + + ); + } + + return ( onSwitchTool(id)} /> - ), - )} + ); + })}
); } diff --git a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx index dc89bdf54..e5f284af2 100644 --- a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx @@ -115,6 +115,7 @@ function createStageProps(): ImageCanvasStageViewProps { return { canvasViewportRef: createRef(), specToolWrapRef: createRef(), + musicToolWrapRef: createRef(), isPanning: false, effectiveTool: 'select', canvasBackgroundColor: '#f8fafc', @@ -191,6 +192,8 @@ function createStageProps(): ImageCanvasStageViewProps { onToggleMinimap: vi.fn(), onMinimapPointerDown: vi.fn(), onSwitchTool: vi.fn(), + onOpenToolOptions: vi.fn(), + onCloseToolOptions: vi.fn(), }; } diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index c077db8e8..7e8389214 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -51,6 +51,7 @@ export function ImageCanvasEditorView() { const captureCanvasHistoryRef = useRef<() => void>(() => {}); const resetCanvasInteractionStateRef = useRef<() => void>(() => {}); const specToolWrapRef = useRef(null); + const musicToolWrapRef = useRef(null); const characterSpecButtonRef = useRef(null); const characterReferenceButtonRef = useRef(null); const generationReferenceButtonRef = useRef(null); @@ -444,6 +445,7 @@ export function ImageCanvasEditorView() { setViewport, layerCounterRef, specToolWrapRef, + musicToolWrapRef, characterSpecButtonRef, characterReferenceButtonRef, generationReferenceButtonRef, @@ -782,6 +784,7 @@ export function ImageCanvasEditorView() { const stageProps = { canvasViewportRef, specToolWrapRef, + musicToolWrapRef, isPanning, effectiveTool, canvasBackgroundColor, @@ -864,6 +867,8 @@ export function ImageCanvasEditorView() { onToggleMinimap: toggleMinimap, onMinimapPointerDown: handleMinimapPointerDown, onSwitchTool: switchTool, + onOpenToolOptions: generationSurface.openToolbarOptionMenu, + onCloseToolOptions: generationSurface.closeToolbarOptionMenu, children: generationComposerNode, }; diff --git a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx index 75d6d2377..090926ef3 100644 --- a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx +++ b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx @@ -101,6 +101,8 @@ type ImageCanvasGenerationComposerViewProps = { onAddIconDescription: () => void; onUpdateCharacterAnimationDuration: (frameCountValue: string) => void; onRememberImageModel: (model: string) => void; + onSpecMenuPointerEnter?: () => void; + onSpecMenuPointerLeave?: () => void; }; function buildPortalMenuStyle( @@ -864,6 +866,8 @@ export function ImageCanvasGenerationComposerView({ onAddIconDescription, onUpdateCharacterAnimationDuration, onRememberImageModel, + onSpecMenuPointerEnter, + onSpecMenuPointerLeave, }: ImageCanvasGenerationComposerViewProps) { return ( <> @@ -874,6 +878,10 @@ export function ImageCanvasGenerationComposerView({ label="生成规范类型" placement="top-start" style={buildPortalMenuStyle(specToolWrapRef.current, 'above')} + onPointerEnter={onSpecMenuPointerEnter} + onPointerLeave={onSpecMenuPointerLeave} + onFocus={onSpecMenuPointerEnter} + onBlur={onSpecMenuPointerLeave} > {(['character', 'ui', 'custom'] as const).map((specType) => ( ; specToolWrapRef: RefObject; + musicToolWrapRef: RefObject; isPanning: boolean; effectiveTool: CanvasTool; canvasBackgroundColor: string; @@ -120,11 +121,14 @@ export type ImageCanvasStageViewProps = { onToggleMinimap: () => void; onMinimapPointerDown: (event: ReactPointerEvent) => void; onSwitchTool: (tool: CanvasTool) => void; + onOpenToolOptions: (tool: Extract) => void; + onCloseToolOptions: (tool: Extract) => void; }; export function ImageCanvasStageView({ canvasViewportRef, specToolWrapRef, + musicToolWrapRef, isPanning, effectiveTool, canvasBackgroundColor, @@ -202,6 +206,8 @@ export function ImageCanvasStageView({ onToggleMinimap, onMinimapPointerDown, onSwitchTool, + onOpenToolOptions, + onCloseToolOptions, }: ImageCanvasStageViewProps) { return (
{children} diff --git a/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx b/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx index d2ddfcfcb..8b8d0b016 100644 --- a/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx @@ -1,6 +1,6 @@ /* @vitest-environment jsdom */ -import { fireEvent, render, screen, within } from '@testing-library/react'; +import { act, fireEvent, render, screen, within } from '@testing-library/react'; import { useRef, useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; @@ -60,6 +60,7 @@ function GenerationSurfaceHarness() { const [, setImageContextMenu] = useState(null); const layerCounterRef = useRef(0); const specToolWrapRef = useRef(null); + const musicToolWrapRef = useRef(null); const characterSpecButtonRef = useRef(null); const characterReferenceButtonRef = useRef(null); const iconSpecButtonRef = useRef(null); @@ -78,6 +79,7 @@ function GenerationSurfaceHarness() { setViewport, layerCounterRef, specToolWrapRef, + musicToolWrapRef, characterSpecButtonRef, characterReferenceButtonRef, iconSpecButtonRef, @@ -105,6 +107,13 @@ function GenerationSurfaceHarness() { return (
规范入口 + surface.openToolbarOptionMenu('music')} + onPointerLeave={() => surface.closeToolbarOptionMenu('music')} + > + 音乐入口 + {activeTool} {activeSidebarPanel ?? '-'} @@ -195,6 +204,51 @@ describe('useImageCanvasGenerationSurface', () => { expect(within(menu).getByRole('menuitem', { name: '角色形象规范' })).toBeTruthy(); }); + it('anchors the music menu to the music tool and closes after hover leaves', () => { + vi.useFakeTimers(); + try { + render(); + const musicAnchor = screen.getByText('音乐入口'); + Object.defineProperty(musicAnchor, 'getBoundingClientRect', { + configurable: true, + value: () => ({ + x: 220, + y: 360, + left: 220, + top: 360, + right: 256, + bottom: 396, + width: 36, + height: 36, + toJSON: () => ({}), + }), + }); + + fireEvent.pointerEnter(musicAnchor); + + const menu = screen.getByRole('menu', { name: '生成音乐类型' }); + expect(menu.style.left).toBe('220px'); + expect(menu.style.top).toBe('360px'); + expect(menu.style.transform).toBe('translateY(calc(-100% - 0.45rem))'); + expect(menu.getAttribute('style') ?? '').not.toContain('left: 50%'); + + fireEvent.pointerLeave(musicAnchor); + fireEvent.pointerEnter(menu); + act(() => { + vi.advanceTimersByTime(200); + }); + expect(screen.getByRole('menu', { name: '生成音乐类型' })).toBeTruthy(); + + fireEvent.pointerLeave(menu); + act(() => { + vi.advanceTimersByTime(200); + }); + expect(screen.queryByRole('menu', { name: '生成音乐类型' })).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + it('opens video and UI design generation dialogs through the generation surface', () => { render(); diff --git a/src/components/image-editor/useImageCanvasGenerationSurface.tsx b/src/components/image-editor/useImageCanvasGenerationSurface.tsx index e3cf501c5..683bca51a 100644 --- a/src/components/image-editor/useImageCanvasGenerationSurface.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSurface.tsx @@ -1,10 +1,13 @@ import { + type CSSProperties, type Dispatch, type MutableRefObject, type ReactNode, type RefObject, type SetStateAction, useCallback, + useEffect, + useRef, } from 'react'; import { createPortal } from 'react-dom'; @@ -36,6 +39,8 @@ type CanvasGenerationDialogUpdater = ( dialog: CanvasGenerationDialogState, ) => CanvasGenerationDialogState | null; +type ToolbarOptionTool = Extract; + type ImageCanvasGenerationSurfaceOptions = { layers: CanvasLayer[]; canvasSize: { width: number; height: number }; @@ -43,6 +48,7 @@ type ImageCanvasGenerationSurfaceOptions = { setViewport: Dispatch>; layerCounterRef: MutableRefObject; specToolWrapRef: RefObject; + musicToolWrapRef: RefObject; characterSpecButtonRef: RefObject; characterReferenceButtonRef: RefObject; iconSpecButtonRef: RefObject; @@ -80,6 +86,34 @@ function renderEditorPortal(node: ReactNode) { return createPortal(node, document.body); } +function buildToolbarPortalMenuStyle( + anchor: HTMLElement | null, +): CSSProperties { + const rect = anchor?.getBoundingClientRect(); + if (!rect) { + return { + position: 'fixed', + left: 0, + top: 0, + right: 'auto', + bottom: 'auto', + zIndex: 70, + }; + } + + return { + position: 'fixed', + left: Math.round(rect.left), + top: Math.round(rect.top), + right: 'auto', + bottom: 'auto', + zIndex: 70, + transform: 'translateY(calc(-100% - 0.45rem))', + }; +} + +const TOOLBAR_OPTION_CLOSE_DELAY_MS = 160; + export function useImageCanvasGenerationSurface({ layers, canvasSize, @@ -87,6 +121,7 @@ export function useImageCanvasGenerationSurface({ setViewport, layerCounterRef, specToolWrapRef, + musicToolWrapRef, characterSpecButtonRef, characterReferenceButtonRef, iconSpecButtonRef, @@ -109,6 +144,9 @@ export function useImageCanvasGenerationSurface({ requestUpload, persistGeneratedAsset, }: ImageCanvasGenerationSurfaceOptions) { + const toolbarOptionCloseTimerRef = useRef | null>(null); const generationWorkflow = useImageCanvasGenerationWorkflow({ layers, canvasSize, @@ -165,6 +203,44 @@ export function useImageCanvasGenerationSurface({ canvasSize, }); + const clearToolbarOptionCloseTimer = useCallback(() => { + if (!toolbarOptionCloseTimerRef.current) { + return; + } + clearTimeout(toolbarOptionCloseTimerRef.current); + toolbarOptionCloseTimerRef.current = null; + }, []); + + useEffect( + () => () => { + clearToolbarOptionCloseTimer(); + }, + [clearToolbarOptionCloseTimer], + ); + + const openToolbarOptionMenu = useCallback( + (tool: ToolbarOptionTool) => { + clearToolbarOptionCloseTimer(); + generationWorkflow.setIsMusicMenuOpen(tool === 'music'); + generationWorkflow.setIsSpecMenuOpen(tool === 'spec'); + }, + [clearToolbarOptionCloseTimer, generationWorkflow], + ); + + const closeToolbarOptionMenu = useCallback( + (tool: ToolbarOptionTool) => { + clearToolbarOptionCloseTimer(); + toolbarOptionCloseTimerRef.current = setTimeout(() => { + if (tool === 'music') { + generationWorkflow.setIsMusicMenuOpen(false); + return; + } + generationWorkflow.setIsSpecMenuOpen(false); + }, TOOLBAR_OPTION_CLOSE_DELAY_MS); + }, + [clearToolbarOptionCloseTimer, generationWorkflow], + ); + const switchGenerationTool = useCallback( (tool: CanvasTool) => { if (tool === 'generate') { @@ -176,12 +252,12 @@ export function useImageCanvasGenerationSurface({ return true; } if (tool === 'music') { - generationWorkflow.setIsMusicMenuOpen((open) => !open); + openToolbarOptionMenu('music'); setActiveTool('music'); return true; } if (tool === 'spec') { - generationWorkflow.setIsSpecMenuOpen((open) => !open); + openToolbarOptionMenu('spec'); setActiveTool('spec'); return true; } @@ -199,7 +275,7 @@ export function useImageCanvasGenerationSurface({ } return false; }, - [generationWorkflow, setActiveTool], + [generationWorkflow, openToolbarOptionMenu, setActiveTool], ); const generationComposerNode = ( @@ -302,6 +378,8 @@ export function useImageCanvasGenerationSurface({ generationWorkflow.updateCharacterAnimationDuration } onRememberImageModel={generationWorkflow.rememberImageModel} + onSpecMenuPointerEnter={() => openToolbarOptionMenu('spec')} + onSpecMenuPointerLeave={() => closeToolbarOptionMenu('spec')} /> {generationWorkflow.isMusicMenuOpen ? renderEditorPortal( @@ -309,13 +387,11 @@ export function useImageCanvasGenerationSurface({ className="image-canvas-editor__spec-menu image-canvas-editor__portal-menu image-canvas-editor__music-menu" label="生成音乐类型" placement="top-start" - style={{ - position: 'fixed', - left: '50%', - bottom: '4.8rem', - zIndex: 70, - transform: 'translateX(-50%)', - }} + style={buildToolbarPortalMenuStyle(musicToolWrapRef.current)} + onPointerEnter={() => openToolbarOptionMenu('music')} + onPointerLeave={() => closeToolbarOptionMenu('music')} + onFocus={() => openToolbarOptionMenu('music')} + onBlur={() => closeToolbarOptionMenu('music')} >