修复画板 Portal 菜单主题继承

新增画板 Portal 主题桥接并同步亮暗主题
让编辑器根节点与 Portal 菜单共享完整品牌 Token
补充静音开关、角色动画与样式契约回归测试
同步图片画布技术方案和 Portal 排障记忆
This commit is contained in:
2026-07-20 20:56:36 +08:00
parent f9e6ae40db
commit 06c12f40b6
11 changed files with 168 additions and 25 deletions
@@ -128,6 +128,11 @@ describe('ImageCanvasCharacterAnimationPanelView', () => {
expect(screen.getByLabelText('当前分辨率').textContent).toBe('720p');
expect(screen.getByLabelText('当前比例').textContent).toBe('16:9');
expect(menu).toBeTruthy();
expect(
menu
.closest('.image-canvas-editor__portal-theme')
?.classList.contains('platform-theme--light'),
).toBe(true);
expect(screen.getByLabelText('当前状态').textContent).toBe('idle');
expect(screen.getByLabelText('当前错误').textContent).toBe('-');
});
@@ -8,7 +8,6 @@ import {
useRef,
useState,
} from 'react';
import { createPortal } from 'react-dom';
import type {
EditorCharacterAnimationRatio,
@@ -19,6 +18,7 @@ import { PlatformFloatingMenu } from '../common/PlatformFloatingMenu';
import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformTextField } from '../common/PlatformTextField';
import { ImageCanvasEditorPortal } from './ImageCanvasEditorPortal';
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
import type {
CanvasLayer,
@@ -79,11 +79,8 @@ function buildLocalMenuStyle(anchor: HTMLElement | null): CSSProperties {
}
function renderPanelPortal(node: ReactNode) {
if (typeof document === 'undefined') {
return node;
}
// 中文注释:参数菜单挂到页面级,避免被角色动画面板滚动边界裁切。
return document.body ? createPortal(node, document.body) : node;
return <ImageCanvasEditorPortal>{node}</ImageCanvasEditorPortal>;
}
function getRatioLabel(value: EditorCharacterAnimationRatio) {
@@ -0,0 +1,21 @@
import type { ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { useAuthUi } from '../auth/AuthUiContext';
export function ImageCanvasEditorPortal({ children }: { children: ReactNode }) {
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
if (typeof document === 'undefined') {
return <>{children}</>;
}
return createPortal(
<div
className={`platform-theme platform-theme--${platformTheme} image-canvas-editor__portal-theme`}
>
{children}
</div>,
document.body,
);
}
@@ -554,6 +554,11 @@ describe('ImageCanvasGenerationComposerView', () => {
const paramsPanel = screen.getByRole('menu', {
name: '视频参数选项',
});
expect(
paramsPanel
.closest('.image-canvas-editor__portal-theme')
?.classList.contains('platform-theme--light'),
).toBe(true);
const ratioChoice = within(paramsPanel).getByRole('button', {
name: '比例 16:9',
});
@@ -9,7 +9,6 @@ import {
useRef,
useState,
} from 'react';
import { createPortal } from 'react-dom';
import { PlatformActionButton } from '../common/PlatformActionButton';
import {
@@ -24,6 +23,7 @@ import { ImageCanvasCharacterAnimationPanelView } from './ImageCanvasCharacterAn
import { ImageCanvasCharacterGenerationComposerView } from './ImageCanvasCharacterGenerationComposerView';
import { ImageCanvasCropExpandPanelView } from './ImageCanvasCropExpandPanelView';
import { ImageCanvasEditGenerationModalView } from './ImageCanvasEditGenerationModalView';
import { ImageCanvasEditorPortal } from './ImageCanvasEditorPortal';
import type {
CanvasLayer,
CharacterAnimationPanelState,
@@ -162,10 +162,7 @@ function buildPortalMenuStyle(
}
function renderEditorPortal(node: ReactNode) {
if (typeof document === 'undefined') {
return node;
}
return createPortal(node, document.body);
return <ImageCanvasEditorPortal>{node}</ImageCanvasEditorPortal>;
}
function resetFailedVideoDialogStatus(dialog: GenerateDialogState) {
@@ -14,6 +14,16 @@ import type {
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
import { useImageCanvasGenerationSurface } from './useImageCanvasGenerationSurface';
const { mockUseAuthUi } = vi.hoisted(() => ({
mockUseAuthUi: vi.fn(() => ({
platformTheme: 'light' as 'light' | 'dark',
})),
}));
vi.mock('../auth/AuthUiContext', () => ({
useAuthUi: mockUseAuthUi,
}));
vi.mock('../../services/image-editor/editorImageReference', () => ({
resolveEditorImageReferenceDataUrl: vi.fn(async (src: string) => src),
}));
@@ -35,6 +45,7 @@ let mockedComposerRect: DOMRect | null = null;
let originalGetBoundingClientRect: typeof Element.prototype.getBoundingClientRect;
beforeEach(() => {
mockUseAuthUi.mockReturnValue({ platformTheme: 'light' });
originalGetBoundingClientRect = Element.prototype.getBoundingClientRect;
Element.prototype.getBoundingClientRect = function getBoundingClientRect() {
if (
@@ -302,6 +313,19 @@ describe('useImageCanvasGenerationSurface', () => {
).toBeTruthy();
});
it('bridges the current platform theme into body-level portal menus', () => {
mockUseAuthUi.mockReturnValue({ platformTheme: 'dark' });
render(<GenerationSurfaceHarness />);
fireEvent.click(screen.getByRole('button', { name: '切换规范' }));
const menu = screen.getByRole('menu', { name: '生成规范类型' });
const themeBridge = menu.closest('.image-canvas-editor__portal-theme');
expect(themeBridge?.parentElement).toBe(document.body);
expect(themeBridge?.classList.contains('platform-theme')).toBe(true);
expect(themeBridge?.classList.contains('platform-theme--dark')).toBe(true);
});
it('renders a warning toast when a non-spec layer is picked as character spec', () => {
render(<GenerationSurfaceHarness />);
@@ -10,7 +10,6 @@ import {
useEffect,
useRef,
} from 'react';
import { createPortal } from 'react-dom';
import type {
EditorAssetSnapshot,
@@ -21,6 +20,7 @@ import {
PlatformFloatingMenuItem,
} from '../common/PlatformFloatingMenu';
import { PlatformRuntimeStatusToast } from '../common/PlatformRuntimeStatusToast';
import { ImageCanvasEditorPortal } from './ImageCanvasEditorPortal';
import type {
CanvasGenerationDialogState,
CanvasHistoryAction,
@@ -110,10 +110,7 @@ type ImageCanvasGenerationSurfaceOptions = {
};
function renderEditorPortal(node: ReactNode) {
if (typeof document === 'undefined') {
return node;
}
return createPortal(node, document.body);
return <ImageCanvasEditorPortal>{node}</ImageCanvasEditorPortal>;
}
function buildToolbarPortalMenuStyle(
+15 -10
View File
@@ -3899,16 +3899,9 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
}
}
.image-canvas-editor {
position: relative;
display: flex;
min-height: 0;
min-width: 0;
height: 100%;
gap: 0;
overflow: hidden;
background: #ffffff;
color: #1f2937;
.image-canvas-editor,
.image-canvas-editor__portal-theme,
.image-canvas-editor__portal-menu {
--image-canvas-brand-accent: var(--platform-accent, #c7653d);
--image-canvas-brand-accent-strong: #6f2f21;
--image-canvas-brand-fill: var(
@@ -3926,6 +3919,18 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
--image-canvas-brand-soft-strong: rgba(238, 208, 183, 0.46);
--image-canvas-brand-shadow: rgba(182, 98, 63, 0.16);
--image-canvas-brand-focus-ring: rgba(204, 117, 76, 0.18);
}
.image-canvas-editor {
position: relative;
display: flex;
min-height: 0;
min-width: 0;
height: 100%;
gap: 0;
overflow: hidden;
background: #ffffff;
color: #1f2937;
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;