Project

General

Profile

Feature #43095 » 0001-Add-automatic-list-marker-insertion-for-textareas.patch

Mizuki ISHIKAWA, 2025-09-18 07:05

View differences:

app/helpers/application_helper.rb
1438 1438
    end
1439 1439
  end
1440 1440

  
1441
  def list_autofill_data_attributes
1442
    return {} if Setting.text_formatting.blank?
1443

  
1444
    {
1445
      controller: "list-autofill",
1446
      action: 'beforeinput->list-autofill#handleBeforeInput',
1447
      list_autofill_text_formatting_param: Setting.text_formatting
1448
    }
1449
  end
1450

  
1441 1451
  unless const_defined?(:MACROS_RE)
1442 1452
    MACROS_RE = /(
1443 1453
                  (!)?                        # escaping
app/helpers/custom_fields_helper.rb
87 87
      css += ' wiki-edit'
88 88
      data = {
89 89
        :auto_complete => true
90
      }
90
      }.merge(list_autofill_data_attributes)
91 91
    end
92 92
    cf.format.edit_tag(
93 93
      self,
......
137 137
      css += ' wiki-edit'
138 138
      data = {
139 139
        :auto_complete => true
140
      }
140
      }.merge(list_autofill_data_attributes)
141 141
    end
142 142
    custom_field.format.bulk_edit_tag(
143 143
      self,
app/javascript/controllers/list_autofill_controller.js
1
import { Controller } from '@hotwired/stimulus'
2

  
3
class ListAutofillHandler {
4
  constructor(inputElement, format) {
5
    this.input = inputElement
6
    this.format = format
7
  }
8

  
9
  run(event) {
10
    const { selectionStart, value } = this.input
11

  
12
    const beforeCursor = value.slice(0, selectionStart)
13
    const lines = beforeCursor.split("\n")
14
    const currentLine = lines[lines.length - 1]
15
    const lineStartPos = beforeCursor.lastIndexOf("\n") + 1
16

  
17
    let formatter
18
    switch (this.format) {
19
      case "common_mark":
20
        formatter = new CommonMarkListFormatter()
21
        break
22
      case "textile":
23
        formatter = new TextileListFormatter()
24
        break
25
      default:
26
        return
27
    }
28

  
29
    const result = formatter.format(currentLine)
30

  
31
    if (!result) return
32

  
33
    switch (result.action) {
34
      case "remove":
35
        event.preventDefault()
36
        this.input.setRangeText("", lineStartPos, selectionStart, "start")
37
        break
38
      case "insert":
39
        event.preventDefault()
40
        const insertText = "\n" + result.text
41
        const newValue = value.slice(0, selectionStart) + insertText + value.slice(selectionStart)
42
        const newCursor = selectionStart + insertText.length
43
        this.input.value = newValue
44
        this.input.setSelectionRange(newCursor, newCursor)
45
        break
46
      default:
47
        return
48
    }
49
  }
50
}
51

  
52
class CommonMarkListFormatter {
53
  format(line) {
54
    // Match list items in CommonMark syntax.
55
    // Captures either an ordered list (e.g., "1. " or "2) ") or an unordered list (e.g., "* ", "- ", "+ ").
56
    // The regex structure:
57
    // ^(\s*)               → leading whitespace
58
    // (?:(\d+)([.)])       → an ordered list marker: number followed by '.' or ')'
59
    // |([*+\-])            → OR an unordered list marker: '*', '+', or '-'
60
    // (.*)$                → the actual list item content
61
    //
62
    // Examples:
63
    // "2. ordered text"           → indent="",  number="2", delimiter=".", bullet=undefined, content="ordered text"
64
    // "  3) nested ordered text"  → indent="  ", number="3", delimiter=")", bullet=undefined, content="nested ordered text"
65
    // "* unordered text"          → indent="", number=undefined, delimiter=undefined, bullet="*", content="unordered text"
66
    // "+ unordered text"          → indent="", number=undefined, delimiter=undefined, bullet="+", content="unordered text"
67
    // "  - nested unordered text" → indent="  ", number=undefined, delimiter=undefined, bullet="-", content="nested unordered text"
68
    const match = line.match(/^(\s*)(?:(\d+)([.)])|([*+\-])) (.*)$/)
69
    if (!match) return null
70

  
71
    const indent = match[1]
72
    const number = match[2]
73
    const delimiter = match[3]
74
    const bullet = match[4]
75
    const content = match[5]
76

  
77
    if (content === "") {
78
      return { action: "remove" }
79
    }
80

  
81
    if (number) {
82
      const nextNumber = parseInt(number, 10) + 1
83
      return { action: "insert", text: `${indent}${nextNumber}${delimiter} ` }
84
    } else {
85
      return { action: "insert", text: `${indent}${bullet} ` }
86
    }
87
  }
88
}
89

  
90
class TextileListFormatter {
91
  format(line) {
92
    // Match list items in Textile syntax.
93
    // Captures either an ordered list (using '#') or an unordered list (using '*').
94
    // The regex structure:
95
    // ^([*#]+)            → one or more list markers: '*' for unordered, '#' for ordered
96
    // (.*)$               → the actual list item content
97
    //
98
    // Examples:
99
    // "# ordered text"            → marker="#",  content="ordered text"
100
    // "## nested ordered text"    → marker="##", content="nested ordered text"
101
    // "* unordered text"          → marker="*",  content="unordered text"
102
    // "** nested unordered text"  → marker="**", content="nested unordered text"
103
    const match = line.match(/^([*#]+) (.*)$/)
104
    if (!match) return null
105

  
106
    const marker = match[1]
107
    const content = match[2]
108

  
109
    if (content === "") {
110
      return { action: "remove" }
111
    }
112

  
113
    return { action: "insert", text: `${marker} ` }
114
  }
115
}
116

  
117
export default class extends Controller {
118
  handleBeforeInput(event) {
119
    if (event.inputType != 'insertLineBreak') return
120

  
121
    const format = event.params.textFormatting
122
    new ListAutofillHandler(event.currentTarget, format).run(event)
123
  }
124
}
app/views/documents/_form.html.erb
6 6
  <p><%= f.text_area :description, :cols => 60, :rows => 15, :class => 'wiki-edit',
7 7
                    :data => {
8 8
                        :auto_complete => true
9
                    } %></p>
9
                    }.merge(list_autofill_data_attributes)
10
                     %></p>
10 11

  
11 12
  <% @document.custom_field_values.each do |value| %>
12 13
    <p><%= custom_field_tag_with_label :document, value %></p>
app/views/issues/_edit.html.erb
32 32
      <%= f.text_area :notes, :cols => 60, :rows => 10, :class => 'wiki-edit',
33 33
            :data => {
34 34
                :auto_complete => true
35
            },
35
            }.merge(list_autofill_data_attributes),
36 36
            :no_label => true %>
37 37
      <%= wikitoolbar_for 'issue_notes', preview_issue_path(:project_id => @project, :issue_id => @issue) %>
38 38

  
app/views/issues/_form.html.erb
36 36
    <%= f.text_area :description, :cols => 60, :accesskey => accesskey(:edit), :class => 'wiki-edit',
37 37
                   :rows => [[10, @issue.description.to_s.length / 50].max, 20].min,
38 38
                   :data => {
39
                       :auto_complete => true,
40
                   },
39
                       :auto_complete => true
40
                   }.merge(list_autofill_data_attributes),
