From da1dc26f6ff0494ffa56b605f0e27380b6ed419a Mon Sep 17 00:00:00 2001 From: FloWalchs Date: Wed, 27 May 2026 19:05:09 +0000 Subject: [PATCH] add tab shift tab indentation for lists with selection --- app/helpers/application_helper.rb | 5 +- .../controllers/list_indent_controller.js | 51 ++++++ test/system/list_indent_test.rb | 164 ++++++++++++++++++ 3 files changed, 218 insertions(+), 2 deletions(-) create mode 100644 app/javascript/controllers/list_indent_controller.js create mode 100644 test/system/list_indent_test.rb diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index eb8312256..d7e3c7e87 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1433,9 +1433,10 @@ module ApplicationHelper return {} if Setting.text_formatting.blank? { - controller: 'list-autofill table-paste', - action: 'beforeinput->list-autofill#handleBeforeInput paste->table-paste#handlePaste', + controller: 'list-autofill list-indent table-paste', + action: 'beforeinput->list-autofill#handleBeforeInput keydown.tab->list-indent#run keydown.shift+tab->list-indent#run paste->table-paste#handlePaste', list_autofill_text_formatting_param: Setting.text_formatting, + list_indent_text_formatting_param: Setting.text_formatting, table_paste_text_formatting_param: Setting.text_formatting } end diff --git a/app/javascript/controllers/list_indent_controller.js b/app/javascript/controllers/list_indent_controller.js new file mode 100644 index 000000000..34ec88c74 --- /dev/null +++ b/app/javascript/controllers/list_indent_controller.js @@ -0,0 +1,51 @@ +import { Controller } from '@hotwired/stimulus' + +export default class extends Controller { + #bulletPattern = /^\s*[*+\-] / + #orderedPattern = /^\s*\d+[.)] / + + run(event) { + const format = event.params.textFormatting + if (format !== 'common_mark') return + + const input = event.currentTarget + const { selectionStart, selectionEnd, value } = input + const hasSelection = selectionStart !== selectionEnd + if (!hasSelection) return + const start = value.lastIndexOf("\n", selectionStart - 1) + 1 + const end = value.indexOf("\n", selectionEnd) + const endPos = end === -1 ? value.length : end + const selectedText = value.slice(start, endPos) + const lines = selectedText.split("\n") + const spaces = this.#indentSize(lines.find(l => this.#indentSize(l)) || "") + if (!spaces) return + + event.preventDefault() + + const newLines = event.shiftKey + ? lines.map(line => this.#unindentLine(line, spaces)) + : lines.map(line => this.#indentLine(line, spaces)) + + const newText = newLines.join("\n") + + input.setRangeText(newText, start, endPos, "preserve") + } + + #indentSize(line) { + if (this.#bulletPattern.test(line)) return 2 + if (this.#orderedPattern.test(line)) return 4 + return 0 + } + + #indentLine(line, spaces) { + if (!this.#indentSize(line)) return line + return " ".repeat(spaces) + line + } + + #unindentLine(line, spaces) { + const currentIndent = line.match(/^(\s*)/)[1].length + const remove = Math.min(spaces, currentIndent) + return line.slice(remove) + } + +} diff --git a/test/system/list_indent_test.rb b/test/system/list_indent_test.rb new file mode 100644 index 000000000..efd7b0d88 --- /dev/null +++ b/test/system/list_indent_test.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +require_relative '../application_system_test_case' + +class ListIndentSystemTest < ApplicationSystemTestCase + def setup + super + log_user('jsmith', 'jsmith') + end + + # Tab: inserts 2 spaces for bullet lists, 4 spaces for ordered lists + def test_tab_indents_common_mark_bullet_list + with_settings :text_formatting => 'common_mark' do + visit '/projects/ecookbook/issues/new' + + within('form#issue-form') do + el = find('#issue_description') + el.click + set_textarea_value 'issue_description', "- item", selection: [0, text.length] + el.send_keys(:tab) + assert_equal " - item", el.value + end + end + end + + def test_tab_indents_common_mark_ordered_list + with_settings :text_formatting => 'common_mark' do + visit '/projects/ecookbook/issues/new' + + within('form#issue-form') do + el = find('#issue_description') + el.click + set_textarea_value 'issue_description', "1. item", selection: [0, text.length] + el.send_keys(:tab) + assert_equal " 1. item", el.value + end + end + end + + # Shift+Tab: removes indentation + def test_shift_tab_unindents_common_mark_bullet_list + with_settings :text_formatting => 'common_mark' do + visit '/projects/ecookbook/issues/new' + + within('form#issue-form') do + el = find('#issue_description') + el.click + set_textarea_value 'issue_description', "- parent\n - child", selection: [0, text.length] + el.send_keys([:shift, :tab]) + assert_equal "- parent\n- child", el.value + end + end + end + + def test_shift_tab_removes_partial_indent_on_common_mark_list + # Removes only as many spaces as exist when indent is less than the step size + with_settings :text_formatting => 'common_mark' do + visit '/projects/ecookbook/issues/new' + + within('form#issue-form') do + el = find('#issue_description') + el.click + set_textarea_value 'issue_description', "- parent\n - child", selection: [0, text.length] + el.send_keys([:shift, :tab]) + assert_equal "- parent\n- child", el.value + end + end + end + + def test_shift_tab_does_nothing_on_common_mark_list_with_no_indent + with_settings :text_formatting => 'common_mark' do + visit '/projects/ecookbook/issues/new' + + within('form#issue-form') do + el = find('#issue_description') + el.click + set_textarea_value 'issue_description', "- item", selection: [0, text.length] + el.send_keys([:shift, :tab]) + assert_equal "- item", el.value + end + end + end + + def test_tab_indents_multiple_lines_when_selected + with_settings :text_formatting => 'common_mark' do + visit '/projects/ecookbook/issues/new' + + within('form#issue-form') do + el = find('#issue_description') + el.click + set_textarea_value 'issue_description', "- item1\n- item2", selection: [0, text.length] + el.send_keys(:tab) + assert_equal " - item1\n - item2", el.value + end + end + end + + def test_tab_does_not_indent_without_list_or_selection + with_settings :text_formatting => 'common_mark' do + visit '/projects/ecookbook/issues/new' + + within('form#issue-form') do + fill_in 'Description', with: "normal text" + el = find('#issue_description') + el.click + el.send_keys(:tab) + assert_equal "normal text", el.value + end + end + end + + def test_tab_indents_only_selected_lines_block + with_settings :text_formatting => 'common_mark' do + visit '/projects/ecookbook/issues/new' + + within('form#issue-form') do + text = "- item1\n- item2\n- item3" + start = text.index("- item2") + finish = start + "- item2".length + el = find('#issue_description') + el.click + set_textarea_value 'issue_description', text, selection: [start, finish] + el.send_keys(:tab) + assert_equal "- item1\n - item2\n- item3", el.value + end + end + end + + def test_tab_indents_only_selected_lines_block_ordered_list + with_settings :text_formatting => 'common_mark' do + visit '/projects/ecookbook/issues/new' + + within('form#issue-form') do + text = "1. item1\n1. item2\n1. item3" + start = text.index("1. item2") + 5 + finish = start + 1 + el = find('#issue_description') + el.click + set_textarea_value 'issue_description', text, selection: [start, finish] + el.send_keys(:tab) + assert_equal "1. item1\n 1. item2\n1. item3", el.value + end + end + end + + private + + # Sets textarea to support multi-line input and custom selection. + # Avoids `fill_in`, which sends keystrokes and can trigger list autofill. + def set_textarea_value(id, text, selection: nil) + page.execute_script( + "const el = document.getElementById(arguments[0]);" \ + "el.value = arguments[1];" \ + "if (arguments[2]) {" \ + " el.setSelectionRange(arguments[2][0], arguments[2][1]);" \ + "} else {" \ + " el.setSelectionRange(el.value.length, el.value.length);" \ + "}", + id, + text, + selection + ) + end +end -- 2.43.0