import { shallowReadonly, shallowRef, toValue } from 'vue'; import type { MaybeRefOrGetter, Ref } from 'vue'; import { isFunction, noop } from '@robonen/stdlib'; import { defaultNavigator } from '@/types'; import type { ConfigurableNavigator } from '@/types'; import { useSupported } from '@/composables/utilities/useSupported'; import { useEventListener } from '@/composables/browser/useEventListener'; import { useTimeoutFn } from '@/composables/animation/useTimeoutFn'; /** * A value to copy: either concrete `ClipboardItems` or an (optionally async) * getter that resolves to them. */ export type ClipboardItemsValue = | ClipboardItems | (() => Promise | ClipboardItems | undefined); export interface UseClipboardItemsOptions extends ConfigurableNavigator { /** * Sync `content` with the system clipboard by listening to copy/cut events * * @default false */ read?: boolean; /** * Default source value to copy when `copy()` is called without an argument */ source?: Source; /** * Milliseconds the `copied` flag stays `true` after a copy * * @default 1500 */ copiedDuring?: number; /** * Called when a read/write rejects, instead of throwing * * @default noop */ onError?: (error: unknown) => void; } export interface UseClipboardItemsReturn { /** * Whether the async Clipboard API (with `ClipboardItem`) is available */ isSupported: Readonly>; /** * The current clipboard items (kept in sync when `read` is enabled) */ content: Readonly>; /** * `true` for `copiedDuring` ms after a successful copy */ copied: Readonly>; /** * `true` while an async `copy()` is in flight */ copyPending: Readonly>; /** * Copy clipboard items to the system clipboard */ copy: Optional extends true ? (content?: ClipboardItemsValue) => Promise : (content: ClipboardItemsValue) => Promise; /** * Manually read the system clipboard into `content` */ read: () => Promise; } /** * @name useClipboardItems * @category Browser * @description Reactive async Clipboard API with rich `ClipboardItem` support * (read/write images, HTML, and arbitrary MIME types — not just text). * SSR-safe; uses passive `copy`/`cut` listeners and guards stale async writes. * * @param {UseClipboardItemsOptions} [options={}] Options * @returns {UseClipboardItemsReturn} `isSupported`, `content`, `copied`, `copyPending`, `copy`, and `read` * * @example * const { content, copy, copied, isSupported } = useClipboardItems(); * copy([new ClipboardItem({ 'text/plain': new Blob(['hello'], { type: 'text/plain' }) })]); * * @example * // Copy a lazily/asynchronously resolved value, kept in sync with the system clipboard * const { content } = useClipboardItems({ read: true }); * copy(async () => buildClipboardItems()); * * @since 0.0.15 */ export function useClipboardItems(options?: UseClipboardItemsOptions): UseClipboardItemsReturn; export function useClipboardItems(options: UseClipboardItemsOptions>): UseClipboardItemsReturn; export function useClipboardItems( options: UseClipboardItemsOptions | undefined> = {}, ): UseClipboardItemsReturn { const { navigator = defaultNavigator, read = false, source, copiedDuring = 1500, onError = noop, } = options; const isSupported = useSupported(() => navigator && 'clipboard' in navigator); const content = shallowRef([]); const copied = shallowRef(false); const copyPending = shallowRef(false); // Guards against a slow async copy clobbering the result of a newer one let lastResolveId = 0; const timeout = useTimeoutFn(() => { copied.value = false; }, copiedDuring, { immediate: false }); async function updateContent(): Promise { if (!isSupported.value) return; try { content.value = await navigator!.clipboard.read(); } catch (error) { onError(error); } } if (isSupported.value && read) useEventListener(['copy', 'cut'], updateContent, { passive: true }); async function copy(value: ClipboardItemsValue | undefined = toValue(source)): Promise { if (!isSupported.value || value === null || value === undefined) return; copyPending.value = true; try { let resolved: ClipboardItems | undefined; if (isFunction(value)) { const currentId = ++lastResolveId; resolved = await value(); // Drop a stale async resolution superseded by a newer copy if (resolved === null || resolved === undefined || currentId !== lastResolveId) return; } else { resolved = value; } await navigator!.clipboard.write(resolved); content.value = resolved; copied.value = true; timeout.start(); } catch (error) { onError(error); } finally { copyPending.value = false; } } return { isSupported, content: shallowReadonly(content), copied: shallowReadonly(copied), copyPending: shallowReadonly(copyPending), copy, read: updateContent, }; }