41 41
                   :no_label => true %>
42 42
  <% end %>
43 43
  <%= 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? %>
app/views/issues/bulk_edit.html.erb
223 223
<%= text_area_tag 'notes', @notes, :cols => 60, :rows => 10, :class => 'wiki-edit',
224 224
                  :data => {
225 225
                      :auto_complete => true
226
                  }
226
                  }.merge(list_autofill_data_attributes)
227 227
%>
228 228
<%= wikitoolbar_for 'notes' %>
229 229

  
app/views/journals/_notes_form.html.erb
7 7
          :rows => (@journal.notes.blank? ? 10 : [[10, @journal.notes.length / 50].max, 100].min),
8 8
          :data => {
9 9
              :auto_complete => true
10
          }
10
          }.merge(list_autofill_data_attributes)
11 11
    %>
12 12
    <% if @journal.safe_attribute? 'private_notes' %>
13 13
      <%= hidden_field_tag 'journal[private_notes]', '0' %>
app/views/messages/_form.html.erb
27 27
                :accesskey => accesskey(:edit),
28 28
                :data => {
29 29
                    :auto_complete => true
30
                }
30
                }.merge(list_autofill_data_attributes)
31 31
%></p>
32 32
<%= wikitoolbar_for 'message_content', preview_board_message_path(:board_id => @board, :id => @message) %>
33 33
<!--[eoform:message]-->
app/views/news/_form.html.erb
13 13
<p><%= f.text_area :description, :required => true, :cols => 60, :rows => 15, :class => 'wiki-edit',
14 14
                   :data => {
15 15
                       :auto_complete => true
16
                   }
