Project

General

Profile

Feature #43950 » 0002-Add-support-for-pasting-spreadsheet-tables-as-CommonMark-Textile-tables-in-wiki-textareas.patch

Katsuya HIDAKA, 2026-04-13 11:45

View differences:

app/helpers/application_helper.rb
1392 1392
    end
1393 1393
  end
1394 1394

  
1395
  def list_autofill_data_attributes
1395
  def wiki_textarea_stimulus_attributes
1396 1396
    return {} if Setting.text_formatting.blank?
1397 1397

  
1398 1398
    {
1399
      controller: 'list-autofill',
1400
      action: 'beforeinput->list-autofill#handleBeforeInput',
1401
      list_autofill_text_formatting_param: Setting.text_formatting
1399
      controller: 'list-autofill table-paste',
1400
      action: 'beforeinput->list-autofill#handleBeforeInput paste->table-paste#handlePaste',
1401
      list_autofill_text_formatting_param: Setting.text_formatting,
1402
      table_paste_text_formatting_param: Setting.text_formatting
1402 1403
    }
1403 1404
  end
1404 1405

  
app/helpers/custom_fields_helper.rb
87 87
      css += ' wiki-edit'
88 88
      data = {
89 89
        :auto_complete => true
90
      }.merge(list_autofill_data_attributes)
90
      }.merge(wiki_textarea_stimulus_attributes)
91 91
    end
92 92
    cf.format.edit_tag(
93 93
      self,
......
137 137
      css += ' wiki-edit'
138 138
      data = {
139 139
        :auto_complete => true
140
      }.merge(list_autofill_data_attributes)
140
      }.merge(wiki_textarea_stimulus_attributes)
141 141
    end
142 142
    custom_field.format.bulk_edit_tag(
143 143
      self,
app/javascript/controllers/table_paste_controller.js
1
import { Controller } from '@hotwired/stimulus'
2

  
3
class CommonMarkTableFormatter {
4
  format(rows) {
5
    const output = []
6
    output.push(this.#formatRow(rows[0]))
7

  
8
    const separator = rows[0].map(() => '--').join(' | ')
9
    output.push(`| ${separator} |`)
10

  
11
    for (let i = 1; i < rows.length; i++) {
12
      output.push(this.#formatRow(rows[i]))
13
    }
14

  
15
    return output.join('\n')
16
  }
17

  
18
  #formatRow(row) {
19
    return `| ${row.map(cell => this.#formatCell(cell)).join(' | ')} |`
20
  }
21

  
22
  #formatCell(cell) {
23
    return cell
24
      .replaceAll('|', '\\|')
25
      .replaceAll('\n', '<br>')
26
  }
27
}
28

  
29
class TextileTableFormatter {
30
  format(rows) {
31
    const output = []
32
    output.push(this.#formatHeader(rows[0]))
33

  
34
    for (let i = 1; i < rows.length; i++) {
35
      output.push(this.#formatRow(rows[i]))
36
    }
37

  
38
    return output.join('\n')
39
  }
40

  
41
  #formatHeader(row) {
42
    return `|_. ${row.map(cell => this.#formatCell(cell)).join(' |_. ')} |`
43
  }
44

  
45
  #formatRow(row) {
46
    return `| ${row.map(cell => this.#formatCell(cell)).join(' | ')} |`
47
  }
48

  
49
  #formatCell(cell) {
50
    return cell.replaceAll('|', '&#124;')
51
  }
52
}
53

  
54
export default class extends Controller {
55
  handlePaste(event) {
56
    const formatter = this.#tableFormatterFor(event.params.textFormatting)
57
    if (!formatter) return
58

  
59
    const html = this.#htmlFromClipboard(event)
60
    if (!html) return
61

  
62
    // Extract the table only when the pasted HTML consists of a single table.
63
    const table = this.#extractTable(html)
64
    if (!table) return
65

  
66
    const tableData = this.#buildTableData(table)
67
    if (!tableData) return
68

  
69
    const formattedTable = formatter.format(tableData)
70
    if (!formattedTable) return
71

  
72
    event.preventDefault()
73
    this.#insertTextAtCursor(event.currentTarget, formattedTable)
74
  }
75

  
76
  // private
77

  
78
  #tableFormatterFor(textFormatting) {
79
    switch (textFormatting) {
80
      case 'common_mark':
81
        return new CommonMarkTableFormatter()
82
      case 'textile':
83
        return new TextileTableFormatter()
84
      default:
85
        return null
86
    }
87
  }
