Files
Redmine/app/javascript/controllers/table_paste_controller.js
Go MAEDA 906fc2dadf Add support for pasting spreadsheet tables as CommonMark/Textile tables in wiki textareas (#43950).
Patch by Katsuya HIDAKA (user:hidakatsuya).


git-svn-id: https://svn.redmine.org/redmine/trunk@24586 e93f8b46-1217-0410-a6f0-8f06a7374b81
2026-04-16 07:27:41 +00:00

160 lines
3.9 KiB
JavaScript

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', '<br>')
}
}
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('|', '&#124;')
}
}
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 <br> 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 }))
}
}