16
                   }.merge(list_autofill_data_attributes)
17 17
%></p>
18 18
<p id="attachments_form"><label><%= l(:label_attachment_plural) %></label><%= render :partial => 'attachments/form', :locals => {:container => @news} %></p>
19 19
</div>
app/views/news/show.html.erb
70 70
    <%= text_area 'comment', 'comments', :cols => 80, :rows => 15, :class => 'wiki-edit',
71 71
                  :data => {
72 72
                    :auto_complete => true
73
                  }
73
                  }.merge(list_autofill_data_attributes)
74 74
    %>
75 75
    <%= wikitoolbar_for 'comment_comments', preview_news_path(:project_id => @project, :id => @news) %>
76 76
</div>
app/views/projects/_form.html.erb
4 4
<!--[form:project]-->
5 5
<p><%= f.text_field :name, :required => true, :size => 60 %></p>
6 6

  
7
<p><%= f.text_area :description, :rows => 8, :class => 'wiki-edit' %></p>
7
<p><%= f.text_area :description, :rows => 8, :class => 'wiki-edit', :data => list_autofill_data_attributes %></p>
8 8
<p><%= f.text_field :identifier, :required => true, :size => 60, :disabled => @project.identifier_frozen?, :maxlength => Project::IDENTIFIER_MAX_LENGTH %>
9 9
<% unless @project.identifier_frozen? %>
10 10
  <em class="info"><%= l(:text_length_between, :min => 1, :max => Project::IDENTIFIER_MAX_LENGTH) %> <%= l(:text_project_identifier_info).html_safe %></em>
app/views/search/index.html.erb
6 6
<p><%= text_field_tag 'q', @question, :size => 60, :id => 'search-input',
7 7
                      :data => {
8 8
                          :auto_complete => true
9
                      } %>
9
                      }.merge(list_autofill_data_attributes) %>
10 10
<%= project_select_tag %>
11 11
<%= hidden_field_tag 'all_words', '', :id => nil %>
12 12
<label><%= check_box_tag 'all_words', 1, @all_words %> <%= l(:label_all_words) %></label>
app/views/settings/_general.html.erb
3 3
<div class="box tabular settings">
4 4
<p><%= setting_text_field :app_title, :size => 30 %></p>
5 5

  
6
<p><%= setting_text_area :welcome_text, :cols => 60, :rows => 5, :class => 'wiki-edit' %></p>
6
<p><%= setting_text_area :welcome_text, :cols => 60, :rows => 5, :class => 'wiki-edit', :data => list_autofill_data_attributes %></p>
7 7
<%= wikitoolbar_for 'settings_welcome_text' %>
8 8

  
9 9

  
app/views/settings/_notifications.html.erb
19 19
</fieldset>
20 20

  
21 21
<fieldset class="box"><legend><%= l(:setting_emails_header) %></legend>
22
<%= setting_text_area :emails_header, :label => false, :class => 'wiki-edit', :rows => 5 %>
22
<%= setting_text_area :emails_header, :label => false, :class => 'wiki-edit', :rows => 5, :data => list_autofill_data_attributes %>
23 23
<%= wikitoolbar_for 'settings_emails_header' %>
24 24
</fieldset>
25 25

  
26 26
<fieldset class="box"><legend><%= l(:setting_emails_footer) %></legend>
27
<%= setting_text_area :emails_footer, :label => false, :class => 'wiki-edit', :rows => 5 %>
27
<%= setting_text_area :emails_footer, :label => false, :class => 'wiki-edit', :rows => 5, :data => list_autofill_data_attributes %>
28 28
<%= wikitoolbar_for 'settings_emails_footer' %>
29 29
</fieldset>
30 30

  
app/views/wiki/edit.html.erb
17 17
                  :class => 'wiki-edit',
