修复编辑器底栏选项面板悬停交互

将生成规范和生成音乐底栏入口改为悬停打开、离开收起

将生成音乐选项面板锚定到音乐按钮上方,避免错位

补充底栏 hover 行为和音乐菜单定位测试

更新生成类面板 Lovart 交互文档
This commit is contained in:
2026-06-18 16:43:02 +08:00
parent 0b1d6ca189
commit 9eab60e427
10 changed files with 254 additions and 31 deletions
@@ -56,6 +56,8 @@
- 父级面板、底部选项框、弹出子面板字号保持一致。
- 底部组合值使用 `·` 分隔,例如 `16:9 · 4秒 · 480p`
- 底部参数热区与生成按钮等高,默认不显示阴影;悬停显示轻量阴影;箭头默认向下,展开后旋转向上。
- 底边栏中会在上方弹出二级选项的入口不再依赖点击展开。鼠标悬停到入口即可打开二级面板,鼠标离开入口和二级面板后自动收起;当前范围包括 `生成规范``生成音乐`
- 底边栏二级选项面板必须锚定到对应入口按钮本身,不使用屏幕居中或固定底部偏移;移动端窄屏下也应保持跟随入口位置。
- 生成图片和生成视频文本输入框紧贴参考图下方,取消旧网格预留导致的空白高度。
- 不再在底部常驻展开全部可选项。
@@ -124,6 +126,8 @@
- 多文本输入面板字段标题和边界仍清晰。
- 生成按钮内能看到 `N泥点` 文案。
- 弹出选项面板通过 `role=menu` 暴露,点击选项后保持打开,可连续修改多个字段。
- 底边栏 `生成规范` / `生成音乐` 悬停即可看到上方选项面板;鼠标从按钮移动到选项面板时不闪退,离开按钮和选项面板后收起。
- `生成音乐` 选项面板出现在音乐按钮上方,不再固定在底栏中间。
- 规范面板比图片生成面板更紧凑,字段间距和输入高度更小,但外层 shell、首行参考图和底部按钮区必须继续对齐生成图片 / 生成角色 / 生成视频。
- 生成视频结果以视频图层加入画布,画布媒体元素标记为 `画布视频:生成视频 N`
- 生成器输入、参数、参考图和占位框在刷新后仍存在;已生成对象的生成器面板继续跟随成品图层。
@@ -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<HTMLSpanElement>();
const musicToolWrapRef = createRef<HTMLSpanElement>();
render(
<ImageCanvasBottomToolbarView
specToolWrapRef={createRef<HTMLSpanElement>()}
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<HTMLSpanElement>();
const musicToolWrapRef = createRef<HTMLSpanElement>();
const renderToolbar = (effectiveTool: Parameters<
typeof ImageCanvasBottomToolbarView
>[0]['effectiveTool']) => (
<ImageCanvasBottomToolbarView
specToolWrapRef={createRef<HTMLSpanElement>()}
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<HTMLSpanElement>();
const musicToolWrapRef = createRef<HTMLSpanElement>();
render(
<ImageCanvasBottomToolbarView
specToolWrapRef={specToolWrapRef}
musicToolWrapRef={musicToolWrapRef}
effectiveTool="select"
onSwitchTool={vi.fn()}
onOpenToolOptions={openToolOptions}
onCloseToolOptions={closeToolOptions}
/>,
);
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');
});
});
@@ -18,10 +18,15 @@ import type { RefObject } from 'react';
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
import type { CanvasTool } from './ImageCanvasEditorTypes';
type ToolbarOptionTool = Extract<CanvasTool, 'music' | 'spec'>;
type ImageCanvasBottomToolbarViewProps = {
specToolWrapRef: RefObject<HTMLSpanElement | null>;
musicToolWrapRef: RefObject<HTMLSpanElement | null>;
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 (
<div
@@ -60,22 +72,35 @@ export function ImageCanvasBottomToolbarView({
aria-label="AI画布工具栏"
onPointerDown={(event) => event.stopPropagation()}
>
{canvasTools.map(({ id, label, icon: Icon }) =>
id === 'spec' ? (
<span
key={id}
ref={specToolWrapRef}
className="image-canvas-editor__spec-tool-wrap"
>
<EditorIconButton
label={label}
title={label}
icon={Icon}
pressed={hasPersistentPressedState(id) && effectiveTool === id}
onClick={() => onSwitchTool(id)}
/>
</span>
) : (
{canvasTools.map(({ id, label, icon: Icon }) => {
if (isToolbarOptionTool(id)) {
return (
<span
key={id}
ref={id === 'spec' ? specToolWrapRef : musicToolWrapRef}
className={[
'image-canvas-editor__bottom-toolbar-option-wrap',
id === 'spec' ? 'image-canvas-editor__spec-tool-wrap' : '',
]
.filter(Boolean)
.join(' ')}
onPointerEnter={() => onOpenToolOptions(id)}
onPointerLeave={() => onCloseToolOptions(id)}
onFocus={() => onOpenToolOptions(id)}
onBlur={() => onCloseToolOptions(id)}
>
<EditorIconButton
label={label}
title={label}
icon={Icon}
pressed={hasPersistentPressedState(id) && effectiveTool === id}
onClick={() => onSwitchTool(id)}
/>
</span>
);
}
return (
<EditorIconButton
key={id}
label={label}
@@ -84,8 +109,8 @@ export function ImageCanvasBottomToolbarView({
pressed={hasPersistentPressedState(id) && effectiveTool === id}
onClick={() => onSwitchTool(id)}
/>
),
)}
);
})}
</div>
);
}
@@ -115,6 +115,7 @@ function createStageProps(): ImageCanvasStageViewProps {
return {
canvasViewportRef: createRef<HTMLDivElement>(),
specToolWrapRef: createRef<HTMLSpanElement>(),
musicToolWrapRef: createRef<HTMLSpanElement>(),
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(),
};
}
@@ -51,6 +51,7 @@ export function ImageCanvasEditorView() {
const captureCanvasHistoryRef = useRef<() => void>(() => {});
const resetCanvasInteractionStateRef = useRef<() => void>(() => {});
const specToolWrapRef = useRef<HTMLSpanElement | null>(null);
const musicToolWrapRef = useRef<HTMLSpanElement | null>(null);
const characterSpecButtonRef = useRef<HTMLButtonElement | null>(null);
const characterReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
const generationReferenceButtonRef = useRef<HTMLButtonElement | null>(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,
};
@@ -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) => (
<PlatformFloatingMenuItem
@@ -31,6 +31,7 @@ import type {
export type ImageCanvasStageViewProps = {
canvasViewportRef: RefObject<HTMLDivElement | null>;
specToolWrapRef: RefObject<HTMLSpanElement | null>;
musicToolWrapRef: RefObject<HTMLSpanElement | null>;
isPanning: boolean;
effectiveTool: CanvasTool;
canvasBackgroundColor: string;
@@ -120,11 +121,14 @@ export type ImageCanvasStageViewProps = {
onToggleMinimap: () => void;
onMinimapPointerDown: (event: ReactPointerEvent<HTMLButtonElement>) => void;
onSwitchTool: (tool: CanvasTool) => void;
onOpenToolOptions: (tool: Extract<CanvasTool, 'music' | 'spec'>) => void;
onCloseToolOptions: (tool: Extract<CanvasTool, 'music' | 'spec'>) => void;
};
export function ImageCanvasStageView({
canvasViewportRef,
specToolWrapRef,
musicToolWrapRef,
isPanning,
effectiveTool,
canvasBackgroundColor,
@@ -202,6 +206,8 @@ export function ImageCanvasStageView({
onToggleMinimap,
onMinimapPointerDown,
onSwitchTool,
onOpenToolOptions,
onCloseToolOptions,
}: ImageCanvasStageViewProps) {
return (
<div
@@ -314,8 +320,11 @@ export function ImageCanvasStageView({
<ImageCanvasBottomToolbarView
specToolWrapRef={specToolWrapRef}
musicToolWrapRef={musicToolWrapRef}
effectiveTool={effectiveTool}
onSwitchTool={onSwitchTool}
onOpenToolOptions={onOpenToolOptions}
onCloseToolOptions={onCloseToolOptions}
/>
{children}
@@ -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<ImageContextMenuState | null>(null);
const layerCounterRef = useRef(0);
const specToolWrapRef = useRef<HTMLSpanElement | null>(null);
const musicToolWrapRef = useRef<HTMLSpanElement | null>(null);
const characterSpecButtonRef = useRef<HTMLButtonElement | null>(null);
const characterReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
const iconSpecButtonRef = useRef<HTMLButtonElement | null>(null);
@@ -78,6 +79,7 @@ function GenerationSurfaceHarness() {
setViewport,
layerCounterRef,
specToolWrapRef,
musicToolWrapRef,
characterSpecButtonRef,
characterReferenceButtonRef,
iconSpecButtonRef,
@@ -105,6 +107,13 @@ function GenerationSurfaceHarness() {
return (
<div>
<span ref={specToolWrapRef}></span>
<span
ref={musicToolWrapRef}
onPointerEnter={() => surface.openToolbarOptionMenu('music')}
onPointerLeave={() => surface.closeToolbarOptionMenu('music')}
>
</span>
<span data-testid="tool">{activeTool}</span>
<span data-testid="sidebar">{activeSidebarPanel ?? '-'}</span>
<span data-testid="dialog">
@@ -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(<GenerationSurfaceHarness />);
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(<GenerationSurfaceHarness />);
@@ -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<CanvasTool, 'music' | 'spec'>;
type ImageCanvasGenerationSurfaceOptions = {
layers: CanvasLayer[];
canvasSize: { width: number; height: number };
@@ -43,6 +48,7 @@ type ImageCanvasGenerationSurfaceOptions = {
setViewport: Dispatch<SetStateAction<CanvasViewport>>;
layerCounterRef: MutableRefObject<number>;
specToolWrapRef: RefObject<HTMLSpanElement | null>;
musicToolWrapRef: RefObject<HTMLSpanElement | null>;
characterSpecButtonRef: RefObject<HTMLButtonElement | null>;
characterReferenceButtonRef: RefObject<HTMLButtonElement | null>;
iconSpecButtonRef: RefObject<HTMLButtonElement | null>;
@@ -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<ReturnType<
typeof setTimeout
> | 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')}
>
<PlatformFloatingMenuItem
onClick={generationWorkflow.openSoundEffectGenerationDialog}
@@ -337,6 +413,8 @@ export function useImageCanvasGenerationSurface({
...generationWorkflow,
generationComposerStyle,
generationComposerNode,
openToolbarOptionMenu,
closeToolbarOptionMenu,
switchGenerationTool,
};
}
+1
View File
@@ -4775,6 +4775,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
color: #0369a1;
}
.image-canvas-editor__bottom-toolbar-option-wrap,
.image-canvas-editor__spec-tool-wrap {
position: relative;
display: inline-flex;