Project

General

Profile

Feature #44061 ยป 0001-Add-Tab-and-Shift-Tab-indentation-for-list-items-in-.patch

Mizuki ISHIKAWA, 2026-05-16 10:29

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, value } = input
13
    const lineStart = value.lastIndexOf("\n", selectionStart - 1) + 1
14
    const lineEnd = value.indexOf("\n", selectionStart)
15
    const currentLine = value.slice(lineStart, lineEnd === -1 ? value.length : lineEnd)
16
    const spaces = this.#indentSize(currentLine)
17
    if (!spaces) return
18

  
19
    event.preventDefault()
20
    event.shiftKey
21
      ? this.#unindent(input, lineStart, currentLine, selectionStart, spaces)
22
      : this.#indent(input, lineStart, selectionStart, spaces)
23
  }
24

  
25
  #indentSize(line) {
26
    if (this.#bulletPattern.test(line)) return 2
27
    if (this.#orderedPattern.test(line)) return 4
28
    return 0
29
  }
30

  
31
  #indent(input, lineStart, cursorPos, spaces) {
32
    input.setRangeText(' '.repeat(spaces), lineStart, lineStart, 'preserve')
33
    input.setSelectionRange(cursorPos + spaces, cursorPos + spaces)
34
  }
35

  
36
  #unindent(input, lineStart, currentLine, cursorPos, spaces) {
37
    const currentIndent = currentLine.match(/^(\s*)/)[1].length
38
    const remove = Math.min(spaces, currentIndent)
39
    if (remove === 0) return
40
    input.setRangeText('', lineStart, lineStart + remove, 'preserve')
41
    const newCursor = Math.max(lineStart, cursorPos - remove)
42
    input.setSelectionRange(newCursor, newCursor)
43
  }
44
}
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
        fill_in 'Description', with: "- item"
18
        find('#issue_description').send_keys(:tab)
19

  
20
        assert_equal "  - item", find('#issue_description').value
21
      end
22
    end
23
  end
24

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

  
29
      within('form#issue-form') do
30
        fill_in 'Description', with: "1. item"
31
        find('#issue_description').send_keys(:tab)
32

  
33
        assert_equal "    1. item", find('#issue_description').value
34
      end
35
    end
36
  end
37

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

  
43
      within('form#issue-form') do
44
        set_textarea_value 'issue_description', "- parent\n  - child"
45
        find('#issue_description').send_keys([:shift, :tab])
46

  
47
        assert_equal "- parent\n- child", find('#issue_description').value
48
      end
49
    end
50
  end
51

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

  
57
      within('form#issue-form') do
58
        set_textarea_value 'issue_description', "- parent\n - child"
59
        find('#issue_description').send_keys([:shift, :tab])
60

  
61
        assert_equal "- parent\n- child", find('#issue_description').value
62
      end
63
    end
64
  end
65

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

  
70
      within('form#issue-form') do
71
        fill_in 'Description', with: "- item"
72
        find('#issue_description').send_keys([:shift, :tab])
73

  
74
        assert_equal "- item", find('#issue_description').value
75
      end
76
    end
77
  end
78

  
79
  private
80

  
81
  # Use execute_script to set multi-line values directly via JS,
82
  # since fill_in sends keystrokes which trigger the list autofill handler.
83
  def set_textarea_value(id, text)
84
    page.execute_script(
85
      "const el = document.getElementById(arguments[0]);" \
86
      "el.value = arguments[1];" \
87
      "el.setSelectionRange(el.value.length, el.value.length);",
88
      id,
89
      text
90
    )
91
  end
92
end
    (1-1/1)