From a3fc88d3ab8d3409352046c37ee6c6f48f6da443 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Wed, 7 Oct 2026 16:12:37 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A7=E4=BB=B6=E4=B8=8E=E5=85=A8=E5=B1=8F?= =?UTF-8?q?=EF=BC=9A=E6=BB=9A=E8=BD=AE=E6=94=B9=E7=AB=96=E5=90=91=E5=B9=B3?= =?UTF-8?q?=E7=A7=BB(Ctrl=E7=BC=A9=E6=94=BE/Shift=E6=A8=AA=E7=A7=BB)?= =?UTF-8?q?=E3=80=81=E9=80=82=E5=BA=94=E7=AA=97=E5=8F=A3=E6=94=B9=E5=90=8D?= =?UTF-8?q?=E6=80=BB=E8=A7=88=E3=80=81ZoomOut/ZoomIn=20=E5=9B=BE=E6=A0=87?= =?UTF-8?q?=E3=80=81=E5=85=A8=E5=B1=8F=E6=9F=A5=E7=9C=8B=E6=97=8F=E8=B0=B1?= =?UTF-8?q?=E6=94=B9=20portal=20=E7=9C=9F=E5=85=A8=E5=B1=8F=20overlay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CoCreationLineageSection.tsx | 54 ++++-- .../game-distribution/GameDetailPage.tsx | 1 - .../GameDistributionPages.test.tsx | 159 +++++++++++++++++- .../game-distribution/GameLineagePage.tsx | 61 +++++-- .../LineageFullscreenOverlay.tsx | 93 ++++++++++ .../game-distribution/lineageTreeLayout.ts | 2 +- 6 files changed, 328 insertions(+), 42 deletions(-) create mode 100644 src/components/game-distribution/LineageFullscreenOverlay.tsx diff --git a/src/components/game-distribution/CoCreationLineageSection.tsx b/src/components/game-distribution/CoCreationLineageSection.tsx index 2e30ad3a7..f17d11362 100644 --- a/src/components/game-distribution/CoCreationLineageSection.tsx +++ b/src/components/game-distribution/CoCreationLineageSection.tsx @@ -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=&from=<当前作品>`。 */ - onOpenFullView?: (rootGameId: string, fromGameId?: string) => void; }) { + const [isFullscreen, setIsFullscreen] = useState(false); + const triggerRef = useRef(null); return (
@@ -32,22 +34,38 @@ export function CoCreationLineageSection({ 创作血缘

这部作品的血缘图

- {onOpenFullView ? ( - - ) : null} + - + {/* + 全屏时**卸载**内嵌那棵树:同一棵树的两次渲染既重复请求又可能与 overlay 抢事件, + 用户此刻看的就是 overlay 里的那一份。 + */} + {isFullscreen ? null : ( + + )} + {isFullscreen ? ( + setIsFullscreen(false)} + triggerRef={triggerRef} + /> + ) : null}
); } diff --git a/src/components/game-distribution/GameDetailPage.tsx b/src/components/game-distribution/GameDetailPage.tsx index 8556f7dd6..43d6ae9ba 100644 --- a/src/components/game-distribution/GameDetailPage.tsx +++ b/src/components/game-distribution/GameDetailPage.tsx @@ -1227,7 +1227,6 @@ function GameDetailContent({ rootGameId={lineage?.rootGameId ?? game.id} currentGameId={game.id} onOpenGameDetail={onOpenGameDetail} - onOpenFullView={onOpenLineage} /> ) : null} { 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( + , + ); + + 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) => + 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( { ), ); 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( { 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( + , + ); + + 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 diff --git a/src/components/game-distribution/GameLineagePage.tsx b/src/components/game-distribution/GameLineagePage.tsx index 8af758fdb..74589cb44 100644 --- a/src/components/game-distribution/GameLineagePage.tsx +++ b/src/components/game-distribution/GameLineagePage.tsx @@ -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)} > -