合并资源工作台分支:PR review 共享契约与样式批次并入滚轮修复
- 把远端最新 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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user