Project

General

Profile

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

Mizuki ISHIKAWA, 2025-08-08 07:15

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: "keydown->list-autofill#handleEnter",
1447
      list_autofill_target: "input",
1448
      list_autofill_text_formatting_param: Setting.text_formatting
1449
    }
1450
  end
1451

  
1441 1452
  unless const_defined?(:MACROS_RE)
1442 1453
    MACROS_RE = /(
1443 1454
                  (!)?                        # escaping
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
    event.preventDefault()
34

  
35
    switch (result.action) {
36
      case "remove":
37
        const beforeLine = value.slice(0, lineStartPos)
38
        const afterCursor = value.slice(selectionStart)
39
        this.input.value = beforeLine + afterCursor
40
        this.input.setSelectionRange(lineStartPos, lineStartPos)
41
        break
42

  
43
      case "insert":
44
        const insertText = "\n" + result.text
45
        const newValue = value.slice(0, selectionStart) + insertText + value.slice(selectionStart)
46
        const newCursor = selectionStart + insertText.length
47
        this.input.value = newValue
48
        this.input.setSelectionRange(newCursor, newCursor)
49
        break
50
      default:
51
        return
52
    }
53
  }
54
}
55

  
56

  
57
class CommonMarkListFormatter {
58
  format(line) {
59
    const match = line.match(/^(\s*)(?:(\d+)([.)])|([*+\-])) (.*)$/)
60
    if (!match) return null
61

  
62
    const indent = match[1]
63
    const number = match[2]
64
    const delimiter = match[3]
65
    const bullet = match[4]
66
    const content = match[5]
67

  
68
    if (content === "") {
69
      return { action: "remove" }
70
    }
71

  
72
    if (number) {
73
      const nextNumber = parseInt(number, 10) + 1
74
      return { action: "insert", text: `${indent}${nextNumber}${delimiter} ` }
75
    } else {
76
      return { action: "insert", text: `${indent}${bullet} ` }
77
    }
78
  }
