export type CountsClipboard = { text: string; html: string } export type CountsClipboardColumn = { label: string; children?: CountsClipboardColumn[] } export type CountsClipboardCell = string | CountsClipboardTable export type CountsClipboardTable = { columns: CountsClipboardColumn[]; rows: CountsClipboardCell[][]; headerRows?: string[][] } const escapeHTML = (value: string): string => value.replace (/[&<>"']/g, character => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]!) const columnWidth = (column: CountsClipboardColumn): number => column.children?.reduce ((sum, child) => sum + columnWidth (child), 0) || 1 const headerDepth = (columns: CountsClipboardColumn[]): number => columns.length === 0 ? 0 : 1 + Math.max (0, ...columns.map (column => headerDepth (column.children ?? []))) const headerRows = (columns: CountsClipboardColumn[]): string[][] => { const width = columns.reduce ((sum, column) => sum + columnWidth (column), 0) const rows = Array.from ({ length: headerDepth (columns) }, () => Array (width).fill ('')) const place = (fields: CountsClipboardColumn[], row: number, offset: number) => { for (const field of fields) { rows[row][offset] = field.label if (field.children?.length) place (field.children, row + 1, offset) offset += columnWidth (field) } } if (columns.length) place (columns, 0, 0) return rows } const dataRows = ( table: CountsClipboardTable, columns = table.columns, ): string[][] => { if (columns.length === 0) return [] return table.rows.flatMap (record => { const blocks = columns.map ((column, index) => { const value = record[index] ?? '' return typeof value === 'string' ? [[value]] : dataRows (value, column.children ?? value.columns) }) const height = Math.max (1, ...blocks.map (block => block.length)) // Sibling arrays share the parent row's height; they never form a Cartesian product. return Array.from ({ length: height }, (_, row) => columns.flatMap ((column, index) => Array.from ({ length: columnWidth (column) }, (_, offset) => blocks[index][row]?.[offset] ?? ''))) }) } export const countsClipboard = ( source: string[][] | CountsClipboardTable, headers = false, ): CountsClipboard => { const headings = (!Array.isArray (source) && headers) ? source.headerRows ?? headerRows (source.columns) : [] const rows = Array.isArray (source) ? source : [...headings, ...dataRows (source)] const headingCount = Array.isArray (source) ? (headers ? 1 : 0) : headings.length // Excel accepts quoted TSV fields only when delimiters or quotes require them. // Prefix formula-like text in both representations, including leading whitespace. const safe = rows.map (row => row.map (value => /^\s*[=+@-]/.test (value) ? `'${value}` : value)) const text = safe.map (row => row.map (value => /[\t\r\n"]/.test (value) ? `"${value.replaceAll ('"', '""')}"` : value).join ('\t')).join ('\r\n') const html = '' + safe.map ((row, index) => '' + row.map (value => { const tag = index < headingCount ? 'th' : 'td' // Preserve time strings, leading zeroes, and user text instead of inferring dates/formulae. const numeric = value.length <= 15 && /^(?:0|[1-9]\d*)(?:\.\d+)?$/.test (value) const style = numeric ? 'white-space:pre-wrap' : 'white-space:pre-wrap;mso-number-format:"\\@"' return `<${tag} style="${escapeHTML (style)}">${escapeHTML (value) .replace (/\r\n|\r|\n/g, '
')}` }).join ('') + '').join ('') + '
' return { text, html } } export const writeCountsClipboard = async (payload: CountsClipboard): Promise => { if (navigator.clipboard?.write != null && typeof ClipboardItem !== 'undefined') { try { await navigator.clipboard.write ([new ClipboardItem ({ 'text/plain': new Blob ([payload.text], { type: 'text/plain' }), 'text/html': new Blob ([payload.html], { type: 'text/html' }) })]) return } catch { // Browsers without HTML clipboard support may still permit plain TSV. } } if (navigator.clipboard?.writeText != null) { try { await navigator.clipboard.writeText (payload.text) return } catch { // Try the synchronous copy event when clipboard permission is unavailable. } } // The synchronous copy event fallback is also used on non-secure local origins. const listener = (event: ClipboardEvent) => { event.preventDefault () event.stopImmediatePropagation () event.clipboardData?.setData ('text/plain', payload.text) event.clipboardData?.setData ('text/html', payload.html) } document.addEventListener ('copy', listener, true) try { if (!document.execCommand ('copy')) throw new Error ('Clipboard unavailable') } finally { document.removeEventListener ('copy', listener, true) } }