修复画板 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
@@ -376,6 +376,14 @@
- 验证:测试断言菜单不包含在底部工具栏 / 参考图行里,并且生成面板打开时底部 `AI画布工具栏` 仍存在;规范参考图来源菜单应能通过 portal 点击“从画布中选择 / 上传图片”并写回规范参考图。
- 关联:`src/components/common/PlatformFloatingMenu.tsx``src/components/image-editor/ImageCanvasEditorView.tsx``src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx`
## 图片编辑器 portal 菜单必须显式继承画板主题 token
- 现象:生成视频参数面板里点击“静音”后,开关轨道和白色滑块一起消失;如果直接把轨道改成 `#00ff00`,虽然重新可见,却变成与画板主题不一致的荧光绿。相同比例、清晰度、slider、时长文字和模型选中勾选也可能丢失选中态主题。
- 原因:`renderEditorPortal(...)``.image-canvas-editor__portal-menu` 挂到 `document.body`,它不再是 `.image-canvas-editor` 的后代,无法继承只定义在编辑器根节点上的 `--image-canvas-brand-*` 自定义属性。浏览器会把依赖缺失变量且没有 fallback 的声明按无效值处理,轨道背景最终为透明。
- 处理:portal 继续挂到 `document.body` 以避免局部 `overflow` 裁切,但外层必须通过 `.image-canvas-editor__portal-theme` 同步当前 `platform-theme--light / platform-theme--dark`;画板品牌 token 由 `.image-canvas-editor`、主题桥接层与 `.image-canvas-editor__portal-menu` 共用同一组声明。控件继续消费主题变量,不使用单点硬编码颜色,也不要只给静音轨道补 fallback 而遗漏同一 portal 内其它 token 消费者。
- 验证:`scripts/image-canvas-portal-theme.test.ts` 应锁定编辑器根节点、portal 主题桥接层与 portal 菜单共享完整品牌 token,静音 pressed 轨道仍使用 `var(--image-canvas-brand-accent)` 且不出现 `#00ff00``useImageCanvasGenerationSurface.test.tsx` 应覆盖暗色主题 class 被桥接到 `document.body` 下的 portal。真实浏览器从 `生成视频 -> 视频参数 -> 静音` 点击后,轨道 computed background 应为非透明当前主题色,portal 内 `--image-canvas-brand-accent``--image-canvas-brand-border-strong``--image-canvas-brand-soft` 均应有值。
- 关联:`src/index.css``scripts/image-canvas-portal-theme.test.ts``src/components/image-editor/ImageCanvasEditorPortal.tsx``src/components/image-editor/useImageCanvasGenerationSurface.tsx``src/components/image-editor/ImageCanvasGenerationComposerView.tsx`
## 图片编辑器规范图片面板不要脱离统一生成 shell
- 现象:生成 UI 设计图或新建图标规范时,面板参考图、输入区和底部生成按钮相对生成图片 / 生成角色 / 生成视频错位;图标规范甚至可能缺少首行参考图入口。
@@ -121,6 +121,7 @@
- 生成器快照刷新后必须恢复;待生成、生成中、失败和已生成后跟随成品图层的生成器都不能因为刷新丢失输入、参数、参考图或占位框位置。宣发素材生成器刷新后必须继续显示正确的卡片类型、游戏名、分类、描述和已绑定参考图。
- 画布多选语义必须同时覆盖普通图层和仍显示占位框的生成器对象:Shift 点选或框选可把生成器加入当前选择;拖动任一已选图层或生成器时,所有已选普通图层和生成器占位框同步移动;删除 / Backspace / Delete 作用于完整选择集合,移除所有已选图层和生成器对象。生成器对象在选择集合中使用稳定 `generation-dialog:<id>` 目标 ID,不把生成器伪装成普通图层,也不新增后端表。
- 生成类入口打开画布内面板时,底部 AI 工具栏必须保持可见;`生成规范`、角色 / 图标规范来源、角色常规参考图来源这类轻量菜单通过页面级 fixed portal 渲染,不能留在底部工具栏或参考图横向滚动容器内部,避免被局部 `overflow` 裁切。角色规范和常规参考图来源菜单必须向上弹出;常规参考图点击后先选择“从画布中选择”或“上传图片”,从画布取图时只绑定参考图,不触发普通画布图层选中、聚焦、面板隐藏或拖拽逻辑,绑定后退出画布选择状态。所有生成面板参考图槽位统一为方形图标组件;角色规范槽位只显示规范 logo 和 `角色规范` 四字,绑定来源标题只保留给可访问名称、悬浮 title 和图片信息。已有参考图槽位只有在 hover / focus 时显示右上角 `×`,点击后只解绑对应参考图。角色形象生成面板每次成功绑定角色规范后,在当前编辑器生命周期内缓存为上一张角色规范;再次新建角色形象时自动带入该缓存。图标素材和 UI 设计图面板每次成功绑定图标规范后,同样缓存为上一张图标规范;再次新建需要图标规范的素材时自动带入该缓存。生成规范菜单里的图标规范对象自身只把首行参考图作为可选参考,不要求必须先绑定图标规范。
- 所有挂到 `document.body``.image-canvas-editor__portal-menu` 必须放在同步当前 `platform-theme--light / platform-theme--dark``.image-canvas-editor__portal-theme` 桥接层下,并与 `.image-canvas-editor` 共用完整的 `--image-canvas-brand-*` token 声明。portal 内的比例 / 清晰度选中态、视频和音频 slider、静音开关、时长文字与模型选中勾选继续消费同一组主题变量;不得用 `#00ff00` 等硬编码颜色绕过变量作用域,也不得只修单个控件而让其它 portal 选中态继续退回无效声明。
- 生成规范类图片面板底部必须以禁用态参数按钮显示 `16:9·2K``gpt-image-2`,视觉对齐可编辑面板参数控件,提交到 `/api/editor/images/generations` 时也固定携带这些参数。
- 快速编辑面板底部只显示模型选择和 `修改` 按钮;打开时视口聚焦必须预留底部面板空间,面板位于素材下方,不得遮挡原素材,且素材在当前屏幕内完整可见。快速编辑请求只把原图或红框序号标注图作为 `sourceImageSrc` 直接提交,信息面板输入快照只展示用户填写的快速编辑提示词。
- 点击生成、生成规范、生成角色形象或生成图标素材后创建的占位图可继续保留;点击画布空白区域让当前图片或占位图失焦时,关闭当前生成面板并移除图片选中样式,但不删除占位图本身。
+83
View File
@@ -0,0 +1,83 @@
import fs from 'node:fs';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
function readIndexCss() {
return fs.readFileSync(path.resolve(process.cwd(), 'src/index.css'), 'utf8');
}
function getCssBlock(source: string, selector: string) {
const selectorIndex = source.indexOf(selector);
expect(selectorIndex, `${selector} should exist`).toBeGreaterThanOrEqual(0);
const openBraceIndex = source.indexOf('{', selectorIndex);
expect(
openBraceIndex,
`${selector} should open a CSS block`,
).toBeGreaterThanOrEqual(0);
let depth = 0;
for (let index = openBraceIndex; index < source.length; index += 1) {
const char = source[index];
if (char === '{') {
depth += 1;
} else if (char === '}') {
depth -= 1;
if (depth === 0) {
return source.slice(openBraceIndex + 1, index);
}
}
}
throw new Error(`${selector} block is not closed`);
}
describe('image canvas portal theme contract', () => {
it('exposes every image canvas brand token to body-level portal menus', () => {
const css = readIndexCss();
const sharedTokenBlock = getCssBlock(
css,
'.image-canvas-editor,\n.image-canvas-editor__portal-theme,\n.image-canvas-editor__portal-menu',
);
const editorBlock = getCssBlock(css, '.image-canvas-editor {');
const pressedToggleTrackBlock = getCssBlock(
css,
".image-canvas-editor__video-toggle[aria-pressed='true'] span",
);
for (const token of [
'--image-canvas-brand-accent:',
'--image-canvas-brand-accent-strong:',
'--image-canvas-brand-fill:',
'--image-canvas-brand-text-on-fill:',
'--image-canvas-brand-border:',
'--image-canvas-brand-border-strong:',
'--image-canvas-brand-border-soft:',
'--image-canvas-brand-soft:',
'--image-canvas-brand-soft-strong:',
'--image-canvas-brand-shadow:',
'--image-canvas-brand-focus-ring:',
]) {
expect(sharedTokenBlock).toContain(token);
}
for (const editorOnlyDeclaration of [
'position:',
'display:',
'height:',
'overflow:',
'background:',
'color:',
'user-select:',
]) {
expect(sharedTokenBlock).not.toContain(editorOnlyDeclaration);
}
expect(editorBlock).not.toContain('--image-canvas-brand-accent:');
expect(pressedToggleTrackBlock).toContain(
'background: var(--image-canvas-brand-accent);',
);
expect(pressedToggleTrackBlock).not.toContain('#00ff00');
});
});
@@ -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;