UNPKG

sanity

Version:

Sanity is a real-time content infrastructure with a scalable, hosted backend featuring a Graph Oriented Query Language (GROQ), asset pipelines and fast edge caches

185 lines (155 loc) • 6.22 kB
import {type Path} from '@sanity/types' import {isString} from '../../util/isString' import {type SanityClipboardItem} from './types' /** * The custom mimetype used when populating a ClipboardItem. Note that this * uses the new `web ` prefix. This is not currently implemented in Safari and * Firefox as of 2024-07-08. * * https://caniuse.com/mdn-api_clipboarditem_supports_static_optional_type_web * https://github.com/w3c/editing/blob/gh-pages/docs/clipboard-pickling/explainer.md */ const MIMETYPE_SANITY_CLIPBOARD = 'web application/vnd.sanity-clipboard-item+json' const MIMETYPE_HTML = 'text/html' const MIMETYPE_PLAINTEXT = 'text/plain' /** * Reports whether or not the current browser supports custom mimetype types * within the clipboard. Note that this uses the new ClipboardItem.supports * method that was released in Chrome/Edge 121 and is currently not implemented * in Safari and Firefox. * * https://caniuse.com/mdn-api_clipboarditem_supports_static_optional_type_web */ const SUPPORTS_SANITY_CLIPBOARD_MIMETYPE = typeof ClipboardItem !== 'undefined' && 'supports' in ClipboardItem && ClipboardItem.supports(MIMETYPE_SANITY_CLIPBOARD) /** * The name of the attributed used to store the base64 data. Note that we store * serialized data into a base64 data attribute because safari will mangle and * sanitize the HTML pasted into the clipboard, however it keeps data attributes * https://stackoverflow.com/a/68958287/5776910 */ const BASE64_ATTR = 'sanity-clipboard-base64' export async function getClipboardItem(): Promise<SanityClipboardItem | null> { const items = await navigator.clipboard.read() for (const item of items) { const sanityClipboardItem = await parseClipboardItem(item) if (!sanityClipboardItem) continue return sanityClipboardItem } return null } export async function writeClipboardItem(copyActionResult: SanityClipboardItem): Promise<boolean> { const textValue = transformValueToText(copyActionResult.value) const escapedTextValue = escapeHtml(textValue) // we use a utf8-safe base64 encoded string to preserve the data as safely as // possible when serializing into HTML const base64SanityClipboardItem = utf8ToBase64(JSON.stringify(copyActionResult)) const clipboardItem = new ClipboardItem({ ...(SUPPORTS_SANITY_CLIPBOARD_MIMETYPE && { [MIMETYPE_SANITY_CLIPBOARD]: new Blob([JSON.stringify(copyActionResult)], { type: MIMETYPE_SANITY_CLIPBOARD, }), }), [MIMETYPE_HTML]: new Blob( // we store the data within a data attribute because safari will sanitize // and mangle the HTML written to the clipboard // https://stackoverflow.com/a/68958287/5776910 [`<p data-${BASE64_ATTR}="${base64SanityClipboardItem}">${escapedTextValue}</p>`], {type: MIMETYPE_HTML}, ), [MIMETYPE_PLAINTEXT]: new Blob([textValue], {type: MIMETYPE_PLAINTEXT}), }) try { await navigator.clipboard.write([clipboardItem]) return true } catch (error) { console.error(`Failed to write to clipboard: ${error.message}`, error) return false } } export async function parseClipboardItem(item: ClipboardItem): Promise<SanityClipboardItem | null> { if (item.types.includes(MIMETYPE_SANITY_CLIPBOARD)) { const blob = await item.getType(MIMETYPE_SANITY_CLIPBOARD) const text = await blob.text() return JSON.parse(text) } if (!item.types.includes(MIMETYPE_HTML)) return null const blob = await item.getType(MIMETYPE_HTML) const html = await blob.text() const parser = new DOMParser() const doc = parser.parseFromString(html, 'text/html') try { const el = doc.querySelector(`[data-${BASE64_ATTR}]`) as HTMLParagraphElement if (!el) return null type CamelCase<S extends string> = S extends `${infer P1}-${infer P2}${infer P3}` ? `${P1}${Capitalize<P2>}${CamelCase<P3>}` : S const {sanityClipboardBase64} = el.dataset as Record<CamelCase<typeof BASE64_ATTR>, string> if (!sanityClipboardBase64) return null return JSON.parse(base64ToUtf8(sanityClipboardBase64)) } catch { return null } } /** * allows for a safe conversion of utf-8 text to a base64 string */ function utf8ToBase64(text: string) { const encoder = new TextEncoder() const uint8Array = encoder.encode(text) let binary = '' for (let i = 0; i < uint8Array.byteLength; i++) { binary += String.fromCharCode(uint8Array[i]) } return btoa(binary) } function base64ToUtf8(base64String: string) { const binary = atob(base64String) const uint8Array = new Uint8Array(binary.length) for (let i = 0; i < binary.length; i++) { uint8Array[i] = binary.charCodeAt(i) } const decoder = new TextDecoder() return decoder.decode(uint8Array) } function escapeHtml(text: string) { const parser = new DOMParser() const doc = parser.parseFromString(text, 'text/html') return doc.documentElement.textContent } export function transformValueToText(value: unknown): string { if (!value) return '' if (isString(value)) return value if (Number.isFinite(value)) return value.toString() if (Array.isArray(value)) { return value.map(transformValueToText).filter(Boolean).join(', ') } if (typeof value === 'object') { return Object.entries(value) .map(([key, subValue]) => (key.startsWith('_') ? '' : transformValueToText(subValue))) .filter(Boolean) .join(', ') } return '' } export function isEmptyValue(value: unknown): boolean { if (value === null || value === undefined) return true if (Array.isArray(value) && value.length === 0) return true return false } export function isNativeEditableElement(el: EventTarget): boolean { if (el instanceof HTMLElement && el.isContentEditable) return true if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) return true return false } export function hasSelection(): boolean { if (typeof window === 'undefined' || !window.getSelection) return false const selection = window.getSelection() return selection !== null && !selection.isCollapsed } /** @internal */ export function isEmptyFocusPath(path: Path): boolean { return path.length === 0 || (path.length === 1 && path[0] === '') }