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

GitOrigin-RevId: d1ddfeed4ba453afa348e57d75fdb3e12d29f5fc
This commit is contained in:
Alf Eaton
2024-05-03 08:04:53 +00:00
committed by Copybot
parent 49a74544b8
commit ab5495023a
14 changed files with 182 additions and 27 deletions
@@ -10,8 +10,10 @@ import {
ancestorListType, ancestorListType,
toggleListForRanges, toggleListForRanges,
unwrapBulletList, unwrapBulletList,
unwrapDescriptionList,
unwrapNumberedList, unwrapNumberedList,
wrapInBulletList, wrapInBulletList,
wrapInDescriptionList,
wrapInNumberedList, wrapInNumberedList,
} from './lists' } from './lists'
import { snippet } from '@codemirror/autocomplete' import { snippet } from '@codemirror/autocomplete'
@@ -114,6 +116,8 @@ export const indentDecrease: Command = view => {
return unwrapBulletList(view) return unwrapBulletList(view)
case 'enumerate': case 'enumerate':
return unwrapNumberedList(view) return unwrapNumberedList(view)
case 'description':
return unwrapDescriptionList(view)
default: default:
return false return false
} }
@@ -136,6 +140,8 @@ export const indentIncrease: Command = view => {
return wrapInBulletList(view) return wrapInBulletList(view)
case 'enumerate': case 'enumerate':
return wrapInNumberedList(view) return wrapInNumberedList(view)
case 'description':
return wrapInDescriptionList(view)
default: default:
return false return false
} }
@@ -343,5 +343,7 @@ export const toggleListForRanges =
export const wrapInBulletList = wrapRangesInList('itemize') export const wrapInBulletList = wrapRangesInList('itemize')
export const wrapInNumberedList = wrapRangesInList('enumerate') export const wrapInNumberedList = wrapRangesInList('enumerate')
export const wrapInDescriptionList = wrapRangesInList('description')
export const unwrapBulletList = unwrapRangesFromList('itemize') export const unwrapBulletList = unwrapRangesFromList('itemize')
export const unwrapNumberedList = unwrapRangesFromList('enumerate') export const unwrapNumberedList = unwrapRangesFromList('enumerate')
export const unwrapDescriptionList = unwrapRangesFromList('description')
@@ -51,6 +51,9 @@ import { EditableInlineGraphicsWidget } from './visual-widgets/editable-inline-g
import { import {
CloseBrace, CloseBrace,
OpenBrace, OpenBrace,
CloseBracket,
OpenBracket,
OptionalArgument,
ShortTextArgument, ShortTextArgument,
TextArgument, TextArgument,
} from '../../lezer-latex/latex.terms.mjs' } from '../../lezer-latex/latex.terms.mjs'
@@ -74,6 +77,7 @@ import {
validateParsedTable, validateParsedTable,
} from '../../components/table-generator/utils' } from '../../components/table-generator/utils'
import { debugConsole } from '@/utils/debugging' import { debugConsole } from '@/utils/debugging'
import { DescriptionItemWidget } from './visual-widgets/description-item'
type Options = { type Options = {
previewByPath: (path: string) => PreviewPath | null previewByPath: (path: string) => PreviewPath | null
@@ -101,13 +105,17 @@ function decorateArgumentBraces(
argumentNode: SyntaxNode | null | undefined, argumentNode: SyntaxNode | null | undefined,
start: number, start: number,
decorateEmptyArguments = false, decorateEmptyArguments = false,
endWidget?: WidgetType endWidget?: WidgetType,
braceTypes = {
open: OpenBrace,
close: CloseBrace,
}
): Range<Decoration>[] { ): Range<Decoration>[] {
if (!argumentNode) { if (!argumentNode) {
return [] return []
} }
const openBrace = argumentNode.getChild('OpenBrace') const openBrace = argumentNode.getChild(braceTypes.open)
const closeBrace = argumentNode.getChild('CloseBrace') const closeBrace = argumentNode.getChild(braceTypes.close)
if (openBrace && closeBrace) { if (openBrace && closeBrace) {
if ( if (
@@ -120,10 +128,9 @@ function decorateArgumentBraces(
widget: startWidget, widget: startWidget,
}).range(start, openBrace.to), }).range(start, openBrace.to),
Decoration.replace({ widget: endWidget }).range( Decoration.replace({
closeBrace.from, widget: endWidget,
closeBrace.to }).range(closeBrace.from, closeBrace.to),
),
] ]
} }
} }
@@ -377,6 +384,7 @@ export const atomicDecorations = (options: Options) => {
switch (envName) { switch (envName) {
case 'itemize': case 'itemize':
case 'enumerate': case 'enumerate':
case 'description':
startListEnvironment(envName) startListEnvironment(envName)
listDepth++ listDepth++
break break
@@ -480,6 +488,7 @@ export const atomicDecorations = (options: Options) => {
switch (envName) { switch (envName) {
case 'itemize': case 'itemize':
case 'enumerate': case 'enumerate':
case 'description':
if (currentListEnvironment === envName) { if (currentListEnvironment === envName) {
endListEnvironment() endListEnvironment()
} }
@@ -963,16 +972,51 @@ export const atomicDecorations = (options: Options) => {
state.sliceDoc(line.from, nodeRef.from) state.sliceDoc(line.from, nodeRef.from)
) )
const from = onlySpaceBeforeNode ? line.from : nodeRef.from const from = onlySpaceBeforeNode ? line.from : nodeRef.from
decorations.push(
Decoration.replace({ if (currentListEnvironment === 'description') {
widget: new ItemWidget( const argumentNode = nodeRef.node.getChild(OptionalArgument)
currentListEnvironment || 'document', const to = argumentNode ? argumentNode.from : nodeRef.to
currentOrdinal,
listDepth const onlySpaceAfterNode =
), !argumentNode &&
}).range(from, nodeRef.to) /^\s*$/.test(state.sliceDoc(nodeRef.to, line.to))
)
return false 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')) { } else if (nodeRef.type.is('NewTheoremCommand')) {
const result = parseTheoremArguments(state, nodeRef.node) const result = parseTheoremArguments(state, nodeRef.node)
@@ -49,7 +49,10 @@ const chooseTargetPosition = (
targetNode = node targetNode = node
} else if (node.type.is('ItemCtrlSeq')) { } else if (node.type.is('ItemCtrlSeq')) {
targetNode = node.parent 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 targetNode = node.nextSibling?.firstChild?.firstChild
} }
@@ -12,6 +12,7 @@ import {
indentIncrease, indentIncrease,
} from '../toolbar/commands' } from '../toolbar/commands'
import { createListItem } from '@/features/source-editor/extensions/visual/utils/list-item' 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, * A keymap which provides behaviours for the visual editor,
@@ -37,7 +38,7 @@ export const visualKeymap = Prec.highest(
if (line.number === endLine.number - 1) { if (line.number === endLine.number - 1) {
// last item line // last item line
if (line.text.trim() === '\\item') { if (/^\\item(\[])?$/.test(line.text.trim())) {
// no content on this line // no content on this line
// outside the end of the current list // 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 // handle a list item that isn't at the end of a list
let insert = '\n' + createListItem(state, from)
const insert = '\n' + createListItem(state, from)
const countWhitespaceAfterPosition = (pos: number) => { const countWhitespaceAfterPosition = (pos: number) => {
const line = state.doc.lineAt(pos) const line = state.doc.lineAt(pos)
@@ -95,9 +95,16 @@ export const visualKeymap = Prec.highest(
return matches ? matches[1].length : 0 return matches ? matches[1].length : 0
} }
// move the cursor past any whitespace on the new line let pos: number
const pos =
from + insert.length + countWhitespaceAfterPosition(from) 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 handled = true
@@ -16,6 +16,7 @@ export const visualHighlightStyle = syntaxHighlighting(
{ tag: tags.string, class: 'ol-cm-monospace' }, { tag: tags.string, class: 'ol-cm-monospace' },
{ tag: tags.punctuation, class: 'ol-cm-punctuation' }, { tag: tags.punctuation, class: 'ol-cm-punctuation' },
{ tag: tags.literal, class: 'ol-cm-monospace' }, { tag: tags.literal, class: 'ol-cm-monospace' },
{ tag: tags.strong, class: 'ol-cm-strong' },
{ {
tag: tags.monospace, tag: tags.monospace,
fontFamily: 'var(--source-font-family)', fontFamily: 'var(--source-font-family)',
@@ -70,6 +71,9 @@ const mainVisualTheme = EditorView.theme({
fontVariant: 'normal', fontVariant: 'normal',
textDecoration: 'none', textDecoration: 'none',
}, },
'.ol-cm-strong': {
fontWeight: 700,
},
'.ol-cm-punctuation': { '.ol-cm-punctuation': {
fontFamily: 'var(--source-font-family)', fontFamily: 'var(--source-font-family)',
lineHeight: 1, lineHeight: 1,
@@ -184,7 +188,7 @@ const mainVisualTheme = EditorView.theme({
'.ol-cm-begin-theorem > .ol-cm-environment-padding:first-of-type': { '.ol-cm-begin-theorem > .ol-cm-environment-padding:first-of-type': {
flex: 0, flex: 0,
}, },
'.ol-cm-item': { '.ol-cm-item, .ol-cm-description-item': {
paddingInlineStart: 'calc(var(--list-depth) * 2ch)', paddingInlineStart: 'calc(var(--list-depth) * 2ch)',
}, },
'.ol-cm-item::before': { '.ol-cm-item::before': {
@@ -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))
}
}
@@ -18,6 +18,12 @@ export const environments = new Map([
\\end{array}`, \\end{array}`,
], ],
['center', snippet('center')], ['center', snippet('center')],
[
'description',
`\\begin{description}
\t\\item[$1] $2
\\end{description}`,
],
['document', snippetNoIndent('document')], ['document', snippetNoIndent('document')],
['equation', snippet('equation')], ['equation', snippet('equation')],
['equation*', snippet('equation*')], ['equation*', snippet('equation*')],
@@ -155,6 +155,7 @@ export const LaTeXLanguage = LRLanguage.define({
'DocumentClass/OptionalArgument/ShortOptionalArg/Normal': 'DocumentClass/OptionalArgument/ShortOptionalArg/Normal':
t.attributeValue, t.attributeValue,
'DocumentClass/ShortTextArgument/ShortArg/Normal': t.typeName, 'DocumentClass/ShortTextArgument/ShortArg/Normal': t.typeName,
'ListEnvironment/BeginEnv/OptionalArgument/...': t.monospace,
Number: t.number, Number: t.number,
OpenBrace: t.brace, OpenBrace: t.brace,
CloseBrace: t.brace, CloseBrace: t.brace,
@@ -193,6 +194,7 @@ export const LaTeXLanguage = LRLanguage.define({
'BareFilePathArgument/SpaceDelimitedLiteralArgContent': 'BareFilePathArgument/SpaceDelimitedLiteralArgContent':
t.attributeValue, t.attributeValue,
TrailingContent: t.comment, TrailingContent: t.comment,
'Item/OptionalArgument/ShortOptionalArg/...': t.strong,
// TODO: t.strong, t.emphasis // TODO: t.strong, t.emphasis
}), }),
], ],
@@ -354,7 +354,7 @@ KnownCommand {
CenteringCtrlSeq CenteringCtrlSeq
} | } |
Item { Item {
ItemCtrlSeq optionalWhitespace? ItemCtrlSeq OptionalArgument? optionalWhitespace?
} | } |
Maketitle { Maketitle {
MaketitleCtrlSeq optionalWhitespace? MaketitleCtrlSeq optionalWhitespace?
@@ -744,6 +744,7 @@ const otherKnownEnvNames = {
enumerate: ListEnvName, enumerate: ListEnvName,
itemize: ListEnvName, itemize: ListEnvName,
table: TableEnvName, table: TableEnvName,
description: ListEnvName,
} }
export const specializeEnvName = (name, terms) => { export const specializeEnvName = (name, terms) => {
@@ -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) => { export const listDepthForNode = (node: SyntaxNode) => {
let depth = 0 let depth = 0
@@ -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()
}
}
@@ -285,4 +285,32 @@ describe('<CodeMirrorEditor/> lists in Rich Text mode', function () {
[' foo', ' bar', ' baz', ' ', ' test'].join('') [' 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('')
)
})
}) })