88

  
89
  #htmlFromClipboard(event) {
90
    const clipboardData = event.clipboardData
91
    if (!clipboardData) return null
92

  
93
    return clipboardData.getData('text/html') || null
94
  }
95

  
96
  #extractTable(html) {
97
    const temp = document.createElement('div')
98
    temp.innerHTML = html.replace(/\r?\n/g, '')
99

  
100
    const tables = temp.querySelectorAll('table')
101
    if (tables.length !== 1) return null
102

  
103
    const clone = temp.cloneNode(true)
104
    // Ignore metadata elements and confirm that nothing remains outside the table.
105
    clone.querySelectorAll('meta, style, link, title, table').forEach(element => element.remove())
106

  
107
    return clone.textContent.trim() === '' ? tables[0] : null
108
  }
109

  
110
  #buildTableData(table) {
111
    const rows = []
112
    table.querySelectorAll('tr').forEach(tr => {
113
      const cells = []
114
      tr.querySelectorAll('td, th').forEach(cell => {
115
        cells.push(this.#extractCellText(cell).trim())
116
      })
117
      if (cells.length > 0) {
118
        rows.push(cells)
119
      }
120
    })
121

  
122
    if (rows.length < 2) return null
123

  
124
    const maxColumns = rows.reduce((currentMax, row) => Math.max(currentMax, row.length), 0)
125
    if (maxColumns < 2) return null
126

  
127
    rows.forEach(row => {
128
      while (row.length < maxColumns) {
129
        row.push('')
130
      }
131
    })
132

  
133
    return rows
134
  }
135

  
136
  #extractCellText(cell) {
137
    const clone = cell.cloneNode(true)
138

  
139
    // Treat <br> as an in-cell line break and keep it as an internal newline
140
    // so each formatter can render it appropriately.
141
    clone.querySelectorAll('br').forEach(br => {
142
      br.replaceWith('\n')
143
    })
144

  
145
    return clone.textContent
146
  }
147

  
148
  #insertTextAtCursor(input, text) {
149
    const { selectionStart, selectionEnd } = input
150

  
151
    const replacement = `${text}\n\n`
152

  
153
    input.setRangeText(replacement, selectionStart, selectionEnd, 'end')
154
    const newCursorPos = selectionStart + replacement.length
155
    input.setSelectionRange(newCursorPos, newCursorPos)
156

  
157
    input.dispatchEvent(new Event('input', { bubbles: true }))
158
  }
159
}
app/views/documents/_form.html.erb
5 5
  <p><%= f.text_field :title, :required => true, :size => 60 %></p>
6 6
  <p><%= f.textarea :description, :cols => 60, :rows => 15, :class => 'wiki-edit',
7 7
                    :data => {
8
                        :auto_complete => true
9
                    }.merge(list_autofill_data_attributes)
8
                      :auto_complete => true
9
                    }.merge(wiki_textarea_stimulus_attributes)
10 10
                     %></p>
11 11

  
12 12
  <% @document.custom_field_values.each do |value| %>
app/views/issues/_edit.html.erb
32 32
      <fieldset id="add_notes"><legend><%= l(:field_notes) %></legend>
33 33
      <%= f.textarea :notes, :cols => 60, :rows => 10, :class => 'wiki-edit',
34 34
            :data => {
35
                :auto_complete => true
36
            }.merge(list_autofill_data_attributes),
35
              :auto_complete => true
36
            }.merge(wiki_textarea_stimulus_attributes),
37 37
            :no_label => true %>
38 38
      <%= wikitoolbar_for 'issue_notes', preview_issue_path(:project_id => @project, :issue_id => @issue) %>
39 39

  
app/views/issues/_form.html.erb
36 36
    <%= f.textarea :description, :cols => 60, :accesskey => accesskey(:edit), :class => 'wiki-edit',
37 37
                   :rows => [[10, @issue.description.to_s.length / 50].max, 20].min,
38 38
                   :data => {
39
                       :auto_complete => true
40
                   }.merge(list_autofill_data_attributes),
39
                     :auto_complete => true
40
                   }.merge(wiki_textarea_stimulus_attributes),
41 41
                   :no_label => true %>
42 42
  <% end %>
