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
This commit is contained in:
Marius Balteanu
2025-09-21 16:42:07 +00:00
parent c85c827d22
commit 0da90f00e1
18 changed files with 468 additions and 17 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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)
}
}

View File

@@ -6,7 +6,8 @@
<p><%= f.text_area :description, :cols => 60, :rows => 15, :class => 'wiki-edit',
:data => {
:auto_complete => true
} %></p>
}.merge(list_autofill_data_attributes)
%></p>
<% @document.custom_field_values.each do |value| %>
<p><%= custom_field_tag_with_label :document, value %></p>

View File

@@ -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) %>

View File

@@ -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? %>

View File

@@ -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' %>

View File

@@ -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' %>

View File

@@ -27,7 +27,7 @@
:accesskey => accesskey(:edit),
:data => {
:auto_complete => true
}
}.merge(list_autofill_data_attributes)
%></p>
<%= wikitoolbar_for 'message_content', preview_board_message_path(:board_id => @board, :id => @message) %>
<!--[eoform:message]-->

View File

@@ -13,7 +13,7 @@
<p><%= f.text_area :description, :required => true, :cols => 60, :rows => 15, :class => 'wiki-edit',
:data => {
:auto_complete => true
}
}.merge(list_autofill_data_attributes)
%></p>
<p id="attachments_form"><label><%= l(:label_attachment_plural) %></label><%= render :partial => 'attachments/form', :locals => {:container => @news} %></p>
</div>

View File

@@ -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) %>
</div>

View File

@@ -4,7 +4,7 @@
<!--[form:project]-->
<p><%= f.text_field :name, :required => true, :size => 60 %></p>
<p><%= f.text_area :description, :rows => 8, :class => 'wiki-edit' %></p>
<p><%= f.text_area :description, :rows => 8, :class => 'wiki-edit', :data => list_autofill_data_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>

View File

@@ -6,7 +6,7 @@
<p><%= 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 %>
<label><%= check_box_tag 'all_words', 1, @all_words %> <%= l(:label_all_words) %></label>

View File

@@ -3,7 +3,7 @@
<div class="box tabular settings">
<p><%= setting_text_field :app_title, :size => 30 %></p>
<p><%= setting_text_area :welcome_text, :cols => 60, :rows => 5, :class => 'wiki-edit' %></p>
<p><%= setting_text_area :welcome_text, :cols => 60, :rows => 5, :class => 'wiki-edit', :data => list_autofill_data_attributes %></p>
<%= wikitoolbar_for 'settings_welcome_text' %>

View File

@@ -19,12 +19,12 @@
</fieldset>
<fieldset class="box"><legend><%= l(:setting_emails_header) %></legend>
<%= 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' %>
</fieldset>
<fieldset class="box"><legend><%= l(:setting_emails_footer) %></legend>
<%= 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' %>
</fieldset>

View File

@@ -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? %>

View File

@@ -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

View File

@@ -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