From 57507ce21b655dca2e973daa24f1cf451efc5783 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 | 219 ++++++++++++++++++ 5 files changed, 377 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..a10ebcc2a --- /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 = null + + 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+)\.|[*\-+])\s*(.*)$/) + if (!match) return null + + const indent = match[1] + const marker = match[2] + const number = match[3] + const content = match[4] + + if (content.trim() === "") { + return { action: "remove" } + } + + if (number) { + const nextNumber = parseInt(number, 10) + 1 + return { action: "insert", text: `${indent}${nextNumber}. ` } + } else { + return { action: "insert", text: `${indent}${marker} ` } + } + } +} + +class TextileListFormatter { + format(line) { + const match = line.match(/^([*#]+)\s*(.*)$/); + if (!match) return null; + + const marker = match[1]; + const content = match[2]; + + if (content.trim().length === 0) { + 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 2e2e8b933..d962b584a 100644 --- a/test/helpers/application_helper_test.rb +++ b/test/helpers/application_helper_test.rb @@ -2441,4 +2441,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..c807bf0f8 --- /dev/null +++ b/test/system/list_autofill_test.rb @@ -0,0 +1,219 @@ +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 + fill_in 'Subject', :with => 'Test list autofill feature' + find('#issue_description_and_toolbar').click + 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 + fill_in 'Subject', :with => 'Test ordered list autofill' + find('#issue_description_and_toolbar').click + 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 + fill_in 'Subject', :with => 'Test empty list item removal' + find('#issue_description_and_toolbar').click + 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 + fill_in 'Subject', :with => 'Test markdown list autofill' + find('#issue_description_and_toolbar').click + 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 + fill_in 'Subject', :with => 'Test markdown numbered list autofill' + find('#issue_description_and_toolbar').click + 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 + fill_in 'Subject', :with => 'Test list autofill disabled' + find('#issue_description_and_toolbar').click + 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 + fill_in 'Subject', :with => 'Test nested list autofill in Markdown' + find('#issue_description_and_toolbar').click + + 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 + fill_in 'Subject', :with => 'Test mixed list types in Markdown' + find('#issue_description_and_toolbar').click + + 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 +end -- 2.49.0