mirror of
https://github.com/redmine/redmine.git
synced 2026-05-07 06:56:34 +02:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
159
app/javascript/controllers/table_paste_controller.js
Normal file
159
app/javascript/controllers/table_paste_controller.js
Normal 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('|', '|')
|
||||
}
|
||||
}
|
||||
|
||||
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 }))
|
||||
}
|
||||
}
|
||||
@@ -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| %>
|
||||
|
||||
@@ -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) %>
|
||||
|
||||
|
||||
@@ -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? %>
|
||||
|
||||
@@ -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' %>
|
||||
|
||||
|
||||
@@ -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' %>
|
||||
|
||||
@@ -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]-->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' %>
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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? %>
|
||||
|
||||
@@ -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
|
||||
|
||||
143
test/system/table_paste_test.rb
Normal file
143
test/system/table_paste_test.rb
Normal 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 <wiki> 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 & 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 | 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
|
||||
Reference in New Issue
Block a user