18 18
                  :data => {
19 19
                      :auto_complete => true
20
                  }
20
                  }.merge(list_autofill_data_attributes)
21 21
%>
22 22

  
23 23
<% if @page.safe_attribute_names.include?('parent_id') && @wiki.pages.any? %>
test/helpers/application_helper_test.rb
2427 2427
                  :class => "wiki-page new"),
2428 2428
    }
2429 2429
  end
2430

  
2431
  def test_list_autofill_data_attributes
2432
    with_settings :text_formatting => 'textile' do
2433
      expected = {
2434
        controller: "list-autofill",
2435
        action: "keydown->list-autofill#handleEnter",
2436
        list_autofill_target: "input",
2437
        list_autofill_text_formatting_param: "textile"
2438
      }
2439

  
2440
      assert_equal expected, list_autofill_data_attributes
2441
    end
2442
  end
2443

  
2444
  def test_list_autofill_data_attributes_with_blank_text_formatting
2445
    with_settings :text_formatting => '' do
2446
      assert_equal({}, list_autofill_data_attributes)
2447
    end
2448
  end
2430 2449
end
test/system/list_autofill_test.rb
1
# frozen_string_literal: true
2

  
3
require_relative '../application_system_test_case'
4

  
5
class ListAutofillSystemTest < ApplicationSystemTestCase
6
  def setup
7
    super
8
    log_user('jsmith', 'jsmith')
9
  end
10

  
11
  def test_autofill_textile_unordered_list
12
    with_settings :text_formatting => 'textile' do
13
      visit '/projects/ecookbook/issues/new'
14

  
15
      within('form#issue-form') do
16
        find('#issue_description').send_keys('* First item')
17
        find('#issue_description').send_keys(:enter)
18

  
19
        assert_equal(
20
          "* First item\n" \
21
          "* ",
22
          find('#issue_description').value
23
        )
24
      end
25
    end
26
  end
27

  
28
  def test_autofill_textile_ordered_list
29
    with_settings :text_formatting => 'textile' do
30
      visit '/projects/ecookbook/issues/new'
31

  
32
      within('form#issue-form') do
33
        find('#issue_description').send_keys('# First item')
34
        find('#issue_description').send_keys(:enter)
35

  
36
        assert_equal(
37
          "# First item\n" \
38
          "# ",
39
          find('#issue_description').value
40
        )
41
      end
42
    end
43
  end
44

  
45
  def test_remove_list_marker_for_empty_item
46
    with_settings :text_formatting => 'textile' do
47
      visit '/projects/ecookbook/issues/new'
48

  
49
      within('form#issue-form') do
50
        find('#issue_description').send_keys('* First item')
51
        find('#issue_description').send_keys(:enter)
52
        find('#issue_description').send_keys(:enter)  # Press Enter on empty line removes the marker
53

  
54
        assert_equal(
55
          "* First item\n",
56
          find('#issue_description').value
57
        )
58
      end
59
    end
60
  end
61

  
62
  def test_autofill_markdown_unordered_list
63
    with_settings :text_formatting => 'common_mark' do
64
      visit '/projects/ecookbook/issues/new'
65

  
66
      within('form#issue-form') do
67
        find('#issue_description').send_keys('- First item')
68
        find('#issue_description').send_keys(:enter)
69
        assert_equal(
70
          "- First item\n" \
71
          "- ",
72
          find('#issue_description').value
73
        )
74

  
75
        fill_in 'Description', with: ''
76
        find('#issue_description').send_keys('* First item')
77
        find('#issue_description').send_keys(:enter)
