import { Controller } from '@hotwired/stimulus' class CommonMarkTableFormatter { format(rows) { const output = [] output.push(this.#formatRow(rows[0])) const separator = rows[0].map(() => '--').join(' | ') output.push(`| ${separator} |`) for (let i = 1; i < rows.length; i++) { output.push(this.#formatRow(rows[i])) } return output.join('\n') } #formatRow(row) { return `| ${row.map(cell => this.#formatCell(cell)).join(' | ')} |` } #formatCell(cell) { return cell .replaceAll('|', '\\|') .replaceAll('\n', '
') } } class TextileTableFormatter { format(rows) { const output = [] output.push(this.#formatHeader(rows[0])) for (let i = 1; i < rows.length; i++) { output.push(this.#formatRow(rows[i])) } return output.join('\n') } #formatHeader(row) { return `|_. ${row.map(cell => this.#formatCell(cell)).join(' |_. ')} |` } #formatRow(row) { return `| ${row.map(cell => this.#formatCell(cell)).join(' | ')} |` } #formatCell(cell) { return cell.replaceAll('|', '|') } } export default class extends Controller { handlePaste(event) { const formatter = this.#tableFormatterFor(event.params.textFormatting) if (!formatter) return const html = this.#htmlFromClipboard(event) if (!html) return // Extract the table only when the pasted HTML consists of a single table. const table = this.#extractTable(html) if (!table) return const tableData = this.#buildTableData(table) if (!tableData) return const formattedTable = formatter.format(tableData) if (!formattedTable) return event.preventDefault() this.#insertTextAtCursor(event.currentTarget, formattedTable) } // private #tableFormatterFor(textFormatting) { switch (textFormatting) { case 'common_mark': return new CommonMarkTableFormatter() case 'textile': return new TextileTableFormatter() default: return null } } #htmlFromClipboard(event) { const clipboardData = event.clipboardData if (!clipboardData) return null return clipboardData.getData('text/html') || null } #extractTable(html) { const temp = document.createElement('div') temp.innerHTML = html.replace(/\r?\n/g, '') const tables = temp.querySelectorAll('table') if (tables.length !== 1) return null const clone = temp.cloneNode(true) // Ignore metadata elements and confirm that nothing remains outside the table. clone.querySelectorAll('meta, style, link, title, table').forEach(element => element.remove()) return clone.textContent.trim() === '' ? tables[0] : null } #buildTableData(table) { const rows = [] table.querySelectorAll('tr').forEach(tr => { const cells = [] tr.querySelectorAll('td, th').forEach(cell => { cells.push(this.#extractCellText(cell).trim()) }) if (cells.length > 0) { rows.push(cells) } }) if (rows.length < 2) return null const maxColumns = rows.reduce((currentMax, row) => Math.max(currentMax, row.length), 0) if (maxColumns < 2) return null rows.forEach(row => { while (row.length < maxColumns) { row.push('') } }) return rows } #extractCellText(cell) { const clone = cell.cloneNode(true) // Treat
as an in-cell line break and keep it as an internal newline // so each formatter can render it appropriately. clone.querySelectorAll('br').forEach(br => { br.replaceWith('\n') }) return clone.textContent } #insertTextAtCursor(input, text) { const { selectionStart, selectionEnd } = input const replacement = `${text}\n\n` input.setRangeText(replacement, selectionStart, selectionEnd, 'end') const newCursorPos = selectionStart + replacement.length input.setSelectionRange(newCursorPos, newCursorPos) input.dispatchEvent(new Event('input', { bubbles: true })) } }