Add support for pasting spreadsheet tables as CommonMark/Textile tables in wiki textareas (#43950).

Patch by Katsuya HIDAKA (user:hidakatsuya).


git-svn-id: https://svn.redmine.org/redmine/trunk@24586 e93f8b46-1217-0410-a6f0-8f06a7374b81
This commit is contained in:
Go MAEDA
2026-04-16 07:27:41 +00:00
parent 2507ca02ca
commit 906fc2dadf
17 changed files with 338 additions and 35 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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', '<br>')
}
}
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('|', '&#124;')
}
}
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 <br> 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 }))
}
}

View File

@@ -5,8 +5,8 @@
<p><%= f.text_field :title, :required => true, :size => 60 %></p>
<p><%= 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)
%></p>
<% @document.custom_field_values.each do |value| %>

View File

@@ -32,8 +32,8 @@
<fieldset id="add_notes"><legend><%= l(:field_notes) %></legend>
<%= 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) %>

View File

@@ -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? %>

View File

@@ -222,8 +222,8 @@
<legend><%= l(:field_notes) %></legend>
<%= 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' %>

View File

@@ -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' %>

View File

@@ -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)
%></p>
<%= wikitoolbar_for 'message_content', preview_board_message_path(:board_id => @board, :id => @message) %>
<!--[eoform:message]-->

View File

@@ -12,8 +12,8 @@
<p><%= f.textarea :summary, :cols => 60, :rows => 2 %></p>
<p><%= 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)
%></p>
<p id="attachments_form"><label><%= l(:label_attachment_plural) %></label><%= render :partial => 'attachments/form', :locals => {:container => @news} %></p>
</div>

View File

@@ -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) %>
</div>

View File

@@ -4,7 +4,7 @@
<!--[form:project]-->
<p><%= f.text_field :name, :required => true, :size => 60 %></p>
<p><%= f.textarea :description, :rows => 8, :class => 'wiki-edit', :data => list_autofill_data_attributes %></p>
<p><%= f.textarea :description, :rows => 8, :class => 'wiki-edit', :data => wiki_textarea_stimulus_attributes %></p>
<p><%= f.text_field :identifier, :required => true, :size => 60, :disabled => @project.identifier_frozen?, :maxlength => Project::IDENTIFIER_MAX_LENGTH %>
<% unless @project.identifier_frozen? %>
<em class="info"><%= l(:text_length_between, :min => 1, :max => Project::IDENTIFIER_MAX_LENGTH) %> <%= l(:text_project_identifier_info).html_safe %></em>

View File

@@ -3,7 +3,7 @@
<div class="box tabular settings">
<p><%= setting_text_field :app_title, :size => 30 %></p>
<p><%= setting_textarea :welcome_text, :cols => 60, :rows => 5, :class => 'wiki-edit', :data => list_autofill_data_attributes %></p>
<p><%= setting_textarea :welcome_text, :cols => 60, :rows => 5, :class => 'wiki-edit', :data => wiki_textarea_stimulus_attributes %></p>
<%= wikitoolbar_for 'settings_welcome_text' %>

View File

@@ -19,12 +19,12 @@
</fieldset>
<fieldset class="box"><legend><%= l(:setting_emails_header) %></legend>
<%= 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' %>
</fieldset>
<fieldset class="box"><legend><%= l(:setting_emails_footer) %></legend>
<%= 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' %>
</fieldset>

View File

@@ -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? %>

View File

@@ -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

View File

@@ -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'
<table>
<tr><th>Item</th><th>Notes</th></tr>
<tr><td>Redmine 6.1</td><td>Supports &lt;wiki&gt; tags</td></tr>
<tr><td>Multi-line</td><td>First line<br>Second line | escaped</td></tr>
<tr><td>Path value</td><td>C:\Temp\redmine &amp; logs</td></tr>
</table>
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 <wiki> tags |
| Multi-line | First line<br>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 <wiki> tags |
| Multi-line | First line
Second line &#124; 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)
<html>
<head>
<meta http-equiv=Content-Type content="text/html; charset=utf-8">
<meta name=ProgId content=Excel.Sheet>
<style>td {white-space:nowrap;}</style>
<link rel=File-List href="file:///C:/Temp/clip_filelist.xml">
</head>
<body>
<table><tr><td>Item</td><td>Notes</td></tr><tr><td>Table</td><td>Value</td></tr></table>
</body>
</html>
HTML
assert_includes pasted, "| Item | Notes |"
# Pasted content from Google Sheets
pasted = dispatch_paste(find('#issue_description'), html: <<~HTML)
<meta><style>td {border: 1px solid #cccccc;}</style>
<table><tr><td>Item</td><td>Notes</td></tr><tr><td>Table</td><td>Value</td></tr></table>
HTML
assert_includes pasted, "| Item | Notes |"
# Pasted content without a table
pasted = dispatch_paste(find('#issue_description'), html: '<p>Content</p>')
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)
<h1>Title</h1><table><tr><td>Item</td><td>Notes</td></tr></table>
HTML
assert_equal '', pasted # Handled as a normal paste.
# Pasted content with multiple tables
pasted = dispatch_paste(find('#issue_description'), html: <<~HTML)
<table><tr><td>Item</td><td>Notes</td></tr></table>
<table><tr><td>Item</td><td>Notes</td></tr></table>
HTML
assert_equal '', pasted # Handled as a normal paste.
# Pasted content with a single table row
pasted = dispatch_paste(find('#issue_description'), html: <<~HTML)
<table><tr><td>Item</td><td>Notes</td></tr></table>
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