From 0da90f00e1474006c30b10bedee5a82111621390 Mon Sep 17 00:00:00 2001 From: Marius Balteanu Date: Sun, 21 Sep 2025 16:42:07 +0000 Subject: [PATCH] Support automatic list marker insertion in textareas (#43095). Patch by Mizuki ISHIKAWA (user:ishikawa999). git-svn-id: https://svn.redmine.org/redmine/trunk@24003 e93f8b46-1217-0410-a6f0-8f06a7374b81 --- app/helpers/application_helper.rb | 10 + app/helpers/custom_fields_helper.rb | 4 +- .../controllers/list_autofill_controller.js | 124 ++++++++ app/views/documents/_form.html.erb | 3 +- app/views/issues/_edit.html.erb | 2 +- app/views/issues/_form.html.erb | 4 +- app/views/issues/bulk_edit.html.erb | 2 +- app/views/journals/_notes_form.html.erb | 2 +- app/views/messages/_form.html.erb | 2 +- app/views/news/_form.html.erb | 2 +- app/views/news/show.html.erb | 2 +- app/views/projects/_form.html.erb | 2 +- app/views/search/index.html.erb | 2 +- app/views/settings/_general.html.erb | 2 +- app/views/settings/_notifications.html.erb | 4 +- app/views/wiki/edit.html.erb | 2 +- test/helpers/application_helper_test.rb | 19 ++ test/system/list_autofill_test.rb | 297 ++++++++++++++++++ 18 files changed, 468 insertions(+), 17 deletions(-) create mode 100644 app/javascript/controllers/list_autofill_controller.js create mode 100644 test/system/list_autofill_test.rb diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index ab418fb38..e64eb3616 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1438,6 +1438,16 @@ module ApplicationHelper end end + def list_autofill_data_attributes + return {} if Setting.text_formatting.blank? + + { + controller: 'list-autofill', + action: 'beforeinput->list-autofill#handleBeforeInput', + list_autofill_text_formatting_param: Setting.text_formatting + } + end + unless const_defined?(:MACROS_RE) MACROS_RE = /( (!)? # escaping diff --git a/app/helpers/custom_fields_helper.rb b/app/helpers/custom_fields_helper.rb index 82b78cdcb..14025b934 100644 --- a/app/helpers/custom_fields_helper.rb +++ b/app/helpers/custom_fields_helper.rb @@ -87,7 +87,7 @@ module CustomFieldsHelper css += ' wiki-edit' data = { :auto_complete => true - } + }.merge(list_autofill_data_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) end custom_field.format.bulk_edit_tag( self, diff --git a/app/javascript/controllers/list_autofill_controller.js b/app/javascript/controllers/list_autofill_controller.js new file mode 100644 index 000000000..4db836ad6 --- /dev/null +++ b/app/javascript/controllers/list_autofill_controller.js @@ -0,0 +1,124 @@ +import { Controller } from '@hotwired/stimulus' + +class ListAutofillHandler { + constructor(inputElement, format) { + this.input = inputElement + this.format = format + } + + run(event) { + const { selectionStart, value } = this.input + + const beforeCursor = value.slice(0, selectionStart) + const lines = beforeCursor.split("\n") + const currentLine = lines[lines.length - 1] + const lineStartPos = beforeCursor.lastIndexOf("\n") + 1 + + let formatter + switch (this.format) { + case 'common_mark': + formatter = new CommonMarkListFormatter() + break + case 'textile': + formatter = new TextileListFormatter() + break + default: + return + } + + const result = formatter.format(currentLine) + + if (!result) return + + switch (result.action) { + case 'remove': + event.preventDefault() + this.input.setRangeText('', lineStartPos, selectionStart, 'start') + break + case 'insert': + event.preventDefault() + const insertText = "\n" + result.text + const newValue = value.slice(0, selectionStart) + insertText + value.slice(selectionStart) + const newCursor = selectionStart + insertText.length + this.input.value = newValue + this.input.setSelectionRange(newCursor, newCursor) + break + default: + return + } + } +} + +class CommonMarkListFormatter { + format(line) { + // Match list items in CommonMark syntax. + // Captures either an ordered list (e.g., '1. ' or '2) ') or an unordered list (e.g., '* ', '- ', '+ '). + // The regex structure: + // ^(\s*) → leading whitespace + // (?:(\d+)([.)]) → an ordered list marker: number followed by '.' or ')' + // |([*+\-]) → OR an unordered list marker: '*', '+', or '-' + // (.*)$ → the actual list item content + // + // Examples: + // '2. ordered text' → indent='', number='2', delimiter='.', bullet=undefined, content='ordered text' + // ' 3) nested ordered text' → indent=' ', number='3', delimiter=')', bullet=undefined, content='nested ordered text' + // '* unordered text' → indent='', number=undefined, delimiter=undefined, bullet='*', content='unordered text' + // '+ unordered text' → indent='', number=undefined, delimiter=undefined, bullet='+', content='unordered text' + // ' - nested unordered text' → indent=' ', number=undefined, delimiter=undefined, bullet='-', content='nested unordered text' + const match = line.match(/^(\s*)(?:(\d+)([.)])|([*+\-])) (.*)$/) + if (!match) return null + + const indent = match[1] + const number = match[2] + const delimiter = match[3] + const bullet = match[4] + const content = match[5] + + if (content === '') { + return { action: 'remove' } + } + + if (number) { + const nextNumber = parseInt(number, 10) + 1 + return { action: 'insert', text: `${indent}${nextNumber}${delimiter} ` } + } else { + return { action: 'insert', text: `${indent}${bullet} ` } + } + } +} + +class TextileListFormatter { + format(line) { + // Match list items in Textile syntax. + // Captures either an ordered list (using '#') or an unordered list (using '*'). + // The regex structure: + // ^([*#]+) → one or more list markers: '*' for unordered, '#' for ordered + // (.*)$ → the actual list item content + // + // Examples: + // '# ordered text' → marker='#', content='ordered text' + // '## nested ordered text' → marker='##', content='nested ordered text' + // '* unordered text' → marker='*', content='unordered text' + // '** nested unordered text' → marker='**', content='nested unordered text' + const match = line.match(/^([*#]+) (.*)$/) + if (!match) return null + + const marker = match[1] + const content = match[2] + + if (content === '') { + return { action: 'remove' } + } + + return { action: 'insert', text: `${marker} ` } + } +} + +export default class extends Controller { + handleBeforeInput(event) { + if (event.inputType != 'insertLineBreak') return + + const format = event.params.textFormatting + new ListAutofillHandler(event.currentTarget, format).run(event) + } +} diff --git a/app/views/documents/_form.html.erb b/app/views/documents/_form.html.erb index 139afe24a..82dcd9293 100644 --- a/app/views/documents/_form.html.erb +++ b/app/views/documents/_form.html.erb @@ -6,7 +6,8 @@

<%= f.text_area :description, :cols => 60, :rows => 15, :class => 'wiki-edit', :data => { :auto_complete => true - } %>

+ }.merge(list_autofill_data_attributes) + %>

