diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 2a26c235f..d2ed6fe6d 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -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 设计图或新建图标规范时,面板参考图、输入区和底部生成按钮相对生成图片 / 生成角色 / 生成视频错位;图标规范甚至可能缺少首行参考图入口。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 478583ba6..043e13bf9 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -121,6 +121,7 @@ - 生成器快照刷新后必须恢复;待生成、生成中、失败和已生成后跟随成品图层的生成器都不能因为刷新丢失输入、参数、参考图或占位框位置。宣发素材生成器刷新后必须继续显示正确的卡片类型、游戏名、分类、描述和已绑定参考图。 - 画布多选语义必须同时覆盖普通图层和仍显示占位框的生成器对象:Shift 点选或框选可把生成器加入当前选择;拖动任一已选图层或生成器时,所有已选普通图层和生成器占位框同步移动;删除 / Backspace / Delete 作用于完整选择集合,移除所有已选图层和生成器对象。生成器对象在选择集合中使用稳定 `generation-dialog:` 目标 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` 直接提交,信息面板输入快照只展示用户填写的快速编辑提示词。 - 点击生成、生成规范、生成角色形象或生成图标素材后创建的占位图可继续保留;点击画布空白区域让当前图片或占位图失焦时,关闭当前生成面板并移除图片选中样式,但不删除占位图本身。 diff --git a/scripts/image-canvas-portal-theme.test.ts b/scripts/image-canvas-portal-theme.test.ts new file mode 100644 index 000000000..1b176553d --- /dev/null +++ b/scripts/image-canvas-portal-theme.test.ts @@ -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'); + }); +}); diff --git a/src/components/image-editor/ImageCanvasCharacterAnimationPanelView.test.tsx b/src/components/image-editor/ImageCanvasCharacterAnimationPanelView.test.tsx index 84e0d5b48..27f29f47c 100644 --- a/src/components/image-editor/ImageCanvasCharacterAnimationPanelView.test.tsx +++ b/src/components/image-editor/ImageCanvasCharacterAnimationPanelView.test.tsx @@ -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('-'); }); diff --git a/src/components/image-editor/ImageCanvasCharacterAnimationPanelView.tsx b/src/components/image-editor/ImageCanvasCharacterAnimationPanelView.tsx index 0482a424a..015a36e9a 100644 --- a/src/components/image-editor/ImageCanvasCharacterAnimationPanelView.tsx +++ b/src/components/image-editor/ImageCanvasCharacterAnimationPanelView.tsx @@ -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 {node}; } function getRatioLabel(value: EditorCharacterAnimationRatio) { diff --git a/src/components/image-editor/ImageCanvasEditorPortal.tsx b/src/components/image-editor/ImageCanvasEditorPortal.tsx new file mode 100644 index 000000000..534d5ec98 --- /dev/null +++ b/src/components/image-editor/ImageCanvasEditorPortal.tsx @@ -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( +
+ {children} +
, + document.body, + ); +} diff --git a/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx b/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx index 6a17e51c5..fcf7d2ebe 100644 --- a/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx +++ b/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx @@ -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', }); diff --git a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx index 9d4f3fa96..1756f8174 100644 --- a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx +++ b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx @@ -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 {node}; } function resetFailedVideoDialogStatus(dialog: GenerateDialogState) { diff --git a/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx b/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx index 7996aeff9..fab101b69 100644 --- a/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx @@ -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(); + + 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(); diff --git a/src/components/image-editor/useImageCanvasGenerationSurface.tsx b/src/components/image-editor/useImageCanvasGenerationSurface.tsx index 240379ca1..778fd491e 100644 --- a/src/components/image-editor/useImageCanvasGenerationSurface.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSurface.tsx @@ -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 {node}; } function buildToolbarPortalMenuStyle( diff --git a/src/index.css b/src/index.css index 6fd708151..a8ac24ccb 100644 --- a/src/index.css +++ b/src/index.css @@ -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;