修复图谱点不动:只有超过拖动阈值才 setPointerCapture/拦默认,纯点击走原生 click
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled

This commit is contained in:
2026-10-07 16:00:50 +08:00
parent c6677e1323
commit dba4734e01
2 changed files with 55 additions and 18 deletions
@@ -3463,26 +3463,45 @@ describe('GameLineagePage 可视化与交互', () => {
expect(source).not.toMatch(/window\.addEventListener\('wheel'/u);
});
it('拖动平移:进入 panning 态(禁选中/拖文本)、阻止 selectstart、指针捕获跟手', async () => {
it('拖动平移:超过阈值才算拖动(禁选中/拖文本),未拖动时点击照常触发', async () => {
getGameLineageMock.mockResolvedValueOnce(treeWithSibling);
const onOpenDetail = vi.fn();
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
onOpenDetail={onOpenDetail}
/>,
);
const canvas = (await screen.findByLabelText('创作族谱树')) as HTMLElement;
const content = canvas.querySelector(
'.game-lineage-canvas__content',
) as HTMLElement;
// 拖动前:不是 panning 态、全局也允许选择。
expect(canvas.className).not.toContain('game-lineage-canvas--panning');
expect(document.body.style.userSelect).toBe('');
// ① 只按下、不移动:不算拖动(不捕获、不拦),**节点按钮的 click 照常触发**。
fireEvent.pointerDown(canvas, { pointerId: 1, clientX: 100, clientY: 100 });
// 拖动中:panning 态(CSS 里 user-select/touch-action 生效)+ **全局禁选**(松手即恢复)。
expect(canvas.className).not.toContain('game-lineage-canvas--panning');
expect(document.body.style.userSelect).toBe('');
const openButton = screen
.getAllByRole('button', { name: '打开作品' })
.find((button) => button.closest('.lineage-relations') === null);
expect(openButton).toBeTruthy();
fireEvent.click(openButton as HTMLElement);
expect(onOpenDetail).toHaveBeenCalledTimes(1);
fireEvent.pointerUp(canvas, { pointerId: 1, clientX: 100, clientY: 100 });
// ② 移动超过阈值:进入拖动(panning 态 + 全局禁选 + transform 变化)。
const before = content.style.transform;
fireEvent.pointerDown(canvas, { pointerId: 2, clientX: 100, clientY: 100 });
fireEvent.pointerMove(canvas, { pointerId: 2, clientX: 160, clientY: 130 });
expect(canvas.className).toContain('game-lineage-canvas--panning');
expect(document.body.style.userSelect).toBe('none');
// 原生 selectstart / dragstart 也被挂上了阻止(双保险:不依赖事件默认行为是否真的会选中)。
expect(content.style.transform).not.toBe(before);
// 拖动中的 selectstart / dragstart 被阻止(双保险)。
canvas.dispatchEvent(
new Event('selectstart', { bubbles: true, cancelable: true }),
);
@@ -3490,15 +3509,7 @@ describe('GameLineagePage 可视化与交互', () => {
new Event('dragstart', { bubbles: true, cancelable: true }),
);
// 平移跟手:移动后内容层 transform 的位移发生变化。
const content = canvas.querySelector(
'.game-lineage-canvas__content',
) as HTMLElement;
const before = content.style.transform;
fireEvent.pointerMove(canvas, { pointerId: 1, clientX: 160, clientY: 130 });
expect(content.style.transform).not.toBe(before);
fireEvent.pointerUp(canvas, { pointerId: 1, clientX: 160, clientY: 130 });
fireEvent.pointerUp(canvas, { pointerId: 2, clientX: 160, clientY: 130 });
expect(canvas.className).not.toContain('game-lineage-canvas--panning');
expect(document.body.style.userSelect).toBe('');
});
@@ -76,6 +76,9 @@ type GameLineagePageProps = {
*/
const LINEAGE_COMPACT_CARD_BASE_HEIGHT = 352;
/** 拖动阈值(像素):超过它才算「拖动」,否则一律当作点击,交给浏览器原生 click。 */
const DRAG_START_THRESHOLD_PX = 4;
const MIN_SCALE = 0.35;
const MAX_SCALE = 1.8;
const FIT_PADDING = 24;
@@ -305,6 +308,9 @@ export function GameLineagePage({
y: number;
offsetX: number;
offsetY: number;
pointerId: number;
/** 是否已经越过拖动阈值(越过才捕获指针、才拦默认行为)。 */
started: boolean;
} | null>(null);
const loadLineage = useCallback(async () => {
@@ -760,7 +766,7 @@ export function GameLineagePage({
const element = viewportRef.current;
if (!element) return;
const block = (event: Event) => {
if (!dragRef.current) return;
if (!dragRef.current?.started) return;
event.preventDefault();
};
const blockDragStart = (event: Event) => event.preventDefault();
@@ -787,23 +793,38 @@ export function GameLineagePage({
};
}, [isPanning]);
/**
* 按下:只记起点,**不**捕获指针、**不** preventDefault。
*
* 为什么不在按下时就 `setPointerCapture`:一旦在**容器**上捕获,后续 `pointerup` 会被重定向到容器,
* 于是 `click` 的 target 变成画布而**不是节点卡片里的按钮** ⇒ 「图谱里的东西点不动」
* (Chromium 的经典坑)。这里改成「移动超过阈值才算拖动」,纯点击完全走浏览器原生 click。
*/
const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
// 只响应主键 / 触摸 / 笔;中键与右键留给浏览器(新标签页打开、右键菜单)。
if (event.pointerType === 'mouse' && event.button !== 0) return;
// 指针捕获:移出画布/越界后仍然跟手,不用把监听挂到 window 上。
event.currentTarget.setPointerCapture?.(event.pointerId);
setIsPanning(true);
dragRef.current = {
x: event.clientX,
y: event.clientY,
offsetX: viewport.offsetX,
offsetY: viewport.offsetY,
pointerId: event.pointerId,
started: false,
};
};
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (!drag) return;
if (!drag.started) {
const moved = Math.hypot(event.clientX - drag.x, event.clientY - drag.y);
// 还没超过阈值:不当作拖动 —— 不动画布、不拦事件,让 click / 文本选择走原生路径。
if (moved < DRAG_START_THRESHOLD_PX) return;
drag.started = true;
setIsPanning(true);
// 真正开始拖动才捕获指针:移出画布/越界后仍然跟手。
event.currentTarget.setPointerCapture?.(drag.pointerId);
}
event.preventDefault();
const viewportElement = viewportRef.current;
setViewport((current) => {
@@ -828,9 +849,14 @@ export function GameLineagePage({
};
const endDrag = (event?: ReactPointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
dragRef.current = null;
setIsPanning(false);
if (event?.currentTarget?.hasPointerCapture?.(event.pointerId)) {
// 只有真的捕获过才释放(没拖动过就完全没碰过指针)。
if (
drag?.started &&
event?.currentTarget?.hasPointerCapture?.(event.pointerId)
) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
};