<% @document.custom_field_values.each do |value| %>

<%= custom_field_tag_with_label :document, value %>

diff --git a/app/views/issues/_edit.html.erb b/app/views/issues/_edit.html.erb index 5692eb80b..299efec7a 100644 --- a/app/views/issues/_edit.html.erb +++ b/app/views/issues/_edit.html.erb @@ -32,7 +32,7 @@ <%= f.text_area :notes, :cols => 60, :rows => 10, :class => 'wiki-edit', :data => { :auto_complete => true - }, + }.merge(list_autofill_data_attributes), :no_label => true %> <%= wikitoolbar_for 'issue_notes', preview_issue_path(:project_id => @project, :issue_id => @issue) %> diff --git a/app/views/issues/_form.html.erb b/app/views/issues/_form.html.erb index c1d130236..4e8e07856 100644 --- a/app/views/issues/_form.html.erb +++ b/app/views/issues/_form.html.erb @@ -36,8 +36,8 @@ <%= f.text_area :description, :cols => 60, :accesskey => accesskey(:edit), :class => 'wiki-edit', :rows => [[10, @issue.description.to_s.length / 50].max, 20].min, :data => { - :auto_complete => true, - }, + :auto_complete => true + }.merge(list_autofill_data_attributes), :no_label => true %> <% end %> <%= link_to_function content_tag(:span, sprite_icon('edit', l(:button_edit)), :class => 'icon icon-edit'), '$(this).hide(); $("#issue_description_and_toolbar").show()' unless @issue.new_record? %> diff --git a/app/views/issues/bulk_edit.html.erb b/app/views/issues/bulk_edit.html.erb index b8bf87beb..70582b735 100644 --- a/app/views/issues/bulk_edit.html.erb +++ b/app/views/issues/bulk_edit.html.erb @@ -223,7 +223,7 @@ <%= text_area_tag 'notes', @notes, :cols => 60, :rows => 10, :class => 'wiki-edit', :data => { :auto_complete => true - } + }.merge(list_autofill_data_attributes) %> <%= wikitoolbar_for 'notes' %> diff --git a/app/views/journals/_notes_form.html.erb b/app/views/journals/_notes_form.html.erb index 13a6c7fdc..45f20a6c4 100644 --- a/app/views/journals/_notes_form.html.erb +++ b/app/views/journals/_notes_form.html.erb @@ -7,7 +7,7 @@ :rows => (@journal.notes.blank? ? 10 : [[10, @journal.notes.length / 50].max, 100].min), :data => { :auto_complete => true - } + }.merge(list_autofill_data_attributes) %> <% if @journal.safe_attribute? 'private_notes' %> <%= hidden_field_tag 'journal[private_notes]', '0' %> diff --git a/app/views/messages/_form.html.erb b/app/views/messages/_form.html.erb index e030627a2..492390036 100644 --- a/app/views/messages/_form.html.erb +++ b/app/views/messages/_form.html.erb @@ -27,7 +27,7 @@ :accesskey => accesskey(:edit), :data => { :auto_complete => true - } + }.merge(list_autofill_data_attributes) %>

