族谱树收尾:剪贴板永不 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:
2026-10-06 15:11:07 +08:00
parent 87af3bdaf0
commit e2056db146
7 changed files with 371 additions and 41 deletions
@@ -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,
});
}
+24
View File
@@ -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')
+21 -2
View File
@@ -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,但会因权限上下文拒绝写入,继续走兼容路径。
}