From 32806f7dee5a2dfe4ea5187fa1209a93f3a99dab Mon Sep 17 00:00:00 2001 From: ishikawa999 Date: Tue, 7 Oct 2025 16:32:40 +0900 Subject: [PATCH] Support task list autocompletion --- .../controllers/list_autofill_controller.js | 108 ++++++++++-------- test/system/list_autofill_test.rb | 54 +++++++++ 2 files changed, 117 insertions(+), 45 deletions(-) diff --git a/app/javascript/controllers/list_autofill_controller.js b/app/javascript/controllers/list_autofill_controller.js index 4db836ad6..342515834 100644 --- a/app/javascript/controllers/list_autofill_controller.js +++ b/app/javascript/controllers/list_autofill_controller.js @@ -50,67 +50,85 @@ class ListAutofillHandler { } class CommonMarkListFormatter { - format(line) { - // Match list items in CommonMark syntax. - // Captures either an ordered list (e.g., '1. ' or '2) ') or an unordered list (e.g., '* ', '- ', '+ '). - // The regex structure: - // ^(\s*) → leading whitespace - // (?:(\d+)([.)]) → an ordered list marker: number followed by '.' or ')' - // |([*+\-]) → OR an unordered list marker: '*', '+', or '-' - // (.*)$ → the actual list item content - // - // Examples: - // '2. ordered text' → indent='', number='2', delimiter='.', bullet=undefined, content='ordered text' - // ' 3) nested ordered text' → indent=' ', number='3', delimiter=')', bullet=undefined, content='nested ordered text' - // '* unordered text' → indent='', number=undefined, delimiter=undefined, bullet='*', content='unordered text' - // '+ unordered text' → indent='', number=undefined, delimiter=undefined, bullet='+', content='unordered text' - // ' - nested unordered text' → indent=' ', number=undefined, delimiter=undefined, bullet='-', content='nested unordered text' - const match = line.match(/^(\s*)(?:(\d+)([.)])|([*+\-])) (.*)$/) - if (!match) return null + // Example: ' * text' → indent=' ', bullet='*', content='text' (or '+' or '-') + #bulletItemPattern = /^(?\s*)(?[*+\-]) (?.*)$/; + // Example: ' 1. text' → indent=' ', num='1', delimiter='.', content='text' (or ')') + #orderedItemPattern = /^(?\s*)(?\d+)(?[.)]) (?.*)$/; + // Example: '[ ] Task' → taskContent='Task' + // '[x] Task' → taskContent='Task' + #taskAtStartPattern = /^\[[ x]\] (?.*)$/; - const indent = match[1] - const number = match[2] - const delimiter = match[3] - const bullet = match[4] - const content = match[5] - - if (content === '') { - return { action: 'remove' } + format(line) { + const bulletMatch = line.match(this.#bulletItemPattern); + if (bulletMatch) { + return ( + this.#formatBulletTask(bulletMatch.groups) || + this.#formatBulletList(bulletMatch.groups) + ); } - if (number) { - const nextNumber = parseInt(number, 10) + 1 - return { action: 'insert', text: `${indent}${nextNumber}${delimiter} ` } - } else { - return { action: 'insert', text: `${indent}${bullet} ` } + const orderedMatch = line.match(this.#orderedItemPattern); + if (orderedMatch) { + return ( + this.#formatOrderedTask(orderedMatch.groups) || + this.#formatOrderedList(orderedMatch.groups) + ); } } + + // '- [ ] Task' or '* [ ] Task' or '+ [ ] Task' + #formatBulletTask({ indent, bullet, content }) { + const m = content.match(this.#taskAtStartPattern); + if (!m) return null; + const taskContent = m.groups.taskContent; + + return taskContent === '' + ? { action: 'remove' } + : { action: 'insert', text: `${indent}${bullet} [ ] ` }; + } + + // '- Item' or '* Item' or '+ Item' + #formatBulletList({ indent, bullet, content }) { + return content === '' + ? { action: 'remove' } + : { action: 'insert', text: `${indent}${bullet} ` }; + } + + // '1. [ ] Task' or '1) [ ] Task' + #formatOrderedTask({ indent, num, delimiter, content }) { + const m = content.match(this.#taskAtStartPattern); + if (!m) return null; + const taskContent = m.groups.taskContent; + + const next = `${Number(num) + 1}${delimiter}`; + return taskContent === '' + ? { action: 'remove' } + : { action: 'insert', text: `${indent}${next} [ ] ` }; + } + + // '1. Item' or '1) Item' + #formatOrderedList({ indent, num, delimiter, content }) { + const next = `${Number(num) + 1}${delimiter}`; + return content === '' + ? { action: 'remove' } + : { action: 'insert', text: `${indent}${next} ` }; + } } class TextileListFormatter { format(line) { - // Match list items in Textile syntax. - // Captures either an ordered list (using '#') or an unordered list (using '*'). - // The regex structure: - // ^([*#]+) → one or more list markers: '*' for unordered, '#' for ordered - // (.*)$ → the actual list item content - // // Examples: // '# ordered text' → marker='#', content='ordered text' // '## nested ordered text' → marker='##', content='nested ordered text' // '* unordered text' → marker='*', content='unordered text' // '** nested unordered text' → marker='**', content='nested unordered text' - const match = line.match(/^([*#]+) (.*)$/) + const match = line.match(/^(?[*#]+) (?.*)$/); if (!match) return null - const marker = match[1] - const content = match[2] - - if (content === '') { - return { action: 'remove' } - } - - return { action: 'insert', text: `${marker} ` } + const { marker, content } = match.groups; + return content === '' + ? { action: 'remove' } + : { action: 'insert', text: `${marker} ` }; } } diff --git a/test/system/list_autofill_test.rb b/test/system/list_autofill_test.rb index 95898c734..6eef8d056 100644 --- a/test/system/list_autofill_test.rb +++ b/test/system/list_autofill_test.rb @@ -127,6 +127,60 @@ class ListAutofillSystemTest < ApplicationSystemTestCase end end + def test_autofill_with_markdown_unchecked_task_list + with_settings :text_formatting => 'common_mark' do + visit '/projects/ecookbook/issues/new' + + within('form#issue-form') do + find('#issue_description').send_keys('- [ ] First item') + find('#issue_description').send_keys(:enter) + + assert_equal( + "- [ ] First item\n" \ + "- [ ] ", + find('#issue_description').value + ) + + fill_in 'Description', with: '' + find('#issue_description').send_keys('1. [ ] First item') + find('#issue_description').send_keys(:enter) + + assert_equal( + "1. [ ] First item\n" \ + "2. [ ] ", + find('#issue_description').value + ) + end + end + end + + def test_autofill_with_markdown_checked_task_list + with_settings :text_formatting => 'common_mark' do + visit '/projects/ecookbook/issues/new' + + within('form#issue-form') do + find('#issue_description').send_keys('- [x] First item') + find('#issue_description').send_keys(:enter) + + assert_equal( + "- [x] First item\n" \ + "- [ ] ", + find('#issue_description').value + ) + + fill_in 'Description', with: '' + find('#issue_description').send_keys('1. [x] First item') + find('#issue_description').send_keys(:enter) + + assert_equal( + "1. [x] First item\n" \ + "2. [ ] ", + find('#issue_description').value + ) + end + end + end + def test_textile_nested_list_autofill with_settings :text_formatting => 'textile' do visit '/projects/ecookbook/issues/new' -- 2.51.0