diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts index 6096de61c..1c37bd3eb 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts @@ -38,6 +38,37 @@ export function isResourceCanvasInteractionTarget( return Boolean(target?.closest(RESOURCE_CANVAS_INTERACTION_SELECTOR)); } +/** + * 画布浮层里有自己滚动区的那几个:落在它们里面的滚轮归浮层,画布不得消费。 + * + * 词表与 `RESOURCE_CANVAS_INTERACTION_SELECTOR` 的浮层成员同一套类名,只去掉按钮 / 输入 / + * 资源卡这类本身不该吞滚轮的交互控件(在卡片上滚轮照旧平移 / 缩放画布): + * - `.image-canvas-editor__generation-composer`:快速编辑 / 角色动画浮层(自带提示词滚动区); + * - `.game-resource-info-panel`:资源信息浮层(`overflow: auto`); + * - `.game-resource-filter-panel`:筛选面板(卡片区 `overflow-y: auto`)。 + */ +export const RESOURCE_CANVAS_WHEEL_OVERLAY_SELECTOR = [ + '.image-canvas-editor__generation-composer', + '.game-resource-info-panel', + '.game-resource-filter-panel', +].join(', '); + +/** + * 这一次滚轮是不是落在「自己吃滚轮的画布浮层」里。 + * + * portal 到 `document.body` 的浮层(`@` 资源选择器与候选菜单、共享模型 / 比例弹出层…)由 + * `isResourceReferenceOverlayTarget` 与共享 hook 的 portal 弹层判据负责,这里只管留在画布 + * DOM 里的浮层。 + */ +export function isResourceCanvasWheelOverlayTarget( + target: EventTarget | null, +): boolean { + return ( + target instanceof Element && + target.closest(RESOURCE_CANVAS_WHEEL_OVERLAY_SELECTOR) !== null + ); +} + /** * 画布浮层是否可以被清焦点顺手关掉。 * diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 10f93b156..2fed9dc75 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -5768,31 +5768,45 @@ iframe.preview-frame { transition: none; } +/* 缩略卡悬停 / 聚焦时高亮它所在栏目(那一摞)的标题条。 + 取值必须与 `PROJECT_RESOURCE_CANVAS_SECTIONS`(7 个现行分区)逐一对齐: + `code` / `art` 是旧四、五栏目的历史值,总览里不会渲染,写它们等于空规则; + 少写一个现行分区,对应栏目就完全没有悬停高亮。 */ .game-resource-book-manager--main:has( - .game-resource-book-thumbnail[data-resource-book-category='code']:hover, - .game-resource-book-thumbnail[data-resource-book-category='code']:focus-visible + .game-resource-book-thumbnail[data-resource-book-category='ui-interaction']:hover, + .game-resource-book-thumbnail[data-resource-book-category='ui-interaction']:focus-visible ) - .game-resource-book-scene-titlebar[data-resource-book-category='code'], + .game-resource-book-scene-titlebar[data-resource-book-category='ui-interaction'], +.game-resource-book-manager--main:has( + .game-resource-book-thumbnail[data-resource-book-category='character']:hover, + .game-resource-book-thumbnail[data-resource-book-category='character']:focus-visible + ) + .game-resource-book-scene-titlebar[data-resource-book-category='character'], +.game-resource-book-manager--main:has( + .game-resource-book-thumbnail[data-resource-book-category='scene']:hover, + .game-resource-book-thumbnail[data-resource-book-category='scene']:focus-visible + ) + .game-resource-book-scene-titlebar[data-resource-book-category='scene'], +.game-resource-book-manager--main:has( + .game-resource-book-thumbnail[data-resource-book-category='audio']:hover, + .game-resource-book-thumbnail[data-resource-book-category='audio']:focus-visible + ) + .game-resource-book-scene-titlebar[data-resource-book-category='audio'], .game-resource-book-manager--main:has( .game-resource-book-thumbnail[data-resource-book-category='document']:hover, .game-resource-book-thumbnail[data-resource-book-category='document']:focus-visible ) .game-resource-book-scene-titlebar[data-resource-book-category='document'], +.game-resource-book-manager--main:has( + .game-resource-book-thumbnail[data-resource-book-category='unclassified']:hover, + .game-resource-book-thumbnail[data-resource-book-category='unclassified']:focus-visible + ) + .game-resource-book-scene-titlebar[data-resource-book-category='unclassified'], .game-resource-book-manager--main:has( .game-resource-book-thumbnail[data-resource-book-category='version']:hover, .game-resource-book-thumbnail[data-resource-book-category='version']:focus-visible ) - .game-resource-book-scene-titlebar[data-resource-book-category='version'], -.game-resource-book-manager--main:has( - .game-resource-book-thumbnail[data-resource-book-category='art']:hover, - .game-resource-book-thumbnail[data-resource-book-category='art']:focus-visible - ) - .game-resource-book-scene-titlebar[data-resource-book-category='art'], -.game-resource-book-manager--main:has( - .game-resource-book-thumbnail[data-resource-book-category='audio']:hover, - .game-resource-book-thumbnail[data-resource-book-category='audio']:focus-visible - ) - .game-resource-book-scene-titlebar[data-resource-book-category='audio'] { + .game-resource-book-scene-titlebar[data-resource-book-category='version'] { border-top-color: #cc8060; border-right-color: #cc8060; border-bottom-color: #ebd9cf; @@ -6902,10 +6916,13 @@ iframe.preview-frame { 0 0 0 2px rgb(216 115 66 / 24%); } +/* 卡片本体是 `border: 0`(下面那条基规则)。`border-color` 单独写没有意义——0 宽的边框 + 画不出颜色,所以选中 / 悬停 / 聚焦都必须写成完整的 `border`,否则这三个状态在视觉上 + 完全看不出来。宽度与圆角保持 1px / 12px,`box-sizing: border-box` 下不会改变卡片尺寸。 */ .game-resource-card:hover, .game-resource-card:focus-within, .game-resource-card.is-selected { - border-color: #d57b51; + border: 1px solid #d57b51; outline: 0; box-shadow: 0 8px 22px rgb(195 105 62 / 15%); } @@ -10418,13 +10435,18 @@ button.design-workspace-tree__entry:hover, box-shadow: none; } +/* direct-codex 输入区去默认灰描边:焦点可见性交给下面那条 + `.project-supervisor-composer .resource-reference-input:focus-within` + (`--platform-input-focus-ring` 光环)。这条规则比它多一个 `is-direct-codex` 类, + 优先级更高,所以**不能写 `box-shadow: none`**——写回去会把那条光环整条盖掉, + 而 base `.resource-reference-input-editor` 又是 `outline: 0`,输入区就完全没有 + 可见焦点了(无障碍回归)。 */ .game-workbench-chat .project-supervisor-surface.is-direct-codex .project-supervisor-composer.is-direct-codex .resource-reference-input:focus-within { border: 0; outline: none; - box-shadow: none; } .game-workbench-chat @@ -10704,18 +10726,19 @@ button.design-workspace-tree__entry:hover, line-height: 1.6; } -/* 陶泥儿输入区的操作排(`@` / AI 润色 / 恢复原文)回到文档流:`grid-column: 1 / -1` - 让它独占编辑器下面的一行,`align-items: end` + `justify-content: flex-end` 把它 - 贴到输入框右下角。之前这里用 `position: absolute; right: 48px; bottom: 10px` - 把整排从网格里摘出来浮在输入框中间,AI 润色因此看起来压在文本区里、和下面那排 - 主操作(`.project-supervisor-composer-controls` 的 `@` / 快速 / 发送)脱节。 +/* 陶泥儿输入区的操作排(`AI 润色` / `恢复原文`)回到文档流:网格第二行右侧那一列, + `justify-content: flex-end` 贴住输入框右下角。之前这里用 + `position: absolute; right: 48px; bottom: 10px` 把整排从网格里摘出来浮在输入框中间, + AI 润色因此看起来压在文本区里、和下面那排主操作(`.project-supervisor-composer-controls` + 的 `@` / 快速 / 发送)脱节。 不设 `min-height`:这一行的高度由 28px 方钮自己撑开,行高一旦被顶起来会连带把 编辑器的 `min-height: 96px` 改掉。 */ .game-workbench-chat .project-supervisor-surface.is-direct-codex .project-supervisor-composer .resource-reference-input-actions { - grid-column: 1 / -1; + grid-row: 2; + grid-column: 2; position: static; display: flex; align-items: end; @@ -10724,46 +10747,35 @@ button.design-workspace-tree__entry:hover, height: auto; } -/* 「润色中… / 润色失败」和应用排同一行:状态文字靠左占剩余宽度,操作排靠右。 - 单独占一行会把输入框整体顶高 22.8px,把操作排和下面那排主操作推开一个状态行的 - 距离;共行则状态出现/消失都不改变输入框高度。 */ +/* 「润色中… / 润色失败」和应用排**同一行**:状态文字在第二行左侧那一列(占剩余宽度), + 操作排在第二行右侧那一列。单独占一行会把输入框整体顶高一个状态行的距离;而给它 + `grid-column: 1 / -1` 又会和操作排落进同一个网格单元互相重叠(状态文字长时压到按钮下面)。 + 共行分列则状态出现/消失都不改变输入框高度,也不会互相压。 */ .game-workbench-chat .project-supervisor-surface.is-direct-codex .project-supervisor-composer .resource-reference-input-status { - grid-column: 1 / -1; grid-row: 2; + grid-column: 1; align-self: center; min-width: 0; margin-top: 0; } -.game-workbench-chat - .project-supervisor-surface.is-direct-codex - .project-supervisor-composer - .resource-reference-input-at { - position: static !important; - right: auto !important; - bottom: auto !important; - display: grid; - width: 30px; - min-width: 30px; - height: 30px; - min-height: 30px; - padding: 0; - border: 0; - border-radius: 9px; - background: var(--platform-button-primary-fill); - color: var(--platform-button-primary-text); - place-items: center; -} +/* direct-codex 里 `ResourceReferenceInput` 收到的是 `showTriggerButton={!directCodex}`, + 即输入框内的 `.resource-reference-input-at` 根本不会渲染(`@` 触发钮是下面控制排里的 + `.project-supervisor-reference-trigger`)。这里原本有一条针对它的 + `position: static !important` / 30px 主色方块规则,永远匹配不到,已删除; + 要改 direct-codex 的 `@` 触发钮外观请改 `.project-supervisor-reference-trigger`。 */ .design-agent-reasoning { margin: 8px 0; color: var(--text-muted); font-size: 0.82em; } -.design-agent-reasoning summary { cursor: pointer; } +.design-agent-reasoning summary { + cursor: pointer; +} .design-agent-reasoning pre { margin: 6px 0 0; white-space: pre-wrap; diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index a63058a09..884c20fb2 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -88,7 +88,10 @@ import { import { ImageCanvasProjectAssetPickerDialog } from '../../../../../src/components/image-editor/ImageCanvasProjectAssetPickerDialog'; import { ImageCanvasQuickEditPanelView } from '../../../../../src/components/image-editor/ImageCanvasQuickEditPanelView'; import { ImageCanvasSelectedLayerToolbarView } from '../../../../../src/components/image-editor/ImageCanvasSelectedLayerToolbarView'; -import { useImageCanvasFloatingOptionDismiss } from '../../../../../src/components/image-editor/useImageCanvasFloatingOptionDismiss'; +import { + isFloatingOverlayWheelEvent, + useImageCanvasFloatingOptionDismiss, +} from '../../../../../src/components/image-editor/useImageCanvasFloatingOptionDismiss'; import { DesignWorkspacePanel } from '../../features/project-workspace/DesignWorkspacePanel'; import { LocalGamePreviewFrame, @@ -115,6 +118,7 @@ import { import { canDismissResourceCanvasQuickEdit, isResourceCanvasInteractionTarget, + isResourceCanvasWheelOverlayTarget, resolveResourceCanvasFloatingPanelDismissOpen, resolveResourceCanvasFocusEscapeActive, } from '../../features/resource-canvas/resourceCanvasFocusModel'; @@ -1120,6 +1124,23 @@ function ResourceBookTitleBar({ ); } +/** + * 画布滚轮归属的「已登记浮层」总表,与「点外部关闭」用的是同一套浮层口径: + * - `@` 资源选择器与候选菜单 portal 到 `document.body`; + * - 留在画布 DOM 里、自带滚动区的画布浮层(快速编辑 / 信息 / 筛选,见 + * `isResourceCanvasWheelOverlayTarget`)。 + * + * 落在它们里面的滚轮归浮层自己,画布不消费(不 preventDefault、不动视口)。 + */ +function isResourceCanvasFloatingOverlayWheelTarget( + target: EventTarget | null, +) { + return ( + isResourceReferenceOverlayTarget(target) || + isResourceCanvasWheelOverlayTarget(target) + ); +} + function ResourceBookScene({ plan, resourceBookState, @@ -4406,11 +4427,28 @@ export default function ProjectDevelopmentView({ const handleResourceBookWheel = useCallback( (event: ReactWheelEvent | WheelEvent) => { - resourceBookTransitionControllerRef.current.settle(); const sceneElement = resourceBookManagerRef.current; if (!sceneElement) { return; } + /* + 浮层优先:滚轮只在「这一下确实落在资源画布管理区里」时才归画布。 + + React 的 portal 事件沿 **React 树** 冒泡(React 把委托监听挂在 portal 容器 + `document.body` 上),所以 `@` 选择素材浮层里的滚轮也会走到场景根的 `onWheel` + 这里;而它在 DOM 上根本不在管理区里。判据与上面「点外部关闭」同一份 + (`isFloatingOverlayWheelEvent` = 浮层边界 + 已登记浮层):用户在选择器列表上 + 滚轮时,列表自己滚,背后的画布不再跟着平移 / 缩放。 + */ + if ( + isFloatingOverlayWheelEvent(event.target, { + boundaryRefs: [resourceBookManagerRef], + isInsideExtraOverlay: isResourceCanvasFloatingOverlayWheelTarget, + }) + ) { + return; + } + resourceBookTransitionControllerRef.current.settle(); event.preventDefault(); const rect = sceneElement.getBoundingClientRect(); const screenPoint = { diff --git a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx index 58c5ebf77..d02c743b5 100644 --- a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx +++ b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx @@ -893,6 +893,93 @@ describe('project resource live canvas integration', () => { expect(deriveCalls[0]?.prompt).toBe(expectedText); }); + /** + * 用户报的原始现象:在「快速编辑 → 插入素材引用」开出的选择器列表上滚鼠标滚轮, + * 滚的不是列表,而是背后的资源画布(画布跟着缩放 / 平移)。 + * + * 链路:选择器 portal 到 `document.body`,而 React 的 portal 事件沿 **React 树** 冒泡 + * (React 把委托监听挂在 portal 容器上),所以它的 wheel 照样走到画布场景根的 + * `onWheel`;修复前那一下会被画布消费掉。这里用真实事件序列钉住「浮层里的滚轮归浮层、 + * 画布视口一格不动」,同时用对照用例钉住「画布本体的滚轮照旧」。 + */ + it('在选择素材浮层里滚轮:浮层自己收到、画布视口不动,画布本体滚轮照旧', async () => { + installTauri(); + render(); + + fireEvent.click(screen.getByRole('button', { name: '打开待归类' })); + fireEvent.click(await findResourceSelectButton('source-art.png')); + const toolbar = await screen.findByRole('toolbar', { + name: '图片工具栏', + }); + fireEvent.click(within(toolbar).getByRole('button', { name: '快速编辑' })); + const panel = await screen.findByRole('dialog', { + name: '快速编辑图片', + }); + fireEvent.click( + within(panel).getByRole('button', { name: '插入素材引用' }), + ); + const picker = await screen.findByRole('dialog', { name: '选择素材' }); + + // 前提自检:选择器 DOM 上确实不在资源画本里(这正是 React 事件仍会冒泡到画布的原因)。 + const manager = document.querySelector('.game-resource-book-manager'); + expect(manager?.contains(picker)).toBe(false); + expect(picker.parentElement).toBe(document.body); + + const list = picker.querySelector('.resource-reference-picker-list'); + if (!list) throw new Error('missing picker list'); + const overlayWheelCalls = vi.fn(); + list.addEventListener('wheel', overlayWheelCalls); + + const readViewport = () => + document + .querySelector('[data-resource-viewport]') + ?.getAttribute('data-resource-viewport'); + const readSceneWorldTransform = () => + document + .querySelector('.game-resource-book-scene-world') + ?.getAttribute('style'); + const viewportBefore = readViewport(); + const sceneWorldBefore = readSceneWorldTransform(); + expect(viewportBefore).toBeTruthy(); + + // 真实事件序列:从浮层内部元素派发滚轮(等同用户在选择器列表上滚)。 + const overlayWheel = new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaY: 240, + clientX: 80, + clientY: 60, + }); + act(() => { + list.dispatchEvent(overlayWheel); + }); + + // 浮层自己收到该事件、且没被画布消费(列表按原生行为滚动)。 + expect(overlayWheelCalls).toHaveBeenCalledTimes(1); + expect(overlayWheel.defaultPrevented).toBe(false); + // 画布视口一格不动。 + expect(readViewport()).toBe(viewportBefore); + expect(readSceneWorldTransform()).toBe(sceneWorldBefore); + + list.removeEventListener('wheel', overlayWheelCalls); + + // 对照用例:画布本体(场景根)上的滚轮必须照旧平移视口——修复没把画布交互一起关掉。 + const sceneRoot = document.querySelector('.game-resource-book-scene'); + if (!sceneRoot) throw new Error('missing scene root'); + const canvasWheel = new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaY: 120, + clientX: 90, + clientY: 70, + }); + act(() => { + sceneRoot.dispatchEvent(canvasWheel); + }); + expect(canvasWheel.defaultPrevented).toBe(true); + expect(readViewport()).not.toBe(viewportBefore); + }); + it('creates a brand new media asset from the canvas generation entry with a create-mode derive request', async () => { const { deriveCalls } = installTauri({ failFirstDerive: true }); render(); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx index 6313488ab..e4bd72b11 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx @@ -6,11 +6,15 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import type { QuickEditPanelState } from '../../../src/components/image-editor/ImageCanvasEditorTypes'; import { ImageCanvasQuickEditPanelView } from '../../../src/components/image-editor/ImageCanvasQuickEditPanelView'; -import { useImageCanvasFloatingOptionDismiss } from '../../../src/components/image-editor/useImageCanvasFloatingOptionDismiss'; +import { + isFloatingOverlayWheelEvent, + useImageCanvasFloatingOptionDismiss, +} from '../../../src/components/image-editor/useImageCanvasFloatingOptionDismiss'; import { canDismissResourceCanvasQuickEdit, isResourceCanvasHostOverlayOpen, isResourceCanvasInteractionTarget, + isResourceCanvasWheelOverlayTarget, resolveResourceCanvasFloatingPanelDismissOpen, resolveResourceCanvasFocusEscapeActive, } from '../src/features/resource-canvas/resourceCanvasFocusModel'; @@ -315,6 +319,69 @@ describe('资源画布浮层关闭时机', () => { }); }); +describe('画布滚轮归属判据', () => { + /** + * 与「点外部关闭」同一份口径:portal 出去的东西 DOM 上不在边界里,但 React 的事件会沿 + * React 树冒泡到画布宿主的 `onWheel`。滚轮这类事件必须按 DOM 判归属,否则用户在 + * `@` 选择器列表上滚动时,背后的画布会跟着平移 / 缩放。 + */ + test('portal 出去的浮层与已登记浮层归浮层,边界里的画布元素归画布', () => { + document.body.innerHTML = ` +
+
画布空白
+
共享弹出层选项
+
选择器条目
+
+
portal 到 body 的候选项
+ `; + const ownerRef = { current: document.querySelector('#owner') }; + const options = { + boundaryRefs: [ownerRef], + isInsideExtraOverlay: (target: EventTarget | null) => + target instanceof Element && + target.closest('.resource-reference-picker') !== null, + }; + const at = (selector: string) => + document.querySelector(selector); + + // 画布自己的:DOM 在边界里,也不是浮层。 + expect(isFloatingOverlayWheelEvent(at('#card-blank'), options)).toBe(false); + // portal 到 body 的浮层:DOM 根本不在边界里 ⇒ 归浮层。 + expect(isFloatingOverlayWheelEvent(at('#portal-item'), options)).toBe(true); + // 边界里但落在共享弹出层上:与「点外部关闭」同口径 ⇒ 归浮层。 + expect(isFloatingOverlayWheelEvent(at('#menu-item'), options)).toBe(true); + // 边界里但由宿主登记成浮层(`@` 选择器 / 候选菜单)⇒ 归浮层。 + expect(isFloatingOverlayWheelEvent(at('#picker-item'), options)).toBe(true); + // 判不出归属(没有事件目标)时不抢:归浮层。 + expect(isFloatingOverlayWheelEvent(null, options)).toBe(true); + + document.body.innerHTML = ''; + }); + + test('自带滚动区的画布浮层归浮层,资源卡与画布空白仍归画布', () => { + document.body.innerHTML = ` +
+
卡片
+
画布空白
+
快速编辑提示词
+
信息浮层
+
筛选面板
+
+ `; + const at = (selector: string) => + document.querySelector(selector); + + expect(isResourceCanvasWheelOverlayTarget(at('#card'))).toBe(false); + expect(isResourceCanvasWheelOverlayTarget(at('#blank'))).toBe(false); + expect(isResourceCanvasWheelOverlayTarget(at('#prompt'))).toBe(true); + expect(isResourceCanvasWheelOverlayTarget(at('#info'))).toBe(true); + expect(isResourceCanvasWheelOverlayTarget(at('#filter'))).toBe(true); + expect(isResourceCanvasWheelOverlayTarget(null)).toBe(false); + + document.body.innerHTML = ''; + }); +}); + /** 与 AGC 一样只给必要 props:下拉弹层就地渲染,不走 portal。 */ function QuickEditPanelHarness({ initialPanel, diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 39626df0b..b55af4ce4 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2,6 +2,15 @@ > 当前口径:本文件保留可复用的排障经验;历史条目的旧路由、旧版本和已删除文档仅作根因背景,不得据此恢复退役入口。当前命令、路由和 schema 以代码与 `docs/README.md` 为准。 +## 2026-09-12 画布滚轮要按 DOM 归属判定,portal 出去的浮层不能把滚轮让给画布 + +- **现象**:资源卡「快速编辑」里用 `@` 开出「选择素材」浮层后,在选择器列表上滚鼠标滚轮,列表自己在滚,背后的资源画布也一起平移 / 缩放(用户口语:「滚轮还是回滚到画布上」)。 +- **原因**:选择器与输入区候选菜单 `createPortal(..., document.body)`,DOM 上不在画布管理区里;但 React 的 portal 事件沿 **React 树** 冒泡(React 把委托监听挂在 portal 容器上),所以它们的 `wheel` 照样走到画布场景根的 `onWheel`,被当成画布手势消费。React 的 `wheel` 委托监听是 passive 的:`preventDefault()` 是空操作(只报 warning),真正出问题的是视口状态被改写——所以「事件没被 preventDefault」不能作为「画布没吃这一下」的判据。 +- **处理**:滚轮归属与「点外部关闭」共用同一份浮层口径(`src/components/image-editor/useImageCanvasFloatingOptionDismiss.ts`:`isEventInsideFloatingOverlay` + `isFloatingOverlayWheelEvent`)——DOM 不在宿主边界里的(portal 出去的一律算浮层)与已登记为浮层内部的都归浮层。资源画布只保留 `handleResourceBookWheel` 一处守卫,留在画布 DOM 里自带滚动区的浮层(快速编辑 / 信息 / 筛选,见 `RESOURCE_CANVAS_WHEEL_OVERLAY_SELECTOR`)按同一入口登记,不要再逐浮层加 `stopPropagation`。判据判不出归属时(没有元素目标)不抢滚轮。 +- **排查顺序**:先确认浮层是不是 portal 出去的;是的话不要先怀疑 CSS `overflow`、`overscroll-behavior` 或事件被 `preventDefault`,直接查画布宿主上的 `onWheel` / `onPointerDown` 有没有做 DOM 归属判断。 +- **验证**:`npm run test -- apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx`(判据单测,含 portal / 共享弹出层 / 已登记浮层三种来源与「判不出归属不抢」)与 `npm run test -- apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx`(真实事件序列:选择器列表里派发 `wheel` → 画布 `data-resource-viewport` 不变、浮层自己收到该事件;对照组:场景根上派发 `wheel` → 视口照旧变化)。 +- **关联**:`src/components/image-editor/useImageCanvasFloatingOptionDismiss.ts`、`apps/ai-game-creator-shell/src/view/project-development/index.tsx`(`handleResourceBookWheel`)、`apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts` + ## 2026-09-12 策划项目重开前必须恢复运行模式 - 工作台不能只依赖创建时的内存 `startMode`:重开时丢失该值会挂载游戏资源画布,而对话恢复后又进入策划状态,造成左右区域不一致。 diff --git a/packages/shared/src/components/PlatformResourceFilterBar.test.tsx b/packages/shared/src/components/PlatformResourceFilterBar.test.tsx index f226662ff..b146e9810 100644 --- a/packages/shared/src/components/PlatformResourceFilterBar.test.tsx +++ b/packages/shared/src/components/PlatformResourceFilterBar.test.tsx @@ -6,7 +6,10 @@ import { resolve } from 'node:path'; import { fireEvent, render, screen } from '@testing-library/react'; import { describe, expect, test, vi } from 'vitest'; -import { PlatformResourceFilterBar } from './PlatformResourceFilterBar'; +import { + PlatformResourceFilterBar, + type PlatformResourceFilterBarProps, +} from './PlatformResourceFilterBar'; import { PlatformSegmentedTabs } from './PlatformSegmentedTabs'; const CATEGORY_ITEMS = [ @@ -15,9 +18,10 @@ const CATEGORY_ITEMS = [ ]; /** - * 分段页签的子项宽度下限是「不叠字」的唯一判据:grid 的轨道按容器等分、scroll 的 - * flex 子项默认可收缩,两者都会把中文标签压到内容宽度以下,标签虽然 nowrap, - * 文字仍会画出自己的盒子压到相邻项上。这条规则一旦被删掉就要失败。 + * 分段页签子项的宽度下限只属于 `layout="scroll"`:那一条有 `overflow-x-auto` 做兜底, + * 宽度下限换来的是「不叠字」。grid 布局没有溢出容器、轨道是 `minmax(0, 1fr)`,同样的 + * `min-width: max-content` 只会把内容画到相邻格上。这两条声明级断言(jsdom 不计算外部 + * 样式表,可见性只能钉在声明与类名上)钉住「下限只挂在 --scroll 修饰类」。 */ function segmentedTabsStylesheet() { return readFileSync( @@ -110,6 +114,50 @@ describe('PlatformResourceFilterBar', () => { expect(screen.getByRole('button', { name: '全部' })).toBeTruthy(); }); + test('标签行是横向滚动容器且不画滚动条', () => { + render( + {}} + tagItems={[{ tag: '像素风', assetCount: 1 }]} + onToggleTag={() => {}} + />, + ); + + const row = screen.getByRole('group', { name: '素材筛选标签' }); + expect(row.className).toContain('platform-category-chip-scroll'); + // `platform-category-chip-scroll` 只提供 `overflow-x: auto`;不配 `scrollbar-hide` + // 它就会成为唯一的「看得见滚动条」的横向滚动容器。 + expect(row.className).toContain('scrollbar-hide'); + expect(segmentedTabsStylesheet()).toMatch( + /\.scrollbar-hide\s*\{[^}]*scrollbar-width:\s*none/s, + ); + }); + + test('没有 onToggleTag 时标签 chip 不得渲染成可点按钮', () => { + // 类型上 `tagItems` 必须带 `onToggleTag`(联合类型让这个非法状态不可表达); + // 这里刻意 `as unknown as` 绕过类型检查,模拟不带类型检查的调用方, + // 断言运行时兜底是「禁用」而不是「看起来可点、点了没反应」。 + const props = { + ariaLabel: '素材筛选', + categoryItems: CATEGORY_ITEMS, + activeCategoryId: 'all', + onCategoryChange: () => {}, + tagItems: [{ tag: '像素风', assetCount: 1 }], + activeTags: [], + } as unknown as PlatformResourceFilterBarProps<'all' | 'character'>; + + render(); + + const chip = screen.getByRole('button', { + name: /像素风/, + }) as HTMLButtonElement; + expect(chip.disabled).toBe(true); + expect(chip.getAttribute('aria-pressed')).toBe('false'); + }); + test('never lets a category chip shrink below its label width', () => { const { container } = render( { // 分类条是 scroll 布局:容器横向可滚,标签不换行。 const row = container.querySelector('.platform-segmented-tabs'); expect(row).not.toBeNull(); + expect(row?.className).toContain('platform-segmented-tabs--scroll'); expect(row?.className).toContain('overflow-x-auto'); for (const button of row?.querySelectorAll('button') ?? []) { expect(button.className).toContain('whitespace-nowrap'); @@ -130,11 +179,15 @@ describe('PlatformResourceFilterBar', () => { // 单靠 nowrap 不够:子项必须有不低于内容宽度的下限,否则文字会画出盒子压到相邻项。 expect(segmentedTabsStylesheet()).toMatch( + /\.platform-segmented-tabs--scroll\s*>\s*button\s*\{[^}]*min-width:\s*max-content/s, + ); + // 反过来,没有溢出容器的 grid 布局不得吃这条下限(否则内容会压到相邻格上)。 + expect(segmentedTabsStylesheet()).not.toMatch( /\.platform-segmented-tabs\s*>\s*button\s*\{[^}]*min-width:\s*max-content/s, ); }); - test('grid 布局的分段页签同样带宽度下限钩子', () => { + test('grid 布局的分段页签不带 scroll 修饰类,靠轨道等分收口', () => { render( { .closest('div'); expect(row?.className).toContain('platform-segmented-tabs'); expect(row?.className).toContain('grid-cols-2'); + // grid 分支没有溢出容器,宽度下限一旦回到这里就会压到相邻格(本轮 review 的回归点)。 + expect(row?.className).not.toContain('platform-segmented-tabs--scroll'); + expect(row?.className).not.toContain('overflow-x-auto'); }); }); diff --git a/packages/shared/src/components/PlatformResourceFilterBar.tsx b/packages/shared/src/components/PlatformResourceFilterBar.tsx index 7d24f96f8..a5b685b1c 100644 --- a/packages/shared/src/components/PlatformResourceFilterBar.tsx +++ b/packages/shared/src/components/PlatformResourceFilterBar.tsx @@ -21,7 +21,7 @@ export type PlatformResourceFilterSearch = { inputRef?: Ref; }; -export type PlatformResourceFilterBarProps = { +type PlatformResourceFilterBarBaseProps = { /** 控件组无障碍名称,例如「资源筛选」。 */ ariaLabel: string; /** 搜索行;宿主不需要搜索时省略即可,其余筛选照常渲染。 */ @@ -30,14 +30,29 @@ export type PlatformResourceFilterBarProps = { categoryItems: readonly PlatformResourceFilterOption[]; activeCategoryId: TId; onCategoryChange: (id: TId) => void; - /** 派生标签库;为空时不渲染标签行。 */ - tagItems?: readonly PlatformResourceTagOption[]; - /** 已选标签(多选叠加)。 */ - activeTags?: readonly string[]; - onToggleTag?: (tag: string) => void; className?: string; }; +/** + * `tagItems` 与 `onToggleTag` 是**成对的**:标签 chip 是带 `aria-pressed` 的按钮, + * 只给标签、不给处理函数就会渲染出一排「看起来可点、点了没反应」的控件。 + * 这里用联合类型让这个非法状态在编译期就不可表达;`Component` 内部再兜一层 + * `disabled`,供不带类型检查的调用方(JS / 动态构造的 props)也拿到诚实的行为。 + */ +export type PlatformResourceFilterBarProps = + | (PlatformResourceFilterBarBaseProps & { + tagItems?: undefined; + activeTags?: readonly string[]; + onToggleTag?: undefined; + }) + | (PlatformResourceFilterBarBaseProps & { + /** 派生标签库;为空数组时不渲染标签行。 */ + tagItems: readonly PlatformResourceTagOption[]; + /** 已选标签(多选叠加)。 */ + activeTags?: readonly string[]; + onToggleTag: (tag: string) => void; + }); + function tagChipClassName(active: boolean) { return [ 'platform-category-chip gap-1.5 px-2.5 text-xs font-bold', @@ -104,7 +119,7 @@ export function PlatformResourceFilterBar({ /> {tagOptions.length > 0 ? (
@@ -116,6 +131,9 @@ export function PlatformResourceFilterBar({ type="button" aria-pressed={active} className={tagChipClassName(active)} + // 类型上 `tagItems` 必然带 `onToggleTag`;这一层兜底是给不带类型检查的 + // 调用方(JS / 动态构造的 props):没有处理函数就不该渲染成可点按钮。 + disabled={!onToggleTag} onClick={() => onToggleTag?.(option.tag)} >