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);
}
};