From facde0d8508f65d7cb9a737bf3ac5e286db4e54f Mon Sep 17 00:00:00 2001 From: Katsuya HIDAKA Date: Tue, 7 Apr 2026 00:26:09 +0900 Subject: Add support for pasting spreadsheet tables as CommonMark/Textile tables in wiki textareas --- app/helpers/application_helper.rb | 9 +- app/helpers/custom_fields_helper.rb | 4 +- .../controllers/table_paste_controller.js | 159 ++++++++++++++++++ app/views/documents/_form.html.erb | 4 +- app/views/issues/_edit.html.erb | 4 +- app/views/issues/_form.html.erb | 4 +- app/views/issues/bulk_edit.html.erb | 4 +- app/views/journals/_notes_form.html.erb | 4 +- app/views/messages/_form.html.erb | 4 +- app/views/news/_form.html.erb | 4 +- app/views/news/show.html.erb | 2 +- app/views/projects/_form.html.erb | 2 +- app/views/settings/_general.html.erb | 2 +- app/views/settings/_notifications.html.erb | 4 +- app/views/wiki/edit.html.erb | 4 +- test/helpers/application_helper_test.rb | 16 +- test/system/table_paste_test.rb | 143 ++++++++++++++++ 17 files changed, 338 insertions(+), 35 deletions(-) create mode 100644 app/javascript/controllers/table_paste_controller.js create mode 100644 test/system/table_paste_test.rb 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 @@
<%= l(:field_notes) %> <%= f.textarea :notes, :cols => 60, :rows => 10, :class => 'wiki-edit', :data => { - :auto_complete => true - }.merge(list_autofill_data_attributes), + :auto_complete => true + }.merge(wiki_textarea_stimulus_attributes), :no_label => true %> <%= wikitoolbar_for 'issue_notes', preview_issue_path(:project_id => @project, :issue_id => @issue) %> diff --git a/app/views/issues/_form.html.erb b/app/views/issues/_form.html.erb index 4ef58d657..a97b854df 100644 --- a/app/views/issues/_form.html.erb +++ b/app/views/issues/_form.html.erb @@ -36,8 +36,8 @@ <%= f.textarea :description, :cols => 60, :accesskey => accesskey(:edit), :class => 'wiki-edit', :rows => [[10, @issue.description.to_s.length / 50].max, 20].min, :data => { - :auto_complete => true - }.merge(list_autofill_data_attributes), + :auto_complete => true + }.merge(wiki_textarea_stimulus_attributes), :no_label => true %> <% end %> <%= link_to_function content_tag(:span, sprite_icon('edit', l(:button_edit))), '$(this).hide(); $("#issue_description_and_toolbar").show()', :class => 'icon icon-edit' unless @issue.new_record? %> diff --git a/app/views/issues/bulk_edit.html.erb b/app/views/issues/bulk_edit.html.erb index e0fe9175d..e3374329e 100644 --- a/app/views/issues/bulk_edit.html.erb +++ b/app/views/issues/bulk_edit.html.erb @@ -222,8 +222,8 @@ <%= l(:field_notes) %> <%= textarea_tag 'notes', @notes, :cols => 60, :rows => 10, :class => 'wiki-edit', :data => { - :auto_complete => true - }.merge(list_autofill_data_attributes) + :auto_complete => true + }.merge(wiki_textarea_stimulus_attributes) %> <%= wikitoolbar_for 'notes' %> diff --git a/app/views/journals/_notes_form.html.erb b/app/views/journals/_notes_form.html.erb index a4693be97..4213d6613 100644 --- a/app/views/journals/_notes_form.html.erb +++ b/app/views/journals/_notes_form.html.erb @@ -6,8 +6,8 @@ <%= textarea_tag 'journal[notes]', @journal.notes, :id => "journal_#{@journal.id}_notes", :class => 'wiki-edit', :rows => (@journal.notes.blank? ? 10 : [[10, @journal.notes.length / 50].max, 100].min), :data => { - :auto_complete => true - }.merge(list_autofill_data_attributes) + :auto_complete => true + }.merge(wiki_textarea_stimulus_attributes) %> <% if @journal.safe_attribute? 'private_notes' %> <%= hidden_field_tag 'journal[private_notes]', '0' %> diff --git a/app/views/messages/_form.html.erb b/app/views/messages/_form.html.erb index ce13e4a72..c7d33f0ff 100644 --- a/app/views/messages/_form.html.erb +++ b/app/views/messages/_form.html.erb @@ -26,8 +26,8 @@ <%= f.textarea :content, :cols => 80, :rows => 15, :class => 'wiki-edit', :id => 'message_content', :accesskey => accesskey(:edit), :data => { - :auto_complete => true - }.merge(list_autofill_data_attributes) + :auto_complete => true + }.merge(wiki_textarea_stimulus_attributes) %>