<%= wikitoolbar_for 'message_content', preview_board_message_path(:board_id => @board, :id => @message) %> diff --git a/app/views/news/_form.html.erb b/app/views/news/_form.html.erb index 8ce430732..f86bba176 100644 --- a/app/views/news/_form.html.erb +++ b/app/views/news/_form.html.erb @@ -13,7 +13,7 @@

<%= f.text_area :description, :required => true, :cols => 60, :rows => 15, :class => 'wiki-edit', :data => { :auto_complete => true - } + }.merge(list_autofill_data_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 704d3d04e..11126998e 100644 --- a/app/views/news/show.html.erb +++ b/app/views/news/show.html.erb @@ -70,7 +70,7 @@ <%= text_area 'comment', 'comments', :cols => 80, :rows => 15, :class => 'wiki-edit', :data => { :auto_complete => true - } + }.merge(list_autofill_data_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 3cde41599..0f8733e28 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.text_area :description, :rows => 8, :class => 'wiki-edit' %>

+

<%= f.text_area :description, :rows => 8, :class => 'wiki-edit', :data => list_autofill_data_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/search/index.html.erb b/app/views/search/index.html.erb index c17bbd8ea..b092bbae0 100644 --- a/app/views/search/index.html.erb +++ b/app/views/search/index.html.erb @@ -6,7 +6,7 @@

<%= text_field_tag 'q', @question, :size => 60, :id => 'search-input', :data => { :auto_complete => true - } %> + }.merge(list_autofill_data_attributes) %> <%= project_select_tag %> <%= hidden_field_tag 'all_words', '', :id => nil %> diff --git a/app/views/settings/_general.html.erb b/app/views/settings/_general.html.erb index 44206b6c2..5afd1cb44 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_text_area :welcome_text, :cols => 60, :rows => 5, :class => 'wiki-edit' %>

+

<%= setting_text_area :welcome_text, :cols => 60, :rows => 5, :class => 'wiki-edit', :data => list_autofill_data_attributes %>