79
}
80

  
81
class TextileListFormatter {
82
  format(line) {
83
    const match = line.match(/^([*#]+) (.*)$/)
84
    if (!match) return null
85

  
86
    const marker = match[1]
87
    const content = match[2]
88

  
89
    if (content === "") {
90
      return { action: "remove" }
91
    }
92

  
93
    return { action: "insert", text: `${marker} ` }
94
  }
95
}
96

  
97
export default class extends Controller {
98
  static targets = ['input']
99

  
100
  connect() {
101
    this.inputTarget.addEventListener('compositionstart', this.handleCompositionStart)
102
    this.inputTarget.addEventListener('compositionend', this.handleCompositionEnd)
103
    this.isComposing = false
104
  }
105

  
106
  disconnect() {
107
    this.inputTarget.removeEventListener('compositionstart', this.handleCompositionStart)
108
    this.inputTarget.removeEventListener('compositionend', this.handleCompositionEnd)
109
  }
110

  
111
  handleCompositionStart = () => {
112
    this.isComposing = true
113
  }
114

  
115
  handleCompositionEnd = () => {
116
    this.isComposing = false
117
  }
118

  
119
  handleEnter(event) {
120
    if (this.isComposing || event.key !== 'Enter') return
121

  
122
    const format = event.params.textFormatting
123
    const handler = new ListAutofillHandler(this.inputTarget, format)
124
    handler.run(event)
125
  }
126
}
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? %>
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_with_markdown_format
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

  
70
        assert_equal(
71
          "- First item\n" \
72
          "- ",
73
          find('#issue_description').value
74
        )
75
      end
76
    end
77
  end
78

  
79
  def test_autofill_with_markdown_numbered_list
80
    with_settings :text_formatting => 'common_mark' do
81
      visit '/projects/ecookbook/issues/new'
82

  
83
      within('form#issue-form') do
84
        find('#issue_description').send_keys('1. First item')
85
        find('#issue_description').send_keys(:enter)
86

  
87
        assert_equal(
88
          "1. First item\n" \
89
          "2. ",
90
          find('#issue_description').value
91
        )
92
      end
93
    end
94
  end
95

  
96
  def test_autofill_with_markdown_numbered_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_numbered_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_list_autofill_disabled_when_text_formatting_blank
131
    with_settings :text_formatting => '' do
132
      visit '/projects/ecookbook/issues/new'
133

  
134
      within('form#issue-form') do
135
        find('#issue_description').send_keys('* First item')
136
        find('#issue_description').send_keys(:enter)
137

  
138
        assert_equal(
139
          "* First item\n",
140
          find('#issue_description').value
141
        )
142
      end
143
    end
144
  end
145

  
146
  def test_textile_nested_list_autofill
147
    with_settings :text_formatting => 'textile' do
148
      visit '/projects/ecookbook/issues/new'
149

  
150
      within('form#issue-form') do
151
        find('#issue_description').send_keys('* Parent item')
152
        find('#issue_description').send_keys(:enter)
153
        find('#issue_description').send_keys(:backspace, :backspace)  # Remove auto-filled marker
154
        find('#issue_description').send_keys('** Child item')
155
        find('#issue_description').send_keys(:enter)
156
        find('#issue_description').send_keys(:backspace, :backspace, :backspace)  # Remove auto-filled marker
157
        find('#issue_description').send_keys("*** Grandchild item")
158
        find('#issue_description').send_keys(:enter)
159

  
160
        assert_equal(
161
          "* Parent item\n" \
162
          "** Child item\n" \
163
          "*** Grandchild item\n" \
164
          "*** ",
165
          find('#issue_description').value
166
        )
167
      end
168
    end
169
  end
170

  
171
  def test_common_mark_nested_list_autofill
172
    with_settings :text_formatting => 'common_mark' do
173
      visit '/projects/ecookbook/issues/new'
174

  
175
      within('form#issue-form') do
176
        find('#issue_description').send_keys('- Parent item')
177
        find('#issue_description').send_keys(:enter)
178
        find('#issue_description').send_keys(:backspace, :backspace)  # Remove auto-filled marker
179
        find('#issue_description').send_keys('  - Child item')
180
        find('#issue_description').send_keys(:enter)
181

  
182
        assert_equal(
183
          "- Parent item\n" \
184
          "  - Child item\n" \
185
          "  - ",
186
          find('#issue_description').value
187
        )
188

  
189
        find('#issue_description').send_keys(:backspace, :backspace, :backspace, :backspace)  # Remove auto-filled marker
190
        find('#issue_description').send_keys('    - Grandchild item')
191
        find('#issue_description').send_keys(:enter)
192

  
193
        assert_equal(
194
          "- Parent item\n" \
195
          "  - Child item\n" \
196
          "    - Grandchild item\n" \
197
          "    - ",
198
          find('#issue_description').value
199
        )
200
      end
201
    end
202
  end
203

  
204
  def test_common_mark_mixed_list_types
205
    with_settings :text_formatting => 'common_mark' do
206
      visit '/projects/ecookbook/issues/new'
207

  
208
      within('form#issue-form') do
209
        find('#issue_description').send_keys('1. First numbered item')
210
        find('#issue_description').send_keys(:enter)
211
        find('#issue_description').send_keys(:backspace, :backspace, :backspace)  # Remove auto-filled numbered list marker
212
        find('#issue_description').send_keys('   - Nested bullet item')
213
        find('#issue_description').send_keys(:enter)
214

  
215
        assert_equal(
216
          "1. First numbered item\n" \
217
          "   - Nested bullet item\n" \
218
          "   - ",
219
          find('#issue_description').value
220
        )
221

  
222
        find('#issue_description').send_keys(:backspace, :backspace, :backspace, :backspace, :backspace)  # Remove auto-filled numbered list marker
223
        find('#issue_description').send_keys('2. Second numbered item')
224
        find('#issue_description').send_keys(:enter)
225

  
226
        assert_equal(
227
          "1. First numbered item\n" \
228
          "   - Nested bullet item\n" \
229
          "2. Second numbered item\n" \
230
          "3. ",
231
          find('#issue_description').value
232
        )
233
      end
234
    end
235
  end
236

  
237
  def test_remove_list_marker_with_single_halfwidth_space_variants
238
    with_settings :text_formatting => 'common_mark' do
239
      visit '/projects/ecookbook/issues/new'
240

  
241
      within('form#issue-form') do
242
        find('#issue_description').click
243

  
244
        # Half-width space only → should remove marker
245
        find('#issue_description').send_keys('1. First item', :enter)
246
        assert_equal("1. First item\n2. ", find('#issue_description').value)
247
        find('#issue_description').send_keys(:enter)
248
        assert_equal("1. First item\n", find('#issue_description').value)
249

  
250
        fill_in 'Description', with: ''
251
        # Full-width space only → should NOT remove marker
252
        find('#issue_description').send_keys('1. First item', :enter)
253
        find('#issue_description').send_keys(:backspace, :backspace, :backspace)
254
        find('#issue_description').send_keys("2. ", :enter)
255
        assert_equal("1. First item\n2. \n", find('#issue_description').value)
256

  
257
        fill_in 'Description', with: ''
258
        # Two or more spaces → should NOT remove marker
259
        find('#issue_description').send_keys('1. First item', :enter)
260
        find('#issue_description').send_keys(:backspace, :backspace, :backspace)
261
        find('#issue_description').send_keys("2.  ", :enter)
262
        assert_equal("1. First item\n2.  \n3. ", find('#issue_description').value)
263
      end
264
    end
265
  end
266

  
267
  def test_no_autofill_when_content_is_missing_or_invalid_marker
268
    with_settings :text_formatting => 'common_mark' do
269
      visit '/projects/ecookbook/issues/new'
270

  
271
      within('form#issue-form') do
272
        find('#issue_description').click
273

  
274
        # Marker only with no content → should not trigger insert
275
        find('#issue_description').send_keys('1.', :enter)
276
        assert_equal("1.\n", find('#issue_description').value)
277

  
278
        fill_in 'Description', with: ''
279
        # Invalid marker pattern (e.g. double dot) → should not trigger insert
280
        find('#issue_description').send_keys('1.. Invalid marker', :enter)
281
        assert_equal("1.. Invalid marker\n", find('#issue_description').value)
282
      end
283
    end
284
  end
285

  
286
  def test_autofill_ignored_with_none_text_formatting
287
    with_settings :text_formatting => '' do
288
      visit '/projects/ecookbook/issues/new'
289

  
290
      within('form#issue-form') do
291
        find('#issue_description').click
292

  
293
        # Unsupported format → no autofill should occur
294
        find('#issue_description').send_keys('* First item', :enter)
295
        assert_equal("* First item\n", find('#issue_description').value)
296
      end
297
    end
298
  end
299

  
300
  def test_marker_not_inserted_on_empty_line
301
    with_settings :text_formatting => 'textile' do
302
      visit '/projects/ecookbook/issues/new'
303

  
304
      within('form#issue-form') do
305
        find('#issue_description').click
306

  
307
        # Pressing enter on an empty line → should not trigger insert
308
        find('#issue_description').send_keys(:enter)
309
        assert_equal("\n", find('#issue_description').value)
310
      end
311
    end
312
  end
313
end
(4-4/4)