Explorar el Código

[visual] Add support for `description` list environments (#13460)

GitOrigin-RevId: d1ddfeed4ba453afa348e57d75fdb3e12d29f5fc
Alf Eaton hace 2 años
padre
commit
ab5495023a

+ 6 - 0
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
   }

+ 2 - 0
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')

+ 61 - 17
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<Decoration>[] {
   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)

+ 4 - 1
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
   }
 

+ 13 - 6
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
 

+ 5 - 1
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': {

+ 31 - 0
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))
+  }
+}

+ 6 - 0
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*')],

+ 2 - 0
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
       }),
     ],

+ 1 - 1
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?

+ 1 - 0
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) => {

+ 1 - 1
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

+ 21 - 0
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()
+  }
+}

+ 28 - 0
services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-list.spec.tsx

@@ -285,4 +285,32 @@ describe('<CodeMirrorEditor/> 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('')
+    )
+  })
 })