<%= wikitoolbar_for 'settings_welcome_text' %> diff --git a/app/views/settings/_notifications.html.erb b/app/views/settings/_notifications.html.erb index aafbe4340..9856d4fec 100644 --- a/app/views/settings/_notifications.html.erb +++ b/app/views/settings/_notifications.html.erb @@ -19,12 +19,12 @@
<%= l(:setting_emails_header) %> -<%= setting_text_area :emails_header, :label => false, :class => 'wiki-edit', :rows => 5 %> +<%= setting_text_area :emails_header, :label => false, :class => 'wiki-edit', :rows => 5, :data => list_autofill_data_attributes %> <%= wikitoolbar_for 'settings_emails_header' %>
<%= l(:setting_emails_footer) %> -<%= setting_text_area :emails_footer, :label => false, :class => 'wiki-edit', :rows => 5 %> +<%= setting_text_area :emails_footer, :label => false, :class => 'wiki-edit', :rows => 5, :data => list_autofill_data_attributes %> <%= wikitoolbar_for 'settings_emails_footer' %>
diff --git a/app/views/wiki/edit.html.erb b/app/views/wiki/edit.html.erb index 47204c945..115b5b2c4 100644 --- a/app/views/wiki/edit.html.erb +++ b/app/views/wiki/edit.html.erb @@ -17,7 +17,7 @@ :class => 'wiki-edit', :data => { :auto_complete => true - } + }.merge(list_autofill_data_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 1f60bbbe2..aad2c4542 100644 --- a/test/helpers/application_helper_test.rb +++ b/test/helpers/application_helper_test.rb @@ -2427,4 +2427,23 @@ class ApplicationHelperTest < Redmine::HelperTest :class => "wiki-page new"), } end + + def test_list_autofill_data_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" + } + + assert_equal expected, list_autofill_data_attributes + end + end + + def test_list_autofill_data_attributes_with_blank_text_formatting + with_settings :text_formatting => '' do + assert_equal({}, list_autofill_data_attributes) + end + end end diff --git a/test/system/list_autofill_test.rb b/test/system/list_autofill_test.rb new file mode 100644 index 000000000..95898c734 --- /dev/null +++ b/test/system/list_autofill_test.rb @@ -0,0 +1,297 @@ +# frozen_string_literal: true + +require_relative '../application_system_test_case' + +class ListAutofillSystemTest < ApplicationSystemTestCase + def setup + super + log_user('jsmith', 'jsmith') + end + + def test_autofill_textile_unordered_list + with_settings :text_formatting => 'textile' do + visit '/projects/ecookbook/issues/new' + + within('form#issue-form') do + find('#issue_description').send_keys('* First item') + find('#issue_description').send_keys(:enter) + + assert_equal( + "* First item\n" \ + "* ", + find('#issue_description').value + ) + end + end + end + + def test_autofill_textile_ordered_list + with_settings :text_formatting => 'textile' do + visit '/projects/ecookbook/issues/new' + + within('form#issue-form') do + find('#issue_description').send_keys('# First item') + find('#issue_description').send_keys(:enter) + + assert_equal( + "# First item\n" \ + "# ", + find('#issue_description').value + ) + end + end + end + + def test_remove_list_marker_for_empty_item + with_settings :text_formatting => 'textile' do + visit '/projects/ecookbook/issues/new' + + within('form#issue-form') do + find('#issue_description').send_keys('* First item') + find('#issue_description').send_keys(:enter) + find('#issue_description').send_keys(:enter) # Press Enter on empty line removes the marker + + assert_equal( + "* First item\n", + find('#issue_description').value + ) + end + end + end + + def test_autofill_markdown_unordered_list + with_settings :text_formatting => 'common_mark' do + visit '/projects/ecookbook/issues/new' + + within('form#issue-form') do + find('#issue_description').send_keys('- First item') + find('#issue_description').send_keys(:enter) + assert_equal( + "- First item\n" \ + "- ", + find('#issue_description').value + ) + + fill_in 'Description', with: '' + find('#issue_description').send_keys('* First item') + find('#issue_description').send_keys(:enter) + assert_equal( + "* First item\n" \ + "* ", + find('#issue_description').value + ) + + fill_in 'Description', with: '' + find('#issue_description').send_keys('+ First item') + find('#issue_description').send_keys(:enter) + assert_equal( + "+ First item\n" \ + "+ ", + find('#issue_description').value + ) + end + end + end + + def test_autofill_with_markdown_ordered_list + with_settings :text_formatting => 'common_mark' do + visit '/projects/ecookbook/issues/new' + + within('form#issue-form') do + find('#issue_description').send_keys('1. First item') + find('#issue_description').send_keys(:enter) + + assert_equal( + "1. First item\n" \ + "2. ", + find('#issue_description').value + ) + end + end + end + + def test_autofill_with_markdown_ordered_list_using_parenthesis + with_settings :text_formatting => 'common_mark' do + visit '/projects/ecookbook/issues/new' + + within('form#issue-form') do + find('#issue_description').send_keys('1) First item') + find('#issue_description').send_keys(:enter) + + assert_equal( + "1) First item\n" \ + "2) ", + find('#issue_description').value + ) + end + end + end + + def test_textile_nested_list_autofill + with_settings :text_formatting => 'textile' do + visit '/projects/ecookbook/issues/new' + + within('form#issue-form') do + find('#issue_description').send_keys('* Parent item') + find('#issue_description').send_keys(:enter) + find('#issue_description').send_keys(:backspace, :backspace) # Remove auto-filled marker + find('#issue_description').send_keys('** Child item') + find('#issue_description').send_keys(:enter) + find('#issue_description').send_keys(:backspace, :backspace, :backspace) # Remove auto-filled marker + find('#issue_description').send_keys("*** Grandchild item") + find('#issue_description').send_keys(:enter) + + assert_equal( + "* Parent item\n" \ + "** Child item\n" \ + "*** Grandchild item\n" \ + "*** ", + find('#issue_description').value + ) + end + end + end + + def test_common_mark_nested_list_autofill + with_settings :text_formatting => 'common_mark' do + visit '/projects/ecookbook/issues/new' + + within('form#issue-form') do + find('#issue_description').send_keys('- Parent item') + find('#issue_description').send_keys(:enter) + find('#issue_description').send_keys(:backspace, :backspace) # Remove auto-filled marker + find('#issue_description').send_keys(' - Child item') + find('#issue_description').send_keys(:enter) + + assert_equal( + "- Parent item\n" \ + " - Child item\n" \ + " - ", + find('#issue_description').value + ) + + find('#issue_description').send_keys(:backspace, :backspace, :backspace, :backspace) # Remove auto-filled marker + find('#issue_description').send_keys(' - Grandchild item') + find('#issue_description').send_keys(:enter) + + assert_equal( + "- Parent item\n" \ + " - Child item\n" \ + " - Grandchild item\n" \ + " - ", + find('#issue_description').value + ) + end + end + end + + def test_common_mark_mixed_list_types + with_settings :text_formatting => 'common_mark' do + visit '/projects/ecookbook/issues/new' + + within('form#issue-form') do + find('#issue_description').send_keys('1. First numbered item') + find('#issue_description').send_keys(:enter) + find('#issue_description').send_keys(:backspace, :backspace, :backspace) # Remove auto-filled numbered list marker + find('#issue_description').send_keys(' - Nested bullet item') + find('#issue_description').send_keys(:enter) + + assert_equal( + "1. First numbered item\n" \ + " - Nested bullet item\n" \ + " - ", + find('#issue_description').value + ) + + find('#issue_description').send_keys(:backspace, :backspace, :backspace, :backspace, :backspace) # Remove auto-filled numbered list marker + find('#issue_description').send_keys('2. Second numbered item') + find('#issue_description').send_keys(:enter) + + assert_equal( + "1. First numbered item\n" \ + " - Nested bullet item\n" \ + "2. Second numbered item\n" \ + "3. ", + find('#issue_description').value + ) + end + end + end + + def test_remove_list_marker_with_single_halfwidth_space_variants + with_settings :text_formatting => 'common_mark' do + visit '/projects/ecookbook/issues/new' + + within('form#issue-form') do + find('#issue_description').click + + # Half-width space only → should remove marker + find('#issue_description').send_keys('1. First item', :enter) + assert_equal("1. First item\n2. ", find('#issue_description').value) + find('#issue_description').send_keys(:enter) + assert_equal("1. First item\n", find('#issue_description').value) + + fill_in 'Description', with: '' + # Full-width space only → should NOT remove marker + find('#issue_description').send_keys('1. First item', :enter) + find('#issue_description').send_keys(:backspace, :backspace, :backspace) + find('#issue_description').send_keys("2. ", :enter) + assert_equal("1. First item\n2. \n", find('#issue_description').value) + + fill_in 'Description', with: '' + # Two or more spaces → should NOT remove marker + find('#issue_description').send_keys('1. First item', :enter) + find('#issue_description').send_keys(:backspace, :backspace, :backspace) + find('#issue_description').send_keys("2. ", :enter) + assert_equal("1. First item\n2. \n3. ", find('#issue_description').value) + end + end + end + + def test_no_autofill_when_content_is_missing_or_invalid_marker + with_settings :text_formatting => 'common_mark' do + visit '/projects/ecookbook/issues/new' + + within('form#issue-form') do + find('#issue_description').click + + # Marker only with no content → should not trigger insert + find('#issue_description').send_keys('1.', :enter) + assert_equal("1.\n", find('#issue_description').value) + + fill_in 'Description', with: '' + # Invalid marker pattern (e.g. double dot) → should not trigger insert + find('#issue_description').send_keys('1.. Invalid marker', :enter) + assert_equal("1.. Invalid marker\n", find('#issue_description').value) + end + end + end + + def test_autofill_ignored_with_none_text_formatting + with_settings :text_formatting => '' do + visit '/projects/ecookbook/issues/new' + + within('form#issue-form') do + find('#issue_description').click + + # Unsupported format → no autofill should occur + find('#issue_description').send_keys('* First item', :enter) + assert_equal("* First item\n", find('#issue_description').value) + end + end + end + + def test_marker_not_inserted_on_empty_line + with_settings :text_formatting => 'textile' do + visit '/projects/ecookbook/issues/new' + + within('form#issue-form') do + find('#issue_description').click + + # Pressing enter on an empty line → should not trigger insert + find('#issue_description').send_keys(:enter) + assert_equal("\n", find('#issue_description').value) + end + end + end +end