diff --git a/src/components/game-distribution/GameDistributionPages.test.tsx b/src/components/game-distribution/GameDistributionPages.test.tsx index b43fcbe5a..bbfe63f72 100644 --- a/src/components/game-distribution/GameDistributionPages.test.tsx +++ b/src/components/game-distribution/GameDistributionPages.test.tsx @@ -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( , ); 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(''); }); diff --git a/src/components/game-distribution/GameLineagePage.tsx b/src/components/game-distribution/GameLineagePage.tsx index b1a11c755..8af758fdb 100644 --- a/src/components/game-distribution/GameLineagePage.tsx +++ b/src/components/game-distribution/GameLineagePage.tsx @@ -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) => { // 只响应主键 / 触摸 / 笔;中键与右键留给浏览器(新标签页打开、右键菜单)。 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) => { 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) => { + 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); } };