From 4b67127b3ead401bce32997e08222fdc76b4436b Mon Sep 17 00:00:00 2001 From: ishikawa999 Date: Mon, 4 Aug 2025 15:05:53 +0900 Subject: [PATCH] Add automatic list marker insertion for textareas --- app/helpers/application_helper.rb | 11 + .../controllers/list_autofill_controller.js | 126 +++++++ app/views/issues/_form.html.erb | 4 +- test/helpers/application_helper_test.rb | 19 ++ test/system/list_autofill_test.rb | 313 ++++++++++++++++++ 5 files changed, 471 insertions(+), 2 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..81f0fed64 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1438,6 +1438,17 @@ module ApplicationHelper end end + def list_autofill_data_attributes + return {} if Setting.text_formatting.blank? + + { + controller: "list-autofill", + action: "keydown->list-autofill#handleEnter", + list_autofill_target: "input", + list_autofill_text_formatting_param: Setting.text_formatting + } + end + unless const_defined?(:MACROS_RE) MACROS_RE = /( (!)? # escaping diff --git a/app/javascript/controllers/list_autofill_controller.js b/app/javascript/controllers/list_autofill_controller.js new file mode 100644 index 000000000..98ea61b7c --- /dev/null +++ b/app/javascript/controllers/list_autofill_controller.js @@ -0,0 +1,126 @@ +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 + + event.preventDefault() + + switch (result.action) { + case "remove": + const beforeLine = value.slice(0, lineStartPos) + const afterCursor = value.slice(selectionStart) + this.input.value = beforeLine + afterCursor + this.input.setSelectionRange(lineStartPos, lineStartPos) + break + + case "insert": + 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) { + 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) { + 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 { + static targets = ['input'] + + connect() { + this.inputTarget.addEventListener('compositionstart', this.handleCompositionStart) + this.inputTarget.addEventListener('compositionend', this.handleCompositionEnd) + this.isComposing = false + } + + disconnect() { + this.inputTarget.removeEventListener('compositionstart', this.handleCompositionStart) + this.inputTarget.removeEventListener('compositionend', this.handleCompositionEnd) + } + + handleCompositionStart = () => { + this.isComposing = true + } + + handleCompositionEnd = () => { + this.isComposing = false + } + + handleEnter(event) { + if (this.isComposing || event.key !== 'Enter') return + + const format = event.params.textFormatting + const handler = new ListAutofillHandler(this.inputTarget, format) + handler.run(event) + } +} 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/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..f0266e371 --- /dev/null +++ b/test/system/list_autofill_test.rb @@ -0,0 +1,313 @@ +# 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_with_markdown_format + 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 + ) + end + end + end + + def test_autofill_with_markdown_numbered_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_numbered_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_numbered_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_list_autofill_disabled_when_text_formatting_blank + with_settings :text_formatting => '' 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_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 -- 2.49.0