mirror of
https://github.com/redmine/redmine.git
synced 2026-05-07 01:37:14 +02:00
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
This commit is contained in:
159
app/javascript/controllers/table_paste_controller.js
Normal file
159
app/javascript/controllers/table_paste_controller.js
Normal file
@@ -0,0 +1,159 @@
|
||||
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('|', '|')
|
||||
}
|
||||
}
|
||||
|
||||
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 }))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user