族谱树收尾:剪贴板永不 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:
@@ -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