修复图谱点不动:只有超过拖动阈值才 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
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:
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user