mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
[visual] Add support for description
list environments (#13460)
GitOrigin-RevId: d1ddfeed4ba453afa348e57d75fdb3e12d29f5fc
This commit is contained in:
parent
49a74544b8
commit
ab5495023a
14 changed files with 182 additions and 27 deletions
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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': {
|
||||
|
|
|
@ -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}`,
|
||||
],
|
||||
['center', snippet('center')],
|
||||
[
|
||||
'description',
|
||||
`\\begin{description}
|
||||
\t\\item[$1] $2
|
||||
\\end{description}`,
|
||||
],
|
||||
['document', snippetNoIndent('document')],
|
||||
['equation', snippet('equation')],
|
||||
['equation*', snippet('equation*')],
|
||||
|
|
|
@ -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
|
||||
}),
|
||||
],
|
||||
|
|
|
@ -354,7 +354,7 @@ KnownCommand {
|
|||
CenteringCtrlSeq
|
||||
} |
|
||||
Item {
|
||||
ItemCtrlSeq optionalWhitespace?
|
||||
ItemCtrlSeq OptionalArgument? optionalWhitespace?
|
||||
} |
|
||||
Maketitle {
|
||||
MaketitleCtrlSeq optionalWhitespace?
|
||||
|
|
|
@ -744,6 +744,7 @@ const otherKnownEnvNames = {
|
|||
enumerate: ListEnvName,
|
||||
itemize: ListEnvName,
|
||||
table: TableEnvName,
|
||||
description: ListEnvName,
|
||||
}
|
||||
|
||||
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) => {
|
||||
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('')
|
||||
)
|
||||
})
|
||||
|
||||
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('')
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
Loading…
Reference in a new issue