From af4fcaf880bddac11756573575f99a56f974aca6 Mon Sep 17 00:00:00 2001 From: ishikawa999 Date: Sat, 16 May 2026 17:25:56 +0900 Subject: [PATCH] Add Tab and Shift+Tab indentation for list items in wiki textarea --- app/helpers/application_helper.rb | 5 +- .../controllers/list_indent_controller.js | 44 +++++++++ test/system/list_indent_test.rb | 92 +++++++++++++++++++ 3 files changed, 139 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..3e20521bc --- /dev/null +++ b/app/javascript/controllers/list_indent_controller.js @@ -0,0 +1,44 @@ +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, value } = input + const lineStart = value.lastIndexOf("\n", selectionStart - 1) + 1 + const lineEnd = value.indexOf("\n", selectionStart) + const currentLine = value.slice(lineStart, lineEnd === -1 ? value.length : lineEnd) + const spaces = this.#indentSize(currentLine) + if (!spaces) return + + event.preventDefault() + event.shiftKey + ? this.#unindent(input, lineStart, currentLine, selectionStart, spaces) + : this.#indent(input, lineStart, selectionStart, spaces) + } + + #indentSize(line) { + if (this.#bulletPattern.test(line)) return 2 + if (this.#orderedPattern.test(line)) return 4 + return 0 + } + + #indent(input, lineStart, cursorPos, spaces) { + input.setRangeText(' '.repeat(spaces), lineStart, lineStart, 'preserve') + input.setSelectionRange(cursorPos + spaces, cursorPos + spaces) + } + + #unindent(input, lineStart, currentLine, cursorPos, spaces) { + const currentIndent = currentLine.match(/^(\s*)/)[1].length + const remove = Math.min(spaces, currentIndent) + if (remove === 0) return + input.setRangeText('', lineStart, lineStart + remove, 'preserve') + const newCursor = Math.max(lineStart, cursorPos - remove) + input.setSelectionRange(newCursor, newCursor) + } +} diff --git a/test/system/list_indent_test.rb b/test/system/list_indent_test.rb new file mode 100644 index 000000000..9ec40f843 --- /dev/null +++ b/test/system/list_indent_test.rb @@ -0,0 +1,92 @@ +# 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 + fill_in 'Description', with: "- item" + find('#issue_description').send_keys(:tab) + + assert_equal " - item", find('#issue_description').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 + fill_in 'Description', with: "1. item" + find('#issue_description').send_keys(:tab) + + assert_equal " 1. item", find('#issue_description').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 + set_textarea_value 'issue_description', "- parent\n - child" + find('#issue_description').send_keys([:shift, :tab]) + + assert_equal "- parent\n- child", find('#issue_description').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 + set_textarea_value 'issue_description', "- parent\n - child" + find('#issue_description').send_keys([:shift, :tab]) + + assert_equal "- parent\n- child", find('#issue_description').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 + fill_in 'Description', with: "- item" + find('#issue_description').send_keys([:shift, :tab]) + + assert_equal "- item", find('#issue_description').value + end + end + end + + private + + # Use execute_script to set multi-line values directly via JS, + # since fill_in sends keystrokes which trigger the list autofill handler. + def set_textarea_value(id, text) + page.execute_script( + "const el = document.getElementById(arguments[0]);" \ + "el.value = arguments[1];" \ + "el.setSelectionRange(el.value.length, el.value.length);", + id, + text + ) + end +end -- 2.52.0