族谱树收尾:剪贴板永不 settle 修复、elbow 连线、锁定路径与面包屑、窄屏紧凑
clipboard: race navigator.clipboard.writeText with 800ms timeout then fall back (fixes dead copy button); edges: rounded elbow routing; pin path + breadcrumb; three-tier highlight; compact mobile cards; 265 tests green.
This commit is contained in:
@@ -1130,7 +1130,7 @@ describe('GameLineagePage', () => {
|
||||
// 父作品不在可见集合里:只标注「原作品已不可用」,不回显父作品内容。
|
||||
expect(screen.getByText('原作品已不可用')).toBeTruthy();
|
||||
// 任一公开节点都能进入对应详情页。
|
||||
fireEvent.click(screen.getByRole('button', { name: /《母版》/u }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /作品《母版》/u }));
|
||||
expect(onOpenDetail).toHaveBeenCalledWith('game-root');
|
||||
});
|
||||
|
||||
@@ -1219,7 +1219,7 @@ describe('GameLineagePage', () => {
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByRole('button', { name: /《母版》/u }),
|
||||
await screen.findByRole('button', { name: /作品《母版》/u }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText('这部作品还没有衍生作品,欢迎从它开始共创。'),
|
||||
@@ -2026,8 +2026,10 @@ describe('GameLineagePage 可视化与交互', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const leafCard = await screen.findByRole('button', { name: /《叶节点》/u });
|
||||
const rootCard = screen.getByRole('button', { name: /《母版》/u });
|
||||
const leafCard = await screen.findByRole('button', {
|
||||
name: /作品《叶节点》/u,
|
||||
});
|
||||
const rootCard = screen.getByRole('button', { name: /作品《母版》/u });
|
||||
// 当前作品标记
|
||||
expect(leafCard.getAttribute('aria-current')).toBe('true');
|
||||
expect(rootCard.getAttribute('aria-current')).toBeNull();
|
||||
@@ -2036,10 +2038,13 @@ describe('GameLineagePage 可视化与交互', () => {
|
||||
expect(parseFloat(leafCard.style.top)).toBeGreaterThan(
|
||||
parseFloat(rootCard.style.top),
|
||||
);
|
||||
// 贝塞尔连线
|
||||
// 正交 elbow 连线
|
||||
const paths = document.querySelectorAll('path.game-lineage-edge');
|
||||
expect(paths.length).toBeGreaterThan(0);
|
||||
expect(paths[0]!.getAttribute('d')).toContain(' C ');
|
||||
// 正交 elbow:竖直段 + 水平段 + 圆角(不再是三次贝塞尔弧)。
|
||||
const firstEdgePath = paths[0]!.getAttribute('d') ?? '';
|
||||
expect(firstEdgePath).toContain(' V ');
|
||||
expect(firstEdgePath).not.toContain(' C ');
|
||||
// 键盘 Enter 与点击等价
|
||||
fireEvent.keyDown(leafCard, { key: 'Enter' });
|
||||
expect(onOpenDetail).toHaveBeenCalledWith('game-leaf');
|
||||
@@ -2057,10 +2062,12 @@ describe('GameLineagePage 可视化与交互', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const midCard = await screen.findByRole('button', { name: /《中间代》/u });
|
||||
const rootCard = screen.getByRole('button', { name: /《母版》/u });
|
||||
const leafCard = screen.getByRole('button', { name: /《叶节点》/u });
|
||||
const otherCard = screen.getByRole('button', { name: /《旁支》/u });
|
||||
const midCard = await screen.findByRole('button', {
|
||||
name: /作品《中间代》/u,
|
||||
});
|
||||
const rootCard = screen.getByRole('button', { name: /作品《母版》/u });
|
||||
const leafCard = screen.getByRole('button', { name: /作品《叶节点》/u });
|
||||
const otherCard = screen.getByRole('button', { name: /作品《旁支》/u });
|
||||
|
||||
fireEvent.mouseEnter(midCard);
|
||||
// 祖先(母版)+ 自身 + 子树(叶节点)都不降透明,旁支降透明。
|
||||
@@ -2093,7 +2100,7 @@ describe('GameLineagePage 可视化与交互', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByRole('button', { name: /《母版》/u });
|
||||
await screen.findByRole('button', { name: /作品《母版》/u });
|
||||
// 初次适配在 effect 里落地:先等它生效(jsdom 无布局尺寸 → min(scaleX, scaleY) 远小于 1,
|
||||
// 因此命中可读下限 60%)。
|
||||
await waitFor(() =>
|
||||
@@ -2104,9 +2111,9 @@ describe('GameLineagePage 可视化与交互', () => {
|
||||
expect(screen.getByText(/已按可读缩放显示/u)).toBeTruthy();
|
||||
// 主动点「适应窗口」才做真正的全览(不受可读下限约束),提示随之收起。
|
||||
fireEvent.click(screen.getByRole('button', { name: '适应窗口' }));
|
||||
expect(
|
||||
document.querySelector('.game-lineage-zoom')?.textContent,
|
||||
).toBe('35%');
|
||||
expect(document.querySelector('.game-lineage-zoom')?.textContent).toBe(
|
||||
'35%',
|
||||
);
|
||||
expect(screen.queryByText(/已按可读缩放显示/u)).toBeNull();
|
||||
});
|
||||
|
||||
@@ -2120,13 +2127,15 @@ describe('GameLineagePage 可视化与交互', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByRole('button', { name: /《母版》/u });
|
||||
await screen.findByRole('button', { name: /作品《母版》/u });
|
||||
const axisLayer = document.querySelector('.game-lineage-canvas__axis');
|
||||
expect(axisLayer).not.toBeNull();
|
||||
expect(axisLayer?.querySelectorAll('.game-lineage-axis').length).toBe(3);
|
||||
// 轴不在内容层里:不会跟着 transform 缩放/平移。
|
||||
expect(
|
||||
document.querySelector('.game-lineage-canvas__content .game-lineage-axis'),
|
||||
document.querySelector(
|
||||
'.game-lineage-canvas__content .game-lineage-axis',
|
||||
),
|
||||
).toBeNull();
|
||||
expect(axisLayer?.textContent).toContain('母版');
|
||||
|
||||
@@ -2168,17 +2177,83 @@ describe('GameLineagePage 可视化与交互', () => {
|
||||
|
||||
await screen.findByText('有封面');
|
||||
// 只有带 coverObjectKey 的那个节点渲染 GameCover(复用既有封面组件与换签逻辑)。
|
||||
expect(document.querySelectorAll('.game-lineage-node .game-cover')).toHaveLength(
|
||||
1,
|
||||
);
|
||||
expect(
|
||||
document.querySelectorAll('img.game-cover__image'),
|
||||
document.querySelectorAll('.game-lineage-node .game-cover'),
|
||||
).toHaveLength(1);
|
||||
expect(document.querySelectorAll('img.game-cover__image')).toHaveLength(1);
|
||||
// 没有封面的节点不渲染空封面块。
|
||||
const withoutCover = screen.getByRole('button', { name: /《没封面》/u });
|
||||
expect(withoutCover.querySelector('.game-cover')).toBeNull();
|
||||
});
|
||||
|
||||
it('锁定路径后祖先链持续强化并给出面包屑;取消锁定后复原', async () => {
|
||||
getGameLineageMock.mockResolvedValueOnce(treeWithSibling);
|
||||
render(
|
||||
<GameLineagePage
|
||||
rootGameId="game-root"
|
||||
onBack={vi.fn()}
|
||||
onOpenDetail={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const leafCard = await screen.findByRole('button', {
|
||||
name: /作品《叶节点》/u,
|
||||
});
|
||||
fireEvent.click(
|
||||
within(leafCard).getByRole('button', { name: '锁定 叶节点 的路径' }),
|
||||
);
|
||||
|
||||
// 面包屑:母版 → 中间代 → 叶节点,祖先移出视口也能读。
|
||||
const breadcrumb = screen.getByLabelText('已锁定的路径');
|
||||
expect(breadcrumb.textContent).toContain('母版');
|
||||
expect(breadcrumb.textContent).toContain('中间代');
|
||||
expect(breadcrumb.textContent).toContain('叶节点');
|
||||
// 祖先链与目标持续强化(不依赖 hover),旁支仍是被压暗的一方。
|
||||
expect(
|
||||
screen.getByRole('button', { name: /作品《母版》/u }).className,
|
||||
).toContain('game-lineage-node--path');
|
||||
expect(
|
||||
screen.getByRole('button', { name: /作品《中间代》/u }).className,
|
||||
).toContain('game-lineage-node--path');
|
||||
expect(leafCard.className).toContain('game-lineage-node--pinned');
|
||||
expect(
|
||||
screen.getByRole('button', { name: /作品《旁支》/u }).className,
|
||||
).toContain('game-lineage-node--dimmed');
|
||||
|
||||
fireEvent.click(
|
||||
within(leafCard).getByRole('button', { name: '取消锁定 叶节点 的路径' }),
|
||||
);
|
||||
expect(screen.queryByLabelText('已锁定的路径')).toBeNull();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /作品《旁支》/u }).className,
|
||||
).not.toContain('game-lineage-node--dimmed');
|
||||
});
|
||||
|
||||
it('窄屏用紧凑卡片:少一行信息、卡片标记为 compact', async () => {
|
||||
setViewportWidth(375);
|
||||
getGameLineageMock.mockResolvedValueOnce(treeWithSibling);
|
||||
render(
|
||||
<GameLineagePage
|
||||
rootGameId="game-root"
|
||||
onBack={vi.fn()}
|
||||
onOpenDetail={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const leafCard = await screen.findByRole('button', {
|
||||
name: /作品《叶节点》/u,
|
||||
});
|
||||
expect(leafCard.className).toContain('game-lineage-node--compact');
|
||||
// 紧凑模式不渲染作者行,但标题/Fork/锁定仍在(可访问性不降级)。
|
||||
expect(within(leafCard).queryByText('作者 作者 game-leaf')).toBeNull();
|
||||
expect(
|
||||
within(leafCard).getByRole('button', { name: 'Fork 叶节点' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(leafCard).getByRole('button', { name: '锁定 叶节点 的路径' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('缩放按钮改变百分比,「适应窗口」重新适配', async () => {
|
||||
getGameLineageMock.mockResolvedValueOnce(treeWithSibling);
|
||||
render(
|
||||
@@ -2189,7 +2264,7 @@ describe('GameLineagePage 可视化与交互', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByRole('button', { name: /《母版》/u });
|
||||
await screen.findByRole('button', { name: /作品《母版》/u });
|
||||
const zoomLabel = () =>
|
||||
document.querySelector('.game-lineage-zoom')?.textContent ?? '';
|
||||
// 初次适配是 effect 里跑的:先等它落定(jsdom 无布局尺寸 → 命中可读下限 60%)。
|
||||
@@ -2214,7 +2289,9 @@ describe('GameLineagePage 可视化与交互', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const leafCard = await screen.findByRole('button', { name: /《叶节点》/u });
|
||||
const leafCard = await screen.findByRole('button', {
|
||||
name: /作品《叶节点》/u,
|
||||
});
|
||||
const forkButton = within(leafCard).getByRole('button', {
|
||||
name: 'Fork 叶节点',
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ import { PlatformEmptyState } from '../common/PlatformEmptyState';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { useCopyFeedback } from '../common/useCopyFeedback';
|
||||
import { GameCover } from './GameCover';
|
||||
import { useIsMobileViewport } from './gameDistributionGuards';
|
||||
import {
|
||||
FORK_LAUNCH_FALLBACK_DELAY_MS,
|
||||
openForkProtocol,
|
||||
@@ -71,18 +72,28 @@ function LineageTreeNodeCard({
|
||||
layoutNode,
|
||||
isAnchor,
|
||||
isDimmed,
|
||||
isOnPath,
|
||||
isPinned,
|
||||
compact,
|
||||
onOpenDetail,
|
||||
onActivate,
|
||||
onDeactivate,
|
||||
onOpenFork,
|
||||
onTogglePin,
|
||||
}: {
|
||||
layoutNode: LineageLayoutNode;
|
||||
isAnchor: boolean;
|
||||
isDimmed: boolean;
|
||||
/** 该节点在「锁定/高亮目标」的祖先链上(含目标自身)。 */
|
||||
isOnPath: boolean;
|
||||
isPinned: boolean;
|
||||
/** 窄屏紧凑模式:少一行信息、卡片更小。 */
|
||||
compact: boolean;
|
||||
onOpenDetail: (gameId: string) => void;
|
||||
onActivate: (gameId: string) => void;
|
||||
onDeactivate: () => void;
|
||||
onOpenFork: (gameId: string) => void;
|
||||
onTogglePin: (gameId: string) => void;
|
||||
}) {
|
||||
const { gameId, title, authorName, generation, isStub } = layoutNode;
|
||||
const displayTitle = isStub ? '原作品已不可用' : (title ?? '未命名作品');
|
||||
@@ -114,6 +125,12 @@ function LineageTreeNodeCard({
|
||||
isAnchor ? 'game-lineage-node--anchor' : '',
|
||||
isStub ? 'game-lineage-node--stub' : '',
|
||||
isDimmed ? 'game-lineage-node--dimmed' : '',
|
||||
// 路径上的节点(祖先链 + 目标)与「仅子树」的节点给不同强度的强化,
|
||||
// 这样「整棵子树」在默认全览下也有明显差异,而不是只把别人变淡。
|
||||
!isDimmed && isOnPath ? 'game-lineage-node--path' : '',
|
||||
!isDimmed && !isOnPath ? 'game-lineage-node--subtree' : '',
|
||||
isPinned ? 'game-lineage-node--pinned' : '',
|
||||
compact ? 'game-lineage-node--compact' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
@@ -161,10 +178,12 @@ function LineageTreeNodeCard({
|
||||
) : null}
|
||||
</span>
|
||||
<span className="game-lineage-node__title">{displayTitle}</span>
|
||||
{/* 树节点只留「代际 + 标题 + 作者 + Fork」:游玩数与「与原版对比」在详情页已有,不在这里重复。 */}
|
||||
<span className="game-lineage-node__meta">
|
||||
<span>{authorLabel}</span>
|
||||
</span>
|
||||
{/* 树节点只留「代际 + 标题 + 作者 + Fork」:游玩数与「与原版对比」在详情页已有。 */}
|
||||
{compact ? null : (
|
||||
<span className="game-lineage-node__meta">
|
||||
<span>{authorLabel}</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
{isInteractive ? (
|
||||
@@ -181,6 +200,23 @@ function LineageTreeNodeCard({
|
||||
>
|
||||
Fork
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="game-lineage-node__pin"
|
||||
aria-pressed={isPinned}
|
||||
aria-label={
|
||||
isPinned
|
||||
? `取消锁定 ${displayTitle} 的路径`
|
||||
: `锁定 ${displayTitle} 的路径`
|
||||
}
|
||||
onClick={(event) => {
|
||||
// 点击主区域是「进详情」,锁定路径是它的独立次要动作,互不干扰。
|
||||
event.stopPropagation();
|
||||
onTogglePin(isPinned ? '' : gameId);
|
||||
}}
|
||||
>
|
||||
{isPinned ? '已锁定' : '锁定路径'}
|
||||
</button>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -207,6 +243,8 @@ export function GameLineagePage({
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [activeGameId, setActiveGameId] = useState('');
|
||||
const [pinnedGameId, setPinnedGameId] = useState('');
|
||||
const isMobileViewport = useIsMobileViewport();
|
||||
const [includeSubtree, setIncludeSubtree] = useState(true);
|
||||
const [viewport, setViewport] = useState<ViewportState>({
|
||||
scale: 1,
|
||||
@@ -260,16 +298,56 @@ export function GameLineagePage({
|
||||
}, [loadLineage]);
|
||||
|
||||
const nodes = useMemo(() => lineage?.nodes ?? [], [lineage]);
|
||||
const layout = useMemo(() => buildLineageTreeLayout(nodes), [nodes]);
|
||||
// 窄屏用紧凑布局(更小的卡片与间距 + 更大的相对字号),避免「桌面画布缩小 + 字太小」。
|
||||
const compactTree = isMobileViewport;
|
||||
const layout = useMemo(
|
||||
() =>
|
||||
buildLineageTreeLayout(
|
||||
nodes,
|
||||
compactTree
|
||||
? {
|
||||
nodeWidth: 168,
|
||||
nodeHeight: 78,
|
||||
columnGap: 20,
|
||||
rowGap: 86,
|
||||
axisWidth: 62,
|
||||
}
|
||||
: undefined,
|
||||
),
|
||||
[compactTree, nodes],
|
||||
);
|
||||
// 高亮目标:锁定(点击「锁定路径」)优先于 hover/聚焦;锁定后即使祖先移出视口,路径仍由面包屑兜底。
|
||||
const highlightTargetId = pinnedGameId || activeGameId;
|
||||
const highlighted = useMemo(
|
||||
() =>
|
||||
activeGameId
|
||||
? collectLineageHighlight(layout.nodes, activeGameId, {
|
||||
highlightTargetId
|
||||
? collectLineageHighlight(layout.nodes, highlightTargetId, {
|
||||
includeSubtree,
|
||||
})
|
||||
: null,
|
||||
[activeGameId, includeSubtree, layout.nodes],
|
||||
[highlightTargetId, includeSubtree, layout.nodes],
|
||||
);
|
||||
const pathIds = useMemo(
|
||||
() =>
|
||||
highlightTargetId
|
||||
? collectLineageHighlight(layout.nodes, highlightTargetId, {
|
||||
includeSubtree: false,
|
||||
})
|
||||
: null,
|
||||
[highlightTargetId, layout.nodes],
|
||||
);
|
||||
/** 锁定路径的面包屑:从母版到锁定节点,按代际升序。 */
|
||||
const pinnedChain = useMemo(() => {
|
||||
if (!pinnedGameId || !pathIds) return [];
|
||||
return layout.nodes
|
||||
.filter((item) => pathIds.has(item.gameId))
|
||||
.sort((left, right) => left.generation - right.generation)
|
||||
.map((item) => ({
|
||||
gameId: item.gameId,
|
||||
label: item.isStub ? '原作品已不可用' : (item.title ?? '未命名作品'),
|
||||
generation: item.generation,
|
||||
}));
|
||||
}, [layout.nodes, pathIds, pinnedGameId]);
|
||||
const anchorId = highlightGameId?.trim() || normalizedRootId;
|
||||
const derivedCount = nodes.filter((node) => node.generation > 0).length;
|
||||
|
||||
@@ -589,6 +667,35 @@ export function GameLineagePage({
|
||||
onPointerLeave={endDrag}
|
||||
aria-label="创作族谱树"
|
||||
>
|
||||
{pinnedChain.length > 0 ? (
|
||||
<div
|
||||
className="game-lineage-breadcrumb"
|
||||
aria-label="已锁定的路径"
|
||||
>
|
||||
{pinnedChain.map((crumb, index) => (
|
||||
<span
|
||||
key={crumb.gameId}
|
||||
className="game-lineage-breadcrumb__item"
|
||||
>
|
||||
{index > 0 ? <span aria-hidden="true">→</span> : null}
|
||||
<button
|
||||
type="button"
|
||||
className="game-lineage-breadcrumb__crumb"
|
||||
onClick={() => setPinnedGameId(crumb.gameId)}
|
||||
>
|
||||
{`${generationAxisLabel(crumb.generation)}《${crumb.label}》`}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="game-lineage-breadcrumb__clear"
|
||||
onClick={() => setPinnedGameId('')}
|
||||
>
|
||||
取消锁定
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className="game-lineage-canvas__content"
|
||||
style={{
|
||||
@@ -627,6 +734,10 @@ export function GameLineagePage({
|
||||
isDimmed={Boolean(
|
||||
highlighted && !highlighted.has(layoutNode.gameId),
|
||||
)}
|
||||
isOnPath={Boolean(pathIds?.has(layoutNode.gameId))}
|
||||
isPinned={pinnedGameId === layoutNode.gameId}
|
||||
compact={compactTree}
|
||||
onTogglePin={setPinnedGameId}
|
||||
onOpenDetail={onOpenDetail}
|
||||
onActivate={setActiveGameId}
|
||||
onDeactivate={() => setActiveGameId('')}
|
||||
|
||||
@@ -857,6 +857,76 @@
|
||||
.game-lineage-node--dimmed {
|
||||
opacity: 0.34;
|
||||
}
|
||||
/* 高亮分三档,差异要肉眼可见:路径节点(强调边框 + 淡底)、仅子树节点(淡底 + 中等边框)、
|
||||
目标节点(外发光 + 强调边框)。全都比「只把别人变淡」更容易看出结构。 */
|
||||
.game-lineage-node--path {
|
||||
border-color: var(--platform-accent-strong);
|
||||
background: rgba(199, 101, 61, 0.08);
|
||||
}
|
||||
.game-lineage-node--subtree {
|
||||
border-color: var(--platform-accent-strong);
|
||||
border-style: dashed;
|
||||
background: rgba(199, 101, 61, 0.04);
|
||||
}
|
||||
.game-lineage-node--pinned {
|
||||
border-style: solid;
|
||||
border-width: 2px;
|
||||
box-shadow: 0 0 0 3px rgba(199, 101, 61, 0.22);
|
||||
}
|
||||
.game-lineage-breadcrumb {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 0.4rem;
|
||||
left: 0.5rem;
|
||||
right: 0.5rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
background: var(--platform-surface, #fff);
|
||||
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.12);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.game-lineage-breadcrumb__item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
color: var(--platform-text-soft);
|
||||
}
|
||||
.game-lineage-breadcrumb__crumb,
|
||||
.game-lineage-breadcrumb__clear {
|
||||
border: none;
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--platform-text-base);
|
||||
font: inherit;
|
||||
font-size: 0.72rem;
|
||||
cursor: pointer;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.game-lineage-breadcrumb__crumb:hover,
|
||||
.game-lineage-breadcrumb__clear:hover {
|
||||
color: var(--platform-text-strong);
|
||||
background: rgba(199, 101, 61, 0.12);
|
||||
}
|
||||
.game-lineage-node__pin {
|
||||
border: 1px solid var(--platform-surface-border);
|
||||
border-radius: 999px;
|
||||
padding: 0.14rem 0.6rem;
|
||||
background: transparent;
|
||||
color: var(--platform-text-base);
|
||||
font: inherit;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.game-lineage-node__pin[aria-pressed='true'] {
|
||||
border-color: var(--platform-accent-strong);
|
||||
color: var(--platform-accent-strong);
|
||||
}
|
||||
.game-lineage-node--stub {
|
||||
border-style: dashed;
|
||||
cursor: default;
|
||||
|
||||
@@ -173,7 +173,7 @@ describe('buildLineageTreeLayout', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('连线端点落在父底边中点与子顶边中点,路径是贝塞尔', () => {
|
||||
it('连线端点落在父底边中点与子顶边中点,路径是正交 elbow', () => {
|
||||
const layout = buildLineageTreeLayout([
|
||||
node('root', 0, null),
|
||||
node('child', 1, 'root'),
|
||||
@@ -186,9 +186,23 @@ describe('buildLineageTreeLayout', () => {
|
||||
y: root.y + root.height,
|
||||
});
|
||||
expect(edge.to).toEqual({ x: child.x + child.width / 2, y: child.y });
|
||||
// 单链:父与子同一条竖线 → 纯竖直段。
|
||||
expect(edge.path.startsWith('M ')).toBe(true);
|
||||
expect(edge.path).toContain(' C ');
|
||||
expect(edge.path).toContain(' V ');
|
||||
expect(edge.path).not.toContain(' C ');
|
||||
expect(edge.id).toBe('root->child');
|
||||
|
||||
// 分叉:父与子不同列 → 竖直段 + 水平段 + 圆角(elbow),而不是一条弧。
|
||||
const branched = buildLineageTreeLayout([
|
||||
node('root', 0, null),
|
||||
node('a', 1, 'root'),
|
||||
node('b', 1, 'root'),
|
||||
]);
|
||||
const elbow = branched.edges.find((item) => item.childGameId === 'a')!;
|
||||
expect(elbow.path).toContain(' V ');
|
||||
expect(elbow.path).toContain(' H ');
|
||||
expect(elbow.path).toContain(' Q ');
|
||||
expect(elbow.path).not.toContain(' C ');
|
||||
});
|
||||
|
||||
it('确定性:同一输入两次调用结果逐字段相同', () => {
|
||||
|
||||
@@ -15,8 +15,9 @@ import type { GameDistributionLineageNode } from '../../services/gameDistributio
|
||||
* 子节点槽位的**中点**。因为 DFS 让每棵子树的槽位区间连续且互不相交,同层节点的中心
|
||||
* 间距必然 ≥ 1 个槽位;再用「槽位间距 = 节点宽 + 列间距」把槽位映射成像素,
|
||||
* 于是**同层任意两节点至少隔着 `LINEAGE_COLUMN_GAP`,结构上不可能重叠**。
|
||||
* 4. **连线**:父节点底边中点 → 子节点顶边中点,三次贝塞尔(`path` 字符串里同时给出
|
||||
* 端点坐标,测试可以直接断言端点而不是解析路径)。
|
||||
* 4. **连线**:正交路由(elbow)——父节点底边中点竖直下行、在两代之间水平走线、再从子节点
|
||||
* 顶边中点竖直进入,转角倒小圆角;`path` 字符串里同时给出端点坐标,测试可以直接断言端点
|
||||
* 与「竖直段/水平段存在」而不是解析路径。
|
||||
*/
|
||||
|
||||
export const LINEAGE_NODE_WIDTH = 208;
|
||||
@@ -239,17 +240,31 @@ export function buildLineageTreeLayout(
|
||||
y: roundTo(node.y),
|
||||
};
|
||||
const delta = to.y - from.y;
|
||||
// 正交路由(elbow):父底 → 竖直下行到本层与下一层之间的中线 → 水平走线 → 竖直进入子顶;
|
||||
// 转角用二次贝塞尔倒一个小圆角。比三次贝塞尔更容易一眼看出「谁是谁的子节点」,
|
||||
// 竖直段共用中线时分叉看起来是一条主干 + 侧枝,而不是一串弧线。
|
||||
const cornerRadius = Math.min(
|
||||
12,
|
||||
Math.abs(to.x - from.x) / 2,
|
||||
Math.abs(delta) / 2,
|
||||
);
|
||||
const midY = roundTo(from.y + delta * 0.5);
|
||||
const path =
|
||||
from.x === to.x || cornerRadius <= 0
|
||||
? `M ${from.x},${from.y} V ${to.y}`
|
||||
: `M ${from.x},${from.y} ` +
|
||||
`V ${roundTo(midY - cornerRadius)} ` +
|
||||
`Q ${from.x},${midY} ${roundTo(from.x + Math.sign(to.x - from.x) * cornerRadius)},${midY} ` +
|
||||
`H ${roundTo(to.x - Math.sign(to.x - from.x) * cornerRadius)} ` +
|
||||
`Q ${to.x},${midY} ${to.x},${roundTo(midY + cornerRadius)} ` +
|
||||
`V ${to.y}`;
|
||||
edges.push({
|
||||
id: `${parentGameId}->${node.gameId}`,
|
||||
parentGameId,
|
||||
childGameId: node.gameId,
|
||||
from,
|
||||
to,
|
||||
path:
|
||||
`M ${from.x},${from.y} ` +
|
||||
`C ${from.x},${roundTo(from.y + delta * 0.5)} ` +
|
||||
`${to.x},${roundTo(to.y - delta * 0.5)} ` +
|
||||
`${to.x},${to.y}`,
|
||||
path,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,30 @@ describe('copyTextToClipboard', () => {
|
||||
expect(writeText).toHaveBeenCalledWith('PZ-00000002');
|
||||
});
|
||||
|
||||
test('Clipboard API 永不 settle 时超时降级,不把调用方挂死', async () => {
|
||||
vi.spyOn(hostBridge, 'writeHostClipboardText').mockResolvedValue(false);
|
||||
// 页面未聚焦 / 无系统剪贴板时 writeText 可能既不成功也不拒绝——这里固定复现这个 promise。
|
||||
const writeText = vi.fn(() => new Promise<void>(() => {}));
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
});
|
||||
const execCommand = vi.fn(() => false);
|
||||
Object.defineProperty(document, 'execCommand', {
|
||||
configurable: true,
|
||||
value: execCommand,
|
||||
});
|
||||
vi.useFakeTimers();
|
||||
|
||||
const pending = copyTextToClipboard(' PZ-00000003 ');
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
// 超时后走兼容路径,得到确定结果(这里是 false = 复制失败),而不是永远挂着。
|
||||
await expect(pending).resolves.toBe(false);
|
||||
expect(writeText).toHaveBeenCalledWith('PZ-00000003');
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('空文本不触发宿主或浏览器复制', async () => {
|
||||
const writeHostClipboardText = vi
|
||||
.spyOn(hostBridge, 'writeHostClipboardText')
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import { writeHostClipboardText } from './host-bridge/hostBridge';
|
||||
|
||||
/**
|
||||
* 标准 Clipboard API 的等待上限。
|
||||
*
|
||||
* 为什么需要它:`navigator.clipboard.writeText` 在**页面未聚焦 / 无系统剪贴板 /
|
||||
* 自动化上下文**里可能既不成功也不拒绝(promise 永不 settle)。调用方(复制按钮)是在
|
||||
* await 之后才切状态的,一旦这里挂住,用户点下去就是「按钮没反应、剪贴板也没变」——
|
||||
* 反馈窗口调多长都没用。超时后走下面的兼容路径(textarea + execCommand),
|
||||
* 那条路径是同步的,总能给出一个确定结果。
|
||||
*/
|
||||
const CLIPBOARD_API_TIMEOUT_MS = 800;
|
||||
|
||||
export async function copyTextToClipboard(value: string) {
|
||||
const text = value.trim();
|
||||
if (!text) {
|
||||
@@ -12,8 +23,16 @@ export async function copyTextToClipboard(value: string) {
|
||||
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
const written = await Promise.race([
|
||||
navigator.clipboard.writeText(text).then(() => true),
|
||||
new Promise<boolean>((resolve) => {
|
||||
setTimeout(() => resolve(false), CLIPBOARD_API_TIMEOUT_MS);
|
||||
}),
|
||||
]);
|
||||
if (written) {
|
||||
return true;
|
||||
}
|
||||
// 超时:不在这里卡住,继续走兼容路径(结果是同步可判定的)。
|
||||
} catch {
|
||||
// 部分内嵌浏览器会暴露 Clipboard API,但会因权限上下文拒绝写入,继续走兼容路径。
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user