族谱树收尾:剪贴板永不 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
+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,但会因权限上下文拒绝写入,继续走兼容路径。
}