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

GitOrigin-RevId: d1ddfeed4ba453afa348e57d75fdb3e12d29f5fc
This commit is contained in:
Alf Eaton 2024-05-03 08:45:08 +01:00 committed by Copybot
parent 49a74544b8
commit ab5495023a
14 changed files with 182 additions and 27 deletions

View file

@ -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
}

View file

@ -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')

View file

@ -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,6 +972,40 @@ export const atomicDecorations = (options: Options) => {
state.sliceDoc(line.from, nodeRef.from)
)
const from = onlySpaceBeforeNode ? line.from : nodeRef.from
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(
@ -974,6 +1017,7 @@ export const atomicDecorations = (options: Options) => {
)
return false
}
}
} else if (nodeRef.type.is('NewTheoremCommand')) {
const result = parseTheoremArguments(state, nodeRef.node)
if (result) {

View file

@ -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
}

View file

@ -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
}
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
const pos =
from + insert.length + countWhitespaceAfterPosition(from)
pos = from + insert.length + countWhitespaceAfterPosition(from)
}
handled = true

View file

@ -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': {

View file

@ -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))
}
}

View file

@ -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*')],

View file

@ -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
}),
],

View file

@ -354,7 +354,7 @@ KnownCommand {
CenteringCtrlSeq
} |
Item {
ItemCtrlSeq optionalWhitespace?
ItemCtrlSeq OptionalArgument? optionalWhitespace?
} |
Maketitle {
MaketitleCtrlSeq optionalWhitespace?

View file

@ -744,6 +744,7 @@ const otherKnownEnvNames = {
enumerate: ListEnvName,
itemize: ListEnvName,
table: TableEnvName,
description: ListEnvName,
}
export const specializeEnvName = (name, terms) => {

View file

@ -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

View file

@ -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()
}
}

View file

@ -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('')
)
})
})