43 43
  <%= 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? %>
app/views/issues/bulk_edit.html.erb
222 222
<legend><%= l(:field_notes) %></legend>
223 223
<%= textarea_tag 'notes', @notes, :cols => 60, :rows => 10, :class => 'wiki-edit',
224 224
                  :data => {
225
                      :auto_complete => true
226
                  }.merge(list_autofill_data_attributes)
225
                    :auto_complete => true
226
                  }.merge(wiki_textarea_stimulus_attributes)
227 227
%>
228 228
<%= wikitoolbar_for 'notes' %>
229 229

  
app/views/journals/_notes_form.html.erb
6 6
    <%= textarea_tag 'journal[notes]', @journal.notes, :id => "journal_#{@journal.id}_notes", :class => 'wiki-edit',
7 7
          :rows => (@journal.notes.blank? ? 10 : [[10, @journal.notes.length / 50].max, 100].min),
8 8
          :data => {
9
              :auto_complete => true
10
          }.merge(list_autofill_data_attributes)
9
            :auto_complete => true
10
          }.merge(wiki_textarea_stimulus_attributes)
11 11
    %>
12 12
    <% if @journal.safe_attribute? 'private_notes' %>
13 13
      <%= hidden_field_tag 'journal[private_notes]', '0' %>
app/views/messages/_form.html.erb
26 26
<%= f.textarea :content, :cols => 80, :rows => 15, :class => 'wiki-edit', :id => 'message_content',
27 27
                :accesskey => accesskey(:edit),
28 28
                :data => {
29
                    :auto_complete => true
30
                }.merge(list_autofill_data_attributes)
29
                  :auto_complete => true
30
                }.merge(wiki_textarea_stimulus_attributes)
31 31
%></p>
32 32
<%= wikitoolbar_for 'message_content', preview_board_message_path(:board_id => @board, :id => @message) %>
33 33
<!--[eoform:message]-->
app/views/news/_form.html.erb
12 12
<p><%= f.textarea :summary, :cols => 60, :rows => 2 %></p>
13 13
<p><%= f.textarea :description, :required => true, :cols => 60, :rows => 15, :class => 'wiki-edit',
14 14
                   :data => {
15
                       :auto_complete => true
16
                   }.merge(list_autofill_data_attributes)
15
                     :auto_complete => true
16
                   }.merge(wiki_textarea_stimulus_attributes)
17 17
%></p>
18 18
<p id="attachments_form"><label><%= l(:label_attachment_plural) %></label><%= render :partial => 'attachments/form', :locals => {:container => @news} %></p>
19 19
</div>
app/views/news/show.html.erb
70 70
    <%= textarea 'comment', 'comments', :cols => 80, :rows => 15, :class => 'wiki-edit',
71 71
                  :data => {
72 72
                    :auto_complete => true
73
                  }.merge(list_autofill_data_attributes)
73
                  }.merge(wiki_textarea_stimulus_attributes)
74 74
    %>
75 75
    <%= wikitoolbar_for 'comment_comments', preview_news_path(:project_id => @project, :id => @news) %>
76 76
</div>
app/views/projects/_form.html.erb
4 4
<!--[form:project]-->
5 5
<p><%= f.text_field :name, :required => true, :size => 60 %></p>
6 6

  
7
<p><%= f.textarea :description, :rows => 8, :class => 'wiki-edit', :data => list_autofill_data_attributes %></p>
7
<p><%= f.textarea :description, :rows => 8, :class => 'wiki-edit', :data => wiki_textarea_stimulus_attributes %></p>
8 8
<p><%= f.text_field :identifier, :required => true, :size => 60, :disabled => @project.identifier_frozen?, :maxlength => Project::IDENTIFIER_MAX_LENGTH %>
9 9
<% unless @project.identifier_frozen? %>
10 10
  <em class="info"><%= l(:text_length_between, :min => 1, :max => Project::IDENTIFIER_MAX_LENGTH) %> <%= l(:text_project_identifier_info).html_safe %></em>
