diff --git a/services/web/frontend/js/features/source-editor/extensions/toolbar/lists.ts b/services/web/frontend/js/features/source-editor/extensions/toolbar/lists.ts index f1cd9e2986..cc6fb39a3a 100644 --- a/services/web/frontend/js/features/source-editor/extensions/toolbar/lists.ts +++ b/services/web/frontend/js/features/source-editor/extensions/toolbar/lists.ts @@ -20,6 +20,7 @@ import { } from '../../utils/tree-operations/ancestors' import { getEnvironmentName } from '../../utils/tree-operations/environments' import { ListEnvironment } from '../../lezer-latex/latex.terms.mjs' +import { SyntaxNode } from '@lezer/common' export const ancestorListType = (state: EditorState): string | null => { const ancestorNode = ancestorWithType(state, ListEnvironment) @@ -303,6 +304,21 @@ const toggleListForRange = ( return { range } } +export const getListItems = (node: SyntaxNode): SyntaxNode[] => { + const items: SyntaxNode[] = [] + + node.cursor().iterate(nodeRef => { + if (nodeRef.type.is('Item')) { + items.push(nodeRef.node) + } + if (nodeRef.type.is('ListEnvironment') && nodeRef.node !== node) { + return false + } + }) + + return items +} + export const toggleListForRanges = (environment: string) => (view: EditorView) => { view.dispatch( diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/atomic-decorations.ts b/services/web/frontend/js/features/source-editor/extensions/visual/atomic-decorations.ts index 54d3eb4a00..b5dcb3cd58 100644 --- a/services/web/frontend/js/features/source-editor/extensions/visual/atomic-decorations.ts +++ b/services/web/frontend/js/features/source-editor/extensions/visual/atomic-decorations.ts @@ -48,6 +48,7 @@ import { EditableGraphicsWidget } from './visual-widgets/editable-graphics' import { EditableInlineGraphicsWidget } from './visual-widgets/editable-inline-graphics' import { CloseBrace, OpenBrace } from '../../lezer-latex/latex.terms.mjs' import { FootnoteWidget } from './visual-widgets/footnote' +import { getListItems } from '../toolbar/lists' type Options = { fileTreeManager: { @@ -225,92 +226,91 @@ export const atomicDecorations = (options: Options) => { tree.iterate({ enter(nodeRef) { if (nodeRef.type.is('$Environment')) { - if (shouldDecorate(state, nodeRef)) { - const envName = getUnstarredEnvironmentName(nodeRef.node, state) - const hideInEnvironmentTypes = ['figure', 'table'] - if (envName && hideInEnvironmentTypes.includes(envName)) { - const beginNode = nodeRef.node.getChild('BeginEnv') - const endNode = nodeRef.node.getChild('EndEnv') - if ( - beginNode && - endNode && - hasClosingBrace(beginNode) && - hasClosingBrace(endNode) - ) { - const beginLine = state.doc.lineAt(beginNode.from) - const endLine = state.doc.lineAt(endNode.from) + const envName = getUnstarredEnvironmentName(nodeRef.node, state) + const hideInEnvironmentTypes = ['figure', 'table'] + if (envName && hideInEnvironmentTypes.includes(envName)) { + const beginNode = nodeRef.node.getChild('BeginEnv') + const endNode = nodeRef.node.getChild('EndEnv') + if ( + beginNode && + endNode && + hasClosingBrace(beginNode) && + hasClosingBrace(endNode) + ) { + const beginLine = state.doc.lineAt(beginNode.from) + const endLine = state.doc.lineAt(endNode.from) - const begin = { - from: beginLine.from, - to: extendForwardsOverEmptyLines(state.doc, beginLine), - } - const end = { - from: extendBackwardsOverEmptyLines(state.doc, endLine), - to: endLine.to, - } + const begin = { + from: beginLine.from, + to: extendForwardsOverEmptyLines(state.doc, beginLine), + } + const end = { + from: extendBackwardsOverEmptyLines(state.doc, endLine), + to: endLine.to, + } + + if (shouldDecorate(state, { from: begin.from, to: end.to })) { + decorations.push( + Decoration.replace({ + widget: new EnvironmentLineWidget(envName, 'begin'), + block: true, + }).range(begin.from, begin.to), + Decoration.replace({ + widget: new EnvironmentLineWidget(envName, 'end'), + block: true, + }).range(end.from, end.to) + ) + + const centeringNode = centeringNodeForEnvironment(nodeRef) + + if (centeringNode) { + const line = state.doc.lineAt(centeringNode.from) + const from = extendBackwardsOverEmptyLines(state.doc, line) + const to = extendForwardsOverEmptyLines(state.doc, line) - if (shouldDecorate(state, { from: begin.from, to: end.to })) { decorations.push( Decoration.replace({ - widget: new EnvironmentLineWidget(envName, 'begin'), block: true, - }).range(begin.from, begin.to), - Decoration.replace({ - widget: new EnvironmentLineWidget(envName, 'end'), - block: true, - }).range(end.from, end.to) + }).range(from, to) ) - - const centeringNode = centeringNodeForEnvironment(nodeRef) - - if (centeringNode) { - const line = state.doc.lineAt(centeringNode.from) - const from = extendBackwardsOverEmptyLines(state.doc, line) - const to = extendForwardsOverEmptyLines(state.doc, line) - - decorations.push( - Decoration.replace({ - block: true, - }).range(from, to) - ) - } } } - } else if (nodeRef.type.is('ListEnvironment')) { - const beginNode = nodeRef.node.getChild('BeginEnv') - const endNode = nodeRef.node.getChild('EndEnv') + } + } else if (nodeRef.type.is('ListEnvironment')) { + const beginNode = nodeRef.node.getChild('BeginEnv') + const endNode = nodeRef.node.getChild('EndEnv') + + if ( + beginNode && + endNode && + hasClosingBrace(beginNode) && + hasClosingBrace(endNode) + ) { + const beginLine = state.doc.lineAt(beginNode.from) + const endLine = state.doc.lineAt(endNode.from) + + const begin = { + from: beginLine.from, + to: extendForwardsOverEmptyLines(state.doc, beginLine), + } + const end = { + from: extendBackwardsOverEmptyLines(state.doc, endLine), + to: endLine.to, + } if ( - beginNode && - endNode && - hasClosingBrace(beginNode) && - hasClosingBrace(endNode) + !selectionIntersects(state.selection, begin) && + !selectionIntersects(state.selection, end) && + getListItems(nodeRef.node).length > 0 // not empty ) { - const beginLine = state.doc.lineAt(beginNode.from) - const endLine = state.doc.lineAt(endNode.from) - - const begin = { - from: beginLine.from, - to: extendForwardsOverEmptyLines(state.doc, beginLine), - } - const end = { - from: extendBackwardsOverEmptyLines(state.doc, endLine), - to: endLine.to, - } - - if ( - !selectionIntersects(state.selection, begin) && - !selectionIntersects(state.selection, end) - ) { - decorations.push( - Decoration.replace({ - block: true, - }).range(begin.from, begin.to), - Decoration.replace({ - block: true, - }).range(end.from, end.to) - ) - } + decorations.push( + Decoration.replace({ + block: true, + }).range(begin.from, begin.to), + Decoration.replace({ + block: true, + }).range(end.from, end.to) + ) } } } 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 2715025d4f..0fe272c510 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 @@ -43,47 +43,27 @@ describe(' lists in Rich Text mode', function () { // create a nested list cy.get('.cm-line') - .eq(2) + .eq(1) .type(isMac ? '{cmd}]' : '{ctrl}]') - cy.get('.cm-content').should( - 'have.text', - [ - '\\begin{itemize}', - ' Test', - '\\begin{itemize}', - ' Test', - '\\end{itemize}', - '\\end{itemize}', - ].join('') - ) + cy.get('.cm-content').should('have.text', [' Test', ' Test'].join('')) }) it('creates a nested list inside an indented list', function () { const content = [ '\\begin{itemize}', - '\\item Test', - '\\item Test', + ' \\item Test', + ' \\item Test', '\\end{itemize}', ].join('\n') mountEditor(content) // create a nested list cy.get('.cm-line') - .eq(2) + .eq(1) .type(isMac ? '{cmd}]' : '{ctrl}]') - cy.get('.cm-content').should( - 'have.text', - [ - '\\begin{itemize}', - ' Test', - '\\begin{itemize}', - ' Test', - '\\end{itemize}', - '\\end{itemize}', - ].join('') - ) + cy.get('.cm-content').should('have.text', [' Test', ' Test'].join('')) }) it('creates a nested list on Tab at the start of an item', function () { @@ -96,24 +76,14 @@ describe(' lists in Rich Text mode', function () { mountEditor(content) // move to the start of the item and press Tab - cy.get('.cm-line').eq(2).as('line') + cy.get('.cm-line').eq(1).as('line') cy.get('@line').click() cy.get('@line').type('{leftArrow}'.repeat(4)) cy.get('@line').trigger('keydown', { key: 'Tab', }) - cy.get('.cm-content').should( - 'have.text', - [ - '\\begin{itemize}', - ' Test', - '\\begin{itemize}', - ' Test', - '\\end{itemize}', - '\\end{itemize}', - ].join('') - ) + cy.get('.cm-content').should('have.text', [' Test', ' Test'].join('')) }) it('does not creates a nested list on Tab when not at the start of an item', function () { @@ -126,22 +96,13 @@ describe(' lists in Rich Text mode', function () { mountEditor(content) // focus a line (at the end of a list item) and press Tab - cy.get('.cm-line').eq(2).as('line') + cy.get('.cm-line').eq(1).as('line') cy.get('@line').click() cy.get('@line').trigger('keydown', { key: 'Tab', }) - cy.get('.cm-content').should( - 'have.text', - [ - // - '\\begin{itemize}', - ' Test', - ' Test ', - '\\end{itemize}', - ].join('') - ) + cy.get('.cm-content').should('have.text', [' Test', ' Test '].join('')) }) it('removes a nested list on Shift-Tab', function () { @@ -154,41 +115,22 @@ describe(' lists in Rich Text mode', function () { mountEditor(content) // move to the start of the list item and press Tab - cy.get('.cm-line').eq(2).as('line') + cy.get('.cm-line').eq(1).as('line') cy.get('@line').click() cy.get('@line').type('{leftArrow}'.repeat(4)) cy.get('@line').trigger('keydown', { key: 'Tab', }) - cy.get('.cm-content').should( - 'have.text', - [ - '\\begin{itemize}', - ' Test', - '\\begin{itemize}', - ' Test', - '\\end{itemize}', - '\\end{itemize}', - ].join('') - ) + cy.get('.cm-content').should('have.text', [' Test', ' Test'].join('')) // focus the indented line and press Shift-Tab - cy.get('.cm-line').eq(4).trigger('keydown', { + cy.get('.cm-line').eq(1).trigger('keydown', { key: 'Tab', shiftKey: true, }) - cy.get('.cm-content').should( - 'have.text', - [ - // - '\\begin{itemize}', - ' Test', - ' Test', - '\\end{itemize}', - ].join('') - ) + cy.get('.cm-content').should('have.text', [' Test', ' Test'].join('')) }) it('does not remove a top-level nested list on Shift-Tab', function () { @@ -201,21 +143,12 @@ describe(' lists in Rich Text mode', function () { mountEditor(content) // focus a list item and press Shift-Tab - cy.get('.cm-line').eq(2).trigger('keydown', { + cy.get('.cm-line').eq(1).trigger('keydown', { key: 'Tab', shiftKey: true, }) - cy.get('.cm-content').should( - 'have.text', - [ - // - '\\begin{itemize}', - ' Test', - ' Test', - '\\end{itemize}', - ].join('') - ) + cy.get('.cm-content').should('have.text', [' Test', ' Test'].join('')) }) it('handles up arrow at the start of a list item', function () { @@ -227,7 +160,7 @@ describe(' lists in Rich Text mode', function () { ].join('\n') mountEditor(content) - cy.get('.cm-line').eq(2).as('line') + cy.get('.cm-line').eq(1).as('line') cy.get('@line').click() cy.get('@line').type('{leftArrow}'.repeat(3)) // to the start of the item cy.get('@line').type('{upArrow}{Shift}{rightArrow}{rightArrow}{rightArrow}') // up and extend to the end of the item @@ -246,7 +179,7 @@ describe(' lists in Rich Text mode', function () { ].join('\n') mountEditor(content) - cy.get('.cm-line').eq(2).as('line') + cy.get('.cm-line').eq(1).as('line') cy.get('@line').click() cy.get('@line').type('{leftArrow}'.repeat(3)) // to the start of the item cy.get('@line').type('{upArrow}{Shift}{rightArrow}{rightArrow}{rightArrow}') // up and extend to the end of the item @@ -290,15 +223,12 @@ describe(' lists in Rich Text mode', function () { ].join('\n') mountEditor(content) - cy.get('.cm-line').eq(2).as('line') + cy.get('.cm-line').eq(0).as('line') cy.get('@line').click() cy.get('@line').type('\\ite') cy.get('@line').type('{enter}') cy.get('@line').type('second') - cy.get('.cm-content').should( - 'have.text', - ['\\begin{itemize}', ' first', ' second', '\\end{itemize}'].join('') - ) + cy.get('.cm-content').should('have.text', [' first', ' second'].join('')) }) }) diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-toolbar.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-toolbar.spec.tsx index fa2404de64..7df4d4368c 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-toolbar.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-toolbar.spec.tsx @@ -130,18 +130,10 @@ describe(' toolbar in Rich Text mode', function () { clickToolbarButton('Bullet List') - cy.get('.cm-content').should( - 'have.text', - [ - // - '\\begin{itemize}', - ' test', - '\\end{itemize}', - ].join('') - ) + cy.get('.cm-content').should('have.text', ' test') - cy.get('.cm-line').eq(1).type('ing') - cy.get('.cm-line').eq(1).should('have.text', ' testing') + cy.get('.cm-line').eq(0).type('ing') + cy.get('.cm-line').eq(0).should('have.text', ' testing') }) it('should insert a numbered list', function () { @@ -150,17 +142,9 @@ describe(' toolbar in Rich Text mode', function () { clickToolbarButton('Numbered List') - cy.get('.cm-content').should( - 'have.text', - [ - // - '\\begin{enumerate}', - ' test', - '\\end{enumerate}', - ].join('') - ) + cy.get('.cm-content').should('have.text', ' test') - cy.get('.cm-line').eq(1).type('ing') - cy.get('.cm-line').eq(1).should('have.text', ' testing') + cy.get('.cm-line').eq(0).type('ing') + cy.get('.cm-line').eq(0).should('have.text', ' testing') }) }) diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual.spec.tsx index 12ec483590..98325bbaa0 100644 --- a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual.spec.tsx +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual.spec.tsx @@ -68,25 +68,21 @@ describe(' in Visual mode', function () { // select the first autocomplete item cy.findByRole('option').eq(0).click() - cy.get('@first-line').should('have.text', '\\begin{itemize}') - cy.get('@second-line') + cy.get('@first-line') .should('have.text', ' ') .find('.ol-cm-item') .should('have.length', 1) - cy.get('@third-line').should('have.text', '\\end{itemize}') - cy.get('@second-line').type('test{Enter}test') + cy.get('@first-line').type('test{Enter}test') - cy.get('@first-line').should('have.text', '\\begin{itemize}') + cy.get('@first-line') + .should('have.text', ' test') + .find('.ol-cm-item') + .should('have.length', 1) cy.get('@second-line') .should('have.text', ' test') .find('.ol-cm-item') .should('have.length', 1) - cy.get('@third-line') - .should('have.text', ' test') - .find('.ol-cm-item') - .should('have.length', 1) - cy.get('@fourth-line').should('have.text', '\\end{itemize}') }) it('finishes a list on Enter in the last item if empty', function () { @@ -95,10 +91,9 @@ describe(' in Visual mode', function () { // select the first autocomplete item cy.findByRole('option').eq(0).click() - cy.get('@second-line').type('test{Enter}{Enter}') + cy.get('@first-line').type('test{Enter}{Enter}') - cy.get('.cm-line') - .eq(0) + cy.get('@first-line') .should('have.text', ' test') .find('.ol-cm-item') .should('have.length', 1) @@ -113,18 +108,7 @@ describe(' in Visual mode', function () { cy.findByRole('option').eq(0).click() cy.get('@second-line').type('test{Enter}test{Enter}{upArrow}{Enter}{Enter}') - - const lines = [ - '\\begin{itemize}', - ' test', - ' ', - ' ', - ' test', - ' ', - '\\end{itemize}', - ] - - cy.get('.cm-content').should('have.text', lines.join('')) + cy.get('.cm-content').should('have.text', ' testtest') }) forEach(['textbf', 'textit', 'underline']).it(