diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/utils/list-item.ts b/services/web/frontend/js/features/source-editor/extensions/visual/utils/list-item.ts new file mode 100644 index 0000000000..7fbcfb9f34 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/visual/utils/list-item.ts @@ -0,0 +1,13 @@ +import { EditorState } from '@codemirror/state' +import { + getIndentation, + IndentContext, + indentString, +} from '@codemirror/language' + +export const createListItem = (state: EditorState, pos: number) => { + const cx = new IndentContext(state) + const columns = getIndentation(cx, pos) ?? 0 + const indent = indentString(state, columns) + return `${indent}\\item ` +} diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual-keymap.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual-keymap.ts index 1731c38b52..87e7cbf897 100644 --- a/services/web/frontend/js/features/source-editor/extensions/visual/visual-keymap.ts +++ b/services/web/frontend/js/features/source-editor/extensions/visual/visual-keymap.ts @@ -1,16 +1,17 @@ import { keymap } from '@codemirror/view' -import { EditorSelection, Prec } from '@codemirror/state' -import { ancestorNodeOfType } from '../../utils/tree-query' import { - getIndentation, - IndentContext, - indentString, -} from '@codemirror/language' + ChangeSpec, + EditorSelection, + Prec, + SelectionRange, +} from '@codemirror/state' +import { ancestorNodeOfType } from '../../utils/tree-query' import { cursorIsAtStartOfListItem, indentDecrease, indentIncrease, } from '../toolbar/commands' +import { createListItem } from '@/features/source-editor/extensions/visual/utils/list-item' /** * A keymap which provides behaviours for the visual editor, @@ -39,29 +40,53 @@ export const visualKeymap = Prec.highest( if (line.text.trim() === '\\item') { // no content on this line - // delete this line - const changes = state.changes({ + // outside the end of the current list + const pos = listNode.to + 1 + + // delete the current line + const deleteCurrentLine = { from: line.from, to: line.to + 1, insert: '', - }) + } - // the start of the line after the list environment - const range = EditorSelection.cursor(endLine.to + 1).map( - changes - ) + const changes: ChangeSpec[] = [deleteCurrentLine] + + // the new cursor position + let range: SelectionRange + + // if this is a nested list, insert a new empty list item after this list + if ( + listNode.parent?.parent?.parent?.parent?.type.is( + 'ListEnvironment' + ) + ) { + const newListItem = createListItem(state, pos) + + changes.push({ + from: pos, + insert: newListItem + '\n', + }) + + // place the cursor at the end of the new list item + range = EditorSelection.cursor(pos + newListItem.length) + } else { + // place the cursor outside the end of the current list + range = EditorSelection.cursor(pos) + } handled = true - return { changes, range } + return { + changes, + range: range.map(state.changes(deleteCurrentLine)), + } } } - // create a new list item - const cx = new IndentContext(state) - const columns = getIndentation(cx, from) ?? 0 - const indent = indentString(state, columns) - const insert = `\n${indent}\\item ` + // handle a list item that isn't at the end of a list + + const insert = '\n' + createListItem(state, from) const countWhitespaceAfterPosition = (pos: number) => { const line = state.doc.lineAt(pos) diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-list.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-list.spec.tsx index a54f4b1def..f65f00e215 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-list.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-list.spec.tsx @@ -226,4 +226,63 @@ describe(' lists in Rich Text mode', function () { cy.get('.cm-content').should('have.text', [' foo', ' bazbar'].join('')) }) + + it('handles Enter in an empty list item at the end of a top-level list', function () { + const content = [ + '\\begin{itemize}', + '\\item foo', + '\\item ', + '\\end{itemize}', + '', + ].join('\n') + mountEditor(content) + + cy.get('.cm-line').eq(2).click() + cy.focused().type('{enter}') + + cy.get('.cm-content').should('have.text', [' foo'].join('')) + }) + + it('handles Enter in an empty list item at the end of a nested list', function () { + const content = [ + '\\begin{itemize}', + '\\item foo bar', + '\\begin{itemize}', + '\\item baz', + '\\item ', + '\\end{itemize}', + '\\end{itemize}', + ].join('\n') + mountEditor(content) + + cy.get('.cm-line').eq(3).click() + cy.focused().type('{enter}') + + cy.get('.cm-content').should( + 'have.text', + [' foo', ' bar', ' baz', ' '].join('') + ) + }) + + it('handles Enter in an empty list item at the end of a nested list with subsequent items', function () { + const content = [ + '\\begin{itemize}', + '\\item foo bar', + '\\begin{itemize}', + '\\item baz', + '\\item ', + '\\end{itemize}', + '\\item test', + '\\end{itemize}', + ].join('\n') + mountEditor(content) + + cy.get('.cm-line').eq(3).click() + cy.focused().type('{enter}') + + cy.get('.cm-content').should( + 'have.text', + [' foo', ' bar', ' baz', ' ', ' test'].join('') + ) + }) })