<%= wikitoolbar_for 'message_content', preview_board_message_path(:board_id => @board, :id => @message) %> diff --git a/app/views/news/_form.html.erb b/app/views/news/_form.html.erb index 3598faf41..3e2ebe6a8 100644 --- a/app/views/news/_form.html.erb +++ b/app/views/news/_form.html.erb @@ -12,8 +12,8 @@

<%= f.textarea :summary, :cols => 60, :rows => 2 %>

<%= f.textarea :description, :required => true, :cols => 60, :rows => 15, :class => 'wiki-edit', :data => { - :auto_complete => true - }.merge(list_autofill_data_attributes) + :auto_complete => true + }.merge(wiki_textarea_stimulus_attributes) %>

<%= render :partial => 'attachments/form', :locals => {:container => @news} %>

diff --git a/app/views/news/show.html.erb b/app/views/news/show.html.erb index f71dec41e..008460fbd 100644 --- a/app/views/news/show.html.erb +++ b/app/views/news/show.html.erb @@ -70,7 +70,7 @@ <%= textarea 'comment', 'comments', :cols => 80, :rows => 15, :class => 'wiki-edit', :data => { :auto_complete => true - }.merge(list_autofill_data_attributes) + }.merge(wiki_textarea_stimulus_attributes) %> <%= wikitoolbar_for 'comment_comments', preview_news_path(:project_id => @project, :id => @news) %> diff --git a/app/views/projects/_form.html.erb b/app/views/projects/_form.html.erb index deae8f357..33c801032 100644 --- a/app/views/projects/_form.html.erb +++ b/app/views/projects/_form.html.erb @@ -4,7 +4,7 @@

<%= f.text_field :name, :required => true, :size => 60 %>

-

<%= f.textarea :description, :rows => 8, :class => 'wiki-edit', :data => list_autofill_data_attributes %>

+

<%= f.textarea :description, :rows => 8, :class => 'wiki-edit', :data => wiki_textarea_stimulus_attributes %>

<%= f.text_field :identifier, :required => true, :size => 60, :disabled => @project.identifier_frozen?, :maxlength => Project::IDENTIFIER_MAX_LENGTH %> <% unless @project.identifier_frozen? %> <%= l(:text_length_between, :min => 1, :max => Project::IDENTIFIER_MAX_LENGTH) %> <%= l(:text_project_identifier_info).html_safe %> diff --git a/app/views/settings/_general.html.erb b/app/views/settings/_general.html.erb index 934f63411..da498865b 100644 --- a/app/views/settings/_general.html.erb +++ b/app/views/settings/_general.html.erb @@ -3,7 +3,7 @@

<%= setting_text_field :app_title, :size => 30 %>

-

<%= setting_textarea :welcome_text, :cols => 60, :rows => 5, :class => 'wiki-edit', :data => list_autofill_data_attributes %>

+

<%= setting_textarea :welcome_text, :cols => 60, :rows => 5, :class => 'wiki-edit', :data => wiki_textarea_stimulus_attributes %>

