控件与全屏:滚轮改竖向平移(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
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:
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user