diff --git a/release-notes/8.0.0/feat/4072.md b/release-notes/8.0.0/feat/4072.md new file mode 100644 index 000000000..ff1d324d2 --- /dev/null +++ b/release-notes/8.0.0/feat/4072.md @@ -0,0 +1,7 @@ +Added handling of Tab and Enter keys to the new Markdown editor, in line with what standalone text editors usually do. This is mostly focused on quickly writing and rearranging lists. + +- Pressing Tab prepending 4 spaces to the line under cursor, or all the lines in the selection. +- Pressing Shift+Tab removes up to 4 spaces. +- Pressing Enter repeats any indentation and a "repeatable" prefix (list or blockquote) from the current line. +- To avoid interfering with keyboard navigation, the Tab presses are only handled once there has been some other interaction with the element after focusing. +- Pressing Escape removes focus from the editor and resumes default Tab navigation. diff --git a/tests/e2e/markdown-editor.test.e2e.js b/tests/e2e/markdown-editor.test.e2e.js new file mode 100644 index 000000000..49f66f738 --- /dev/null +++ b/tests/e2e/markdown-editor.test.e2e.js @@ -0,0 +1,177 @@ +// @ts-check +import {expect, test} from '@playwright/test'; +import {load_logged_in_context, login_user} from './utils_e2e.js'; + +test.beforeAll(async ({browser}, workerInfo) => { + await login_user(browser, workerInfo, 'user2'); +}); + +test('Test markdown indentation', async ({browser}, workerInfo) => { + const context = await load_logged_in_context(browser, workerInfo, 'user2'); + + const initText = `* first\n* second\n* third\n* last`; + + const page = await context.newPage(); + const response = await page.goto('/user2/repo1/issues/new'); + await expect(response?.status()).toBe(200); + + const textarea = page.locator('textarea[name=content]'); + const tab = ' '; + await textarea.fill(initText); + await textarea.click(); // Tab handling is disabled until pointer event or input. + + // Indent, then unindent first line + await textarea.evaluate((it) => it.setSelectionRange(0, 0)); + await textarea.press('Tab'); + await expect(textarea).toHaveValue(`${tab}* first\n* second\n* third\n* last`); + await textarea.press('Shift+Tab'); + await expect(textarea).toHaveValue(initText); + + // Indent second line while somewhere inside of it + await textarea.press('ArrowDown'); + await textarea.press('ArrowRight'); + await textarea.press('ArrowRight'); + await textarea.press('Tab'); + await expect(textarea).toHaveValue(`* first\n${tab}* second\n* third\n* last`); + + // Subsequently, select a chunk of 2nd and 3rd line and indent both, preserving the cursor position in relation to text + await textarea.evaluate((it) => it.setSelectionRange(it.value.indexOf('cond'), it.value.indexOf('hird'))); + await textarea.press('Tab'); + const lines23 = `* first\n${tab}${tab}* second\n${tab}* third\n* last`; + await expect(textarea).toHaveValue(lines23); + await expect(textarea).toHaveJSProperty('selectionStart', lines23.indexOf('cond')); + await expect(textarea).toHaveJSProperty('selectionEnd', lines23.indexOf('hird')); + + // Then unindent twice, erasing all indents. + await textarea.press('Shift+Tab'); + await expect(textarea).toHaveValue(`* first\n${tab}* second\n* third\n* last`); + await textarea.press('Shift+Tab'); + await expect(textarea).toHaveValue(initText); + + // Indent and unindent with cursor at the end of the line + await textarea.evaluate((it) => it.setSelectionRange(it.value.indexOf('cond'), it.value.indexOf('cond'))); + await textarea.press('End'); + await textarea.press('Tab'); + await expect(textarea).toHaveValue(`* first\n${tab}* second\n* third\n* last`); + await textarea.press('Shift+Tab'); + await expect(textarea).toHaveValue(initText); + + // Ensure textarea is blurred on Esc, and does not intercept Tab before input + await textarea.press('Escape'); + await expect(textarea).not.toBeFocused(); + await textarea.focus(); + await textarea.press('Tab'); + await expect(textarea).toHaveValue(initText); + await expect(textarea).not.toBeFocused(); // because tab worked as normal + + // Check that Tab does work after input + await textarea.focus(); + await textarea.evaluate((it) => it.setSelectionRange(it.value.length, it.value.length)); + await textarea.press('Shift+Enter'); // Avoid triggering the prefix continuation feature + await textarea.pressSequentially('* least'); + await textarea.press('Tab'); + await expect(textarea).toHaveValue(`* first\n* second\n* third\n* last\n${tab}* least`); + + // Check that partial indents are cleared + await textarea.fill(initText); + await textarea.evaluate((it) => it.setSelectionRange(it.value.indexOf('* second'), it.value.indexOf('* second'))); + await textarea.pressSequentially(' '); + await textarea.press('Shift+Tab'); + await expect(textarea).toHaveValue(initText); +}); + +test('Test markdown list continuation', async ({browser}, workerInfo) => { + const context = await load_logged_in_context(browser, workerInfo, 'user2'); + + const initText = `* first\n* second\n* third\n* last`; + + const page = await context.newPage(); + const response = await page.goto('/user2/repo1/issues/new'); + await expect(response?.status()).toBe(200); + + const textarea = page.locator('textarea[name=content]'); + const tab = ' '; + await textarea.fill(initText); + + // Test continuation of '* ' prefix + await textarea.evaluate((it) => it.setSelectionRange(it.value.indexOf('cond'), it.value.indexOf('cond'))); + await textarea.press('End'); + await textarea.press('Enter'); + await textarea.pressSequentially('middle'); + await expect(textarea).toHaveValue(`* first\n* second\n* middle\n* third\n* last`); + + // Test continuation of ' * ' prefix + await textarea.press('Tab'); + await textarea.press('Enter'); + await textarea.pressSequentially('muddle'); + await expect(textarea).toHaveValue(`* first\n* second\n${tab}* middle\n${tab}* muddle\n* third\n* last`); + + // Test breaking in the middle of a line + await textarea.evaluate((it) => it.setSelectionRange(it.value.lastIndexOf('ddle'), it.value.lastIndexOf('ddle'))); + await textarea.pressSequentially('tate'); + await textarea.press('Enter'); + await textarea.pressSequentially('me'); + await expect(textarea).toHaveValue(`* first\n* second\n${tab}* middle\n${tab}* mutate\n${tab}* meddle\n* third\n* last`); + + // Test not triggering when Shift held + await textarea.fill(initText); + await textarea.evaluate((it) => it.setSelectionRange(it.value.length, it.value.length)); + await textarea.press('Shift+Enter'); + await textarea.press('Enter'); + await textarea.pressSequentially('...but not least'); + await expect(textarea).toHaveValue(`* first\n* second\n* third\n* last\n\n...but not least`); + + // Test continuation of ordered list + await textarea.fill(`1. one\n2. two`); + await textarea.evaluate((it) => it.setSelectionRange(it.value.length, it.value.length)); + await textarea.press('Enter'); + await textarea.pressSequentially('three'); + await expect(textarea).toHaveValue(`1. one\n2. two\n3. three`); + + // Test continuation of alternative ordered list syntax + await textarea.fill(`1) one\n2) two`); + await textarea.evaluate((it) => it.setSelectionRange(it.value.length, it.value.length)); + await textarea.press('Enter'); + await textarea.pressSequentially('three'); + await expect(textarea).toHaveValue(`1) one\n2) two\n3) three`); + + // Test continuation of blockquote + await textarea.fill(`> knowledge is power`); + await textarea.evaluate((it) => it.setSelectionRange(it.value.length, it.value.length)); + await textarea.press('Enter'); + await textarea.pressSequentially('france is bacon'); + await expect(textarea).toHaveValue(`> knowledge is power\n> france is bacon`); + + // Test continuation of checklists + await textarea.fill(`- [ ] have a problem\n- [x] create a solution`); + await textarea.evaluate((it) => it.setSelectionRange(it.value.length, it.value.length)); + await textarea.press('Enter'); + await textarea.pressSequentially('write a test'); + await expect(textarea).toHaveValue(`- [ ] have a problem\n- [x] create a solution\n- [ ] write a test`); + + // Test all conceivable syntax (except ordered lists) + const prefixes = [ + '- ', // A space between the bullet and the content is required. + ' - ', // I have seen single space in front of -/* being used and even recommended, I think. + '* ', + '+ ', + ' ', + ' ', + ' - ', + '\t', + '\t\t* ', + '> ', + '> > ', + '- [ ] ', + '- [ ]', // This does seem to render, so allow. + '* [ ] ', + '+ [ ] ', + ]; + for (const prefix of prefixes) { + await textarea.fill(`${prefix}one`); + await textarea.evaluate((it) => it.setSelectionRange(it.value.length, it.value.length)); + await textarea.press('Enter'); + await textarea.pressSequentially('two'); + await expect(textarea).toHaveValue(`${prefix}one\n${prefix}two`); + } +}); diff --git a/web_src/js/features/comp/ComboMarkdownEditor.js b/web_src/js/features/comp/ComboMarkdownEditor.js index d3fab375a..20c03a2f9 100644 --- a/web_src/js/features/comp/ComboMarkdownEditor.js +++ b/web_src/js/features/comp/ComboMarkdownEditor.js @@ -84,10 +84,36 @@ class ComboMarkdownEditor { if (el.nodeName === 'BUTTON' && !el.getAttribute('type')) el.setAttribute('type', 'button'); } + // Track whether any actual input or pointer action was made after focusing, and only intercept Tab presses after that. + this.interceptTab = false; + this.textarea.addEventListener('focus', () => { + this.interceptTab = false; + }); + this.textarea.addEventListener('pointerup', () => { + // Assume if a pointer is used then Tab handling is a bit less of an issue. + this.interceptTab = true; + }); this.textarea.addEventListener('keydown', (e) => { if (e.shiftKey) { e.target._shiftDown = true; } + const unmodified = !e.shiftKey && !e.ctrlKey && !e.altKey; + if (e.key === 'Escape') { + // Explicitly lose focus and reenable tab navigation. + e.target.blur(); + this.interceptTab = false; + } else if (e.key === 'Tab' && this.interceptTab && !e.altKey && !e.ctrlKey) { + this.indentSelection(e.shiftKey); + this.options?.onContentChanged?.(this, e); + e.preventDefault(); + } else if (e.key === 'Enter' && unmodified) { + if (this.breakLine()) { + this.options?.onContentChanged?.(this, e); + e.preventDefault(); + } + } else { + this.interceptTab ||= unmodified; + } }); this.textarea.addEventListener('keyup', (e) => { if (!e.shiftKey) { @@ -288,6 +314,90 @@ class ComboMarkdownEditor { } } + indentSelection(unindent) { + // Indent with 4 spaces, unindent 4 spaces or fewer or a lost tab. + const indentPrefix = ' '; + const unindentRegex = /^( {1,4}|\t)/; + + // Indent all lines that are included in the selection, partially or whole, while preserving the original selection at the end. + const lines = this.textarea.value.split('\n'); + const changedLines = []; + // The current selection or cursor position. + const [start, end] = [this.textarea.selectionStart, this.textarea.selectionEnd]; + // The range containing whole lines that will effectively be replaced. + let [editStart, editEnd] = [start, end]; + // The range that needs to be re-selected to match previous selection. + let [newStart, newEnd] = [start, end]; + // The start and end position of the current line (where end points to the newline or EOF) + let [lineStart, lineEnd] = [0, 0]; + + for (const line of lines) { + lineEnd = lineStart + line.length + 1; + if (lineEnd <= start) { + lineStart = lineEnd; + continue; + } + + const updated = unindent ? line.replace(unindentRegex, '') : indentPrefix + line; + changedLines.push(updated); + const move = updated.length - line.length; + + if (start >= lineStart && start < lineEnd) { + editStart = lineStart; + newStart = Math.max(start + move, lineStart); + } + + newEnd += move; + editEnd = lineEnd - 1; + lineStart = lineEnd; + if (lineStart > end) break; + } + + // Update changed lines whole. + const text = changedLines.join('\n'); + this.textarea.setSelectionRange(editStart, editEnd); + if (!document.execCommand('insertText', false, text)) { + // execCommand is deprecated, but setRangeText (and any other direct value modifications) erases the native undo history. + // So only fall back to it if execCommand fails. + this.textarea.setRangeText(text); + } + + // Set selection to (effectively) be the same as before. + this.textarea.setSelectionRange(newStart, Math.max(newStart, newEnd)); + } + + breakLine() { + const [start, end] = [this.textarea.selectionStart, this.textarea.selectionEnd]; + + // Do nothing if a range is selected + if (start !== end) return false; + + const value = this.textarea.value; + // Find the beginning of the current line. + const lineStart = Math.max(0, value.lastIndexOf('\n', start - 1) + 1); + // Find the end and extract the line. + const lineEnd = value.indexOf('\n', start); + const line = value.slice(lineStart, lineEnd < 0 ? value.length : lineEnd); + // Match any whitespace at the start + any repeatable prefix + exactly one space after. + const prefix = line.match(/^\s*((\d+)[.)]\s|[-*+]\s+(\[[ x]\]\s?)?|(>\s+)+)?/); + + // Defer to browser if we can't do anything more useful, or if the cursor is inside the prefix. + if (!prefix || !prefix[0].length || lineStart + prefix[0].length > start) return false; + + // Insert newline + prefix. + let text = `\n${prefix[0]}`; + // Increment a number if present. (perhaps detecting repeating 1. and not doing that then would be a good idea) + const num = text.match(/\d+/); + if (num) text = text.replace(num[0], Number(num[0]) + 1); + text = text.replace('[x]', '[ ]'); + + if (!document.execCommand('insertText', false, text)) { + this.textarea.setRangeText(text); + } + + return true; + } + get userPreferredEditor() { return window.localStorage.getItem(`markdown-editor-${this.options.useScene ?? 'default'}`); }