From c2f7824e163a533dd151627356a68f7c07bf9dc4 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 | 153 ++++++++++++++++++ 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 | 90 +++++++++++ 17 files changed, 279 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..c8a6d8024 --- /dev/null +++ b/app/javascript/controllers/table_paste_controller.js @@ -0,0 +1,153 @@ +import { Controller } from '@hotwired/stimulus' + +class CommonMarkTableFormatter { + format(rows) { + if (rows.length === 0) return null + + 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) { + if (rows.length === 0) return null + + 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 rows = this.#extractTableFromClipboard(event) + if (!rows) return + + const table = formatter.format(rows) + if (!table) return + + event.preventDefault() + this.#insertTextAtCursor(event.currentTarget, table) + } + + #tableFormatterFor(textFormatting) { + switch (textFormatting) { + case 'common_mark': + return new CommonMarkTableFormatter() + case 'textile': + return new TextileTableFormatter() + default: + return null + } + } + + #extractTableFromClipboard(event) { + const clipboardData = event.clipboardData + if (!clipboardData) return null + + const htmlData = clipboardData.getData('text/html') + if (!htmlData) return null + + return this.#extractTableFromHtml(htmlData) + } + + #extractTableFromHtml(html) { + const temp = document.createElement('div') + temp.innerHTML = html.replace(/\r?\n/g, '') + + const table = temp.querySelector('table') + if (!table) return null + + 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) + } + }) + + return this.#normalizeRows(rows) + } + + #normalizeRows(rows) { + if (rows.length === 0) 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..43fd51130 --- /dev/null +++ b/test/system/table_paste_test.rb @@ -0,0 +1,90 @@ +# 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 + + private + + def dispatch_paste(field, html:) + page.evaluate_script(<<~JS, field, html) + ((element, htmlText) => { + element.value = '' + element.setSelectionRange(0, 0) + + const clipboardData = { + getData() { + return 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