Project

General

Profile

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

Mizuki ISHIKAWA, 2025-08-04 08:31

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
# 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
        fill_in 'Subject', :with => 'Test list autofill feature'
17
        find('#issue_description_and_toolbar').click
18
        find('#issue_description').send_keys('* First item')
19
        find('#issue_description').send_keys(:enter)
20

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  
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
end
(3-3/3)