Project

General

Profile

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

Katsuya HIDAKA, 2026-04-11 07:03

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
    if (rows.length === 0) return null
6

  
7
    const output = []
8
    output.push(this.#formatRow(rows[0]))
9

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

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

  
17
    return output.join('\n')
18
  }
19

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

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

  
31
class TextileTableFormatter {
32
  format(rows) {
33
    if (rows.length === 0) return null
34

  
35
    const output = []
36
    output.push(this.#formatHeader(rows[0]))
37

  
38
    for (let i = 1; i < rows.length; i++) {
39
      output.push(this.#formatRow(rows[i]))
40
    }
41

  
42
    return output.join('\n')
43
  }
44

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

  
49
  #formatRow(row) {
50
    return `| ${row.map(cell => this.#formatCell(cell)).join(' | ')} |`
51
  }
52

  
53
  #formatCell(cell) {
54
    return cell.replaceAll('|', '&#124;')
55
  }
56
}
57

  
58
export default class extends Controller {
59
  handlePaste(event) {
60
    const formatter = this.#tableFormatterFor(event.params.textFormatting)
61
    if (!formatter) return
62

  
63
    const rows = this.#extractTableFromClipboard(event)
64
    if (!rows) return
65

  
66
    const table = formatter.format(rows)
67
    if (!table) return
68

  
69
    event.preventDefault()
70
    this.#insertTextAtCursor(event.currentTarget, table)
71
  }
72

  
73
  #tableFormatterFor(textFormatting) {
74
    switch (textFormatting) {
75
      case 'common_mark':
76
        return new CommonMarkTableFormatter()
77
      case 'textile':
78
        return new TextileTableFormatter()
79
      default:
80
        return null
81
    }
82
  }
83

  
84
  #extractTableFromClipboard(event) {
85
    const clipboardData = event.clipboardData
86
    if (!clipboardData) return null
87

  
88
    const htmlData = clipboardData.getData('text/html')
89
    if (!htmlData) return null
90

  
91
    return this.#extractTableFromHtml(htmlData)
92
  }
93

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

  
98
    const table = temp.querySelector('table')
99
    if (!table) return null
100

  
101
    const rows = []
102
    table.querySelectorAll('tr').forEach(tr => {
103
      const cells = []
104
      tr.querySelectorAll('td, th').forEach(cell => {
105
        cells.push(this.#extractCellText(cell).trim())
106
      })
107
      if (cells.length > 0) {
108
        rows.push(cells)
109
      }
110
    })
111

  
112
    return this.#normalizeRows(rows)
113
  }
114

  
115
  #normalizeRows(rows) {
116
    if (rows.length === 0) return null
117

  
118
    const maxColumns = rows.reduce((currentMax, row) => Math.max(currentMax, row.length), 0)
119
    if (maxColumns < 2) return null
120

  
121
    rows.forEach(row => {
122
      while (row.length < maxColumns) {
123
        row.push('')
124
      }
125
    })
126

  
127
    return rows
128
  }
129

  
130
  #extractCellText(cell) {
131
    const clone = cell.cloneNode(true)
132

  
133
    // Treat <br> as an in-cell line break and keep it as an internal newline
134
    // so each formatter can render it appropriately.
135
    clone.querySelectorAll('br').forEach(br => {
136
      br.replaceWith('\n')
137
    })
138

  
139
    return clone.textContent
140
  }
141

  
142
  #insertTextAtCursor(input, text) {
143
    const { selectionStart, selectionEnd } = input
144

  
145
    const replacement = `${text}\n\n`
146

  
147
    input.setRangeText(replacement, selectionStart, selectionEnd, 'end')
148
    const newCursorPos = selectionStart + replacement.length
149
    input.setSelectionRange(newCursorPos, newCursorPos)
150

  
151
    input.dispatchEvent(new Event('input', { bubbles: true }))
152
  }
153
}
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
  private
69

  
70
  def dispatch_paste(field, html:)
71
    page.evaluate_script(<<~JS, field, html)
72
      ((element, htmlText) => {
73
        element.value = ''
74
        element.setSelectionRange(0, 0)
75

  
76
        const clipboardData = {
77
          getData() {
78
            return htmlText
79
          }
80
        }
81

  
82
        const event = new Event('paste', { bubbles: true, cancelable: true })
83
        Object.defineProperty(event, 'clipboardData', { value: clipboardData })
84
        element.dispatchEvent(event)
85

  
86
        return element.value
87
      })(arguments[0], arguments[1])
88
    JS
89
  end
90
end
(7-7/7)