diff --git a/services/web/frontend/js/features/source-editor/extensions/toolbar/commands.ts b/services/web/frontend/js/features/source-editor/extensions/toolbar/commands.ts index b6db570f8a..0f7930f41c 100644 --- a/services/web/frontend/js/features/source-editor/extensions/toolbar/commands.ts +++ b/services/web/frontend/js/features/source-editor/extensions/toolbar/commands.ts @@ -10,8 +10,10 @@ import { ancestorListType, toggleListForRanges, unwrapBulletList, + unwrapDescriptionList, unwrapNumberedList, wrapInBulletList, + wrapInDescriptionList, wrapInNumberedList, } from './lists' import { snippet } from '@codemirror/autocomplete' @@ -114,6 +116,8 @@ export const indentDecrease: Command = view => { return unwrapBulletList(view) case 'enumerate': return unwrapNumberedList(view) + case 'description': + return unwrapDescriptionList(view) default: return false } @@ -136,6 +140,8 @@ export const indentIncrease: Command = view => { return wrapInBulletList(view) case 'enumerate': return wrapInNumberedList(view) + case 'description': + return wrapInDescriptionList(view) default: return false } 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 73bd285f8d..a1243fdeee 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 @@ -343,5 +343,7 @@ export const toggleListForRanges = export const wrapInBulletList = wrapRangesInList('itemize') export const wrapInNumberedList = wrapRangesInList('enumerate') +export const wrapInDescriptionList = wrapRangesInList('description') export const unwrapBulletList = unwrapRangesFromList('itemize') export const unwrapNumberedList = unwrapRangesFromList('enumerate') +export const unwrapDescriptionList = unwrapRangesFromList('description') 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 cc4084b02f..13f5a52168 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 @@ -51,6 +51,9 @@ import { EditableInlineGraphicsWidget } from './visual-widgets/editable-inline-g import { CloseBrace, OpenBrace, + CloseBracket, + OpenBracket, + OptionalArgument, ShortTextArgument, TextArgument, } from '../../lezer-latex/latex.terms.mjs' @@ -74,6 +77,7 @@ import { validateParsedTable, } from '../../components/table-generator/utils' import { debugConsole } from '@/utils/debugging' +import { DescriptionItemWidget } from './visual-widgets/description-item' type Options = { previewByPath: (path: string) => PreviewPath | null @@ -101,13 +105,17 @@ function decorateArgumentBraces( argumentNode: SyntaxNode | null | undefined, start: number, decorateEmptyArguments = false, - endWidget?: WidgetType + endWidget?: WidgetType, + braceTypes = { + open: OpenBrace, + close: CloseBrace, + } ): Range[] { if (!argumentNode) { return [] } - const openBrace = argumentNode.getChild('OpenBrace') - const closeBrace = argumentNode.getChild('CloseBrace') + const openBrace = argumentNode.getChild(braceTypes.open) + const closeBrace = argumentNode.getChild(braceTypes.close) if (openBrace && closeBrace) { if ( @@ -120,10 +128,9 @@ function decorateArgumentBraces( widget: startWidget, }).range(start, openBrace.to), - Decoration.replace({ widget: endWidget }).range( - closeBrace.from, - closeBrace.to - ), + Decoration.replace({ + widget: endWidget, + }).range(closeBrace.from, closeBrace.to), ] } } @@ -377,6 +384,7 @@ export const atomicDecorations = (options: Options) => { switch (envName) { case 'itemize': case 'enumerate': + case 'description': startListEnvironment(envName) listDepth++ break @@ -480,6 +488,7 @@ export const atomicDecorations = (options: Options) => { switch (envName) { case 'itemize': case 'enumerate': + case 'description': if (currentListEnvironment === envName) { endListEnvironment() } @@ -963,16 +972,51 @@ export const atomicDecorations = (options: Options) => { state.sliceDoc(line.from, nodeRef.from) ) const from = onlySpaceBeforeNode ? line.from : nodeRef.from - decorations.push( - Decoration.replace({ - widget: new ItemWidget( - currentListEnvironment || 'document', - currentOrdinal, - listDepth - ), - }).range(from, nodeRef.to) - ) - return false + + if (currentListEnvironment === 'description') { + const argumentNode = nodeRef.node.getChild(OptionalArgument) + const to = argumentNode ? argumentNode.from : nodeRef.to + + const onlySpaceAfterNode = + !argumentNode && + /^\s*$/.test(state.sliceDoc(nodeRef.to, line.to)) + + if (!onlySpaceAfterNode) { + // decorate the \item command and subsequent whitespace, if there is other content on the line + decorations.push( + Decoration.replace({ + widget: new DescriptionItemWidget(listDepth), + }).range(from, to) + ) + } + + if (argumentNode) { + // decorate the optional argument + const decorateBrackets = shouldDecorate(state, argumentNode) + + decorations.push( + ...decorateArgumentBraces( + new BraceWidget(decorateBrackets ? '' : '['), + argumentNode, + from, + false, + new BraceWidget(decorateBrackets ? '' : ']'), + { open: OpenBracket, close: CloseBracket } + ) + ) + } + } else { + decorations.push( + Decoration.replace({ + widget: new ItemWidget( + currentListEnvironment || 'document', + currentOrdinal, + listDepth + ), + }).range(from, nodeRef.to) + ) + return false + } } } else if (nodeRef.type.is('NewTheoremCommand')) { const result = parseTheoremArguments(state, nodeRef.node) diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/list-item-marker.ts b/services/web/frontend/js/features/source-editor/extensions/visual/list-item-marker.ts index e0be2c307a..c6d40460e9 100644 --- a/services/web/frontend/js/features/source-editor/extensions/visual/list-item-marker.ts +++ b/services/web/frontend/js/features/source-editor/extensions/visual/list-item-marker.ts @@ -49,7 +49,10 @@ const chooseTargetPosition = ( targetNode = node } else if (node.type.is('ItemCtrlSeq')) { targetNode = node.parent - } else if (node.type.is('Whitespace')) { + } else if ( + node.type.is('Whitespace') && + node.nextSibling?.type.is('Command') + ) { targetNode = node.nextSibling?.firstChild?.firstChild } 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 87e7cbf897..9266c1d280 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 @@ -12,6 +12,7 @@ import { indentIncrease, } from '../toolbar/commands' import { createListItem } from '@/features/source-editor/extensions/visual/utils/list-item' +import { getListType } from '../../utils/tree-operations/lists' /** * A keymap which provides behaviours for the visual editor, @@ -37,7 +38,7 @@ export const visualKeymap = Prec.highest( if (line.number === endLine.number - 1) { // last item line - if (line.text.trim() === '\\item') { + if (/^\\item(\[])?$/.test(line.text.trim())) { // no content on this line // outside the end of the current list @@ -85,8 +86,7 @@ export const visualKeymap = Prec.highest( } // handle a list item that isn't at the end of a list - - const insert = '\n' + createListItem(state, from) + let insert = '\n' + createListItem(state, from) const countWhitespaceAfterPosition = (pos: number) => { const line = state.doc.lineAt(pos) @@ -95,9 +95,16 @@ export const visualKeymap = Prec.highest( return matches ? matches[1].length : 0 } - // move the cursor past any whitespace on the new line - const pos = - from + insert.length + countWhitespaceAfterPosition(from) + let pos: number + + if (getListType(state, listNode) === 'description') { + insert = insert.replace(/\\item $/, '\\item[] ') + // position the cursor inside the square brackets + pos = from + insert.length - 2 + } else { + // move the cursor past any whitespace on the new line + pos = from + insert.length + countWhitespaceAfterPosition(from) + } handled = true diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual-theme.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual-theme.ts index 6443c06c38..5683af73f2 100644 --- a/services/web/frontend/js/features/source-editor/extensions/visual/visual-theme.ts +++ b/services/web/frontend/js/features/source-editor/extensions/visual/visual-theme.ts @@ -16,6 +16,7 @@ export const visualHighlightStyle = syntaxHighlighting( { tag: tags.string, class: 'ol-cm-monospace' }, { tag: tags.punctuation, class: 'ol-cm-punctuation' }, { tag: tags.literal, class: 'ol-cm-monospace' }, + { tag: tags.strong, class: 'ol-cm-strong' }, { tag: tags.monospace, fontFamily: 'var(--source-font-family)', @@ -70,6 +71,9 @@ const mainVisualTheme = EditorView.theme({ fontVariant: 'normal', textDecoration: 'none', }, + '.ol-cm-strong': { + fontWeight: 700, + }, '.ol-cm-punctuation': { fontFamily: 'var(--source-font-family)', lineHeight: 1, @@ -184,7 +188,7 @@ const mainVisualTheme = EditorView.theme({ '.ol-cm-begin-theorem > .ol-cm-environment-padding:first-of-type': { flex: 0, }, - '.ol-cm-item': { + '.ol-cm-item, .ol-cm-description-item': { paddingInlineStart: 'calc(var(--list-depth) * 2ch)', }, '.ol-cm-item::before': { diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/description-item.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/description-item.ts new file mode 100644 index 0000000000..aec814e14e --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/description-item.ts @@ -0,0 +1,31 @@ +import { WidgetType } from '@codemirror/view' + +export class DescriptionItemWidget extends WidgetType { + constructor(public listDepth: number) { + super() + } + + toDOM() { + const element = document.createElement('span') + element.classList.add('ol-cm-description-item') + this.setProperties(element) + return element + } + + eq(widget: DescriptionItemWidget) { + return widget.listDepth === this.listDepth + } + + updateDOM(element: HTMLElement) { + this.setProperties(element) + return true + } + + ignoreEvent(event: Event): boolean { + return event.type !== 'mousedown' && event.type !== 'mouseup' + } + + setProperties(element: HTMLElement) { + element.style.setProperty('--list-depth', String(this.listDepth)) + } +} diff --git a/services/web/frontend/js/features/source-editor/languages/latex/completions/data/environments.ts b/services/web/frontend/js/features/source-editor/languages/latex/completions/data/environments.ts index f0dee55c35..2864789865 100644 --- a/services/web/frontend/js/features/source-editor/languages/latex/completions/data/environments.ts +++ b/services/web/frontend/js/features/source-editor/languages/latex/completions/data/environments.ts @@ -18,6 +18,12 @@ export const environments = new Map([ \\end{array}`, ], ['center', snippet('center')], + [ + 'description', + `\\begin{description} +\t\\item[$1] $2 +\\end{description}`, + ], ['document', snippetNoIndent('document')], ['equation', snippet('equation')], ['equation*', snippet('equation*')], diff --git a/services/web/frontend/js/features/source-editor/languages/latex/latex-language.ts b/services/web/frontend/js/features/source-editor/languages/latex/latex-language.ts index 54812124af..509a604c93 100644 --- a/services/web/frontend/js/features/source-editor/languages/latex/latex-language.ts +++ b/services/web/frontend/js/features/source-editor/languages/latex/latex-language.ts @@ -155,6 +155,7 @@ export const LaTeXLanguage = LRLanguage.define({ 'DocumentClass/OptionalArgument/ShortOptionalArg/Normal': t.attributeValue, 'DocumentClass/ShortTextArgument/ShortArg/Normal': t.typeName, + 'ListEnvironment/BeginEnv/OptionalArgument/...': t.monospace, Number: t.number, OpenBrace: t.brace, CloseBrace: t.brace, @@ -193,6 +194,7 @@ export const LaTeXLanguage = LRLanguage.define({ 'BareFilePathArgument/SpaceDelimitedLiteralArgContent': t.attributeValue, TrailingContent: t.comment, + 'Item/OptionalArgument/ShortOptionalArg/...': t.strong, // TODO: t.strong, t.emphasis }), ], diff --git a/services/web/frontend/js/features/source-editor/lezer-latex/latex.grammar b/services/web/frontend/js/features/source-editor/lezer-latex/latex.grammar index ff5c9830ee..01a5aa8f1f 100644 --- a/services/web/frontend/js/features/source-editor/lezer-latex/latex.grammar +++ b/services/web/frontend/js/features/source-editor/lezer-latex/latex.grammar @@ -354,7 +354,7 @@ KnownCommand { CenteringCtrlSeq } | Item { - ItemCtrlSeq optionalWhitespace? + ItemCtrlSeq OptionalArgument? optionalWhitespace? } | Maketitle { MaketitleCtrlSeq optionalWhitespace? diff --git a/services/web/frontend/js/features/source-editor/lezer-latex/tokens.mjs b/services/web/frontend/js/features/source-editor/lezer-latex/tokens.mjs index a58e15b8ef..40c2e00937 100644 --- a/services/web/frontend/js/features/source-editor/lezer-latex/tokens.mjs +++ b/services/web/frontend/js/features/source-editor/lezer-latex/tokens.mjs @@ -744,6 +744,7 @@ const otherKnownEnvNames = { enumerate: ListEnvName, itemize: ListEnvName, table: TableEnvName, + description: ListEnvName, } export const specializeEnvName = (name, terms) => { diff --git a/services/web/frontend/js/features/source-editor/utils/tree-operations/ancestors.ts b/services/web/frontend/js/features/source-editor/utils/tree-operations/ancestors.ts index 00aeb158fb..487019a377 100644 --- a/services/web/frontend/js/features/source-editor/utils/tree-operations/ancestors.ts +++ b/services/web/frontend/js/features/source-editor/utils/tree-operations/ancestors.ts @@ -257,7 +257,7 @@ export const withinFormattingCommand = (state: EditorState) => { } } -export type ListEnvironmentName = 'itemize' | 'enumerate' +export type ListEnvironmentName = 'itemize' | 'enumerate' | 'description' export const listDepthForNode = (node: SyntaxNode) => { let depth = 0 diff --git a/services/web/frontend/js/features/source-editor/utils/tree-operations/lists.ts b/services/web/frontend/js/features/source-editor/utils/tree-operations/lists.ts new file mode 100644 index 0000000000..8d9eaae59d --- /dev/null +++ b/services/web/frontend/js/features/source-editor/utils/tree-operations/lists.ts @@ -0,0 +1,21 @@ +import { EditorState } from '@codemirror/state' +import { SyntaxNode } from '@lezer/common' + +export const getListType = ( + state: EditorState, + listEnvironmentNode: SyntaxNode +) => { + const beginEnvNameNode = listEnvironmentNode + .getChild('BeginEnv') + ?.getChild('EnvNameGroup') + ?.getChild('ListEnvName') + + const endEnvNameNode = listEnvironmentNode + .getChild('EndEnv') + ?.getChild('EnvNameGroup') + ?.getChild('ListEnvName') + + if (beginEnvNameNode && endEnvNameNode) { + return state.sliceDoc(beginEnvNameNode.from, beginEnvNameNode.to).trim() + } +} 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 f65f00e215..7d891b8d10 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 @@ -285,4 +285,32 @@ describe(' lists in Rich Text mode', function () { [' foo', ' bar', ' baz', ' ', ' test'].join('') ) }) + + it('decorates a description list', function () { + const content = [ + '\\begin{description}', + '\\item[foo] Bar', + '\\item Test', + '\\end{description}', + ].join('\n') + mountEditor(content) + + cy.get('.cm-line').eq(1).click() + + cy.get('.cm-content').should('have.text', ['foo Bar', 'Test'].join('')) + + cy.get('.cm-line').eq(1).type('{Enter}baz') + + cy.get('.cm-content').should( + 'have.text', + ['foo Bar', 'Test', '[baz] '].join('') + ) + + cy.get('.cm-line').eq(2).type('{rightArrow}{rightArrow}Test') + + cy.get('.cm-content').should( + 'have.text', + ['foo Bar', 'Test', 'baz Test'].join('') + ) + }) })