控件与全屏:滚轮改竖向平移(Ctrl缩放/Shift横移)、适应窗口改名总览、ZoomOut/ZoomIn 图标、全屏查看族谱改 portal 真全屏 overlay
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:12:37 +08:00
parent dba4734e01
commit a3fc88d3ab
6 changed files with 328 additions and 42 deletions
@@ -1,4 +1,7 @@
import { useRef, useState } from 'react';
import { GameLineagePage } from './GameLineagePage';
import { LineageFullscreenOverlay } from './LineageFullscreenOverlay';
/**
* 详情页正文之后的**整宽血缘图区块**。
@@ -15,16 +18,15 @@ export function CoCreationLineageSection({
rootGameId,
currentGameId,
onOpenGameDetail,
onOpenFullView,
}: {
/** 树的作用域:当前作品所在分支的**根**(详情 DTO 的 `lineage.rootGameId`);默认展示以根为顶的整棵树。 */
rootGameId: string;
/** 当前作品:作为高亮锚点与「父代 / 子代」区分的基准。 */
currentGameId: string;
onOpenGameDetail: (gameId: string) => void;
/** 「全屏查看族谱」:跳到 `/games/lineage?id=<root>&from=<当前作品>`。 */
onOpenFullView?: (rootGameId: string, fromGameId?: string) => void;
}) {
const [isFullscreen, setIsFullscreen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
return (
<section className="game-lineage-block" aria-label="创作血缘">
<div className="game-lineage-block__heading">
@@ -32,22 +34,38 @@ export function CoCreationLineageSection({
<span className="game-eyebrow">创作血缘</span>
<h2>这部作品的血缘图</h2>
</div>
{onOpenFullView ? (
<button
type="button"
className="game-fork-lineage-link"
onClick={() => onOpenFullView(rootGameId, currentGameId)}
>
全屏查看族谱
</button>
) : null}
<button
ref={triggerRef}
type="button"
className="game-fork-lineage-link"
aria-haspopup="dialog"
aria-expanded={isFullscreen}
onClick={() => setIsFullscreen(true)}
>
全屏查看族谱
</button>
</div>
<GameLineagePage
embedded
rootGameId={rootGameId}
highlightGameId={currentGameId}
onOpenDetail={onOpenGameDetail}
/>
{/*
全屏时**卸载**内嵌那棵树:同一棵树的两次渲染既重复请求又可能与 overlay 抢事件,
用户此刻看的就是 overlay 里的那一份。
*/}
{isFullscreen ? null : (
<GameLineagePage
embedded
rootGameId={rootGameId}
highlightGameId={currentGameId}
onOpenDetail={onOpenGameDetail}
/>
)}
{isFullscreen ? (
<LineageFullscreenOverlay
rootGameId={rootGameId}
currentGameId={currentGameId}
onOpenGameDetail={onOpenGameDetail}
onClose={() => setIsFullscreen(false)}
triggerRef={triggerRef}
/>
) : null}
</section>
);
}
@@ -1227,7 +1227,6 @@ function GameDetailContent({
rootGameId={lineage?.rootGameId ?? game.id}
currentGameId={game.id}
onOpenGameDetail={onOpenGameDetail}
onOpenFullView={onOpenLineage}
/>
) : null}
<GameReviews
@@ -3307,7 +3307,7 @@ describe('GameLineagePage 可视化与交互', () => {
if (floored) {
expect(
screen.getByText(
'树较大:已按可读缩放显示(其余代际在视口外),拖拽平移查看,或点「适应窗口」看全树。',
'树较大:已按可读缩放显示(其余代际在视口外),拖拽平移查看,或点「总览」看全树。',
),
).toBeTruthy();
// 触底时仍然把当前作品放进视口(不是把它也丢出去)。
@@ -3447,7 +3447,63 @@ describe('GameLineagePage 可视化与交互', () => {
).toBeNull();
});
it('滚轮只缩放画布:原生非 passive 监听(不踩 React passive 的坑),且不绑 window', async () => {
it('滚轮 = 竖向平移(不缩放);Ctrl/⌘+滚轮 = 缩放;Shift+滚轮 = 横向平移', async () => {
getGameLineageMock.mockResolvedValueOnce(treeWithSibling);
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
const canvas = (await screen.findByLabelText('创作族谱树')) as HTMLElement;
const content = canvas.querySelector(
'.game-lineage-canvas__content',
) as HTMLElement;
const read = () => {
const m = /translate\(([-\d.]+)px, ([-\d.]+)px\) scale\(([\d.]+)\)/u.exec(
content.style.transform,
);
return {
x: Number(m?.[1]),
y: Number(m?.[2]),
scale: Number(m?.[3]),
};
};
const wheel = (init: Record<string, unknown>) =>
fireEvent.wheel(canvas, init); // 返回 false 表示事件被 preventDefault
// 先等初始适配稳定(jsdom 下退到可读缩放下限 60%),否则会把「适配后的首次写入」误当成滚轮的效果。
await waitFor(() =>
expect(document.querySelector('.game-lineage-zoom')?.textContent).toBe(
'60%',
),
);
const start = read();
// 裸滚轮:**只竖向平移**,缩放不变,且事件被 preventDefault(页面不会滚)。
expect(wheel({ deltaY: 120 })).toBe(false); // 事件被 preventDefault(页面不会跟着滚)
const afterPlain = read();
expect(afterPlain.y).not.toBe(start.y);
expect(afterPlain.x).toBe(start.x);
expect(afterPlain.scale).toBe(start.scale);
// Ctrl + 滚轮:缩放(口径写明在报告与规范里)。
const beforeZoom = read();
wheel({ deltaY: -120, ctrlKey: true });
const afterZoom = read();
expect(afterZoom.scale).not.toBe(beforeZoom.scale);
// 缩放以视口中心为锚点(jsdom 视口为 0 时中心就是 0,位移会跟着变),所以这里只断言缩放变了。
// Shift + 滚轮:横向平移。
const beforeShift = read();
wheel({ deltaY: 120, shiftKey: true });
const afterShift = read();
expect(afterShift.x).not.toBe(beforeShift.x);
expect(afterShift.y).toBe(beforeShift.y);
});
it('滚轮监听仍是非 passive 且绑在画布元素上(不许退回 React onWheel / window)', async () => {
const source = await import('node:fs').then((fs) =>
fs.readFileSync(
'src/components/game-distribution/GameLineagePage.tsx',
@@ -3458,9 +3514,11 @@ describe('GameLineagePage 可视化与交互', () => {
expect(source).not.toContain('onWheel={handleWheel}');
expect(source).toContain("addEventListener('wheel'");
expect(source).toContain('{ passive: false }');
// 绑在画布元素上(viewportRef.current),不是 window。
expect(source).toContain('viewportRef.current');
expect(source).not.toMatch(/window\.addEventListener\('wheel'/u);
// 平移必须过边界钳制,且**不再**是缩放。
expect(source).toContain('clampLineagePan');
expect(source).toMatch(/offsetY: clampLineagePan\(/u);
});
it('拖动平移:超过阈值才算拖动(禁选中/拖文本),未拖动时点击照常触发', async () => {
@@ -3617,7 +3675,7 @@ describe('GameLineagePage 可视化与交互', () => {
).toBe(0);
});
it('初次进入满足宽高两个维度并带可读下限;适应窗口按钮给全览', async () => {
it('初次进入满足宽高两个维度并带可读下限;总览按钮给全览', async () => {
getGameLineageMock.mockResolvedValueOnce(treeWithSibling);
render(
<GameLineagePage
@@ -3636,8 +3694,8 @@ describe('GameLineagePage 可视化与交互', () => {
),
);
expect(screen.getByText(/已按可读缩放显示/u)).toBeTruthy();
// 主动点「适应窗口」才做真正的全览(不受可读下限约束),提示随之收起。
fireEvent.click(screen.getByRole('button', { name: '适应窗口' }));
// 主动点「总览」才做真正的全览(不受可读下限约束),提示随之收起。
fireEvent.click(screen.getByRole('button', { name: '总览' }));
expect(document.querySelector('.game-lineage-zoom')?.textContent).toBe(
'35%',
);
@@ -3755,7 +3813,7 @@ describe('GameLineagePage 可视化与交互', () => {
);
});
it('缩放按钮改变百分比,「适应窗口」重新适配', async () => {
it('缩放按钮改变百分比,「总览」重新适配', async () => {
getGameLineageMock.mockResolvedValueOnce(treeWithSibling);
render(
<GameLineagePage
@@ -3774,8 +3832,8 @@ describe('GameLineagePage 可视化与交互', () => {
expect(zoomLabel()).toBe('72%');
fireEvent.click(screen.getByRole('button', { name: '缩小' }));
expect(zoomLabel()).toBe('60%');
// 适应窗口(强制全览)在 jsdom 下退到最小缩放下限。
fireEvent.click(screen.getByRole('button', { name: '适应窗口' }));
// 总览(强制全览)在 jsdom 下退到最小缩放下限。
fireEvent.click(screen.getByRole('button', { name: '总览' }));
expect(zoomLabel()).toBe('35%');
});
@@ -4635,6 +4693,89 @@ describe('GameDetailPage 共创模块(文档对齐)', () => {
).toBeTruthy();
});
it('「全屏查看族谱」= 真·全屏 overlay(portal 到 body / Esc 与关闭按钮 / 焦点与滚动恢复)', async () => {
getGameMock.mockResolvedValueOnce(
createGame({
id: 'game-current',
lineage: { rootGameId: 'game-root', parentGameId: 'game-mid' },
}),
);
getGameLineageMock.mockResolvedValue(buildChainDataset());
render(
<GameDetailPage
gameId="game-current"
onBack={vi.fn()}
onPlay={vi.fn()}
onOpenGameDetail={vi.fn()}
/>,
);
const trigger = await screen.findByRole('button', {
name: '全屏查看族谱',
});
expect(document.body.style.overflow).toBe('');
trigger.focus();
fireEvent.click(trigger);
const dialog = await screen.findByRole('dialog', {
name: '创作族谱(全屏)',
});
// portal 到 `document.body`(不在详情页/平台容器的 DOM 里)⇒ 不受容器宽度与 overflow 约束。
expect(dialog.parentElement).toBe(document.body);
expect(dialog.getAttribute('aria-modal')).toBe('true');
expect(dialog.className).toContain('lineage-fullscreen');
// 打开:锁 body 滚动 + 焦点移入 overlay。
expect(document.body.style.overflow).toBe('hidden');
expect(document.activeElement?.getAttribute('aria-label')).toBe(
'关闭全屏族谱',
);
// overlay 里就是同一套画布(不是第二套实现),且内嵌那棵树已卸载(DOM 里只剩一个画布)。
expect(within(dialog).getByLabelText('创作族谱树')).toBeTruthy();
expect(document.querySelectorAll('.game-lineage-canvas')).toHaveLength(1);
// Esc 关闭:overlay 消失、滚动锁恢复、焦点回到触发按钮、内嵌树回来。
fireEvent.keyDown(document, { key: 'Escape' });
await waitFor(() =>
expect(
screen.queryByRole('dialog', { name: '创作族谱(全屏)' }),
).toBeNull(),
);
expect(document.body.style.overflow).toBe('');
expect(document.activeElement).toBe(trigger);
expect(document.querySelectorAll('.game-lineage-canvas')).toHaveLength(1);
// 关闭按钮同样能关。
fireEvent.click(screen.getByRole('button', { name: '全屏查看族谱' }));
fireEvent.click(
await screen.findByRole('button', { name: '关闭全屏族谱' }),
);
await waitFor(() =>
expect(
screen.queryByRole('dialog', { name: '创作族谱(全屏)' }),
).toBeNull(),
);
});
it('封面契约:容器强制 16:9 且不被拉伸,图片 100%×100% + object-fit: cover(三处同一套)', async () => {
const css = await import('node:fs').then((fs) =>
fs.readFileSync(
'src/components/game-distribution/gameDistribution.css',
'utf8',
),
);
// 同一规则里必须同时具备 16:9 + 不被 flex 拉伸 + 裁切(历史上有多条同名片段,
// 所以用「同一规则内」的前瞻断言,不去猜是哪一条)。
expect(css).toMatch(
/\.game-lineage-node__cover \{(?=[^}]*aspect-ratio: 16 \/ 9)(?=[^}]*flex: 0 0 auto)(?=[^}]*overflow: hidden)[^}]*\}/u,
);
expect(css).toMatch(
/\.game-lineage-node__cover \.game-cover,[\s\S]{0,240}?height: 100%;/u,
);
expect(css).toMatch(
/\.game-lineage-node__cover img \{[\s\S]{0,120}?object-fit: cover;/u,
);
});
it('区块默认以根为作用域,并把当前作品定位进视口', async () => {
// jsdom 不做布局:给画布一个真机尺寸,初始适配与定位才有可断言的数值。
const widthSpy = vi
@@ -1,6 +1,6 @@
import './gameDistribution.css';
import { ArrowLeft, Expand, Minus, Plus, RefreshCcw } from 'lucide-react';
import { ArrowLeft, Expand, RefreshCcw, ZoomIn, ZoomOut } from 'lucide-react';
import {
type PointerEvent as ReactPointerEvent,
useCallback,
@@ -84,7 +84,7 @@ const MAX_SCALE = 1.8;
const FIT_PADDING = 24;
/**
* 可读下限:把整棵树硬塞进窗口会把字缩到看不清(尤其窄屏),
* 所以初次进入若全览缩放低于它,就按可读缩放 + 可平移呈现,由用户点「适应窗口」主动全览。
* 所以初次进入若全览缩放低于它,就按可读缩放 + 可平移呈现,由用户点「总览」主动全览。
*/
const READABLE_MIN_SCALE = 0.6;
@@ -529,7 +529,7 @@ export function GameLineagePage({
? `${layout.nodes.length} 个节点${lineage?.truncated ? '(已截断)' : ''}`
: '';
/** 适应窗口:宽高**两个维度同时**满足(取更小的那个比例),并居中。 */
/** 总览:宽高**两个维度同时**满足(取更小的那个比例),并居中。 */
const fitToView = useCallback(
(forceFullView = false) => {
const viewportElement = viewportRef.current;
@@ -554,7 +554,7 @@ export function GameLineagePage({
);
setFitHint(
floored
? '树较大,已按可读缩放显示:拖拽平移查看,或点「适应窗口」看全树。'
? '树较大,已按可读缩放显示:拖拽平移查看,或点「总览」看全树。'
: '',
);
setViewport({
@@ -602,7 +602,7 @@ export function GameLineagePage({
);
setFitHint(
floored
? '树较大:已按可读缩放显示(其余代际在视口外),拖拽平移查看,或点「适应窗口」看全树。'
? '树较大:已按可读缩放显示(其余代际在视口外),拖拽平移查看,或点「总览」看全树。'
: scale > target + 0.001
? '树较宽,已按最小缩放显示:拖拽平移查看。'
: '',
@@ -750,13 +750,48 @@ export function GameLineagePage({
const element = viewportRef.current;
if (!element) return;
const onWheel = (event: WheelEvent) => {
// 阻止页面滚动与浏览器的「捏合缩放整页」;缩放只作用在本画布。
// 无论做什么,先阻止页面滚动(含浏览器「捏合缩放整页」)——这是上一轮修好的口径,不许退回去。
event.preventDefault();
zoomBy(event.deltaY < 0 ? 1.08 : 1 / 1.08);
// deltaMode=1 是「按行」滚动(部分浏览器/鼠标驱动),折算成像素量级,避免一格跳很远。
const delta = event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY;
if (delta === 0) return;
// **Ctrl / ⌘ + 滚轮 = 缩放**(画布类应用的通用手势,也保住「键盘/触控板也能放大」的能力);
// 裸滚轮 = **竖向平移**(2026-10-07 用户口径:「滚动滚轮改成上下滚动吧,而不是现在的缩放」)。
if (event.ctrlKey || event.metaKey) {
zoomBy(delta < 0 ? 1.08 : 1 / 1.08);
return;
}
const viewportElement = viewportRef.current;
// **Shift + 滚轮 = 横向平移**(内容超宽时用得上;纵向仍是默认)。
const horizontal = event.shiftKey;
setViewport((current) => {
const width = viewportElement?.clientWidth ?? 0;
const height = viewportElement?.clientHeight ?? 0;
const contentWidth = layout.width * current.scale;
const contentHeight = layout.height * current.scale;
if (horizontal) {
return {
...current,
offsetX: clampLineagePan(
current.offsetX - delta,
width,
contentWidth,
),
};
}
return {
...current,
offsetY: clampLineagePan(
current.offsetY - delta,
height,
contentHeight,
),
};
});
};
element.addEventListener('wheel', onWheel, { passive: false });
return () => element.removeEventListener('wheel', onWheel);
}, [canvasHasContent, zoomBy]);
}, [canvasHasContent, layout.height, layout.width, zoomBy]);
/**
* 拖动平移期间禁止文本选择 / 原生拖拽(否则按住划一下就把文字选走、再拖变成拖文本)。
@@ -1261,7 +1296,7 @@ export function GameLineagePage({
disabled={viewport.scale <= MIN_SCALE + 0.001}
onClick={() => zoomBy(1 / 1.2)}
>
<Minus aria-hidden="true" />
<ZoomOut aria-hidden="true" />
</button>
<span className="game-lineage-zoom" role="status">
{Math.round(viewport.scale * 100)}%
@@ -1274,7 +1309,7 @@ export function GameLineagePage({
disabled={viewport.scale >= MAX_SCALE - 0.001}
onClick={() => zoomBy(1.2)}
>
<Plus aria-hidden="true" />
<ZoomIn aria-hidden="true" />
</button>
<span
className="game-lineage-controls__divider"
@@ -1283,13 +1318,13 @@ export function GameLineagePage({
<button
type="button"
className="game-lineage-controls__button game-lineage-controls__button--wide"
aria-label="适应窗口"
title="适应窗口"
aria-label="总览"
title="总览"
onClick={() => fitToView(true)}
>
<Expand aria-hidden="true" />
<span className="game-lineage-controls__label">
适应窗口
总览
</span>
</button>
<button
@@ -0,0 +1,93 @@
import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { GameLineagePage } from './GameLineagePage';
/**
* 「全屏查看族谱」的**真·全屏 overlay**(2026-10-07 用户口径:「你这个全屏查看族谱压根就不是全屏」)。
*
* 之前的实现其实是**路由跳转**到 `/games/lineage`:页面仍然活在平台 tab 面板里(左侧有导航栏、
* 四周有内边距),所以「全屏」名不副实。这里改成:
*
* - **`createPortal` 挂到 `document.body`** + `position: fixed; inset: 0` ⇒ 完全不受详情页/平台
* 容器的宽度、`max-width`、`overflow` 约束;
* - 关闭路径齐备:右上「关闭」按钮 + `Esc`;
* - 无障碍:`role="dialog"` + `aria-modal="true"`,打开时焦点移入 overlay(关闭按钮),关闭后
* 焦点回到触发按钮;
* - 打开时锁 `body` 滚动,关闭时**恢复原滚动位置**;
* - 移动端同样整屏,留 `env(safe-area-inset-*)`,不被刘海/底部条压住;
* - 里面的图谱**就是** `GameLineagePage`(`embedded`)——与内嵌区共用同一套画布实现
* (点选 / 拖拽 / 滚轮竖向平移 / 封面 16:9 全都同一份代码,不存在第二套画布)。
*/
export function LineageFullscreenOverlay({
rootGameId,
currentGameId,
onOpenGameDetail,
onClose,
triggerRef,
}: {
rootGameId: string;
currentGameId: string | null;
onOpenGameDetail: (gameId: string) => void;
onClose: () => void;
/** 触发按钮:关闭后把焦点还给它。 */
triggerRef: React.RefObject<HTMLButtonElement | null>;
}) {
const closeButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const previousOverflow = document.body.style.overflow;
const previousScrollY = window.scrollY;
// 关闭时要还原焦点:在 effect 里取一次触发按钮的引用(cleanup 里直接读 ref.current 会被 lint 拦,
// 而且那时元素可能已经换了)。
const trigger = triggerRef.current;
document.body.style.overflow = 'hidden';
closeButtonRef.current?.focus();
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
event.stopPropagation();
onClose();
};
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('keydown', onKeyDown);
document.body.style.overflow = previousOverflow;
// 关闭后回到打开前的位置(锁定滚动期间不会累积位移,这里只是兜底)。
window.scrollTo(0, previousScrollY);
trigger?.focus();
};
}, [onClose, triggerRef]);
return createPortal(
<div
className="lineage-fullscreen"
role="dialog"
aria-modal="true"
aria-label="创作族谱(全屏)"
>
<header className="lineage-fullscreen__bar">
<h2 className="lineage-fullscreen__title">创作族谱</h2>
<button
ref={closeButtonRef}
type="button"
className="lineage-fullscreen__close"
aria-label="关闭全屏族谱"
onClick={onClose}
>
关闭
</button>
</header>
<div className="lineage-fullscreen__body">
<GameLineagePage
embedded
rootGameId={rootGameId}
highlightGameId={currentGameId}
onOpenDetail={onOpenGameDetail}
/>
</div>
</div>,
document.body,
);
}
export default LineageFullscreenOverlay;
@@ -106,7 +106,7 @@ export type LineageTreeLayout = {
nodes: LineageLayoutNode[];
edges: LineageLayoutEdge[];
axis: LineageGenerationAxisEntry[];
/** 内容总尺寸(含左侧代际轴),供画布与「适应窗口」使用。 */
/** 内容总尺寸(含左侧代际轴),供画布与「总览」使用。 */
width: number;
height: number;
};