From fc6743980224180d728382ed04a51b19f3d7eaaa Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sat, 12 Sep 2026 19:29:20 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E7=94=BB=E5=B8=83=E6=B5=AE=E5=B1=82=E9=87=8C=E7=9A=84=E6=BB=9A?= =?UTF-8?q?=E8=BD=AE=E8=A2=AB=E7=94=BB=E5=B8=83=E6=B6=88=E8=B4=B9=EF=BC=9A?= =?UTF-8?q?@=20=E9=80=89=E6=8B=A9=E5=99=A8=E5=88=97=E8=A1=A8=E6=BB=9A?= =?UTF-8?q?=E5=8A=A8=E6=97=B6=E7=94=BB=E5=B8=83=E8=B7=9F=E7=9D=80=E5=B9=B3?= =?UTF-8?q?=E7=A7=BB=20/=20=E7=BC=A9=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 共享浮层判据抽出 isEventInsideFloatingOverlay,并新增 isFloatingOverlayWheelEvent:关闭判定与滚轮归属共用同一份口径(DOM 不在边界里的 portal 浮层 + 已登记浮层) - isEventInsideFloatingMenu 返回值收紧为布尔,避免判据把命中的元素当真值往下传 - 资源画布唯一滚轮入口 handleResourceBookWheel 加统一守卫:浮层里的滚轮一律不消费(不 preventDefault、不动视口),不再逐浮层加 stopPropagation - resourceCanvasFocusModel 增加 RESOURCE_CANVAS_WHEEL_OVERLAY_SELECTOR 与 isResourceCanvasWheelOverlayTarget,登记留在画布 DOM 里自带滚动区的浮层(快速编辑 / 信息 / 筛选) - projectResourceLiveIntegration 新增用例:在选择器列表上派发真实 wheel → 浮层自己收到该事件、画布 data-resource-viewport 不变;对照组场景根派发 wheel → 视口照旧变化 - resourceCanvasFloatingDismiss 新增判据单测:portal 浮层 / 共享弹出层 / 已登记浮层三种来源归浮层,边界里的卡片与空白仍归画布,判不出归属不抢 - pitfalls.md 记录「portal 事件沿 React 树冒泡导致画布误吃滚轮」的根因与排查口径 --- .../resourceCanvasFocusModel.ts | 31 +++++++ .../src/view/project-development/index.tsx | 42 ++++++++- .../projectResourceLiveIntegration.test.tsx | 87 +++++++++++++++++++ .../resourceCanvasFloatingDismiss.test.tsx | 69 ++++++++++++++- docs/project-memory/shared-memory/pitfalls.md | 9 ++ .../useImageCanvasFloatingOptionDismiss.ts | 67 +++++++++++++- 6 files changed, 298 insertions(+), 7 deletions(-) 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/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/src/components/image-editor/useImageCanvasFloatingOptionDismiss.ts b/src/components/image-editor/useImageCanvasFloatingOptionDismiss.ts index 6e9762b5e..25e13d409 100644 --- a/src/components/image-editor/useImageCanvasFloatingOptionDismiss.ts +++ b/src/components/image-editor/useImageCanvasFloatingOptionDismiss.ts @@ -4,6 +4,12 @@ type FloatingOptionBoundaryRef = { readonly current: HTMLElement | null; }; +/** 浮层边界登记:谁算「浮层内部」,关闭判定与滚轮归属共用同一份。 */ +type FloatingOverlayBoundaryOptions = { + boundaryRefs?: Array; + isInsideExtraOverlay?: (target: EventTarget | null) => boolean; +}; + type UseImageCanvasFloatingOptionDismissOptions = { isOpen: boolean; boundaryRefs: Array; @@ -38,10 +44,62 @@ function isEventInsideBoundary( function isEventInsideFloatingMenu(target: EventTarget | null) { return ( target instanceof Element && - target.closest('.image-canvas-editor__portal-menu') + target.closest('.image-canvas-editor__portal-menu') !== null ); } +/** + * 事件是否落在「已登记为浮层」的那几块 DOM 里。 + * + * 这些浮层 portal 到 `document.body`(共享弹出层)或由宿主额外登记(`isInsideExtraOverlay`), + * 关闭判定与滚轮归属共用这一份口径,避免两处各写一套白名单。 + */ +function isEventInsideRegisteredOverlay( + target: EventTarget | null, + isInsideExtraOverlay?: (target: EventTarget | null) => boolean, +) { + return ( + isEventInsideFloatingMenu(target) || isInsideExtraOverlay?.(target) === true + ); +} + +/** + * 「点外部关闭」的边界判据:DOM 在边界里(含 portal 出去的浮层登记)就算内部。 + */ +export function isEventInsideFloatingOverlay( + target: EventTarget | null, + { boundaryRefs = [], isInsideExtraOverlay }: FloatingOverlayBoundaryOptions, +) { + return ( + isEventInsideBoundary(target, boundaryRefs) || + isEventInsideRegisteredOverlay(target, isInsideExtraOverlay) + ); +} + +/** + * 「这一下滚轮归浮层还是归画布」的判据,与上面的关闭判定同源。 + * + * React 的 portal 事件沿 **React 树** 冒泡(React 把委托监听挂在 portal 容器 `document.body` + * 上),所以 portal 出去的浮层里的滚轮照样会走到宿主的 `onWheel`。DOM 上根本不在宿主里的 + * 那一下,只可能是浮层派出来的:必须原样放过,否则用户在 `@` 选择器列表上滚轮时,列表 + * 在滚、背后的画布也一起平移 / 缩放。 + * + * 两条判据: + * 1. DOM 不在边界里 —— portal 出去的浮层(`@` 选择器、候选菜单、共享弹出层……),一律归浮层; + * 2. DOM 仍在边界里、但已登记为浮层内部的(`isInsideExtraOverlay` 与共享弹出层),同样归浮层。 + * + * 返回 `true` 表示这次滚轮归浮层,宿主不得消费(不 preventDefault、不动视口)。 + */ +export function isFloatingOverlayWheelEvent( + target: EventTarget | null, + { boundaryRefs = [], isInsideExtraOverlay }: FloatingOverlayBoundaryOptions, +) { + if (!isEventInsideBoundary(target, boundaryRefs)) { + return true; + } + return isEventInsideRegisteredOverlay(target, isInsideExtraOverlay); +} + export function useImageCanvasFloatingOptionDismiss({ isOpen, boundaryRefs, @@ -57,9 +115,10 @@ export function useImageCanvasFloatingOptionDismiss({ const handleClick = (event: MouseEvent) => { // 中文注释:选项项点击后要保留浮层;父级面板其它区域点击才收起。 if ( - isEventInsideBoundary(event.target, boundaryRefs) || - isEventInsideFloatingMenu(event.target) || - isInsideExtraOverlay?.(event.target) === true + isEventInsideFloatingOverlay(event.target, { + boundaryRefs, + isInsideExtraOverlay, + }) ) { return; } From 9835bbec9f6b19df2098b2cee04c183fe2bf7494 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sat, 12 Sep 2026 19:43:19 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20PR316=20review?= =?UTF-8?q?=EF=BC=9Ashared-contracts=20=E8=B5=84=E4=BA=A7=E7=BA=A7?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E5=85=B3=E9=97=AD=20+=20=E5=BF=AB=E9=80=9F?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=88=AB=E5=90=8D=E7=99=BD=E5=90=8D=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - server-rs/crates/shared-contracts/src/game_creation_app.rs:517 [bug high] 资产级 wire 结构补 deny_unknown_fields:与顶层 GameCreationAppManifest 同一取向,避免「读一次 + 任意一次写」静默抹掉未来新增的 assets[i] 字段;新增契约测试 asset_manifest_entry_rejects_unknown_asset_fields(裸条目 + 顶层 manifest 两条读路径都失败关闭,且已知字段照旧往返)。 - server-rs/crates/shared-contracts/src/game_creation_app.rs:536 [bug medium] 未知 category 从「退回 kind 派生值」改为失败关闭:反序列化结果就是落盘原值、要被整结构体写回,静默替换等于无迁移改落盘数据;缺字段仍按 kind 派生,派生只保留在 game_creation_app_asset_effective_category;原用例改名重写为 asset_manifest_entry_keeps_explicit_category_and_rejects_unknown_category。 - server-rs/crates/shared-contracts/src/game_creation_app.rs:780 [test low] 分区顺序跨语言交叉校验:packages/shared 侧新增解析 Rust PROJECT_RESOURCE_CANVAS_SECTIONS 并与 TS 常量对齐的测试(TS 侧补,见后续提交)。 - server-rs/crates/api-server/src/editor_project.rs:4120 [bug medium] 静态图别名白名单补 "asset"(register_local_asset_entry 空 kind 兜底值,canonical 映射到 image),数组长度 17→18,并在用例里补放行/拒绝两条断言。 - server-rs/crates/api-server/src/editor_project.rs:20747 [test low] AGC 快速编辑回归用例不再自洽:agc_payload 真正反序列化成 EditorImageEditRequest 并由请求体自己的 generationInputs.assetKind 驱动判据,另加源码级闸门断言(resolve_editor_image_edit_source / ensure_editor_image_edit_target_matches_source 体内不得再出现 generation_inputs)。 - TS 侧 gameCreationApp 的口径注释同步为「非法值这一支已不可达」(见后续提交)。 --- .../crates/api-server/src/editor_project.rs | 70 ++++++++++++--- .../shared-contracts/src/game_creation_app.rs | 87 +++++++++++++++---- 2 files changed, 129 insertions(+), 28 deletions(-) diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index e2f2c1ee3..b6b2b1477 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -4117,7 +4117,7 @@ fn align_editor_image_edit_dimension(value: u32) -> u32 { /// /// 必须与 `media_type == image` 一起构成 AND 门:视频、音频、序列帧等非静态媒体即使挂着 /// 图片类 assetKind 也照旧拒绝。 -pub(crate) const EDITOR_IMAGE_EDIT_STATIC_IMAGE_ASSET_KINDS: [&str; 17] = [ +pub(crate) const EDITOR_IMAGE_EDIT_STATIC_IMAGE_ASSET_KINDS: [&str; 18] = [ // canonical 静态图类型 "image", "scene", @@ -4138,6 +4138,11 @@ pub(crate) const EDITOR_IMAGE_EDIT_STATIC_IMAGE_ASSET_KINDS: [&str; 17] = [ "ui-prototype", // AGC 本地 manifest 等在用的等价静态图类型 "ui", + // `register_local_asset_entry`(assets.rs)空 kind 兜底写的是 `"asset"`: + // 未归类上传的 PNG 会以 `kind: "asset"` 登记,而共享契约把 `"asset"` canonical 化成 + // `"image"`,漏掉它就等于把「未归类静态图」挡在快速编辑之外(400)。 + // 白名单必须在 canonical 静态图别名下封闭。 + "asset", ]; pub(crate) fn ensure_editor_image_edit_source_kind_allowed( @@ -20738,13 +20743,20 @@ mod tests { } /// 实测 400×5 的回归用例:AGC「图片快速编辑」发出的请求,来源资源的 assetKind 是本地 - /// manifest 原始类型 `art-spritesheet` / `ui`(mediaType=image)。这条路径必须放行; - /// 非静态媒体(video/audio/image-sequence)必须继续拒绝。 + /// manifest 原始类型 `art-spritesheet` / `ui` / `asset`(mediaType=image)。这条路径必须 + /// 放行;非静态媒体(video/audio/image-sequence)必须继续拒绝。 + /// + /// 夹具必须真的走进被测路径,不能是自洽空断言:请求体先反序列化成 + /// `EditorImageEditRequest`,再从**请求体自己的** `generationInputs.assetKind` 取类型喂给 + /// 放行判据;并用源码级闸门断言钉住 + /// `resolve_editor_image_edit_source` / `ensure_editor_image_edit_target_matches_source` + /// 两个函数体内不得再出现 `generation_inputs`(历史上 `generationInputs.source` 参与过 + /// gating,把 AGC 请求整体挡成 400;解析与对账需要 DB/OSS,源码级断言是无需基础设施 + /// 也能钉住这条回归的唯一位置)。 #[test] fn game_creator_client_quick_edit_accepts_local_manifest_static_image_kinds() { - // 与客户端 `submit_resource_edit_remote` 发送的 JSON 同形;放行判据只看来源资源的 - // 权威 assetKind 与 mediaType,请求体里的 `generationInputs.source` 不参与。 - let agc_payload: Value = serde_json::from_str( + // 与客户端 `submit_resource_edit_remote` 发送的 JSON 同形。 + let agc_payload: EditorImageEditRequest = serde_json::from_str( r#"{ "prompt": "把这张图改成夜间配色", "sourceReferenceId": "resource-art-spritesheet", @@ -20754,18 +20766,55 @@ mod tests { } }"#, ) - .expect("AGC quick edit payload should be JSON"); + .expect("AGC quick edit payload should deserialize into the request DTO"); assert_eq!( - agc_payload - .pointer("/generationInputs/assetKind") - .and_then(Value::as_str), + agc_payload.source_reference_id, "resource-art-spritesheet", + "回归夹具必须保持 AGC 客户端实际发送的请求形状" + ); + assert_eq!( + agc_payload.target_layer_id, None, + "AGC 快速编辑不带 targetLayerId,走的是「主来源即目标」分支" + ); + // 关键:放行判据只吃来源资源的权威 assetKind 与 mediaType。这里用请求体自己声明的 + // assetKind 驱动判据——请求体里同时带着 `generationInputs.source`,它不得参与 gating。 + let requested_kind = agc_payload + .generation_inputs + .as_ref() + .and_then(|inputs| inputs.pointer("/assetKind")) + .and_then(Value::as_str); + assert_eq!( + requested_kind, Some("art-spritesheet"), "回归夹具必须保持 AGC 客户端实际发送的请求形状" ); + assert!( + ensure_editor_image_edit_source_kind_allowed( + normalize_editor_image_edit_resolved_source_kind(requested_kind).as_deref(), + Some("image"), + ) + .is_ok(), + "AGC 快速编辑请求不得因为 generationInputs.source 被拒" + ); + + // 源码级闸门:请求解析与目标对账都不得重新引入 generationInputs 门槛。 + let source = include_str!("editor_project.rs"); + assert_function_not_contains( + source, + "async fn resolve_editor_image_edit_source(", + "fn ensure_editor_image_edit_source_snapshot_matches(", + &["generation_inputs", "generationInputs"], + ); + assert_function_not_contains( + source, + "fn ensure_editor_image_edit_target_matches_source(", + "async fn resolve_editor_image_edit_source(", + &["generation_inputs", "generationInputs"], + ); for (asset_kind, media_type) in [ ("art-spritesheet", "image"), ("ui", "image"), + ("asset", "image"), ("ui-prototype", "image"), ("game-art", "image"), ("game-background", "image"), @@ -20781,6 +20830,7 @@ mod tests { for (asset_kind, media_type) in [ ("art-spritesheet", "video"), ("ui", "audio"), + ("asset", "video"), ("video", "video"), ("sound-effect", "audio"), ("background-music", "audio"), diff --git a/server-rs/crates/shared-contracts/src/game_creation_app.rs b/server-rs/crates/shared-contracts/src/game_creation_app.rs index 8a8f5cea2..8eb1422fb 100644 --- a/server-rs/crates/shared-contracts/src/game_creation_app.rs +++ b/server-rs/crates/shared-contracts/src/game_creation_app.rs @@ -502,13 +502,18 @@ pub struct GameCreationAppAssetManifestEntry { } /// 历史 manifest 缺少 `category` / `tags` 时按 `kind` 派生默认值; -/// 显式写入的合法分类必须原样保留,未知分类值按前向兼容退回 `kind` 派生。 +/// 显式写入的合法分类必须原样保留,未知分类值**失败关闭**(见下方 `Deserialize` 实现)。 /// /// 这里**刻意不做读时自愈**(落盘 `unclassified` 而 kind 能派生明确分类时改用派生值): /// 反序列化结果就是落盘原值,「编辑标签」面板要靠它把落盘分类原样回写,否则用户只改标签 /// 也会静默改分类。自愈只属于读显示口径,见 `game_creation_app_asset_effective_category`。 +/// +/// `deny_unknown_fields` 与顶层 `GameCreationAppManifest` 同一取向:AGC 客户端是 +/// 「整结构体反序列化 + 整结构体重新序列化覆盖落盘」,资产级未知字段(例如未来版本的 +/// `rotation` / `animations`)一旦被静默丢弃,就会在任意一次写入里被抹掉。顶层有这道门、 +/// 资产级没有的话,等于只挡住了一半的静默数据丢失。 #[derive(Deserialize)] -#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields, rename_all = "camelCase")] struct GameCreationAppAssetManifestEntryWire { id: String, kind: String, @@ -531,11 +536,19 @@ impl<'de> Deserialize<'de> for GameCreationAppAssetManifestEntry { D: serde::Deserializer<'de>, { let wire = GameCreationAppAssetManifestEntryWire::deserialize(deserializer)?; - let category = wire - .category - .as_deref() - .and_then(game_creation_app_asset_category_from_str) - .unwrap_or_else(|| game_creation_app_asset_category_for_kind(&wire.kind)); + // 未知 `category` 必须失败关闭,不能退回 `kind` 派生值:反序列化结果就是「编辑标签」 + // 面板要原样回写的落盘原值,静默替换成派生值会让「只改标签」变成静默改分类 + // (写侧整结构体覆盖落盘,替换值会真的写回文件)。派生只属于缺字段的读兼容, + // 以及读显示口径 `game_creation_app_asset_effective_category`。 + let category = match wire.category.as_deref() { + None => game_creation_app_asset_category_for_kind(&wire.kind), + Some(raw) => game_creation_app_asset_category_from_str(raw).ok_or_else(|| { + ::custom(format!( + "未知的素材分类 category = {raw}:本客户端会整结构体重写 manifest,\ + 无法原样回写该值,因此失败关闭(请升级客户端)" + )) + })?, + }; Ok(Self { id: wire.id, kind: wire.kind, @@ -2292,7 +2305,7 @@ mod tests { } #[test] - fn asset_manifest_entry_keeps_explicit_category_and_derives_unknown_category_from_kind() { + fn asset_manifest_entry_keeps_explicit_category_and_rejects_unknown_category() { let mut explicit_unclassified = asset_entry_json("character"); explicit_unclassified["category"] = json!("unclassified"); assert_eq!( @@ -2307,18 +2320,56 @@ mod tests { GameCreationAppAssetCategory::Audio ); - let mut unknown_category = asset_entry_json("character"); - unknown_category["category"] = json!("future-category"); - assert_eq!( - asset_entry_from_json(unknown_category).category, - GameCreationAppAssetCategory::Character + // 未知分类失败关闭:不能退回 `kind` 派生值。否则「读一次 + 任意一次写」会把 + // 未来客户端写下的 `category` 静默改成本机派生值,等于无迁移地改落盘数据。 + for kind in ["character", "image"] { + let mut unknown_category = asset_entry_json(kind); + unknown_category["category"] = json!("future-category"); + let error = + serde_json::from_value::(unknown_category) + .expect_err("an unknown asset category must fail closed"); + assert!( + error.to_string().contains("future-category"), + "unexpected error for kind={kind}: {error}" + ); + } + } + + /// 资产级未知字段与顶层同一取向:必须失败关闭,而不是被 serde 静默丢弃。 + /// + /// AGC 的写路径是「整结构体反序列化 + 整结构体重新序列化覆盖落盘」,静默丢弃等于 + /// 「读一次 + 任意一次写」抹掉未来版本新增的资产字段(例如 `rotation` / `animations`)。 + #[test] + fn asset_manifest_entry_rejects_unknown_asset_fields() { + let mut payload = asset_entry_json("character"); + payload["rotation"] = json!(90); + + let error = serde_json::from_value::(payload) + .expect_err("an unknown asset-level field must fail closed"); + assert!( + error.to_string().contains("rotation"), + "unexpected error: {error}" ); - let mut unknown_category_without_kind_semantics = asset_entry_json("image"); - unknown_category_without_kind_semantics["category"] = json!("future-category"); - assert_eq!( - asset_entry_from_json(unknown_category_without_kind_semantics).category, - GameCreationAppAssetCategory::Unclassified + // 已知字段(含可选字段)必须照旧往返:拒绝未知字段不得顺手把已知字段一起拒掉。 + let entry = asset_entry_from_json(asset_entry_json("character")); + let round_tripped: GameCreationAppAssetManifestEntry = + serde_json::from_value(serde_json::to_value(&entry).expect("asset entry serializes")) + .expect("known asset fields must still deserialize"); + assert_eq!(round_tripped, entry); + + // 顶层 manifest 也走同一条资产反序列化路径,未知资产字段不得只在裸条目上失败。 + let mut manifest = + serde_json::to_value(new_game_creation_app_manifest("project-1", "像素动作原型")) + .expect("manifest should serialize"); + let mut asset = asset_entry_json("character"); + asset["animations"] = json!([{ "name": "idle" }]); + manifest["assets"] = json!([asset]); + let error = serde_json::from_value::(manifest) + .expect_err("an unknown asset field must fail the whole manifest read"); + assert!( + error.to_string().contains("animations"), + "unexpected error: {error}" ); } From 8b859778283d90eb84416570d2cff14204ef0367 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sat, 12 Sep 2026 19:43:58 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20PR316=20review?= =?UTF-8?q?=EF=BC=9A=E5=85=B1=E4=BA=AB=E5=88=86=E6=AE=B5=E9=A1=B5=E7=AD=BE?= =?UTF-8?q?=E5=AE=BD=E5=BA=A6=E4=B8=8B=E9=99=90=E4=BD=9C=E7=94=A8=E5=9F=9F?= =?UTF-8?q?=E3=80=81=E6=A0=87=E7=AD=BE=E5=A5=91=E7=BA=A6=E4=B8=8E=E8=B5=84?= =?UTF-8?q?=E6=BA=90=E7=94=BB=E5=B8=83=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - packages/shared/src/components/styles.css:528 [bug medium] min-width: max-content 收窄到新的 .platform-segmented-tabs--scroll 修饰类:grid 分支没有溢出容器、轨道是 minmax(0,1fr),同样的下限只会让内容画到相邻格上;grid 侧靠轨道等分与宿主 truncateLabels 收口,选中 PlatformSegmentedTabs.tsx 的 scroll 分支补修修饰类。 - packages/shared/src/components/PlatformResourceFilterBar.test.tsx 同步改成钉「下限只挂在 --scroll」+「grid 分支不带 --scroll/overflow-x-auto」(声明级断言,jsdom 不计算外部样式表,属弱验证,真机判据见提交说明)。 - packages/shared/src/components/PlatformResourceFilterBar.tsx:24 [maintainability low] Props 改为以 tagItems 为判别项的联合类型:给 tagItems 就必须给 onToggleTag,非法状态编译期不可表达;组件内部再兜 disabled={!onToggleTag} 供给不带类型检查的调用方,并补对应运行时用例。 - packages/shared/src/components/PlatformResourceFilterBar.tsx:107 [style low] 标签行补 scrollbar-hide:它是唯一还画滚动条的横向滚动容器,与 styles.css 里 .scrollbar-hide 的注释口径对齐;补类名与声明级用例。 - packages/shared/src/contracts/gameCreationAppAssetTagLibrary.ts:51 [maintainability low] assetIds 排序改为确定性 code-unit sort(),不再依赖运行环境默认 locale(标签那一层仍显式钉 zh-CN),补跨输入顺序可复现的用例。 - packages/shared/src/contracts/gameCreationAppAssetTagLibrary.ts:68 [bug low] assetTagsMatchSelection 两侧对称归一化:selectedTags 也过 normalizeGameCreationAppAssetTags,' 像素风 ' 这类未归一化的选中项不再静默匹配不上。 - packages/shared/src/contracts/gameCreationApp.test.ts [test low] 新增解析 Rust PROJECT_RESOURCE_CANVAS_SECTIONS 与 TS 常量对齐的交叉校验(仿 assetKindCanonicalMapping.test.ts 解析 Rust 常量先例),钉住两侧分区顺序不漂移。 - packages/shared/src/contracts/gameCreationApp.ts 同步「读显示 / 写回」口径注释:未知 category 已在 Rust 读侧失败关闭,「非法值 → 派生」只剩内存对象语义。 - apps/ai-game-creator-shell/src/styles.css:5790 [bug medium] :has() 悬停高亮分类值换成现行 7 个分区(ui-interaction/character/scene/audio/document/unclassified/version),删掉永不渲染的 code/art 死选择器,补齐缺失栏目的高亮。 - apps/ai-game-creator-shell/src/styles.css:6873 [bug medium] hover/focus-within/is-selected 由只写 border-color 改为完整 border: 1px solid #d57b51(基规则 border: 0 下 border-color 无效果,选中/焦点态此前完全不可见),box-sizing: border-box 下不改卡片尺寸。 - apps/ai-game-creator-shell/src/styles.css:10151 [bug medium] 去掉高优先级 :focus-within 规则里的 box-shadow: none:它盖掉了 --platform-input-focus-ring 焦点光环,direct-codex 输入区此前没有任何可见焦点(无障碍回归),现在光环生效。 - apps/ai-game-creator-shell/src/styles.css:10198 [bug low] 润色状态文字与操作排改为同一行分列(grid-row: 2 + grid-column: 1 / 2),不再整行独占或与按钮重叠,状态出现/消失不改变输入框高度。 - apps/ai-game-creator-shell/src/styles.css:10206 [maintainability low] 删除永不命中的 .resource-reference-input-at direct-codex 规则(showTriggerButton={!directCodex} 不渲染该按钮),保留说明指向真正的 .project-supervisor-reference-trigger。 - 附带 prettier 对 .design-agent-reasoning summary 单行规则的换行归一(该文件本来未过 prettier --check)。 --- apps/ai-game-creator-shell/src/styles.css | 104 ++++++++++-------- .../PlatformResourceFilterBar.test.tsx | 66 ++++++++++- .../components/PlatformResourceFilterBar.tsx | 32 ++++-- .../src/components/PlatformSegmentedTabs.tsx | 2 +- packages/shared/src/components/styles.css | 11 +- .../src/contracts/gameCreationApp.test.ts | 60 ++++++++-- .../shared/src/contracts/gameCreationApp.ts | 9 +- .../gameCreationAppAssetTagLibrary.test.ts | 33 ++++++ .../gameCreationAppAssetTagLibrary.ts | 11 +- 9 files changed, 253 insertions(+), 75 deletions(-) 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/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)} >