Project

General

Profile

Feature #44061 » 0001-add-tab-shift-tab-indentation-for-lists-with-selecti.patch

Florian Walchshofer, 2026-05-27 23:11

View differences:

app/helpers/application_helper.rb
1433 1433
    return {} if Setting.text_formatting.blank?
1434 1434

  
1435 1435
    {
1436
      controller: 'list-autofill table-paste',
1437
      action: 'beforeinput->list-autofill#handleBeforeInput paste->table-paste#handlePaste',
1436
      controller: 'list-autofill list-indent table-paste',
1437
      action: 'beforeinput->list-autofill#handleBeforeInput keydown.tab->list-indent#run keydown.shift+tab->list-indent#run paste->table-paste#handlePaste',
1438 1438
      list_autofill_text_formatting_param: Setting.text_formatting,
1439
      list_indent_text_formatting_param: Setting.text_formatting,
1439 1440
      table_paste_text_formatting_param: Setting.text_formatting
1440 1441
    }
1441 1442
  end
app/javascript/controllers/list_indent_controller.js
1
import { Controller } from '@hotwired/stimulus'
2

  
3
export default class extends Controller {
4
  #bulletPattern = /^\s*[*+\-] /
5
  #orderedPattern = /^\s*\d+[.)] /
6

  
7
  run(event) {
8
    const format = event.params.textFormatting
9
    if (format !== 'common_mark') return
10

  
11
    const input = event.currentTarget
12
    const { selectionStart, selectionEnd, value } = input
13
    const hasSelection = selectionStart !== selectionEnd
14
    if (!hasSelection) return
15
    const start = value.lastIndexOf("\n", selectionStart - 1) + 1
16
    const end = value.indexOf("\n", selectionEnd)
17
    const endPos = end === -1 ? value.length : end
18
    const selectedText = value.slice(start, endPos)
19
    const lines = selectedText.split("\n")
20
    const spaces = this.#indentSize(lines.find(l => this.#indentSize(l)) || "")
21
    if (!spaces) return
22

  
23
    event.preventDefault()
24

  
25
    const newLines = event.shiftKey
26
      ? lines.map(line => this.#unindentLine(line, spaces))
27
      : lines.map(line => this.#indentLine(line, spaces))
28

  
29
    const newText = newLines.join("\n")
30

  
31
    input.setRangeText(newText, start, endPos, "preserve")
32
  }
33

  
34
  #indentSize(line) {
35
    if (this.#bulletPattern.test(line)) return 2
36
    if (this.#orderedPattern.test(line)) return 4
37
    return 0
38
  }
39

  
40
  #indentLine(line, spaces) {
41
    if (!this.#indentSize(line)) return line
42
    return " ".repeat(spaces) + line
43
  }
44

  
45
  #unindentLine(line, spaces) {
46
    const currentIndent = line.match(/^(\s*)/)[1].length
47
    const remove = Math.min(spaces, currentIndent)
48
    return line.slice(remove)
49
  }
50

  
51
}
test/system/list_indent_test.rb
1
# frozen_string_literal: true
2

  
3
require_relative '../application_system_test_case'
4

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

  
11
  # Tab: inserts 2 spaces for bullet lists, 4 spaces for ordered lists
12
  def test_tab_indents_common_mark_bullet_list
13
    with_settings :text_formatting => 'common_mark' do
14
      visit '/projects/ecookbook/issues/new'
15

  
16
      within('form#issue-form') do
17
        el = find('#issue_description')
18
        el.click
19
        set_textarea_value 'issue_description', "- item", selection: [0, text.length]
20
        el.send_keys(:tab)
21
        assert_equal "  - item", el.value
22
      end
23
    end
24
  end
25

  
26
  def test_tab_indents_common_mark_ordered_list
27
    with_settings :text_formatting => 'common_mark' do
28
      visit '/projects/ecookbook/issues/new'
29

  
30
      within('form#issue-form') do
31
        el = find('#issue_description')
32
        el.click
33
        set_textarea_value 'issue_description', "1. item", selection: [0, text.length]
34
        el.send_keys(:tab)
35
        assert_equal "    1. item", el.value
36
      end
37
    end
38
  end
39

  
40
  # Shift+Tab: removes indentation
41
  def test_shift_tab_unindents_common_mark_bullet_list
42
    with_settings :text_formatting => 'common_mark' do
43
      visit '/projects/ecookbook/issues/new'
44

  
45
      within('form#issue-form') do
46
        el = find('#issue_description')
47
        el.click
48
        set_textarea_value 'issue_description', "- parent\n  - child", selection: [0, text.length]
49
        el.send_keys([:shift, :tab])
50
        assert_equal "- parent\n- child", el.value
51
      end
52
    end
53
  end
54

  
55
  def test_shift_tab_removes_partial_indent_on_common_mark_list
56
    # Removes only as many spaces as exist when indent is less than the step size
57
    with_settings :text_formatting => 'common_mark' do
58
      visit '/projects/ecookbook/issues/new'
59

  
60
      within('form#issue-form') do
61
        el = find('#issue_description')
62
        el.click
63
        set_textarea_value 'issue_description', "- parent\n - child", selection: [0, text.length]
64
        el.send_keys([:shift, :tab])
65
        assert_equal "- parent\n- child", el.value
66
      end
67
    end
68
  end
69

  
70
  def test_shift_tab_does_nothing_on_common_mark_list_with_no_indent
71
    with_settings :text_formatting => 'common_mark' do
72
      visit '/projects/ecookbook/issues/new'
73

  
74
      within('form#issue-form') do
75
        el = find('#issue_description')
76
        el.click
77
        set_textarea_value 'issue_description', "- item", selection: [0, text.length]
78
        el.send_keys([:shift, :tab])
79
        assert_equal "- item", el.value
80
      end
81
    end
82
  end
83

  
84
  def test_tab_indents_multiple_lines_when_selected
85
    with_settings :text_formatting => 'common_mark' do
86
      visit '/projects/ecookbook/issues/new'
87

  
88
      within('form#issue-form') do
89
        el = find('#issue_description')
90
        el.click
91
        set_textarea_value 'issue_description', "- item1\n- item2", selection: [0, text.length]
92
        el.send_keys(:tab)
93
        assert_equal "  - item1\n  - item2", el.value
94
      end
95
    end
96
  end
97

  
98
  def test_tab_does_not_indent_without_list_or_selection
99
    with_settings :text_formatting => 'common_mark' do
100
      visit '/projects/ecookbook/issues/new'
101

  
102
      within('form#issue-form') do
103
        fill_in 'Description', with: "normal text"
104
        el = find('#issue_description')
105
        el.click
106
        el.send_keys(:tab)
107
        assert_equal "normal text", el.value
108
      end
109
    end
110
  end
111

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

  
116
      within('form#issue-form') do
117
        text = "- item1\n- item2\n- item3"
118
        start = text.index("- item2")
119
        finish = start + "- item2".length
120
        el = find('#issue_description')
121
        el.click
122
        set_textarea_value 'issue_description', text, selection: [start, finish]
123
        el.send_keys(:tab)
124
        assert_equal "- item1\n  - item2\n- item3", el.value
125
      end
126
    end
127
  end
128

  
129
  def test_tab_indents_only_selected_lines_block_ordered_list
130
    with_settings :text_formatting => 'common_mark' do
131
      visit '/projects/ecookbook/issues/new'
132

  
133
      within('form#issue-form') do
134
        text = "1. item1\n1. item2\n1. item3"
135
        start = text.index("1. item2") + 5
136
        finish = start + 1
137
        el = find('#issue_description')
138
        el.click
139
        set_textarea_value 'issue_description', text, selection: [start, finish]
140
        el.send_keys(:tab)
141
        assert_equal "1. item1\n    1. item2\n1. item3", el.value
142
      end
143
    end
144
  end
145

  
146
  private
147

  
148
  # Sets textarea to support multi-line input and custom selection.
149
  # Avoids `fill_in`, which sends keystrokes and can trigger list autofill.
150
  def set_textarea_value(id, text, selection: nil)
151
    page.execute_script(
152
      "const el = document.getElementById(arguments[0]);" \
153
      "el.value = arguments[1];" \
154
      "if (arguments[2]) {" \
155
      "  el.setSelectionRange(arguments[2][0], arguments[2][1]);" \
156
      "} else {" \
157
      "  el.setSelectionRange(el.value.length, el.value.length);" \
158
      "}",
159
      id,
160
      text,
161
      selection
162
    )
163
  end
164
end
(2-2/3)