From a5672aa69b5615a868a78b3f4a3b5ff1d3536bb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=A8=E8=8B=92?= <16112591+chen-ran@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:34:02 +0800 Subject: [PATCH 1/3] fix: make clipboard fallback dialog-safe --- package.json | 1 + src/lib/clipboard.test.ts | 190 ++++++++++++++++++++++++++++++++++++++ src/lib/clipboard.ts | 74 +++++++++------ 3 files changed, 238 insertions(+), 27 deletions(-) create mode 100644 src/lib/clipboard.test.ts diff --git a/package.json b/package.json index a307742..b20a6fa 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "prebuild": "tsx ./scripts/gen-entry.ts", "build": "run-p type-check \"build-only {@}\" --", "preview": "vite preview", + "test": "tsx --test src/lib/clipboard.test.ts", "build-only": "vite build", "type-check": "vue-tsc --build" }, diff --git a/src/lib/clipboard.test.ts b/src/lib/clipboard.test.ts new file mode 100644 index 0000000..6e39b45 --- /dev/null +++ b/src/lib/clipboard.test.ts @@ -0,0 +1,190 @@ +/// + +import assert from 'node:assert/strict' +import { afterEach, test } from 'node:test' +import { useClipboard } from './clipboard' + +const originalGlobals = new Map( + ['document', 'navigator', 'HTMLElement'].map((name) => [ + name, + Object.getOwnPropertyDescriptor(globalThis, name), + ]), +) + +afterEach(() => { + for (const [name, descriptor] of originalGlobals) { + if (descriptor) { + Object.defineProperty(globalThis, name, descriptor) + } + else { + Reflect.deleteProperty(globalThis, name) + } + } +}) + +class FakeHTMLElement { + readonly children: FakeHTMLElement[] = [] + parentElement: FakeHTMLElement | null = null + role: string | null = null + value = '' + readOnly = false + tabIndex = 0 + readonly style: Record = {} + selectCalls = 0 + + constructor(readonly documentRef: FakeDocument) {} + + appendChild(child: T): T { + child.parentElement = this + this.children.push(child) + this.documentRef.appendTargets.push(this) + return child + } + + closest(): T | null { + if (this.role === 'dialog' || this.role === 'alertdialog') { + return this as unknown as T + } + return this.parentElement?.closest() ?? null + } + + focus() { + this.documentRef.activeElement = this + } + + select() { + this.selectCalls += 1 + } + + remove() { + if (!this.parentElement) return + const index = this.parentElement.children.indexOf(this) + if (index >= 0) this.parentElement.children.splice(index, 1) + this.parentElement = null + } +} + +class FakeDocument { + readonly body = new FakeHTMLElement(this) + readonly appendTargets: FakeHTMLElement[] = [] + activeElement: FakeHTMLElement | null = this.body + execCommandCalls = 0 + execCommandResult = true + execCommandError: Error | null = null + lastExecCommandActiveElement: FakeHTMLElement | null = null + + createElement() { + return new FakeHTMLElement(this) + } + + execCommand(command: string) { + assert.equal(command, 'copy') + this.execCommandCalls += 1 + this.lastExecCommandActiveElement = this.activeElement + if (this.execCommandError) throw this.execCommandError + return this.execCommandResult + } +} + +function installBrowserGlobals( + documentRef: FakeDocument, + navigatorRef: { clipboard?: { writeText: (text: string) => Promise } } = {}, +) { + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: documentRef, + }) + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: navigatorRef, + }) + Object.defineProperty(globalThis, 'HTMLElement', { + configurable: true, + value: FakeHTMLElement, + }) +} + +test('uses the Clipboard API when it succeeds', async () => { + const documentRef = new FakeDocument() + const writes: string[] = [] + installBrowserGlobals(documentRef, { + clipboard: { + async writeText(text) { + writes.push(text) + }, + }, + }) + + const { copyText } = useClipboard() + + assert.equal(await copyText('hello'), true) + assert.deepEqual(writes, ['hello']) + assert.equal(documentRef.execCommandCalls, 0) +}) + +test('falls back inside the active dialog and restores focus', async () => { + const documentRef = new FakeDocument() + const dialog = new FakeHTMLElement(documentRef) + dialog.role = 'dialog' + const button = new FakeHTMLElement(documentRef) + dialog.appendChild(button) + button.focus() + documentRef.appendTargets.length = 0 + installBrowserGlobals(documentRef) + + const { copyText } = useClipboard() + + assert.equal(await copyText('hello'), true) + assert.equal(documentRef.appendTargets[documentRef.appendTargets.length - 1], dialog) + assert.equal(documentRef.lastExecCommandActiveElement?.value, 'hello') + assert.equal(documentRef.lastExecCommandActiveElement?.selectCalls, 1) + assert.deepEqual(dialog.children, [button]) + assert.equal(documentRef.activeElement, button) +}) + +test('falls back when the Clipboard API rejects the write', async () => { + const documentRef = new FakeDocument() + installBrowserGlobals(documentRef, { + clipboard: { + async writeText() { + throw new Error('denied') + }, + }, + }) + + const { copyText } = useClipboard() + + assert.equal(await copyText('hello'), true) + assert.equal(documentRef.execCommandCalls, 1) +}) + +test('uses the document body when no dialog is active', async () => { + const documentRef = new FakeDocument() + const button = new FakeHTMLElement(documentRef) + documentRef.body.appendChild(button) + button.focus() + documentRef.appendTargets.length = 0 + installBrowserGlobals(documentRef) + + const { copyText } = useClipboard() + + assert.equal(await copyText('hello'), true) + assert.equal(documentRef.appendTargets[documentRef.appendTargets.length - 1], documentRef.body) + assert.deepEqual(documentRef.body.children, [button]) + assert.equal(documentRef.activeElement, button) +}) + +test('cleans up and reports failure when execCommand throws', async () => { + const documentRef = new FakeDocument() + const button = new FakeHTMLElement(documentRef) + documentRef.body.appendChild(button) + button.focus() + documentRef.execCommandError = new Error('copy failed') + installBrowserGlobals(documentRef) + + const { copyText } = useClipboard() + + assert.equal(await copyText('hello'), false) + assert.deepEqual(documentRef.body.children, [button]) + assert.equal(documentRef.activeElement, button) +}) diff --git a/src/lib/clipboard.ts b/src/lib/clipboard.ts index 91a4340..ea848b6 100644 --- a/src/lib/clipboard.ts +++ b/src/lib/clipboard.ts @@ -1,41 +1,61 @@ -// useClipboard — navigator.clipboard.writeText with an execCommand fallback. -// Lifted from the host (apps/web/composables/useClipboard.ts, now a re-export -// shim). SSR/touch-safe: reports unsupported instead of throwing; copyText -// resolves false on any failure so callers can fall back to manual selection. +const focusTrapContainerSelector = '[role="dialog"], [role="alertdialog"]' + +function resolveFallbackContainer(documentRef: Document): HTMLElement { + const activeElement = documentRef.activeElement + if (activeElement instanceof HTMLElement) { + return activeElement.closest(focusTrapContainerSelector) ?? documentRef.body + } + return documentRef.body +} + +function copyWithExecCommand(documentRef: Document, text: string): boolean { + if (typeof documentRef.execCommand !== 'function') return false + + const previousFocus = documentRef.activeElement instanceof HTMLElement + ? documentRef.activeElement + : null + const textArea = documentRef.createElement('textarea') + textArea.value = text + textArea.readOnly = true + textArea.tabIndex = -1 + textArea.style.position = 'fixed' + textArea.style.left = '-9999px' + textArea.style.top = '0' + resolveFallbackContainer(documentRef).appendChild(textArea) + + try { + textArea.focus() + textArea.select() + return documentRef.execCommand('copy') + } catch { + return false + } finally { + textArea.remove() + previousFocus?.focus() + } +} + +// SSR/touch-safe clipboard access. The legacy fallback stays inside an active +// dialog so focus traps cannot steal its temporary textarea selection. export function useClipboard() { const hasNavigatorClipboard = typeof navigator !== 'undefined' && !!navigator.clipboard?.writeText const hasExecCommandFallback = typeof document !== 'undefined' && typeof document.execCommand === 'function' const isSupported = hasNavigatorClipboard || hasExecCommandFallback async function copyText(text: string): Promise { - if (!isSupported) { - return false - } - - try { - if (hasNavigatorClipboard) { + if (hasNavigatorClipboard && typeof navigator !== 'undefined') { + try { await navigator.clipboard.writeText(text) return true } - - if (!hasExecCommandFallback || typeof document === 'undefined') { - return false + catch { + // Permission behavior varies by browser. Try the legacy fallback as a + // second chance before reporting failure. } - - const textArea = document.createElement('textarea') - textArea.value = text - textArea.style.position = 'fixed' - textArea.style.left = '-9999px' - textArea.style.top = '0' - document.body.appendChild(textArea) - textArea.focus() - textArea.select() - const success = document.execCommand('copy') - document.body.removeChild(textArea) - return success - } catch { - return false } + + if (!hasExecCommandFallback || typeof document === 'undefined') return false + return copyWithExecCommand(document, text) } return { From 7795c118495e5ae26c58617b07b4279784f8f66d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=A8=E8=8B=92?= <16112591+chen-ran@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:38:42 +0800 Subject: [PATCH 2/3] test: streamline clipboard regression coverage --- src/lib/clipboard.test.ts | 167 +++++++++----------------------------- src/lib/clipboard.ts | 5 +- 2 files changed, 40 insertions(+), 132 deletions(-) diff --git a/src/lib/clipboard.test.ts b/src/lib/clipboard.test.ts index 6e39b45..370c403 100644 --- a/src/lib/clipboard.test.ts +++ b/src/lib/clipboard.test.ts @@ -4,7 +4,7 @@ import assert from 'node:assert/strict' import { afterEach, test } from 'node:test' import { useClipboard } from './clipboard' -const originalGlobals = new Map( +const savedGlobals = new Map( ['document', 'navigator', 'HTMLElement'].map((name) => [ name, Object.getOwnPropertyDescriptor(globalThis, name), @@ -12,40 +12,31 @@ const originalGlobals = new Map( ) afterEach(() => { - for (const [name, descriptor] of originalGlobals) { - if (descriptor) { - Object.defineProperty(globalThis, name, descriptor) - } - else { - Reflect.deleteProperty(globalThis, name) - } + for (const [name, descriptor] of savedGlobals) { + if (descriptor) Object.defineProperty(globalThis, name, descriptor) + else Reflect.deleteProperty(globalThis, name) } }) class FakeHTMLElement { - readonly children: FakeHTMLElement[] = [] - parentElement: FakeHTMLElement | null = null - role: string | null = null + closestResult: FakeHTMLElement | null = null + parent: FakeHTMLElement | null = null + appended: FakeHTMLElement | null = null value = '' - readOnly = false - tabIndex = 0 - readonly style: Record = {} selectCalls = 0 + readonly style: Record = {} - constructor(readonly documentRef: FakeDocument) {} + constructor(private readonly documentRef: FakeDocument) {} appendChild(child: T): T { - child.parentElement = this - this.children.push(child) - this.documentRef.appendTargets.push(this) + this.documentRef.appendTarget = this + this.appended = child + child.parent = this return child } closest(): T | null { - if (this.role === 'dialog' || this.role === 'alertdialog') { - return this as unknown as T - } - return this.parentElement?.closest() ?? null + return this.closestResult as T | null } focus() { @@ -57,134 +48,50 @@ class FakeHTMLElement { } remove() { - if (!this.parentElement) return - const index = this.parentElement.children.indexOf(this) - if (index >= 0) this.parentElement.children.splice(index, 1) - this.parentElement = null + if (this.parent?.appended === this) this.parent.appended = null + this.parent = null } } class FakeDocument { readonly body = new FakeHTMLElement(this) - readonly appendTargets: FakeHTMLElement[] = [] - activeElement: FakeHTMLElement | null = this.body + activeElement: FakeHTMLElement = this.body + appendTarget: FakeHTMLElement | null = null + createdElement: FakeHTMLElement | null = null execCommandCalls = 0 - execCommandResult = true - execCommandError: Error | null = null - lastExecCommandActiveElement: FakeHTMLElement | null = null createElement() { - return new FakeHTMLElement(this) + this.createdElement = new FakeHTMLElement(this) + return this.createdElement } execCommand(command: string) { assert.equal(command, 'copy') this.execCommandCalls += 1 - this.lastExecCommandActiveElement = this.activeElement - if (this.execCommandError) throw this.execCommandError - return this.execCommandResult + return true } } -function installBrowserGlobals( - documentRef: FakeDocument, - navigatorRef: { clipboard?: { writeText: (text: string) => Promise } } = {}, -) { - Object.defineProperty(globalThis, 'document', { - configurable: true, - value: documentRef, - }) - Object.defineProperty(globalThis, 'navigator', { - configurable: true, - value: navigatorRef, - }) - Object.defineProperty(globalThis, 'HTMLElement', { - configurable: true, - value: FakeHTMLElement, - }) -} - -test('uses the Clipboard API when it succeeds', async () => { - const documentRef = new FakeDocument() - const writes: string[] = [] - installBrowserGlobals(documentRef, { - clipboard: { - async writeText(text) { - writes.push(text) - }, - }, - }) - - const { copyText } = useClipboard() - - assert.equal(await copyText('hello'), true) - assert.deepEqual(writes, ['hello']) - assert.equal(documentRef.execCommandCalls, 0) -}) - -test('falls back inside the active dialog and restores focus', async () => { +test('keeps the HTTP fallback inside the active dialog', async () => { const documentRef = new FakeDocument() const dialog = new FakeHTMLElement(documentRef) - dialog.role = 'dialog' const button = new FakeHTMLElement(documentRef) - dialog.appendChild(button) - button.focus() - documentRef.appendTargets.length = 0 - installBrowserGlobals(documentRef) - - const { copyText } = useClipboard() - - assert.equal(await copyText('hello'), true) - assert.equal(documentRef.appendTargets[documentRef.appendTargets.length - 1], dialog) - assert.equal(documentRef.lastExecCommandActiveElement?.value, 'hello') - assert.equal(documentRef.lastExecCommandActiveElement?.selectCalls, 1) - assert.deepEqual(dialog.children, [button]) - assert.equal(documentRef.activeElement, button) -}) - -test('falls back when the Clipboard API rejects the write', async () => { - const documentRef = new FakeDocument() - installBrowserGlobals(documentRef, { - clipboard: { - async writeText() { - throw new Error('denied') - }, - }, - }) - - const { copyText } = useClipboard() + button.closestResult = dialog + documentRef.activeElement = button + + for (const [name, value] of [ + ['document', documentRef], + ['navigator', {}], + ['HTMLElement', FakeHTMLElement], + ] as const) { + Object.defineProperty(globalThis, name, { configurable: true, value }) + } - assert.equal(await copyText('hello'), true) + assert.equal(await useClipboard().copyText('hello'), true) assert.equal(documentRef.execCommandCalls, 1) -}) - -test('uses the document body when no dialog is active', async () => { - const documentRef = new FakeDocument() - const button = new FakeHTMLElement(documentRef) - documentRef.body.appendChild(button) - button.focus() - documentRef.appendTargets.length = 0 - installBrowserGlobals(documentRef) - - const { copyText } = useClipboard() - - assert.equal(await copyText('hello'), true) - assert.equal(documentRef.appendTargets[documentRef.appendTargets.length - 1], documentRef.body) - assert.deepEqual(documentRef.body.children, [button]) - assert.equal(documentRef.activeElement, button) -}) - -test('cleans up and reports failure when execCommand throws', async () => { - const documentRef = new FakeDocument() - const button = new FakeHTMLElement(documentRef) - documentRef.body.appendChild(button) - button.focus() - documentRef.execCommandError = new Error('copy failed') - installBrowserGlobals(documentRef) - - const { copyText } = useClipboard() - - assert.equal(await copyText('hello'), false) - assert.deepEqual(documentRef.body.children, [button]) + assert.equal(documentRef.appendTarget, dialog) + assert.equal(documentRef.createdElement?.value, 'hello') + assert.equal(documentRef.createdElement?.selectCalls, 1) + assert.equal(dialog.appended, null) assert.equal(documentRef.activeElement, button) }) diff --git a/src/lib/clipboard.ts b/src/lib/clipboard.ts index ea848b6..0006cc0 100644 --- a/src/lib/clipboard.ts +++ b/src/lib/clipboard.ts @@ -43,14 +43,15 @@ export function useClipboard() { const isSupported = hasNavigatorClipboard || hasExecCommandFallback async function copyText(text: string): Promise { + if (!isSupported) return false + if (hasNavigatorClipboard && typeof navigator !== 'undefined') { try { await navigator.clipboard.writeText(text) return true } catch { - // Permission behavior varies by browser. Try the legacy fallback as a - // second chance before reporting failure. + return false } } From 7816d6ed6fb008ac91e231e0357abdf7322b0331 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=A8=E8=8B=92?= <16112591+chen-ran@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:40:39 +0800 Subject: [PATCH 3/3] test: remove clipboard test harness --- package.json | 1 - src/lib/clipboard.test.ts | 97 --------------------------------------- 2 files changed, 98 deletions(-) delete mode 100644 src/lib/clipboard.test.ts diff --git a/package.json b/package.json index b20a6fa..a307742 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,6 @@ "prebuild": "tsx ./scripts/gen-entry.ts", "build": "run-p type-check \"build-only {@}\" --", "preview": "vite preview", - "test": "tsx --test src/lib/clipboard.test.ts", "build-only": "vite build", "type-check": "vue-tsc --build" }, diff --git a/src/lib/clipboard.test.ts b/src/lib/clipboard.test.ts deleted file mode 100644 index 370c403..0000000 --- a/src/lib/clipboard.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -/// - -import assert from 'node:assert/strict' -import { afterEach, test } from 'node:test' -import { useClipboard } from './clipboard' - -const savedGlobals = new Map( - ['document', 'navigator', 'HTMLElement'].map((name) => [ - name, - Object.getOwnPropertyDescriptor(globalThis, name), - ]), -) - -afterEach(() => { - for (const [name, descriptor] of savedGlobals) { - if (descriptor) Object.defineProperty(globalThis, name, descriptor) - else Reflect.deleteProperty(globalThis, name) - } -}) - -class FakeHTMLElement { - closestResult: FakeHTMLElement | null = null - parent: FakeHTMLElement | null = null - appended: FakeHTMLElement | null = null - value = '' - selectCalls = 0 - readonly style: Record = {} - - constructor(private readonly documentRef: FakeDocument) {} - - appendChild(child: T): T { - this.documentRef.appendTarget = this - this.appended = child - child.parent = this - return child - } - - closest(): T | null { - return this.closestResult as T | null - } - - focus() { - this.documentRef.activeElement = this - } - - select() { - this.selectCalls += 1 - } - - remove() { - if (this.parent?.appended === this) this.parent.appended = null - this.parent = null - } -} - -class FakeDocument { - readonly body = new FakeHTMLElement(this) - activeElement: FakeHTMLElement = this.body - appendTarget: FakeHTMLElement | null = null - createdElement: FakeHTMLElement | null = null - execCommandCalls = 0 - - createElement() { - this.createdElement = new FakeHTMLElement(this) - return this.createdElement - } - - execCommand(command: string) { - assert.equal(command, 'copy') - this.execCommandCalls += 1 - return true - } -} - -test('keeps the HTTP fallback inside the active dialog', async () => { - const documentRef = new FakeDocument() - const dialog = new FakeHTMLElement(documentRef) - const button = new FakeHTMLElement(documentRef) - button.closestResult = dialog - documentRef.activeElement = button - - for (const [name, value] of [ - ['document', documentRef], - ['navigator', {}], - ['HTMLElement', FakeHTMLElement], - ] as const) { - Object.defineProperty(globalThis, name, { configurable: true, value }) - } - - assert.equal(await useClipboard().copyText('hello'), true) - assert.equal(documentRef.execCommandCalls, 1) - assert.equal(documentRef.appendTarget, dialog) - assert.equal(documentRef.createdElement?.value, 'hello') - assert.equal(documentRef.createdElement?.selectCalls, 1) - assert.equal(dialog.appended, null) - assert.equal(documentRef.activeElement, button) -})