fix(client): fix copy to clipboard fallback mechanism

Update copyToClipboard to reliably handle the legacy execCommand fallback across browsers. The utility now selects text before copying, returns a success boolean, and ensures proper element cleanup. Added comprehensive tests for clipboard.ts and updated MagicLinksSettings tests.

Closes #204
This commit is contained in:
neon
2026-06-22 23:55:42 +00:00
parent 488ef04b17
commit 193079621f
15 changed files with 382 additions and 54 deletions
+20
View File
@@ -33,6 +33,26 @@ export default defineConfigWithVueTs(
},
},
{
files: ['src/**/*.{vue,ts,mts,tsx}'],
ignores: ['src/lib/clipboard.ts'],
rules: {
'no-restricted-syntax': [
'error',
{
selector:
"MemberExpression[property.name='clipboard'][object.name='navigator'], MemberExpression[property.value='clipboard'][object.name='navigator'], MemberExpression[property.name='clipboard'][object.property.name='navigator'], MemberExpression[property.value='clipboard'][object.property.name='navigator']",
message: "Use copyToClipboard from '@/lib/clipboard' so copy actions work on HTTP/self-hosted origins.",
},
{
selector:
"CallExpression[callee.property.name='execCommand'][arguments.0.value='copy'], CallExpression[callee.property.value='execCommand'][arguments.0.value='copy']",
message: "Use copyToClipboard from '@/lib/clipboard' instead of adding another copy fallback.",
},
],
},
},
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
skipFormatting,
+30 -9
View File
@@ -1,19 +1,40 @@
<script setup lang="ts">
import { ref } from 'vue'
import { onUnmounted, ref } from 'vue'
import { X } from '@lucide/vue'
import { copyToClipboard } from '@/lib/clipboard'
defineProps<{ resetUrl: string }>()
const emit = defineEmits<{ close: [] }>()
const copied = ref(false)
const copyFailed = ref(false)
let copyFeedbackTimer: ReturnType<typeof setTimeout> | null = null
function copy(url: string) {
navigator.clipboard.writeText(url).then(() => {
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
})
function resetCopyFeedbackAfterDelay() {
if (copyFeedbackTimer) clearTimeout(copyFeedbackTimer)
copyFeedbackTimer = setTimeout(() => {
copied.value = false
copyFailed.value = false
copyFeedbackTimer = null
}, 2000)
}
onUnmounted(() => {
if (copyFeedbackTimer) clearTimeout(copyFeedbackTimer)
})
async function copy(url: string) {
const didCopy = await copyToClipboard(url)
if (!didCopy) {
copied.value = false
copyFailed.value = true
resetCopyFeedbackAfterDelay()
return
}
copyFailed.value = false
copied.value = true
resetCopyFeedbackAfterDelay()
}
</script>
@@ -39,7 +60,7 @@ function copy(url: string) {
@click="copy(resetUrl)"
class="rounded-md bg-primary px-3 py-2 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors whitespace-nowrap"
>
{{ copied ? 'Copied!' : 'Copy' }}
{{ copied ? 'Copied!' : copyFailed ? 'Copy failed' : 'Copy' }}
</button>
</div>
@@ -0,0 +1,58 @@
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import ResetLinkModal from '../ResetLinkModal.vue'
const copyToClipboardMock = vi.hoisted(() => vi.fn<(text: string) => Promise<boolean>>())
vi.mock('@/lib/clipboard', () => ({
copyToClipboard: copyToClipboardMock,
}))
describe('ResetLinkModal', () => {
beforeEach(() => {
vi.useFakeTimers()
copyToClipboardMock.mockResolvedValue(true)
})
afterEach(() => {
vi.runOnlyPendingTimers()
vi.useRealTimers()
vi.clearAllMocks()
})
it('shows copied feedback after a successful copy', async () => {
const wrapper = mount(ResetLinkModal, {
props: { resetUrl: 'http://bookorbit.test/reset' },
})
await wrapper
.findAll('button')
.find((button) => button.text() === 'Copy')
?.trigger('click')
expect(copyToClipboardMock).toHaveBeenCalledWith('http://bookorbit.test/reset')
expect(wrapper.text()).toContain('Copied!')
await vi.advanceTimersByTimeAsync(2000)
expect(wrapper.text()).toContain('Copy')
expect(wrapper.text()).not.toContain('Copied!')
})
it('shows failure feedback when copy fails', async () => {
copyToClipboardMock.mockResolvedValue(false)
const wrapper = mount(ResetLinkModal, {
props: { resetUrl: 'http://bookorbit.test/reset' },
})
await wrapper
.findAll('button')
.find((button) => button.text() === 'Copy')
?.trigger('click')
expect(wrapper.text()).toContain('Copy failed')
await vi.advanceTimersByTimeAsync(2000)
expect(wrapper.text()).toContain('Copy')
expect(wrapper.text()).not.toContain('Copy failed')
})
})
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { ref } from 'vue'
import { onUnmounted, ref } from 'vue'
import { Copy, FileEdit, Palette, Trash2, X, Check } from '@lucide/vue'
import type { AnnotationItem } from '@bookorbit/types'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { copyToClipboard } from '@/lib/clipboard'
import HighlightNoteEditor from './HighlightNoteEditor.vue'
const props = defineProps<{
@@ -27,12 +28,21 @@ const editingNote = ref(false)
const showColorPicker = ref(false)
const confirmDelete = ref(false)
const copied = ref(false)
let copiedTimer: ReturnType<typeof setTimeout> | null = null
onUnmounted(() => {
if (copiedTimer) clearTimeout(copiedTimer)
})
async function handleCopy() {
const didCopy = await copyToClipboard(props.highlight.text)
if (!didCopy) return
function handleCopy() {
navigator.clipboard.writeText(props.highlight.text).catch(() => {})
copied.value = true
setTimeout(() => {
if (copiedTimer) clearTimeout(copiedTimer)
copiedTimer = setTimeout(() => {
copied.value = false
copiedTimer = null
}, 1500)
}
@@ -61,7 +61,9 @@ function applyHighlight(color: string, style: string) {
}
async function onCopy() {
await copyToClipboard(props.selectedText)
const didCopy = await copyToClipboard(props.selectedText)
if (!didCopy) return
copied.value = true
await new Promise((resolve) => setTimeout(resolve, 1500))
copied.value = false
@@ -2,6 +2,7 @@
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import TranslationPanel from './TranslationPanel.vue'
import { useTranslation } from '../composables/useTranslation'
import { copyToClipboard } from '@/lib/clipboard'
const props = defineProps<{
text: string
@@ -54,7 +55,7 @@ function handleChangeLanguage(code: string) {
function handleCopy() {
if (result.value) {
navigator.clipboard.writeText(result.value.translatedText).catch(() => {})
void copyToClipboard(result.value.translatedText)
}
}
@@ -3,6 +3,7 @@ import { onMounted } from 'vue'
import { Sheet, SheetContent } from '@/components/ui/sheet'
import TranslationPanel from './TranslationPanel.vue'
import { useTranslation } from '../composables/useTranslation'
import { copyToClipboard } from '@/lib/clipboard'
const props = defineProps<{
text: string
@@ -26,7 +27,7 @@ function handleChangeLanguage(code: string) {
function handleCopy() {
if (result.value) {
navigator.clipboard.writeText(result.value.translatedText).catch(() => {})
void copyToClipboard(result.value.translatedText)
}
}
@@ -15,6 +15,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { Badge } from '@/components/ui/badge'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import SettingsPageHeader from './SettingsPageHeader.vue'
import { copyToClipboard } from '@/lib/clipboard'
const props = withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
@@ -167,18 +168,31 @@ function getLibraryIconComponent(name: string | null | undefined) {
}
async function copyToken(token: string) {
await navigator.clipboard.writeText(`{${token}}`)
toast.success(`{${token}} copied to clipboard`)
const value = `{${token}}`
const copied = await copyToClipboard(value)
if (copied) {
toast.success(`${value} copied to clipboard`)
} else {
toast.error('Failed to copy token')
}
}
async function copyPattern(pattern: string) {
await navigator.clipboard.writeText(pattern)
toast.success('Pattern copied to clipboard')
const copied = await copyToClipboard(pattern)
if (copied) {
toast.success('Pattern copied to clipboard')
} else {
toast.error('Failed to copy pattern')
}
}
async function copyText(text: string, label: string) {
await navigator.clipboard.writeText(text)
toast.success(`${label} copied to clipboard`)
const copied = await copyToClipboard(text)
if (copied) {
toast.success(`${label} copied to clipboard`)
} else {
toast.error(`Failed to copy ${label.toLowerCase()}`)
}
}
function syncPreviewGlobal(value: string) {
@@ -4,6 +4,7 @@ import { Plus, Trash2, Copy, Check, Pencil, X, Tablet } from '@lucide/vue'
import { toast } from 'vue-sonner'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import SettingsPageHeader from './SettingsPageHeader.vue'
import { copyToClipboard } from '@/lib/clipboard'
import { useKoboDevices } from '@/features/kobo/composables/useKoboDevices'
import { useKoboSettings } from '@/features/kobo/composables/useKoboSettings'
import type { KoboDevice } from '@bookorbit/types'
@@ -111,8 +112,12 @@ function dismissToken() {
async function copyToken() {
if (!newDeviceSyncUrl.value) return
await navigator.clipboard.writeText(newDeviceSyncUrl.value)
toast.success('Sync URL copied to clipboard')
const copied = await copyToClipboard(newDeviceSyncUrl.value)
if (copied) {
toast.success('Sync URL copied to clipboard')
} else {
toast.error('Failed to copy sync URL')
}
}
function startRename(device: KoboDevice) {
@@ -4,6 +4,7 @@ import { Copy, Trash2, BookOpen, Smartphone, Check, Eye, EyeOff, RefreshCw, Chev
import { toast } from 'vue-sonner'
import SettingsPageHeader from './SettingsPageHeader.vue'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import { copyToClipboard } from '@/lib/clipboard'
import { useKoreaderSync } from '@/features/koreader/composables/useKoreaderSync'
const props = withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
@@ -112,7 +113,12 @@ async function handleDelete() {
}
async function handleCopyUrl() {
await navigator.clipboard.writeText(syncUrl.value)
const copied = await copyToClipboard(syncUrl.value)
if (!copied) {
toast.error('Failed to copy sync URL')
return
}
urlCopied.value = true
toast.success('Sync URL copied to clipboard')
if (urlCopiedTimer) clearTimeout(urlCopiedTimer)
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { Plus, Trash2, Copy, Check, Link, Pause, Play } from '@lucide/vue'
import { api } from '@/lib/api'
import { copyToClipboard } from '@/lib/clipboard'
import { useMagicLinks } from '@/features/settings/composables/useMagicLinks'
import SettingsPageHeader from '@/features/settings/SettingsPageHeader.vue'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
@@ -30,6 +31,7 @@ const creating = ref(false)
const createError = ref<string | null>(null)
const copiedId = ref<number | null>(null)
let copiedTimer: ReturnType<typeof setTimeout> | null = null
const revokeConfirmId = ref<number | null>(null)
const revoking = ref(false)
@@ -52,6 +54,10 @@ onMounted(async () => {
await Promise.all([loadTokens(), loadSharedUsers()])
})
onUnmounted(() => {
if (copiedTimer) clearTimeout(copiedTimer)
})
function openCreateForm() {
createLabel.value = ''
createUserId.value = sharedUsers.value.length === 1 ? (sharedUsers.value[0]?.id ?? null) : null
@@ -83,15 +89,19 @@ function getMagicUrl(rawToken: string): string {
}
async function copyMagicUrl(tokenId: number, rawToken: string) {
try {
await navigator.clipboard.writeText(getMagicUrl(rawToken))
copiedId.value = tokenId
setTimeout(() => {
copiedId.value = null
}, 2000)
} catch {
// no-op
const copied = await copyToClipboard(getMagicUrl(rawToken))
if (!copied) {
error.value = 'Failed to copy magic link'
return
}
error.value = null
copiedId.value = tokenId
if (copiedTimer) clearTimeout(copiedTimer)
copiedTimer = setTimeout(() => {
copiedId.value = null
copiedTimer = null
}, 2000)
}
async function handleToggleActive(id: number, currentIsActive: boolean) {
+13 -4
View File
@@ -5,6 +5,7 @@ import { toast } from 'vue-sonner'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import SettingsPageHeader from './SettingsPageHeader.vue'
import { api } from '@/lib/api'
import { copyToClipboard } from '@/lib/clipboard'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import type { OpdsUser, OpdsSortOrder } from '@bookorbit/types'
import { useMediaQuery } from '@vueuse/core'
@@ -82,8 +83,12 @@ async function toggleOpds() {
}
async function copyUrl() {
await navigator.clipboard.writeText(opdsUrl.value)
toast.success('OPDS URL copied to clipboard')
const copied = await copyToClipboard(opdsUrl.value)
if (copied) {
toast.success('OPDS URL copied to clipboard')
} else {
toast.error('Failed to copy OPDS URL')
}
}
async function createUser() {
@@ -176,8 +181,12 @@ function userDetailsOpen(id: number) {
}
async function copyValue(value: string, label: string) {
await navigator.clipboard.writeText(value)
toast.success(`${label} copied`)
const copied = await copyToClipboard(value)
if (copied) {
toast.success(`${label} copied`)
} else {
toast.error(`Failed to copy ${label.toLowerCase()}`)
}
}
watch(
@@ -1,13 +1,14 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
const { apiMock, loadTokensMock, createTokenMock, revokeTokenMock, setActiveMock, tokens, loading, error } = vi.hoisted(() => {
const { apiMock, copyToClipboardMock, loadTokensMock, createTokenMock, revokeTokenMock, setActiveMock, tokens, loading, error } = vi.hoisted(() => {
function makeRef<T>(value: T) {
return { value, __v_isRef: true }
}
return {
apiMock: vi.fn<(input: string) => Promise<{ ok: boolean; json: () => Promise<unknown> }>>(),
copyToClipboardMock: vi.fn<(text: string) => Promise<boolean>>(),
loadTokensMock: vi.fn<() => Promise<void>>(),
createTokenMock: vi.fn<() => Promise<unknown>>(),
revokeTokenMock: vi.fn<() => Promise<void>>(),
@@ -22,6 +23,10 @@ vi.mock('@/lib/api', () => ({
api: apiMock,
}))
vi.mock('@/lib/clipboard', () => ({
copyToClipboard: copyToClipboardMock,
}))
vi.mock('@/features/settings/composables/useMagicLinks', () => ({
useMagicLinks: () => ({
tokens,
@@ -52,10 +57,21 @@ function mockSharedUsers(users: Array<{ id: number; username: string; name: stri
function mountComponent(props: { withHeader?: boolean; withEmbeddedCreateAction?: boolean } = {}) {
return mount(MagicLinksSettings, {
props,
global: {
stubs: {
Tooltip: { template: '<div><slot /></div>' },
TooltipTrigger: { template: '<div><slot /></div>' },
TooltipContent: { template: '<div><slot /></div>' },
},
},
})
}
describe('MagicLinksSettings', () => {
afterEach(() => {
vi.useRealTimers()
})
beforeEach(() => {
vi.clearAllMocks()
tokens.value = []
@@ -65,6 +81,7 @@ describe('MagicLinksSettings', () => {
createTokenMock.mockResolvedValue({ id: 1, token: 'raw-token', label: 'Demo', expiresAt: null })
revokeTokenMock.mockResolvedValue(undefined)
setActiveMock.mockResolvedValue(undefined)
copyToClipboardMock.mockResolvedValue(true)
mockSharedUsers([])
})
@@ -105,4 +122,49 @@ describe('MagicLinksSettings', () => {
expect(createButton).toBeUndefined()
})
it('keeps copied feedback for the latest copied magic link', async () => {
vi.useFakeTimers()
tokens.value = [
{
id: 1,
rawToken: 'first-token',
label: 'First',
username: 'first',
createdByUsername: 'admin',
expiresAt: null,
revokedAt: null,
isActive: true,
useCount: 0,
lastUsedAt: null,
},
{
id: 2,
rawToken: 'second-token',
label: 'Second',
username: 'second',
createdByUsername: 'admin',
expiresAt: null,
revokedAt: null,
isActive: true,
useCount: 0,
lastUsedAt: null,
},
]
const wrapper = mountComponent({ withHeader: false })
await flushPromises()
const actionButtons = wrapper.findAll('button').filter((button) => button.classes().includes('p-1.5'))
await actionButtons[0]?.trigger('click')
await vi.advanceTimersByTimeAsync(1000)
await actionButtons[3]?.trigger('click')
await vi.advanceTimersByTimeAsync(1500)
expect(copyToClipboardMock).toHaveBeenLastCalledWith(expect.stringContaining('second-token'))
expect(wrapper.text()).toContain('Copied!')
await vi.advanceTimersByTimeAsync(500)
expect(wrapper.text()).not.toContain('Copied!')
vi.useRealTimers()
})
})
+105
View File
@@ -0,0 +1,105 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { copyToClipboard } from '../clipboard'
const originalClipboardDescriptor = Object.getOwnPropertyDescriptor(globalThis.navigator, 'clipboard')
const originalExecCommandDescriptor = Object.getOwnPropertyDescriptor(document, 'execCommand')
function setClipboard(writeText: (value: string) => Promise<void>) {
Object.defineProperty(globalThis.navigator, 'clipboard', {
configurable: true,
value: { writeText },
})
}
function setExecCommand(execCommand: (command: string) => boolean) {
Object.defineProperty(document, 'execCommand', {
configurable: true,
writable: true,
value: vi.fn<(command: string) => boolean>(execCommand),
})
}
function restoreProperty(target: object, key: PropertyKey, descriptor: PropertyDescriptor | undefined) {
if (descriptor) {
Object.defineProperty(target, key, descriptor)
} else {
Reflect.deleteProperty(target, key)
}
}
describe('copyToClipboard', () => {
beforeEach(() => {
document.body.innerHTML = ''
restoreProperty(globalThis.navigator, 'clipboard', originalClipboardDescriptor)
setExecCommand(() => true)
})
afterEach(() => {
document.body.innerHTML = ''
restoreProperty(globalThis.navigator, 'clipboard', originalClipboardDescriptor)
restoreProperty(document, 'execCommand', originalExecCommandDescriptor)
vi.restoreAllMocks()
})
it('uses the Clipboard API when it is available', async () => {
const writeText = vi.fn<(value: string) => Promise<void>>().mockResolvedValue(undefined)
setClipboard(writeText)
await expect(copyToClipboard('OPDS URL')).resolves.toBe(true)
expect(writeText).toHaveBeenCalledWith('OPDS URL')
expect(document.execCommand).not.toHaveBeenCalled()
})
it('falls back to execCommand when the Clipboard API is unavailable', async () => {
restoreProperty(globalThis.navigator, 'clipboard', undefined)
const execCommand = vi.fn<(command: string) => boolean>((command: string) => {
expect(command).toBe('copy')
expect(document.querySelector('textarea')?.value).toBe('HTTP origin URL')
return true
})
setExecCommand(execCommand)
await expect(copyToClipboard('HTTP origin URL')).resolves.toBe(true)
expect(execCommand).toHaveBeenCalledWith('copy')
expect(document.querySelector('textarea')).toBeNull()
})
it('falls back to execCommand when the Clipboard API rejects', async () => {
const writeText = vi.fn<(value: string) => Promise<void>>().mockRejectedValue(new DOMException('Blocked', 'NotAllowedError'))
const execCommand = vi.fn<(command: string) => boolean>(() => true)
setClipboard(writeText)
setExecCommand(execCommand)
await expect(copyToClipboard('fallback text')).resolves.toBe(true)
expect(writeText).toHaveBeenCalledWith('fallback text')
expect(execCommand).toHaveBeenCalledWith('copy')
})
it('returns false when no copy strategy succeeds', async () => {
restoreProperty(globalThis.navigator, 'clipboard', undefined)
setExecCommand(() => false)
await expect(copyToClipboard('blocked text')).resolves.toBe(false)
})
it('returns false and cleans up when fallback setup fails', async () => {
restoreProperty(globalThis.navigator, 'clipboard', undefined)
const appendChildSpy = vi.spyOn(document.body, 'appendChild').mockImplementation(() => {
throw new Error('append failed')
})
await expect(copyToClipboard('blocked setup')).resolves.toBe(false)
expect(appendChildSpy).toHaveBeenCalled()
expect(document.querySelector('textarea')).toBeNull()
})
it('returns false for empty text', async () => {
await expect(copyToClipboard('')).resolves.toBe(false)
expect(document.execCommand).not.toHaveBeenCalled()
})
})
+18 -14
View File
@@ -1,28 +1,32 @@
export async function copyToClipboard(text: string): Promise<void> {
if (!text) return
export async function copyToClipboard(text: string): Promise<boolean> {
if (!text) return false
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
return
return true
} catch {
// Fall through to legacy API below.
}
}
if (typeof document === 'undefined') return
if (typeof document === 'undefined' || !document.body) return false
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', '')
textarea.style.position = 'fixed'
textarea.style.top = '-9999px'
textarea.style.left = '-9999px'
document.body.appendChild(textarea)
textarea.focus()
textarea.select()
try {
document.execCommand('copy')
textarea.value = text
textarea.setAttribute('readonly', '')
textarea.style.position = 'fixed'
textarea.style.top = '-9999px'
textarea.style.left = '-9999px'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.focus()
textarea.select()
textarea.setSelectionRange(0, text.length)
return document.execCommand('copy')
} catch {
return false
} finally {
document.body.removeChild(textarea)
textarea.remove()
}
}