78
        assert_equal(
79
          "* First item\n" \
80
          "* ",
81
          find('#issue_description').value
82
        )
83

  
84
        fill_in 'Description', with: ''
85
        find('#issue_description').send_keys('+ First item')
86
        find('#issue_description').send_keys(:enter)
87
        assert_equal(
88
          "+ First item\n" \
89
          "+ ",
90
          find('#issue_description').value
91
        )
92
      end
93
    end
94
  end
95

  
96
  def test_autofill_with_markdown_ordered_list
97
    with_settings :text_formatting => 'common_mark' do
98
      visit '/projects/ecookbook/issues/new'
99

  
100
      within('form#issue-form') do
101
        find('#issue_description').send_keys('1. First item')
102
        find('#issue_description').send_keys(:enter)
103

  
104
        assert_equal(
105
          "1. First item\n" \
106
          "2. ",
107
          find('#issue_description').value
108
        )
109
      end
110
    end
111
  end
112

  
113
  def test_autofill_with_markdown_ordered_list_using_parenthesis
114
    with_settings :text_formatting => 'common_mark' do
115
      visit '/projects/ecookbook/issues/new'
116

  
117
      within('form#issue-form') do
118
        find('#issue_description').send_keys('1) First item')
119
        find('#issue_description').send_keys(:enter)
120

  
121
        assert_equal(
122
          "1) First item\n" \
123
          "2) ",
124
          find('#issue_description').value
125
        )
126
      end
127
    end
128
  end
129

  
130
  def test_textile_nested_list_autofill
131
    with_settings :text_formatting => 'textile' do
132
      visit '/projects/ecookbook/issues/new'
133

  
134
      within('form#issue-form') do
135
        find('#issue_description').send_keys('* Parent item')
136
        find('#issue_description').send_keys(:enter)
137
        find('#issue_description').send_keys(:backspace, :backspace)  # Remove auto-filled marker
138
        find('#issue_description').send_keys('** Child item')
139
        find('#issue_description').send_keys(:enter)
140
        find('#issue_description').send_keys(:backspace, :backspace, :backspace)  # Remove auto-filled marker
141
        find('#issue_description').send_keys("*** Grandchild item")
142
        find('#issue_description').send_keys(:enter)
143

  
144
        assert_equal(
145
          "* Parent item\n" \
146
          "** Child item\n" \
147
          "*** Grandchild item\n" \
148
          "*** ",
149
          find('#issue_description').value
150
        )
151
      end
152
    end
153
  end
154

  
155
  def test_common_mark_nested_list_autofill
156
    with_settings :text_formatting => 'common_mark' do
157
      visit '/projects/ecookbook/issues/new'
158

  
159
      within('form#issue-form') do
160
        find('#issue_description').send_keys('- Parent item')
161
        find('#issue_description').send_keys(:enter)
162
        find('#issue_description').send_keys(:backspace, :backspace)  # Remove auto-filled marker
163
        find('#issue_description').send_keys('  - Child item')
164
        find('#issue_description').send_keys(:enter)
165

  
166
        assert_equal(
167
          "- Parent item\n" \
168
          "  - Child item\n" \
169
          "  - ",
170
          find('#issue_description').value
171
        )
172

  
173
        find('#issue_description').send_keys(:backspace, :backspace, :backspace, :backspace)  # Remove auto-filled marker
174
        find('#issue_description').send_keys('    - Grandchild item')
175
        find('#issue_description').send_keys(:enter)
176

  
177
        assert_equal(
178
          "- Parent item\n" \
179
          "  - Child item\n" \
180
          "    - Grandchild item\n" \
181
          "    - ",
182
          find('#issue_description').value
183
        )
184
      end
185
    end
186
  end
187

  
188
  def test_common_mark_mixed_list_types
189
    with_settings :text_formatting => 'common_mark' do
190
      visit '/projects/ecookbook/issues/new'
191

  
192
      within('form#issue-form') do
193
        find('#issue_description').send_keys('1. First numbered item')
194
        find('#issue_description').send_keys(:enter)
