128 行
4.9 KiB
TypeScript
128 行
4.9 KiB
TypeScript
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<string> (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 = '<html><body><table>' + safe.map ((row, index) => '<tr>' + 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, '<br>')}</${tag}>`
|
|
}).join ('') + '</tr>').join ('') + '</table></body></html>'
|
|
return { text, html }
|
|
}
|
|
|
|
|
|
export const writeCountsClipboard = async (payload: CountsClipboard): Promise<void> => {
|
|
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)
|
|
}
|
|
}
|