diff --git a/web/src/app/use-clipboard-copy.ts b/web/src/app/use-clipboard-copy.ts new file mode 100644 index 00000000..53cf7c39 --- /dev/null +++ b/web/src/app/use-clipboard-copy.ts @@ -0,0 +1,57 @@ +import { onScopeDispose, ref, watch } from 'vue' +import { useRoute } from 'vue-router' + +import { copyText } from '@/lib/clipboard' + +import { registerEphemeralStateCleaner } from './ephemeral-state' + +export type ClipboardCopyResult = 'success' | 'fallback' | 'cancelled' + +export function useClipboardCopy() { + const fallbackText = ref() + const pending = ref(false) + const route = useRoute() + let sequence = 0 + let disposed = false + + function reset(): void { + sequence++ + fallbackText.value = undefined + pending.value = false + } + + async function copy( + source: string | (() => string | Promise), + ): Promise { + if (disposed) return 'cancelled' + reset() + const operation = sequence + const isCurrent = () => !disposed && sequence === operation + pending.value = true + try { + // 已有文本直接复制,避免在用户点击与兼容复制之间额外等待。 + const value = typeof source === 'function' ? await source() : source + if (!isCurrent()) return 'cancelled' + const copied = await copyText(value, undefined, isCurrent) + if (!isCurrent()) return 'cancelled' + if (copied) return 'success' + fallbackText.value = value + return 'fallback' + } catch (error) { + if (!isCurrent()) return 'cancelled' + throw error + } finally { + if (isCurrent()) pending.value = false + } + } + + watch(() => route.fullPath, reset, { flush: 'sync' }) + const unregister = registerEphemeralStateCleaner(reset) + onScopeDispose(() => { + disposed = true + reset() + unregister() + }) + + return { copy, fallbackText, pending, reset } +} diff --git a/web/src/components/ui/CopyButton.vue b/web/src/components/ui/CopyButton.vue index bcf315cb..aad48459 100644 --- a/web/src/components/ui/CopyButton.vue +++ b/web/src/components/ui/CopyButton.vue @@ -1,9 +1,9 @@ diff --git a/web/src/components/ui/CopyChip.vue b/web/src/components/ui/CopyChip.vue index 34065ed5..2a59b076 100644 --- a/web/src/components/ui/CopyChip.vue +++ b/web/src/components/ui/CopyChip.vue @@ -7,10 +7,10 @@ import { TooltipRoot, TooltipTrigger, } from 'reka-ui' -import { onBeforeUnmount, ref } from 'vue' -import { useI18n } from 'vue-i18n' +import { onBeforeUnmount, ref, watch } from 'vue' -import { canWriteToClipboardNatively, copyText } from '@/lib/clipboard' +import { useClipboardCopy } from '@/app/use-clipboard-copy' +import CopyFallbackDialog from './CopyFallbackDialog.vue' import OverflowTooltip from './OverflowTooltip.vue' const props = withDefaults( @@ -29,9 +29,9 @@ const props = withDefaults( { resolveValue: undefined, layout: 'leading' }, ) -type CopyState = 'idle' | 'success' | 'unsupported' | 'failure' +type CopyState = 'idle' | 'success' | 'failure' -const { t } = useI18n() +const { copy, fallbackText, pending, reset } = useClipboardCopy() const state = ref('idle') let resetTimer: ReturnType | undefined @@ -44,21 +44,27 @@ function scheduleReset(): void { } async function copyValue(): Promise { - if (props.resolveValue && !canWriteToClipboardNatively()) { - state.value = 'unsupported' - scheduleReset() - return - } - + if (pending.value) return + state.value = 'idle' try { - const value = props.resolveValue ? await props.resolveValue() : props.value - state.value = (await copyText(value)) ? 'success' : 'failure' + const result = await copy(props.resolveValue ?? props.value) + if (result === 'cancelled') return + state.value = result === 'success' ? 'success' : 'idle' } catch { state.value = 'failure' } scheduleReset() } +watch( + () => props.value, + () => { + reset() + state.value = 'idle' + }, + { flush: 'sync' }, +) + onBeforeUnmount(() => { if (resetTimer !== undefined) clearTimeout(resetTimer) }) @@ -79,6 +85,8 @@ onBeforeUnmount(() => { :data-state="state" type="button" :aria-label="label" + :aria-busy="pending" + :disabled="pending" @click="copyValue" >