合并资源工作台分支:PR review 共享契约与样式批次并入滚轮修复
Project CI / Repository checks (pull_request) Successful in 2m38s
Project CI / Frontend tests (pull_request) Successful in 3m12s
Project CI / Backend tests (pull_request) Successful in 5m33s
Project CI / Native shell tests (pull_request) Successful in 16m46s

- 把远端最新 fc6743980(浮层滚轮归属修复)合入本分支(PR #316 review 的 shared/server-rs/CSS/scripts 三笔:9835bbec9、8b8597782、3b385ef57)
- 冲突零:自动合并成功
- 验证:npm run typecheck exit 0;shared 契约/标签/筛选条与 lint-staged 用例全绿
This commit is contained in:
2026-09-12 19:54:16 +08:00
6 changed files with 298 additions and 7 deletions
@@ -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
);
}
/**
* 画布浮层是否可以被清焦点顺手关掉。
*
@@ -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<HTMLDivElement> | 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 = {
@@ -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(<DerivedWorkbench includeArt />);
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(<DerivedWorkbench />);
@@ -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 = `
<div id="owner">
<div id="card-blank">画布空白</div>
<div class="image-canvas-editor__portal-menu"><span id="menu-item">共享弹出层选项</span></div>
<div class="resource-reference-picker"><span id="picker-item">选择器条目</span></div>
</div>
<div id="portal"><span id="portal-item">portal 到 body 的候选项</span></div>
`;
const ownerRef = { current: document.querySelector<HTMLElement>('#owner') };
const options = {
boundaryRefs: [ownerRef],
isInsideExtraOverlay: (target: EventTarget | null) =>
target instanceof Element &&
target.closest('.resource-reference-picker') !== null,
};
const at = (selector: string) =>
document.querySelector<HTMLElement>(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 = `
<div class="game-resource-book-manager">
<div class="game-resource-card"><span id="card">卡片</span></div>
<div id="blank">画布空白</div>
<div class="image-canvas-editor__generation-composer"><span id="prompt">快速编辑提示词</span></div>
<div class="game-resource-info-panel"><span id="info">信息浮层</span></div>
<div class="game-resource-filter-panel"><span id="filter">筛选面板</span></div>
</div>
`;
const at = (selector: string) =>
document.querySelector<HTMLElement>(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,