修复画板 Portal 菜单主题继承
新增画板 Portal 主题桥接并同步亮暗主题 让编辑器根节点与 Portal 菜单共享完整品牌 Token 补充静音开关、角色动画与样式契约回归测试 同步图片画布技术方案和 Portal 排障记忆
This commit is contained in:
@@ -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` 直接提交,信息面板输入快照只展示用户填写的快速编辑提示词。
|
||||
- 点击生成、生成规范、生成角色形象或生成图标素材后创建的占位图可继续保留;点击画布空白区域让当前图片或占位图失焦时,关闭当前生成面板并移除图片选中样式,但不删除占位图本身。
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user