<%= wikitoolbar_for 'settings_welcome_text' %> diff --git a/app/views/settings/_notifications.html.erb b/app/views/settings/_notifications.html.erb index 49482947d..a4b07f40e 100644 --- a/app/views/settings/_notifications.html.erb +++ b/app/views/settings/_notifications.html.erb @@ -19,12 +19,12 @@
<%= l(:setting_emails_header) %> -<%= setting_textarea :emails_header, :label => false, :class => 'wiki-edit', :rows => 5, :data => list_autofill_data_attributes %> +<%= setting_textarea :emails_header, :label => false, :class => 'wiki-edit', :rows => 5, :data => wiki_textarea_stimulus_attributes %> <%= wikitoolbar_for 'settings_emails_header' %>
<%= l(:setting_emails_footer) %> -<%= setting_textarea :emails_footer, :label => false, :class => 'wiki-edit', :rows => 5, :data => list_autofill_data_attributes %> +<%= setting_textarea :emails_footer, :label => false, :class => 'wiki-edit', :rows => 5, :data => wiki_textarea_stimulus_attributes %> <%= wikitoolbar_for 'settings_emails_footer' %>
diff --git a/app/views/wiki/edit.html.erb b/app/views/wiki/edit.html.erb index 56d9f2708..4255193ad 100644 --- a/app/views/wiki/edit.html.erb +++ b/app/views/wiki/edit.html.erb @@ -16,8 +16,8 @@ <%= textarea_tag 'content[text]', @text, :cols => 100, :rows => 25, :accesskey => accesskey(:edit), :class => 'wiki-edit', :data => { - :auto_complete => true - }.merge(list_autofill_data_attributes) + :auto_complete => true + }.merge(wiki_textarea_stimulus_attributes) %> <% if @page.safe_attribute_names.include?('parent_id') && @wiki.pages.any? %> diff --git a/test/helpers/application_helper_test.rb b/test/helpers/application_helper_test.rb index 873f6aa6f..64df5376f 100644 --- a/test/helpers/application_helper_test.rb +++ b/test/helpers/application_helper_test.rb @@ -2258,22 +2258,22 @@ class ApplicationHelperTest < Redmine::HelperTest } end - def test_list_autofill_data_attributes + def test_wiki_textarea_stimulus_attributes with_settings :text_formatting => 'textile' do expected = { - controller: "list-autofill", - action: "keydown->list-autofill#handleEnter", - list_autofill_target: "input", - list_autofill_text_formatting_param: "textile" + controller: "list-autofill table-paste", + action: "beforeinput->list-autofill#handleBeforeInput paste->table-paste#handlePaste", + list_autofill_text_formatting_param: "textile", + table_paste_text_formatting_param: "textile" } - assert_equal expected, list_autofill_data_attributes + assert_equal expected, wiki_textarea_stimulus_attributes end end - def test_list_autofill_data_attributes_with_blank_text_formatting + def test_wiki_textarea_stimulus_attributes_with_blank_text_formatting with_settings :text_formatting => '' do - assert_equal({}, list_autofill_data_attributes) + assert_equal({}, wiki_textarea_stimulus_attributes) end end end diff --git a/test/system/table_paste_test.rb b/test/system/table_paste_test.rb new file mode 100644 index 000000000..0ecb9e83d --- /dev/null +++ b/test/system/table_paste_test.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +# Redmine - project management software +# Copyright (C) 2006- Jean-Philippe Lang +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +require_relative '../application_system_test_case' + +class TablePasteSystemTest < ApplicationSystemTestCase + HTML_TABLE = <<~'HTML' + + + + + +
ItemNotes
Redmine 6.1Supports <wiki> tags
Multi-lineFirst line
Second line | escaped
Path valueC:\Temp\redmine & logs
+ HTML + + def test_paste_html_table_as_commonmark_table_in_issue_description + with_settings text_formatting: 'common_mark' do + log_user('jsmith', 'jsmith') + visit '/projects/ecookbook/issues/new' + + result = dispatch_paste(find('#issue_description'), html: HTML_TABLE) + + assert_equal <<~'TEXT', result + | Item | Notes | + | -- | -- | + | Redmine 6.1 | Supports tags | + | Multi-line | First line
Second line \| escaped | + | Path value | C:\Temp\redmine & logs | + + TEXT + end + end + + def test_paste_html_table_as_textile_table_in_wiki_edit + with_settings text_formatting: 'textile' do + log_user('jsmith', 'jsmith') + visit '/projects/ecookbook/wiki/CookBook_documentation/edit' + + result = dispatch_paste(find('#content_text'), html: HTML_TABLE) + + assert_equal <<~'TEXT', result + |_. Item |_. Notes | + | Redmine 6.1 | Supports tags | + | Multi-line | First line + Second line | escaped | + | Path value | C:\Temp\redmine & logs | + + TEXT + end + end + + def test_pastes_only_standalone_html_tables + with_settings text_formatting: 'common_mark' do + log_user('jsmith', 'jsmith') + visit '/projects/ecookbook/issues/new' + + # Pasted content from Excel + pasted = dispatch_paste(find('#issue_description'), html: <<~HTML) + + + + + + + + +
ItemNotes
TableValue
+ + + HTML + assert_includes pasted, "| Item | Notes |" + + # Pasted content from Google Sheets + pasted = dispatch_paste(find('#issue_description'), html: <<~HTML) + +
ItemNotes
TableValue
+ HTML + assert_includes pasted, "| Item | Notes |" + + # Pasted content without a table + pasted = dispatch_paste(find('#issue_description'), html: '

Content

') + assert_equal '', pasted # Handled as a normal paste. + + # Pasted content with a table and other HTML content + pasted = dispatch_paste(find('#issue_description'), html: <<~HTML) +

Title

ItemNotes
+ HTML + assert_equal '', pasted # Handled as a normal paste. + + # Pasted content with multiple tables + pasted = dispatch_paste(find('#issue_description'), html: <<~HTML) +
ItemNotes
+
ItemNotes
+ HTML + assert_equal '', pasted # Handled as a normal paste. + + # Pasted content with a single table row + pasted = dispatch_paste(find('#issue_description'), html: <<~HTML) +
ItemNotes
+ HTML + assert_equal '', pasted # Handled as a normal paste. + end + end + + private + + def dispatch_paste(field, html:) + page.evaluate_script(<<~JS, field, html) + ((element, htmlText) => { + element.value = '' + element.setSelectionRange(0, 0) + + const clipboardData = { + getData(type) { + return type === 'text/html' ? htmlText : '' + } + } + + const event = new Event('paste', { bubbles: true, cancelable: true }) + Object.defineProperty(event, 'clipboardData', { value: clipboardData }) + element.dispatchEvent(event) + + return element.value + })(arguments[0], arguments[1]) + JS + end +end -- 2.52.0