app/views/settings/_general.html.erb
3 3
<div class="box tabular settings">
4 4
<p><%= setting_text_field :app_title, :size => 30 %></p>
5 5

  
6
<p><%= setting_textarea :welcome_text, :cols => 60, :rows => 5, :class => 'wiki-edit', :data => list_autofill_data_attributes %></p>
6
<p><%= setting_textarea :welcome_text, :cols => 60, :rows => 5, :class => 'wiki-edit', :data => wiki_textarea_stimulus_attributes %></p>
7 7
<%= wikitoolbar_for 'settings_welcome_text' %>
8 8

  
9 9

  
app/views/settings/_notifications.html.erb
19 19
</fieldset>
20 20

  
21 21
<fieldset class="box"><legend><%= l(:setting_emails_header) %></legend>
22
<%= setting_textarea :emails_header, :label => false, :class => 'wiki-edit', :rows => 5, :data => list_autofill_data_attributes %>
22
<%= setting_textarea :emails_header, :label => false, :class => 'wiki-edit', :rows => 5, :data => wiki_textarea_stimulus_attributes %>
23 23
<%= wikitoolbar_for 'settings_emails_header' %>
24 24
</fieldset>
25 25

  
26 26
<fieldset class="box"><legend><%= l(:setting_emails_footer) %></legend>
27
<%= setting_textarea :emails_footer, :label => false, :class => 'wiki-edit', :rows => 5, :data => list_autofill_data_attributes %>
27
<%= setting_textarea :emails_footer, :label => false, :class => 'wiki-edit', :rows => 5, :data => wiki_textarea_stimulus_attributes %>
28 28
<%= wikitoolbar_for 'settings_emails_footer' %>
29 29
</fieldset>
30 30

  
app/views/wiki/edit.html.erb
16 16
<%= textarea_tag 'content[text]', @text, :cols => 100, :rows => 25, :accesskey => accesskey(:edit),
17 17
                  :class => 'wiki-edit',
18 18
                  :data => {
19
                      :auto_complete => true
20
                  }.merge(list_autofill_data_attributes)
19
                    :auto_complete => true
20
                  }.merge(wiki_textarea_stimulus_attributes)
21 21
%>
22 22

  
23 23
<% if @page.safe_attribute_names.include?('parent_id') && @wiki.pages.any? %>
test/helpers/application_helper_test.rb
2258 2258
    }
2259 2259
  end
2260 2260

  
2261
  def test_list_autofill_data_attributes
2261
  def test_wiki_textarea_stimulus_attributes
2262 2262
    with_settings :text_formatting => 'textile' do
2263 2263
      expected = {
2264
        controller: "list-autofill",
2265
        action: "keydown->list-autofill#handleEnter",
2266
        list_autofill_target: "input",
2267
        list_autofill_text_formatting_param: "textile"
2264
        controller: "list-autofill table-paste",
2265
        action: "beforeinput->list-autofill#handleBeforeInput paste->table-paste#handlePaste",
2266
        list_autofill_text_formatting_param: "textile",
2267
        table_paste_text_formatting_param: "textile"
2268 2268
      }
2269 2269

  
2270
      assert_equal expected, list_autofill_data_attributes
2270
      assert_equal expected, wiki_textarea_stimulus_attributes
2271 2271
    end
2272 2272
  end
2273 2273

  
2274
  def test_list_autofill_data_attributes_with_blank_text_formatting
2274
  def test_wiki_textarea_stimulus_attributes_with_blank_text_formatting
2275 2275
    with_settings :text_formatting => '' do
2276
      assert_equal({}, list_autofill_data_attributes)
2276
      assert_equal({}, wiki_textarea_stimulus_attributes)
2277 2277
    end
2278 2278
  end
2279 2279
end
test/system/table_paste_test.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
require_relative '../application_system_test_case'
21

  
22
class TablePasteSystemTest < ApplicationSystemTestCase
23
  HTML_TABLE = <<~'HTML'
24
    <table>
25
      <tr><th>Item</th><th>Notes</th></tr>
26
      <tr><td>Redmine 6.1</td><td>Supports &lt;wiki&gt; tags</td></tr>
27
      <tr><td>Multi-line</td><td>First line<br>Second line | escaped</td></tr>
28
      <tr><td>Path value</td><td>C:\Temp\redmine &amp; logs</td></tr>
29
    </table>
30
  HTML
31

  
32
  def test_paste_html_table_as_commonmark_table_in_issue_description
33
    with_settings text_formatting: 'common_mark' do
34
      log_user('jsmith', 'jsmith')
35
      visit '/projects/ecookbook/issues/new'
36

  
37
      result = dispatch_paste(find('#issue_description'), html: HTML_TABLE)
