From c2f7824e163a533dd151627356a68f7c07bf9dc4 Mon Sep 17 00:00:00 2001
From: Katsuya HIDAKA <%= 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)
%>
')
+ }
+}
+
+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.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 @@ 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' +| Item | Notes |
|---|---|
| Redmine 6.1 | Supports <wiki> tags |
| Multi-line | First line Second line | escaped |
| Path value | C:\Temp\redmine & logs |