mirror of
https://github.com/redmine/redmine.git
synced 2026-05-06 22:46:53 +02:00
160 lines
3.9 KiB
JavaScript
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('|', '|')
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
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 }))
|
||
|
|
}
|
||
|
|
}
|