195
        find('#issue_description').send_keys(:backspace, :backspace, :backspace)  # Remove auto-filled numbered list marker
196
        find('#issue_description').send_keys('   - Nested bullet item')
197
        find('#issue_description').send_keys(:enter)
198

  
199
        assert_equal(
200
          "1. First numbered item\n" \
201
          "   - Nested bullet item\n" \
202
          "   - ",
203
          find('#issue_description').value
204
        )
205

  
206
        find('#issue_description').send_keys(:backspace, :backspace, :backspace, :backspace, :backspace)  # Remove auto-filled numbered list marker
207
        find('#issue_description').send_keys('2. Second numbered item')
208
        find('#issue_description').send_keys(:enter)
209

  
210
        assert_equal(
211
          "1. First numbered item\n" \
212
          "   - Nested bullet item\n" \
213
          "2. Second numbered item\n" \
214
          "3. ",
215
          find('#issue_description').value
216
        )
217
      end
218
    end
219
  end
220

  
221
  def test_remove_list_marker_with_single_halfwidth_space_variants
222
    with_settings :text_formatting => 'common_mark' do
223
      visit '/projects/ecookbook/issues/new'
224

  
225
      within('form#issue-form') do
226
        find('#issue_description').click
227

  
228
        # Half-width space only → should remove marker
229
        find('#issue_description').send_keys('1. First item', :enter)
230
        assert_equal("1. First item\n2. ", find('#issue_description').value)
231
        find('#issue_description').send_keys(:enter)
232
        assert_equal("1. First item\n", find('#issue_description').value)
233

  
234
        fill_in 'Description', with: ''
235
        # Full-width space only → should NOT remove marker
236
        find('#issue_description').send_keys('1. First item', :enter)
237
        find('#issue_description').send_keys(:backspace, :backspace, :backspace)
238
        find('#issue_description').send_keys("2. ", :enter)
239
        assert_equal("1. First item\n2. \n", find('#issue_description').value)
240

  
241
        fill_in 'Description', with: ''
242
        # Two or more spaces → should NOT remove marker
243
        find('#issue_description').send_keys('1. First item', :enter)
244
        find('#issue_description').send_keys(:backspace, :backspace, :backspace)
245
        find('#issue_description').send_keys("2.  ", :enter)
246
        assert_equal("1. First item\n2.  \n3. ", find('#issue_description').value)
247
      end
248
    end
249
  end
250

  
251
  def test_no_autofill_when_content_is_missing_or_invalid_marker
252
    with_settings :text_formatting => 'common_mark' do
253
      visit '/projects/ecookbook/issues/new'
254

  
255
      within('form#issue-form') do
256
        find('#issue_description').click
257

  
258
        # Marker only with no content → should not trigger insert
259
        find('#issue_description').send_keys('1.', :enter)
260
        assert_equal("1.\n", find('#issue_description').value)
261

  
262
        fill_in 'Description', with: ''
263
        # Invalid marker pattern (e.g. double dot) → should not trigger insert
264
        find('#issue_description').send_keys('1.. Invalid marker', :enter)
265
        assert_equal("1.. Invalid marker\n", find('#issue_description').value)
266
      end
267
    end
268
  end
269

  
270
  def test_autofill_ignored_with_none_text_formatting
271
    with_settings :text_formatting => '' do
272
      visit '/projects/ecookbook/issues/new'
273

  
274
      within('form#issue-form') do
275
        find('#issue_description').click
276

  
277
        # Unsupported format → no autofill should occur
278
        find('#issue_description').send_keys('* First item', :enter)
279
        assert_equal("* First item\n", find('#issue_description').value)
280
      end
281
    end
282
  end
283

  
284
  def test_marker_not_inserted_on_empty_line
285
    with_settings :text_formatting => 'textile' do
286
      visit '/projects/ecookbook/issues/new'
287

  
288
      within('form#issue-form') do
289
        find('#issue_description').click
290

  
291
        # Pressing enter on an empty line → should not trigger insert
292
        find('#issue_description').send_keys(:enter)
293
        assert_equal("\n", find('#issue_description').value)
294
      end
295
    end
296
  end
297
end
(5-5/6)