diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 7fe9d4d9d..cfb6e3a3e 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -1392,13 +1392,14 @@ module ApplicationHelper
end
end
- def list_autofill_data_attributes
+ def wiki_textarea_stimulus_attributes
return {} if Setting.text_formatting.blank?
{
- controller: 'list-autofill',
- action: 'beforeinput->list-autofill#handleBeforeInput',
- list_autofill_text_formatting_param: Setting.text_formatting
+ controller: 'list-autofill table-paste',
+ action: 'beforeinput->list-autofill#handleBeforeInput paste->table-paste#handlePaste',
+ list_autofill_text_formatting_param: Setting.text_formatting,
+ table_paste_text_formatting_param: Setting.text_formatting
}
end
diff --git a/app/helpers/custom_fields_helper.rb b/app/helpers/custom_fields_helper.rb
index 14025b934..e66f430f9 100644
--- a/app/helpers/custom_fields_helper.rb
+++ b/app/helpers/custom_fields_helper.rb
@@ -87,7 +87,7 @@ module CustomFieldsHelper
css += ' wiki-edit'
data = {
:auto_complete => true
- }.merge(list_autofill_data_attributes)
+ }.merge(wiki_textarea_stimulus_attributes)
end
cf.format.edit_tag(
self,
@@ -137,7 +137,7 @@ module CustomFieldsHelper
css += ' wiki-edit'
data = {
:auto_complete => true
- }.merge(list_autofill_data_attributes)
+ }.merge(wiki_textarea_stimulus_attributes)
end
custom_field.format.bulk_edit_tag(
self,
diff --git a/app/javascript/controllers/table_paste_controller.js b/app/javascript/controllers/table_paste_controller.js
new file mode 100644
index 000000000..9c32df834
--- /dev/null
+++ b/app/javascript/controllers/table_paste_controller.js
@@ -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', '
')
+ }
+}
+
+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 }))
+ }
+}
diff --git a/app/views/documents/_form.html.erb b/app/views/documents/_form.html.erb
index 9215ddbb9..7b73673cd 100644
--- a/app/views/documents/_form.html.erb
+++ b/app/views/documents/_form.html.erb
@@ -5,8 +5,8 @@
<%= f.text_field :title, :required => true, :size => 60 %>
<%= f.textarea :description, :cols => 60, :rows => 15, :class => 'wiki-edit',
:data => {
- :auto_complete => true
- }.merge(list_autofill_data_attributes)
+ :auto_complete => true
+ }.merge(wiki_textarea_stimulus_attributes)
%>
<% @document.custom_field_values.each do |value| %>
diff --git a/app/views/issues/_edit.html.erb b/app/views/issues/_edit.html.erb
index 574d8e674..b7e368596 100644
--- a/app/views/issues/_edit.html.erb
+++ b/app/views/issues/_edit.html.erb
@@ -32,8 +32,8 @@