mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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:
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user