Project

General

Profile

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

Mizuki ISHIKAWA, 2025-08-04 08:07

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 = null
18

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

  
30
    const result = formatter.format(currentLine)
31

  
32
    if (!result) return
33

  
34
    event.preventDefault()
35

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

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

  
57

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

  
63
    const indent = match[1]
64
    const marker = match[2]
65
    const number = match[3]
66
    const content = match[4]
67

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

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

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

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

  
89
    if (content.trim().length === 0) {
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
2441 2441
                  :class => "wiki-page new"),
2442 2442
    }
2443 2443
  end
2444

  
2445
  def test_list_autofill_data_attributes
2446
    with_settings :text_formatting => 'textile' do
2447
      expected = {
2448
        controller: "list-autofill",
2449
        action: "keydown->list-autofill#handleEnter",
2450
        list_autofill_target: "input",
2451
        list_autofill_text_formatting_param: "textile"
2452
      }
2453

  
2454
      assert_equal expected, list_autofill_data_attributes
2455
    end
2456
  end
2457

  
2458
  def test_list_autofill_data_attributes_with_blank_text_formatting
2459
    with_settings :text_formatting => '' do
2460
      assert_equal({}, list_autofill_data_attributes)
2461
    end
2462
  end
2444 2463
end
test/system/list_autofill_test.rb
1
require_relative '../application_system_test_case'
2

  
3
class ListAutofillSystemTest < ApplicationSystemTestCase
4

  
5
  def setup
6
    super
7
    log_user('jsmith', 'jsmith')
8
  end
9

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

  
14
      within('form#issue-form') do
15
        fill_in 'Subject', :with => 'Test list autofill feature'
16
        find('#issue_description_and_toolbar').click
17
        find('#issue_description').send_keys('* First item')
18
        find('#issue_description').send_keys(:enter)
19

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

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

  
33
      within('form#issue-form') do
34
        fill_in 'Subject', :with => 'Test ordered list autofill'
35
        find('#issue_description_and_toolbar').click
36
        find('#issue_description').send_keys('# First item')
37
        find('#issue_description').send_keys(:enter)
38

  
39
        assert_equal(
40
          "# First item\n" \
41
          "# ",
42
          find('#issue_description').value
43
        )
44
      end
45
    end
46
  end
47

  
48
  def test_remove_list_marker_for_empty_item
49
    with_settings :text_formatting => 'textile' do
50
      visit '/projects/ecookbook/issues/new'
51

  
52
      within('form#issue-form') do
53
        fill_in 'Subject', :with => 'Test empty list item removal'
54
        find('#issue_description_and_toolbar').click
55
        find('#issue_description').send_keys('* First item')
56
        find('#issue_description').send_keys(:enter)
57
        find('#issue_description').send_keys(:enter)  # Press Enter on empty line removes the marker
58

  
59
        assert_equal(
60
          "* First item\n",
61
          find('#issue_description').value
62
        )
63
      end
64
    end
65
  end
66

  
67
  def test_autofill_with_markdown_format
68
    with_settings :text_formatting => 'common_mark' do
69
      visit '/projects/ecookbook/issues/new'
70

  
71
      within('form#issue-form') do
72
        fill_in 'Subject', :with => 'Test markdown list autofill'
73
        find('#issue_description_and_toolbar').click
74
        find('#issue_description').send_keys('- First item')
75
        find('#issue_description').send_keys(:enter)
76

  
77
        assert_equal(
78
          "- First item\n" \
79
          "- ",
80
          find('#issue_description').value
81
        )
82
      end
83
    end
84
  end
85

  
86
  def test_autofill_with_markdown_numbered_list
87
    with_settings :text_formatting => 'common_mark' do
88
      visit '/projects/ecookbook/issues/new'
89

  
90
      within('form#issue-form') do
91
        fill_in 'Subject', :with => 'Test markdown numbered list autofill'
92
        find('#issue_description_and_toolbar').click
93
        find('#issue_description').send_keys('1. First item')
94
        find('#issue_description').send_keys(:enter)
95

  
96
        assert_equal(
97
          "1. First item\n" \
98
          "2. ",
99
          find('#issue_description').value
100
        )
101
      end
102
    end
103
  end
104

  
105
  def test_list_autofill_disabled_when_text_formatting_blank
106
    with_settings :text_formatting => '' do
107
      visit '/projects/ecookbook/issues/new'
108

  
109
      within('form#issue-form') do
110
        fill_in 'Subject', :with => 'Test list autofill disabled'
111
        find('#issue_description_and_toolbar').click
112
        find('#issue_description').send_keys('* First item')
113
        find('#issue_description').send_keys(:enter)
114

  
115
        assert_equal(
116
          "* First item\n",
117
          find('#issue_description').value
118
        )
119
      end
120
    end
121
  end
122

  
123
  def test_textile_nested_list_autofill
124
    with_settings :text_formatting => 'textile' do
125
      visit '/projects/ecookbook/issues/new'
126

  
127
      within('form#issue-form') do
128
        find('#issue_description').send_keys('* Parent item')
129
        find('#issue_description').send_keys(:enter)
130
        find('#issue_description').send_keys(:backspace, :backspace)  # Remove auto-filled marker
131
        find('#issue_description').send_keys('** Child item')
132
        find('#issue_description').send_keys(:enter)
133
        find('#issue_description').send_keys(:backspace, :backspace, :backspace)  # Remove auto-filled marker
134
        find('#issue_description').send_keys("*** Grandchild item")
135
        find('#issue_description').send_keys(:enter)
136

  
137
        assert_equal(
138
          "* Parent item\n" \
139
          "** Child item\n" \
140
          "*** Grandchild item\n" \
141
          "*** ",
142
          find('#issue_description').value
143
        )
144
      end
145
    end
146
  end
147

  
148
  def test_common_mark_nested_list_autofill
149
    with_settings :text_formatting => 'common_mark' do
150
      visit '/projects/ecookbook/issues/new'
151

  
152
      within('form#issue-form') do
153
        fill_in 'Subject', :with => 'Test nested list autofill in Markdown'
154
        find('#issue_description_and_toolbar').click
155

  
156
        find('#issue_description').send_keys('- Parent item')
157
        find('#issue_description').send_keys(:enter)
158
        find('#issue_description').send_keys(:backspace, :backspace)  # Remove auto-filled marker
159
        find('#issue_description').send_keys('  - Child item')
160
        find('#issue_description').send_keys(:enter)
161

  
162
        assert_equal(
163
          "- Parent item\n" \
164
          "  - Child item\n" \
165
          "  - ",
166
          find('#issue_description').value
167
        )
168

  
169
        find('#issue_description').send_keys(:backspace, :backspace, :backspace, :backspace)  # Remove auto-filled marker
170
        find('#issue_description').send_keys('    - Grandchild item')
171
        find('#issue_description').send_keys(:enter)
172

  
173
        assert_equal(
174
          "- Parent item\n" \
175
          "  - Child item\n" \
176
          "    - Grandchild item\n" \
177
          "    - ",
178
          find('#issue_description').value
179
        )
180
      end
181
    end
182
  end
183

  
184
  def test_common_mark_mixed_list_types
185
    with_settings :text_formatting => 'common_mark' do
186
      visit '/projects/ecookbook/issues/new'
187

  
188
      within('form#issue-form') do
189
        fill_in 'Subject', :with => 'Test mixed list types in Markdown'
190
        find('#issue_description_and_toolbar').click
191

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

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

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

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