38

  
39
      assert_equal <<~'TEXT', result
40
        | Item | Notes |
41
        | -- | -- |
42
        | Redmine 6.1 | Supports <wiki> tags |
43
        | Multi-line | First line<br>Second line \| escaped |
44
        | Path value | C:\Temp\redmine & logs |
45

  
46
      TEXT
47
    end
48
  end
49

  
50
  def test_paste_html_table_as_textile_table_in_wiki_edit
51
    with_settings text_formatting: 'textile' do
52
      log_user('jsmith', 'jsmith')
53
      visit '/projects/ecookbook/wiki/CookBook_documentation/edit'
54

  
55
      result = dispatch_paste(find('#content_text'), html: HTML_TABLE)
56

  
57
      assert_equal <<~'TEXT', result
58
        |_. Item |_. Notes |
59
        | Redmine 6.1 | Supports <wiki> tags |
60
        | Multi-line | First line
61
        Second line &#124; escaped |
62
        | Path value | C:\Temp\redmine & logs |
63

  
64
      TEXT
65
    end
66
  end
67

  
68
  def test_pastes_only_standalone_html_tables
69
    with_settings text_formatting: 'common_mark' do
70
      log_user('jsmith', 'jsmith')
71
      visit '/projects/ecookbook/issues/new'
72

  
73
      # Pasted content from Excel
74
      pasted = dispatch_paste(find('#issue_description'), html: <<~HTML)
75
        <html>
76
        <head>
77
        <meta http-equiv=Content-Type content="text/html; charset=utf-8">
78
        <meta name=ProgId content=Excel.Sheet>
79
        <style>td {white-space:nowrap;}</style>
80
        <link rel=File-List href="file:///C:/Temp/clip_filelist.xml">
81
        </head>
82
        <body>
83
        <table><tr><td>Item</td><td>Notes</td></tr><tr><td>Table</td><td>Value</td></tr></table>
84
        </body>
85
        </html>
86
      HTML
87
      assert_includes pasted, "| Item | Notes |"
88

  
89
      # Pasted content from Google Sheets
90
      pasted = dispatch_paste(find('#issue_description'), html: <<~HTML)
91
        <meta><style>td {border: 1px solid #cccccc;}</style>
92
        <table><tr><td>Item</td><td>Notes</td></tr><tr><td>Table</td><td>Value</td></tr></table>
93
      HTML
94
      assert_includes pasted, "| Item | Notes |"
95

  
96
      # Pasted content without a table
97
      pasted = dispatch_paste(find('#issue_description'), html: '<p>Content</p>')
98
      assert_equal '', pasted # Handled as a normal paste.
99

  
100
      # Pasted content with a table and other HTML content
101
      pasted = dispatch_paste(find('#issue_description'), html: <<~HTML)
102
        <h1>Title</h1><table><tr><td>Item</td><td>Notes</td></tr></table>
103
      HTML
104
      assert_equal '', pasted # Handled as a normal paste.
105

  
106
      # Pasted content with multiple tables
107
      pasted = dispatch_paste(find('#issue_description'), html: <<~HTML)
108
        <table><tr><td>Item</td><td>Notes</td></tr></table>
109
        <table><tr><td>Item</td><td>Notes</td></tr></table>
110
      HTML
111
      assert_equal '', pasted # Handled as a normal paste.
112

  
113
      # Pasted content with a single table row
114
      pasted = dispatch_paste(find('#issue_description'), html: <<~HTML)
115
        <table><tr><td>Item</td><td>Notes</td></tr></table>
116
      HTML
117
      assert_equal '', pasted # Handled as a normal paste.
118
    end
119
  end
120

  
121
  private
122

  
123
  def dispatch_paste(field, html:)
124
    page.evaluate_script(<<~JS, field, html)
125
      ((element, htmlText) => {
126
        element.value = ''
127
        element.setSelectionRange(0, 0)
128

  
129
        const clipboardData = {
130
          getData(type) {
131
            return type === 'text/html' ? htmlText : ''
132
          }
133
        }
134

  
135
        const event = new Event('paste', { bubbles: true, cancelable: true })
136
        Object.defineProperty(event, 'clipboardData', { value: clipboardData })
137
        element.dispatchEvent(event)
138

  
139
        return element.value
140
      })(arguments[0], arguments[1])
141
    JS
142
  end
143
end
(8-8/8)