first commit
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
import { StateEffectType, Transaction } from '@codemirror/state'
|
||||
import { ViewUpdate } from '@codemirror/view'
|
||||
|
||||
export const hasEffect =
|
||||
<T>(effectType: StateEffectType<T>) =>
|
||||
(tr: Transaction) =>
|
||||
tr.effects.some(effect => effect.is(effectType))
|
||||
|
||||
export const updateHasEffect =
|
||||
<T>(effectType: StateEffectType<T>) =>
|
||||
(update: ViewUpdate) =>
|
||||
update.transactions.some(tr =>
|
||||
tr.effects.some(effect => effect.is(effectType))
|
||||
)
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Entity } from '../../file-tree/hooks/use-project-entities'
|
||||
import { OutputEntity } from '../../file-tree/hooks/use-project-output-files'
|
||||
|
||||
export type FileOrDirectory = {
|
||||
name: string
|
||||
id: string
|
||||
type: 'file' | 'doc' | 'folder'
|
||||
children?: FileOrDirectory[]
|
||||
}
|
||||
|
||||
export type File = {
|
||||
path: string
|
||||
name: string
|
||||
id: string
|
||||
}
|
||||
|
||||
function filterByType(type: 'file' | 'doc' | 'folder') {
|
||||
return (
|
||||
tree: FileOrDirectory,
|
||||
path = '',
|
||||
list: undefined | File[] = undefined
|
||||
) => {
|
||||
if (!tree) {
|
||||
return list
|
||||
}
|
||||
if (list === undefined) {
|
||||
list = []
|
||||
}
|
||||
const isRootFolder = tree.name === 'rootFolder' && path === ''
|
||||
if (tree.children) {
|
||||
for (const child of tree.children) {
|
||||
filterByType(type)(
|
||||
child,
|
||||
`${isRootFolder ? '' : `${path ? path + '/' : path}${tree.name}/`}`,
|
||||
list
|
||||
)
|
||||
}
|
||||
}
|
||||
if (tree.type === type) {
|
||||
list.push({ path, id: tree.id, name: tree.name })
|
||||
}
|
||||
return list
|
||||
}
|
||||
}
|
||||
|
||||
export const filterFiles = filterByType('file')
|
||||
export const filterDocs = filterByType('doc')
|
||||
export const filterFolders = filterByType('folder')
|
||||
|
||||
const IMAGE_FILE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'pdf']
|
||||
|
||||
export const hasImageExtension = (filename: string) => {
|
||||
const parts = filename.split('.')
|
||||
if (parts.length < 2) {
|
||||
return false
|
||||
}
|
||||
const extension = parts[parts.length - 1].toLowerCase()
|
||||
return IMAGE_FILE_EXTENSIONS.includes(extension)
|
||||
}
|
||||
|
||||
export function isImageFile(file: File) {
|
||||
return hasImageExtension(file.name)
|
||||
}
|
||||
|
||||
export function isImageEntity(file: Entity | OutputEntity) {
|
||||
return hasImageExtension(file.path)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { EditorView } from '@codemirror/view'
|
||||
|
||||
const TOP_EDGE_THRESHOLD = 100
|
||||
const BOTTOM_EDGE_THRESHOLD = 200
|
||||
|
||||
export function isCursorNearViewportEdge(view: EditorView, pos: number) {
|
||||
return (
|
||||
isCursorNearViewportTop(view, pos) || isCursorNearViewportBottom(view, pos)
|
||||
)
|
||||
}
|
||||
|
||||
export function isCursorNearViewportTop(
|
||||
view: EditorView,
|
||||
pos: number,
|
||||
threshold = TOP_EDGE_THRESHOLD
|
||||
) {
|
||||
const cursorCoords = view.coordsAtPos(pos)
|
||||
|
||||
if (!cursorCoords) {
|
||||
return false
|
||||
}
|
||||
|
||||
const scrollInfo = view.scrollDOM.getBoundingClientRect()
|
||||
|
||||
return Math.abs(cursorCoords.bottom - scrollInfo.top) <= threshold
|
||||
}
|
||||
|
||||
export function isCursorNearViewportBottom(
|
||||
view: EditorView,
|
||||
pos: number,
|
||||
threshold = BOTTOM_EDGE_THRESHOLD
|
||||
) {
|
||||
const cursorCoords = view.coordsAtPos(pos)
|
||||
|
||||
if (!cursorCoords) {
|
||||
return false
|
||||
}
|
||||
|
||||
const scrollInfo = view.scrollDOM.getBoundingClientRect()
|
||||
const viewportHeight = view.scrollDOM.clientHeight
|
||||
const viewportBottom = scrollInfo.top + viewportHeight
|
||||
return Math.abs(cursorCoords.bottom - viewportBottom) <= threshold
|
||||
}
|
||||
304
services/web/frontend/js/features/source-editor/utils/layer.ts
Normal file
304
services/web/frontend/js/features/source-editor/utils/layer.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* This file is adapted from CodeMirror 6, licensed under the MIT license:
|
||||
* https://github.com/codemirror/view/blob/main/src/layer.ts
|
||||
*/
|
||||
import {
|
||||
BlockInfo,
|
||||
BlockType,
|
||||
Direction,
|
||||
EditorView,
|
||||
Rect,
|
||||
RectangleMarker,
|
||||
} from '@codemirror/view'
|
||||
import { EditorSelection, SelectionRange } from '@codemirror/state'
|
||||
import { isVisual } from '../extensions/visual/visual'
|
||||
import { round } from 'lodash'
|
||||
|
||||
function canAssumeUniformLineHeights(view: EditorView) {
|
||||
return !isVisual(view)
|
||||
}
|
||||
|
||||
export const rectangleMarkerForRange = (
|
||||
view: EditorView,
|
||||
className: string,
|
||||
range: SelectionRange
|
||||
): readonly RectangleMarker[] => {
|
||||
if (range.empty) {
|
||||
const pos = fullHeightCoordsAtPos(view, range.head, range.assoc || 1)
|
||||
|
||||
if (!pos) {
|
||||
return []
|
||||
}
|
||||
|
||||
const base = getBase(view)
|
||||
|
||||
return [
|
||||
new RectangleMarker(
|
||||
className,
|
||||
pos.left - base.left,
|
||||
pos.top - base.top,
|
||||
null,
|
||||
pos.bottom - pos.top
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
return rectanglesForRange(view, className, range)
|
||||
}
|
||||
|
||||
export function getBase(view: EditorView) {
|
||||
const rect = view.scrollDOM.getBoundingClientRect()
|
||||
const left =
|
||||
view.textDirection === Direction.LTR
|
||||
? rect.left
|
||||
: rect.right - view.scrollDOM.clientWidth
|
||||
return {
|
||||
left: left - view.scrollDOM.scrollLeft,
|
||||
top: rect.top - view.scrollDOM.scrollTop,
|
||||
}
|
||||
}
|
||||
|
||||
function wrappedLine(
|
||||
view: EditorView,
|
||||
pos: number,
|
||||
inside: { from: number; to: number }
|
||||
) {
|
||||
const range = EditorSelection.cursor(pos)
|
||||
return {
|
||||
from: Math.max(
|
||||
inside.from,
|
||||
view.moveToLineBoundary(range, false, true).from
|
||||
),
|
||||
to: Math.min(inside.to, view.moveToLineBoundary(range, true, true).from),
|
||||
type: BlockType.Text,
|
||||
}
|
||||
}
|
||||
|
||||
function blockAt(view: EditorView, pos: number): BlockInfo {
|
||||
const line = view.lineBlockAt(pos)
|
||||
if (Array.isArray(line.type))
|
||||
for (const l of line.type) {
|
||||
if (
|
||||
l.to > pos ||
|
||||
(l.to === pos && (l.to === line.to || l.type === BlockType.Text))
|
||||
)
|
||||
return l
|
||||
}
|
||||
return line as any
|
||||
}
|
||||
|
||||
// Like coordsAtPos, provides screen coordinates for a document position, but
|
||||
// unlike coordsAtPos, the top and bottom represent the full height of the
|
||||
// visual line rather than the top and bottom of the text. To do this, it relies
|
||||
// on the assumption that all text in the document has the same height and that
|
||||
// the line contains no widget or decoration that changes the height of the
|
||||
// line. This is, I am fairly certain, a safe assumption in source mode but not
|
||||
// in rich text, so in rich text mode this function just returns coordsAtPos.
|
||||
export function fullHeightCoordsAtPos(
|
||||
view: EditorView,
|
||||
pos: number,
|
||||
side?: -2 | -1 | 1 | 2 | undefined
|
||||
): Rect | null {
|
||||
// @ts-ignore CodeMirror has incorrect type on coordsAtPos
|
||||
const coords = view.coordsAtPos(pos, side)
|
||||
if (!coords) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!canAssumeUniformLineHeights(view)) {
|
||||
return coords
|
||||
}
|
||||
|
||||
const { left, right } = coords
|
||||
const halfLeading =
|
||||
(view.defaultLineHeight - (coords.bottom - coords.top)) / 2
|
||||
|
||||
return {
|
||||
left,
|
||||
right,
|
||||
top: round(coords.top - halfLeading, 2),
|
||||
bottom: round(coords.bottom + halfLeading, 2),
|
||||
}
|
||||
}
|
||||
|
||||
// Added to range rectangle's vertical extent to prevent rounding
|
||||
// errors from introducing gaps in the rendered content.
|
||||
const Epsilon = 0.01
|
||||
|
||||
function rectanglesForRange(
|
||||
view: EditorView,
|
||||
className: string,
|
||||
range: SelectionRange
|
||||
): RectangleMarker[] {
|
||||
if (range.to <= view.viewport.from || range.from >= view.viewport.to) {
|
||||
return []
|
||||
}
|
||||
const from = Math.max(range.from, view.viewport.from)
|
||||
const to = Math.min(range.to, view.viewport.to)
|
||||
|
||||
const ltr = view.textDirection === Direction.LTR
|
||||
const content = view.contentDOM
|
||||
const contentRect = content.getBoundingClientRect()
|
||||
const base = getBase(view)
|
||||
|
||||
const lineElt = content.querySelector('.cm-line')
|
||||
const lineStyle = lineElt && window.getComputedStyle(lineElt)
|
||||
const leftSide =
|
||||
contentRect.left +
|
||||
(lineStyle
|
||||
? parseInt(lineStyle.paddingLeft) +
|
||||
Math.min(0, parseInt(lineStyle.textIndent))
|
||||
: 0)
|
||||
const rightSide =
|
||||
contentRect.right - (lineStyle ? parseInt(lineStyle.paddingRight) : 0)
|
||||
|
||||
const startBlock = blockAt(view, from)
|
||||
const endBlock = blockAt(view, to)
|
||||
let visualStart: { from: number; to: number } | null =
|
||||
startBlock.type === BlockType.Text ? startBlock : null
|
||||
let visualEnd: { from: number; to: number } | null =
|
||||
endBlock.type === BlockType.Text ? endBlock : null
|
||||
if (view.lineWrapping) {
|
||||
if (visualStart) visualStart = wrappedLine(view, from, visualStart)
|
||||
if (visualEnd) visualEnd = wrappedLine(view, to, visualEnd)
|
||||
}
|
||||
if (visualStart && visualEnd && visualStart.from === visualEnd.from) {
|
||||
return pieces(drawForLine(range.from, range.to, visualStart))
|
||||
} else {
|
||||
const top = visualStart
|
||||
? drawForLine(range.from, null, visualStart)
|
||||
: drawForWidget(startBlock, false)
|
||||
const bottom = visualEnd
|
||||
? drawForLine(null, range.to, visualEnd)
|
||||
: drawForWidget(endBlock, true)
|
||||
const between = []
|
||||
|
||||
if (
|
||||
(visualStart || startBlock).to <
|
||||
(visualEnd || endBlock).from - (visualStart && visualEnd ? 1 : 0)
|
||||
)
|
||||
between.push(piece(leftSide, top.bottom, rightSide, bottom.top))
|
||||
else if (
|
||||
top.bottom < bottom.top &&
|
||||
view.elementAtHeight((top.bottom + bottom.top) / 2).type ===
|
||||
BlockType.Text
|
||||
)
|
||||
top.bottom = bottom.top = (top.bottom + bottom.top) / 2
|
||||
return pieces(top).concat(between).concat(pieces(bottom))
|
||||
}
|
||||
|
||||
function piece(left: number, top: number, right: number, bottom: number) {
|
||||
return new RectangleMarker(
|
||||
className,
|
||||
left - base.left,
|
||||
top - base.top - Epsilon,
|
||||
right - left,
|
||||
bottom - top + Epsilon
|
||||
)
|
||||
}
|
||||
|
||||
function pieces({
|
||||
top,
|
||||
bottom,
|
||||
horizontal,
|
||||
}: {
|
||||
top: number
|
||||
bottom: number
|
||||
horizontal: number[]
|
||||
}) {
|
||||
const pieces = []
|
||||
for (let i = 0; i < horizontal.length; i += 2)
|
||||
pieces.push(piece(horizontal[i], top, horizontal[i + 1], bottom))
|
||||
return pieces
|
||||
}
|
||||
|
||||
// Gets passed from/to in line-local positions
|
||||
function drawForLine(
|
||||
from: null | number,
|
||||
to: null | number,
|
||||
line: { from: number; to: number }
|
||||
) {
|
||||
let top = 1e9
|
||||
let bottom = -1e9
|
||||
const horizontal: number[] = []
|
||||
|
||||
function addSpan(
|
||||
from: number,
|
||||
fromOpen: boolean,
|
||||
to: number,
|
||||
toOpen: boolean,
|
||||
dir: Direction
|
||||
) {
|
||||
// Passing 2/-2 is a kludge to force the view to return
|
||||
// coordinates on the proper side of block widgets, since
|
||||
// normalizing the side there, though appropriate for most
|
||||
// coordsAtPos queries, would break selection drawing.
|
||||
const fromCoords = fullHeightCoordsAtPos(
|
||||
view,
|
||||
from,
|
||||
(from === line.to ? -2 : 2) as any
|
||||
)
|
||||
const toCoords = fullHeightCoordsAtPos(
|
||||
view,
|
||||
to,
|
||||
(to === line.from ? 2 : -2) as any
|
||||
)
|
||||
// coordsAtPos can sometimes return null even when the document position
|
||||
// is within the viewport. It's not clear exactly when this happens;
|
||||
// sometimes, the editor has previously failed to complete a measure.
|
||||
if (!fromCoords || !toCoords) {
|
||||
return
|
||||
}
|
||||
top = Math.min(fromCoords.top, toCoords.top, top)
|
||||
bottom = Math.max(fromCoords.bottom, toCoords.bottom, bottom)
|
||||
if (dir === Direction.LTR)
|
||||
horizontal.push(
|
||||
ltr && fromOpen ? leftSide : fromCoords.left,
|
||||
ltr && toOpen ? rightSide : toCoords.right
|
||||
)
|
||||
else
|
||||
horizontal.push(
|
||||
!ltr && toOpen ? leftSide : toCoords.left,
|
||||
!ltr && fromOpen ? rightSide : fromCoords.right
|
||||
)
|
||||
}
|
||||
|
||||
const start = from ?? line.from
|
||||
const end = to ?? line.to
|
||||
// Split the range by visible range and document line
|
||||
for (const r of view.visibleRanges)
|
||||
if (r.to > start && r.from < end) {
|
||||
for (
|
||||
let pos = Math.max(r.from, start), endPos = Math.min(r.to, end);
|
||||
;
|
||||
|
||||
) {
|
||||
const docLine = view.state.doc.lineAt(pos)
|
||||
for (const span of view.bidiSpans(docLine)) {
|
||||
const spanFrom = span.from + docLine.from
|
||||
const spanTo = span.to + docLine.from
|
||||
if (spanFrom >= endPos) break
|
||||
if (spanTo > pos)
|
||||
addSpan(
|
||||
Math.max(spanFrom, pos),
|
||||
from === null && spanFrom <= start,
|
||||
Math.min(spanTo, endPos),
|
||||
to === null && spanTo >= end,
|
||||
span.dir
|
||||
)
|
||||
}
|
||||
pos = docLine.to + 1
|
||||
if (pos >= endPos) break
|
||||
}
|
||||
}
|
||||
if (horizontal.length === 0)
|
||||
addSpan(start, from === null, end, to === null, view.textDirection)
|
||||
|
||||
return { top, bottom, horizontal }
|
||||
}
|
||||
|
||||
function drawForWidget(block: BlockInfo, top: boolean) {
|
||||
const y = contentRect.top + (top ? block.top : block.bottom)
|
||||
return { top: y, bottom: y, horizontal: [] }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { NodeProp } from '@lezer/common'
|
||||
|
||||
/**
|
||||
* A node prop that contains an array, each item of which is an array of parent node types that's
|
||||
* passed to [matchContext](https://lezer.codemirror.net/docs/ref/#common.SyntaxNodeRef.matchContext)
|
||||
* to test whether the node matches the given context. If so, the text in the node is excluded from spell checking.
|
||||
* An empty string is treated as a wildcard, so `[['']]` indicates that the node type should always be excluded.
|
||||
*/
|
||||
export const noSpellCheckProp = new NodeProp<string[][]>()
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Text } from '@codemirror/state'
|
||||
|
||||
export const findValidPosition = (
|
||||
doc: Text,
|
||||
lineNumber: number, // 1-indexed
|
||||
columnNumber: number // 0-indexed
|
||||
): number => {
|
||||
const lines = doc.lines
|
||||
|
||||
if (lineNumber > lines) {
|
||||
// end of the doc
|
||||
return doc.length
|
||||
}
|
||||
|
||||
const line = doc.line(lineNumber)
|
||||
|
||||
// requested line and column, or the end of the line
|
||||
return Math.min(line.from + columnNumber, line.to)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Adapted from CodeMirror 6 (@codemirror/autocomplete), licensed under the MIT license:
|
||||
* https://github.com/codemirror/autocomplete/blob/08f63add9f470a032d3802a4599caa86c75de5cb/src/snippet.ts#L29-L45
|
||||
*/
|
||||
|
||||
import { indentUnit } from '@codemirror/language'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
|
||||
// apply correct indentation to passed lines
|
||||
export function prepareLines(
|
||||
lines: (string | null)[],
|
||||
state: EditorState,
|
||||
pos: number
|
||||
) {
|
||||
const text = []
|
||||
const lineStart = [pos]
|
||||
const lineObj = state.doc.lineAt(pos)
|
||||
const baseIndent = /^\s*/.exec(lineObj.text)![0]
|
||||
for (let line of lines) {
|
||||
if (line === null) continue
|
||||
if (text.length) {
|
||||
let indent = baseIndent
|
||||
const tabs = /^\t*/.exec(line)![0].length
|
||||
for (let i = 0; i < tabs; i++) indent += state.facet(indentUnit)
|
||||
lineStart.push(pos + indent.length - tabs)
|
||||
line = indent + line.slice(tabs)
|
||||
}
|
||||
text.push(line)
|
||||
pos += line.length + 1
|
||||
}
|
||||
|
||||
return text.join('\n')
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { ChangeSet, EditorState, StateField } from '@codemirror/state'
|
||||
import {
|
||||
ProjectionItem,
|
||||
ProjectionResult,
|
||||
getUpdatedProjection,
|
||||
EnterNodeFn,
|
||||
ProjectionStatus,
|
||||
} from './tree-operations/projection'
|
||||
import { languageLoadedEffect } from '@/features/source-editor/extensions/language'
|
||||
|
||||
export function mergeChangeRanges(changes: ChangeSet) {
|
||||
let fromA = Number.MAX_VALUE
|
||||
let fromB = Number.MAX_VALUE
|
||||
let toA = Number.MIN_VALUE
|
||||
let toB = Number.MIN_VALUE
|
||||
changes.iterChangedRanges(
|
||||
(changeFromA, changeToA, changeFromB, changeToB) => {
|
||||
fromA = Math.min(changeFromA, fromA)
|
||||
fromB = Math.min(changeFromB, fromB)
|
||||
toA = Math.max(changeToA, toA)
|
||||
toB = Math.max(changeToB, toB)
|
||||
}
|
||||
)
|
||||
return { fromA, toA, fromB, toB }
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a StateField to manage a 'projection' of the document. Type T is the subclass of
|
||||
* ProjectionItem that we will extract from the document.
|
||||
*
|
||||
* @param enterNode A function to call when 'enter'ing a node while traversing the syntax tree,
|
||||
* Used to identify nodes we are interested in, and create instances of T.
|
||||
*/
|
||||
export function makeProjectionStateField<T extends ProjectionItem>(
|
||||
enterNode: EnterNodeFn<T>
|
||||
): StateField<ProjectionResult<T>> {
|
||||
const initialiseProjection = (state: EditorState) =>
|
||||
getUpdatedProjection(
|
||||
state,
|
||||
0,
|
||||
state.doc.length,
|
||||
0,
|
||||
state.doc.length,
|
||||
true,
|
||||
enterNode
|
||||
)
|
||||
|
||||
const field = StateField.define<ProjectionResult<T>>({
|
||||
create(state) {
|
||||
return initialiseProjection(state)
|
||||
},
|
||||
update(currentProjection, transaction) {
|
||||
if (transaction.effects.some(effect => effect.is(languageLoadedEffect))) {
|
||||
return initialiseProjection(transaction.state)
|
||||
}
|
||||
|
||||
if (
|
||||
transaction.docChanged ||
|
||||
currentProjection.status !== ProjectionStatus.Complete
|
||||
) {
|
||||
const { fromA, toA, fromB, toB } = mergeChangeRanges(
|
||||
transaction.changes
|
||||
)
|
||||
const list = getUpdatedProjection<T>(
|
||||
transaction.state,
|
||||
fromA,
|
||||
toA,
|
||||
fromB,
|
||||
toB,
|
||||
false,
|
||||
enterNode,
|
||||
transaction,
|
||||
currentProjection
|
||||
)
|
||||
return list
|
||||
}
|
||||
return currentProjection
|
||||
},
|
||||
})
|
||||
return field
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export class Range {
|
||||
from: number
|
||||
to: number
|
||||
|
||||
constructor(from: number, to: number) {
|
||||
this.from = from
|
||||
this.to = to
|
||||
}
|
||||
|
||||
contains(pos: number, allowBoundaries = true) {
|
||||
return allowBoundaries
|
||||
? pos >= this.from && pos <= this.to
|
||||
: pos > this.from && pos < this.to
|
||||
}
|
||||
|
||||
// Ranges that touch but don't overlap are not considered to intersect
|
||||
intersects(range: Range) {
|
||||
return this.contains(range.from, false) || this.contains(range.to, false)
|
||||
}
|
||||
|
||||
touchesOrIntersects(range: Range) {
|
||||
return this.contains(range.from, true) || this.contains(range.to, true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { SubView } from '../../../../../types/review-panel/review-panel'
|
||||
|
||||
export const isCurrentFileView = (view: SubView) => view === 'cur_file'
|
||||
export const isOverviewView = (view: SubView) => view === 'overview'
|
||||
@@ -0,0 +1,257 @@
|
||||
import { ensureSyntaxTree, syntaxTree } from '@codemirror/language'
|
||||
import { EditorSelection, EditorState, SelectionRange } from '@codemirror/state'
|
||||
import { SyntaxNode, Tree } from '@lezer/common'
|
||||
import { ListEnvironment } from '../../lezer-latex/latex.terms.mjs'
|
||||
|
||||
const HUNDRED_MS = 100
|
||||
|
||||
export type AncestorItem = {
|
||||
node: SyntaxNode
|
||||
label: string
|
||||
type?: string
|
||||
from: number
|
||||
to: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the stack of 'ancestor' nodes at the given position.
|
||||
* The first element is the most distant ancestor, while the last element
|
||||
* is the node at the position.
|
||||
*/
|
||||
export function getAncestorStack(
|
||||
state: EditorState,
|
||||
pos: number
|
||||
): AncestorItem[] | null {
|
||||
const tree = ensureSyntaxTree(state, pos, HUNDRED_MS)
|
||||
|
||||
if (!tree) {
|
||||
return null
|
||||
}
|
||||
|
||||
const stack: AncestorItem[] = []
|
||||
const selectedNode = tree.resolve(pos, 0)
|
||||
|
||||
let node: SyntaxNode | null = selectedNode
|
||||
while (node) {
|
||||
const name = node.type.name
|
||||
switch (name) {
|
||||
case 'Environment':
|
||||
{
|
||||
const data: AncestorItem = {
|
||||
node,
|
||||
label: name,
|
||||
from: node.from,
|
||||
to: node.to,
|
||||
}
|
||||
|
||||
const child = node.getChild('EnvNameGroup')
|
||||
if (child) {
|
||||
data.type = state.doc.sliceString(child.from + 1, child.to - 1)
|
||||
}
|
||||
stack.push(data)
|
||||
}
|
||||
break
|
||||
|
||||
default:
|
||||
stack.push({ node, label: name, from: node.from, to: node.to })
|
||||
break
|
||||
}
|
||||
|
||||
node = node.parent
|
||||
}
|
||||
|
||||
return stack.reverse()
|
||||
}
|
||||
|
||||
export const wrappedNodeOfType = (
|
||||
state: EditorState,
|
||||
range: SelectionRange,
|
||||
type: string | number
|
||||
): SyntaxNode | null => {
|
||||
if (range.empty) {
|
||||
return null
|
||||
}
|
||||
|
||||
const ancestorNode = ancestorNodeOfType(state, range.from, type, 1)
|
||||
|
||||
if (
|
||||
ancestorNode &&
|
||||
ancestorNode.from === range.from &&
|
||||
ancestorNode.to === range.to
|
||||
) {
|
||||
return ancestorNode
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const ancestorNodeOfType = (
|
||||
state: EditorState,
|
||||
pos: number,
|
||||
type: string | number,
|
||||
side: -1 | 0 | 1 = 0
|
||||
): SyntaxNode | null => {
|
||||
const node: SyntaxNode | null = syntaxTree(state).resolveInner(pos, side)
|
||||
return ancestorOfNodeWithType(node, type)
|
||||
}
|
||||
|
||||
export function* ancestorsOfNodeWithType(
|
||||
node: SyntaxNode | null,
|
||||
type: string | number
|
||||
): Generator<SyntaxNode> {
|
||||
for (let ancestor = node; ancestor; ancestor = ancestor.parent) {
|
||||
if (ancestor.type.is(type)) {
|
||||
yield ancestor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const ancestorOfNodeWithType = (
|
||||
node: SyntaxNode | null | undefined,
|
||||
...types: (string | number)[]
|
||||
): SyntaxNode | null => {
|
||||
for (let ancestor = node; ancestor; ancestor = ancestor.parent) {
|
||||
for (const type of types) {
|
||||
if (ancestor.type.is(type)) {
|
||||
return ancestor
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export const lastAncestorAtEndPosition = (
|
||||
node: SyntaxNode | null | undefined,
|
||||
to: number
|
||||
): SyntaxNode | null => {
|
||||
let lastAncestor: SyntaxNode | null = null
|
||||
for (
|
||||
let ancestor = node;
|
||||
ancestor && ancestor.to === to;
|
||||
ancestor = ancestor.parent
|
||||
) {
|
||||
lastAncestor = ancestor
|
||||
}
|
||||
return lastAncestor
|
||||
}
|
||||
|
||||
export const descendantsOfNodeWithType = (
|
||||
node: SyntaxNode,
|
||||
type: string | number,
|
||||
leaveType?: string | number
|
||||
): SyntaxNode[] => {
|
||||
const children: SyntaxNode[] = []
|
||||
|
||||
node.cursor().iterate(nodeRef => {
|
||||
if (nodeRef.type.is(type)) {
|
||||
children.push(nodeRef.node)
|
||||
}
|
||||
if (leaveType && nodeRef.type.is(leaveType) && nodeRef.node !== node) {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
return children
|
||||
}
|
||||
|
||||
export const getBibkeyArgumentNode = (state: EditorState, pos: number) => {
|
||||
return (
|
||||
ancestorNodeOfType(state, pos, 'BibKeyArgument', -1) ??
|
||||
ancestorNodeOfType(state, pos, 'BibKeyArgument')
|
||||
)
|
||||
}
|
||||
|
||||
export function* ancestorsOfSelectionWithType(
|
||||
tree: Tree,
|
||||
selection: EditorSelection,
|
||||
type: string | number
|
||||
) {
|
||||
for (const range of selection.ranges) {
|
||||
const node = tree.resolveInner(range.anchor)
|
||||
for (const ancestor of ancestorsOfNodeWithType(node, type)) {
|
||||
if (ancestor) {
|
||||
yield ancestor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const matchingAncestor = (
|
||||
node: SyntaxNode,
|
||||
predicate: (node: SyntaxNode) => boolean
|
||||
) => {
|
||||
for (
|
||||
let ancestor: SyntaxNode | null | undefined = node;
|
||||
ancestor;
|
||||
ancestor = ancestor.parent
|
||||
) {
|
||||
if (predicate(ancestor)) {
|
||||
return ancestor
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export const ancestorWithType = (
|
||||
state: EditorState,
|
||||
nodeType: string | number
|
||||
) => {
|
||||
const tree = syntaxTree(state)
|
||||
|
||||
const ancestors = ancestorsOfSelectionWithType(
|
||||
tree,
|
||||
state.selection,
|
||||
nodeType
|
||||
)
|
||||
|
||||
return ancestors.next().value
|
||||
}
|
||||
|
||||
export const commonAncestor = (
|
||||
nodeA: SyntaxNode,
|
||||
nodeB: SyntaxNode
|
||||
): SyntaxNode | null => {
|
||||
let cursorA: SyntaxNode | null = nodeA
|
||||
let cursorB: SyntaxNode | null = nodeB
|
||||
while (cursorA && cursorB) {
|
||||
if (cursorA === cursorB) {
|
||||
return cursorA
|
||||
}
|
||||
if (cursorA.from < cursorB.from) {
|
||||
cursorB = cursorB.parent
|
||||
} else {
|
||||
cursorA = cursorA.parent
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export type ListEnvironmentName = 'itemize' | 'enumerate' | 'description'
|
||||
|
||||
export const listDepthForNode = (node: SyntaxNode) => {
|
||||
let depth = 0
|
||||
for (const ancestor of ancestorsOfNodeWithType(node, ListEnvironment)) {
|
||||
if (ancestor) {
|
||||
depth++
|
||||
}
|
||||
}
|
||||
return depth
|
||||
}
|
||||
|
||||
export const minimumListDepthForSelection = (state: EditorState) => {
|
||||
const depths = []
|
||||
for (const range of state.selection.ranges) {
|
||||
const tree = syntaxTree(state)
|
||||
const node = tree.resolveInner(range.anchor)
|
||||
depths.push(listDepthForNode(node))
|
||||
}
|
||||
return Math.min(...depths)
|
||||
}
|
||||
|
||||
export const isDirectChildOfEnvironment = (
|
||||
child?: SyntaxNode | null,
|
||||
ancestor?: SyntaxNode | null
|
||||
) => {
|
||||
const possiblyAncestor = child?.parent?.parent?.parent // Text → Content → Environment
|
||||
return ancestor === possiblyAncestor
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { SyntaxNode } from '@lezer/common'
|
||||
import {
|
||||
LongArg,
|
||||
ShortArg,
|
||||
ShortTextArgument,
|
||||
TextArgument,
|
||||
} from '../../lezer-latex/latex.terms.mjs'
|
||||
|
||||
// basic color definitions from the xcolor package
|
||||
// https://github.com/latex3/xcolor/blob/849682246582946835d28c8f9b2081ff2c340e09/xcolor.dtx#L7051-L7093
|
||||
const colors = new Map<string, string>([
|
||||
['red', 'rgb(255,0,0)'],
|
||||
['green', 'rgb(0,255,0)'],
|
||||
['blue', 'rgb(0,0,255)'],
|
||||
['brown', 'rgb(195,127,63)'],
|
||||
['lime', 'rgb(195,255,0)'],
|
||||
['orange', 'rgb(255,127,0)'],
|
||||
['pink', 'rgb(255,195,195)'],
|
||||
['purple', 'rgb(195,0,63)'],
|
||||
['teal', 'rgb(0,127,127)'],
|
||||
['violet', 'rgb(127,0,127)'],
|
||||
['cyan', 'rgb(0,255,255)'],
|
||||
['magenta', 'rgb(255,0,255)'],
|
||||
['yellow', 'rgb(255,255,0)'],
|
||||
['olive', 'rgb(127,127,0)'],
|
||||
['black', 'rgb(0,0,0)'],
|
||||
['darkgray', 'rgb(63,63,63)'],
|
||||
['gray', 'rgb(127,127,127)'],
|
||||
['lightgray', 'rgb(195,195,195)'],
|
||||
['white', 'rgb(255,255,255)'],
|
||||
])
|
||||
|
||||
export const parseColorArguments = (
|
||||
state: EditorState,
|
||||
node: SyntaxNode
|
||||
): { color: string; from: number; to: number } | undefined => {
|
||||
const colorArgumentNode = node.getChild(ShortTextArgument)?.getChild(ShortArg)
|
||||
const contentArgumentNode = node.getChild(TextArgument)?.getChild(LongArg)
|
||||
|
||||
if (colorArgumentNode && contentArgumentNode) {
|
||||
const { from, to } = contentArgumentNode
|
||||
|
||||
if (to > from) {
|
||||
const colorName = state
|
||||
.sliceDoc(colorArgumentNode.from, colorArgumentNode.to)
|
||||
.trim()
|
||||
|
||||
if (colorName) {
|
||||
const color = colors.get(colorName)
|
||||
|
||||
if (color) {
|
||||
return { color, from, to }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { SyntaxNode, SyntaxNodeRef } from '@lezer/common'
|
||||
import { childOfNodeWithType, getOptionalArgumentText } from './common'
|
||||
import { NodeIntersectsChangeFn, ProjectionItem } from './projection'
|
||||
|
||||
/**
|
||||
* A projection of a command in the document
|
||||
*/
|
||||
export class Command extends ProjectionItem {
|
||||
readonly title: string = ''
|
||||
readonly optionalArgCount: number | undefined = 0
|
||||
readonly requiredArgCount: number | undefined = 0
|
||||
readonly type: 'usage' | 'definition' = 'usage'
|
||||
readonly raw: string | undefined = undefined
|
||||
readonly ignoreInAutocomplete?: boolean = false
|
||||
}
|
||||
|
||||
const getCommandName = (
|
||||
node: SyntaxNode,
|
||||
state: EditorState,
|
||||
childTypes: string[]
|
||||
): string | null => {
|
||||
const child = childOfNodeWithType(node, ...childTypes)
|
||||
|
||||
if (child) {
|
||||
const commandName = state.doc.sliceString(child.from, child.to)
|
||||
if (commandName.length > 0) {
|
||||
return commandName
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts Command instances from the syntax tree.
|
||||
* `\newcommand`, `\renewcommand`, `\newenvironment`, `\renewenvironment`
|
||||
* and `\def` are treated specially.
|
||||
*/
|
||||
export const enterNode = (
|
||||
state: EditorState,
|
||||
node: SyntaxNodeRef,
|
||||
items: Command[],
|
||||
nodeIntersectsChange: NodeIntersectsChangeFn
|
||||
): any => {
|
||||
if (node.type.is('NewCommand') || node.type.is('RenewCommand')) {
|
||||
if (!nodeIntersectsChange(node.node)) {
|
||||
// This should already be in `items`
|
||||
return
|
||||
}
|
||||
|
||||
const commandName = getCommandName(node.node, state, [
|
||||
'LiteralArgContent',
|
||||
'Csname',
|
||||
])
|
||||
|
||||
if (commandName === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const optionalArguments = node.node.getChildren('OptionalArgument')
|
||||
|
||||
let argCountNumber = 0
|
||||
if (optionalArguments.length > 0) {
|
||||
const argumentCountNode = optionalArguments[0]
|
||||
const argCountText = getOptionalArgumentText(state, argumentCountNode)
|
||||
if (argCountText) {
|
||||
try {
|
||||
argCountNumber = parseInt(argCountText, 10)
|
||||
} catch (err) {}
|
||||
}
|
||||
}
|
||||
|
||||
const commandDefinitionHasOptionalArgument = optionalArguments.length === 2
|
||||
|
||||
if (commandDefinitionHasOptionalArgument && argCountNumber > 0) {
|
||||
argCountNumber--
|
||||
}
|
||||
|
||||
items.push({
|
||||
line: state.doc.lineAt(node.from).number,
|
||||
title: commandName,
|
||||
from: node.from,
|
||||
to: node.to,
|
||||
optionalArgCount: commandDefinitionHasOptionalArgument ? 1 : 0,
|
||||
requiredArgCount: argCountNumber,
|
||||
type: 'definition',
|
||||
raw: state.sliceDoc(node.from, node.to),
|
||||
})
|
||||
} else if (node.type.is('Def')) {
|
||||
if (!nodeIntersectsChange(node.node)) {
|
||||
// This should already be in `items`
|
||||
return
|
||||
}
|
||||
|
||||
const commandName = getCommandName(node.node, state, ['Csname', 'CtrlSym'])
|
||||
|
||||
if (commandName === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const requiredArgCount = node.node.getChildren('MacroParameter').length
|
||||
const optionalArgCount = node.node.getChildren(
|
||||
'OptionalMacroParameter'
|
||||
).length
|
||||
|
||||
items.push({
|
||||
line: state.doc.lineAt(node.from).number,
|
||||
title: commandName,
|
||||
from: node.from,
|
||||
to: node.to,
|
||||
optionalArgCount,
|
||||
requiredArgCount,
|
||||
type: 'definition',
|
||||
raw: state.sliceDoc(node.from, node.to),
|
||||
})
|
||||
} else if (node.type.is('Let')) {
|
||||
if (!nodeIntersectsChange(node.node)) {
|
||||
// This should already be in `items`
|
||||
return
|
||||
}
|
||||
|
||||
const commandName = getCommandName(node.node, state, ['Csname'])
|
||||
|
||||
if (commandName === null) {
|
||||
return
|
||||
}
|
||||
items.push({
|
||||
line: state.doc.lineAt(node.from).number,
|
||||
title: commandName,
|
||||
from: node.from,
|
||||
to: node.to,
|
||||
ignoreInAutocomplete: true, // Ignoring since we don't know the argument counts
|
||||
optionalArgCount: undefined,
|
||||
requiredArgCount: undefined,
|
||||
type: 'definition',
|
||||
raw: state.sliceDoc(node.from, node.to),
|
||||
})
|
||||
} else if (
|
||||
node.type.is('UnknownCommand') ||
|
||||
node.type.is('KnownCommand') ||
|
||||
node.type.is('MathUnknownCommand') ||
|
||||
node.type.is('DefinitionFragmentUnknownCommand')
|
||||
) {
|
||||
if (!nodeIntersectsChange(node.node)) {
|
||||
// This should already be in `items`
|
||||
return
|
||||
}
|
||||
|
||||
let commandNode: SyntaxNode | null = node.node
|
||||
if (node.type.is('KnownCommand')) {
|
||||
// KnownCommands are defined as
|
||||
//
|
||||
// KnownCommand {
|
||||
// CommandName {
|
||||
// CommandCtrlSeq [args]
|
||||
// }
|
||||
// }
|
||||
// So for a KnownCommand, use the first child as the actual command node
|
||||
commandNode = commandNode.firstChild
|
||||
}
|
||||
|
||||
if (!commandNode) {
|
||||
return
|
||||
}
|
||||
|
||||
const ctrlSeq = commandNode.getChild('$CtrlSeq')
|
||||
if (!ctrlSeq) {
|
||||
return
|
||||
}
|
||||
|
||||
if (ctrlSeq.type.is('$CtrlSym')) {
|
||||
return
|
||||
}
|
||||
|
||||
const optionalArguments = commandNode.getChildren('OptionalArgument')
|
||||
const commandArgumentsIncludingOptional =
|
||||
commandNode.getChildren('$Argument')
|
||||
const text = state.doc.sliceString(ctrlSeq.from, ctrlSeq.to)
|
||||
|
||||
items.push({
|
||||
line: state.doc.lineAt(commandNode.from).number,
|
||||
title: text,
|
||||
from: commandNode.from,
|
||||
to: commandNode.to,
|
||||
optionalArgCount: optionalArguments.length,
|
||||
requiredArgCount:
|
||||
commandArgumentsIncludingOptional.length - optionalArguments.length,
|
||||
type: 'usage',
|
||||
raw: undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const texOrPdfArgument = { tex: 0, pdf: 1 }
|
||||
|
||||
export const texOrPdfString = (
|
||||
state: EditorState,
|
||||
node: SyntaxNode,
|
||||
version: keyof typeof texOrPdfArgument
|
||||
) => {
|
||||
const commandName = getCommandName(node.node, state, ['CtrlSeq'])
|
||||
if (commandName === '\\texorpdfstring') {
|
||||
const argumentNode = node
|
||||
.getChildren('TextArgument')
|
||||
[texOrPdfArgument[version]]?.getChild('LongArg')
|
||||
if (argumentNode) {
|
||||
return state.doc.sliceString(argumentNode.from, argumentNode.to)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { ensureSyntaxTree } from '@codemirror/language'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { SyntaxNode } from '@lezer/common'
|
||||
|
||||
const HUNDRED_MS = 100
|
||||
|
||||
/**
|
||||
* Does this Comment node look like '% {' ?
|
||||
* */
|
||||
export const commentIsOpenFold = (
|
||||
node: SyntaxNode,
|
||||
state: EditorState
|
||||
): boolean => {
|
||||
const content = state.doc.sliceString(node.from, node.to)
|
||||
return !!content.match(/%\s*\{\s*/)
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this Comment node look like '% }' ?
|
||||
* */
|
||||
export const commentIsCloseFold = (
|
||||
node: SyntaxNode,
|
||||
state: EditorState
|
||||
): boolean => {
|
||||
const content = state.doc.sliceString(node.from, node.to)
|
||||
return !!content.match(/%\s*\}\s*/)
|
||||
}
|
||||
|
||||
const SEARCH_FORWARD_LIMIT = 6000
|
||||
|
||||
/**
|
||||
* Given an opening fold Comment, find its corresponding closing Comment,
|
||||
* accounting for nesting.
|
||||
* */
|
||||
export const findClosingFoldComment = (
|
||||
node: SyntaxNode,
|
||||
state: EditorState
|
||||
): SyntaxNode | undefined => {
|
||||
const start = node.to + 1
|
||||
const upto = Math.min(start + SEARCH_FORWARD_LIMIT, state.doc.length)
|
||||
const tree = ensureSyntaxTree(state, upto, HUNDRED_MS)
|
||||
if (!tree) {
|
||||
return
|
||||
}
|
||||
let closingFoldNode: SyntaxNode | undefined
|
||||
let nestingLevel = 0
|
||||
tree.iterate({
|
||||
from: start,
|
||||
to: upto,
|
||||
enter: n => {
|
||||
if (closingFoldNode) {
|
||||
return false
|
||||
}
|
||||
if (n.node.type.is('Comment')) {
|
||||
if (commentIsOpenFold(n.node, state)) {
|
||||
nestingLevel++
|
||||
} else if (commentIsCloseFold(n.node, state)) {
|
||||
if (nestingLevel > 0) {
|
||||
nestingLevel--
|
||||
} else {
|
||||
closingFoldNode = n.node
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
return closingFoldNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Given two Comment nodes, get the positions we want to actually fold between,
|
||||
* accounting for the opening and closing brace.
|
||||
*
|
||||
* The resulting fold looks like `% {----}` in the editor.
|
||||
*
|
||||
*/
|
||||
export const getFoldRange = (
|
||||
startNode: SyntaxNode,
|
||||
endNode: SyntaxNode,
|
||||
state: EditorState
|
||||
): { from: number; to: number } | null => {
|
||||
const startContent = state.doc.sliceString(startNode.from, startNode.to)
|
||||
const endContent = state.doc.sliceString(endNode.from, endNode.to)
|
||||
|
||||
const openBracePos = startContent.indexOf('{')
|
||||
const closeBracePos = endContent.indexOf('}')
|
||||
if (openBracePos < 0 || closeBracePos < 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
from: startNode.from + openBracePos + 1,
|
||||
to: endNode.from + closeBracePos,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { ensureSyntaxTree } from '@codemirror/language'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { IterMode, SyntaxNode, SyntaxNodeRef, Tree } from '@lezer/common'
|
||||
|
||||
const HUNDRED_MS = 100
|
||||
|
||||
export function iterateDescendantsOf(
|
||||
tree: Tree,
|
||||
ancestors: (string | number)[],
|
||||
spec: {
|
||||
enter(node: SyntaxNodeRef): boolean | void
|
||||
leave?(node: SyntaxNodeRef): void
|
||||
from?: number | undefined
|
||||
to?: number | undefined
|
||||
mode?: IterMode | undefined
|
||||
}
|
||||
) {
|
||||
const filteredEnter = (node: SyntaxNodeRef): boolean | void => {
|
||||
if (!ancestors.some(x => node.type.is(x))) {
|
||||
return false
|
||||
}
|
||||
return spec.enter(node)
|
||||
}
|
||||
tree.iterate({ ...spec, enter: filteredEnter })
|
||||
}
|
||||
|
||||
export const previousSiblingIs = (
|
||||
state: EditorState,
|
||||
pos: number,
|
||||
expectedName: string
|
||||
): boolean | null => {
|
||||
const tree = ensureSyntaxTree(state, pos, HUNDRED_MS)
|
||||
if (!tree) {
|
||||
return null
|
||||
}
|
||||
const thisNode = tree.resolve(pos)
|
||||
const previousNode = thisNode?.prevSibling
|
||||
return previousNode?.type.name === expectedName
|
||||
}
|
||||
|
||||
export const nextSiblingIs = (
|
||||
state: EditorState,
|
||||
pos: number,
|
||||
expectedName: string
|
||||
): boolean | null => {
|
||||
const tree = ensureSyntaxTree(state, pos, HUNDRED_MS)
|
||||
if (!tree) {
|
||||
return null
|
||||
}
|
||||
const thisNode = tree.resolve(pos)
|
||||
const previousNode = thisNode?.nextSibling
|
||||
return previousNode?.type.name === expectedName
|
||||
}
|
||||
|
||||
export const getOptionalArgumentText = (
|
||||
state: EditorState,
|
||||
optionalArgumentNode: SyntaxNode
|
||||
): string | undefined => {
|
||||
const shortArgNode = optionalArgumentNode.getChild('ShortOptionalArg')
|
||||
if (shortArgNode) {
|
||||
return state.doc.sliceString(shortArgNode.from, shortArgNode.to)
|
||||
}
|
||||
}
|
||||
|
||||
export const nodeHasError = (node: SyntaxNode): boolean => {
|
||||
let hasError = false
|
||||
|
||||
node.cursor().iterate(({ type }) => {
|
||||
if (hasError) return false
|
||||
|
||||
if (type.isError) {
|
||||
hasError = true
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
return hasError
|
||||
}
|
||||
|
||||
export const childOfNodeWithType = (
|
||||
node: SyntaxNode,
|
||||
...types: (string | number)[]
|
||||
): SyntaxNode | null => {
|
||||
let childOfType: SyntaxNode | null = null
|
||||
|
||||
node.cursor().iterate(child => {
|
||||
if (childOfType !== null) {
|
||||
return false
|
||||
}
|
||||
|
||||
for (const type of types) {
|
||||
if (child.type.is(type)) {
|
||||
childOfType = child.node
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
return childOfType
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { CompletionContext, CompletionSource } from '@codemirror/autocomplete'
|
||||
import { syntaxTree } from '@codemirror/language'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { SyntaxNode } from '@lezer/common'
|
||||
import { ancestorOfNodeWithType } from './ancestors'
|
||||
|
||||
export const ifInType = (
|
||||
type: string,
|
||||
source: CompletionSource
|
||||
): CompletionSource => {
|
||||
return (context: CompletionContext) => {
|
||||
const tree = syntaxTree(context.state)
|
||||
let node: SyntaxNode | null = tree.resolveInner(context.pos, -1)
|
||||
while (node) {
|
||||
if (node.type.is(type)) {
|
||||
return source(context)
|
||||
}
|
||||
node = node.parent
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function isInEmptyArgumentNodeOfType(state: EditorState, types: string[]) {
|
||||
const main = state.selection.main
|
||||
if (!main.empty) {
|
||||
return false
|
||||
}
|
||||
|
||||
const pos = main.anchor
|
||||
const tree = syntaxTree(state)
|
||||
|
||||
if (tree.length < pos) {
|
||||
return false
|
||||
}
|
||||
|
||||
const nodeLeft = tree.resolveInner(pos, -1)
|
||||
if (!nodeLeft.type.is('OpenBrace')) {
|
||||
return false
|
||||
}
|
||||
|
||||
const nodeRight = tree.resolveInner(pos, 1)
|
||||
if (!nodeRight.type.is('CloseBrace')) {
|
||||
return false
|
||||
}
|
||||
|
||||
const ancestor = ancestorOfNodeWithType(nodeLeft, ...types)
|
||||
if (!ancestor) {
|
||||
return false
|
||||
}
|
||||
|
||||
return ancestor.from === nodeLeft.from && ancestor.to === nodeRight.to
|
||||
}
|
||||
|
||||
export function isInEmptyArgumentNodeForAutocomplete(state: EditorState) {
|
||||
return isInEmptyArgumentNodeOfType(state, [
|
||||
'EnvNameGroup',
|
||||
'BibliographyStyleArgument',
|
||||
'BibliographyArgument',
|
||||
'BibKeyArgument',
|
||||
'DocumentClassArgument',
|
||||
'FilePathArgument',
|
||||
'RefArgument',
|
||||
'PackageArgument',
|
||||
])
|
||||
}
|
||||
|
||||
export function isInEmptyCiteArgumentNode(state: EditorState) {
|
||||
return isInEmptyArgumentNodeOfType(state, ['BibKeyArgument'])
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import { ensureSyntaxTree } from '@codemirror/language'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { SyntaxNode, SyntaxNodeRef } from '@lezer/common'
|
||||
import { previousSiblingIs } from './common'
|
||||
import { NodeIntersectsChangeFn, ProjectionItem } from './projection'
|
||||
import { FigureData } from '../../extensions/figure-modal'
|
||||
|
||||
const HUNDRED_MS = 100
|
||||
|
||||
export class Environment extends ProjectionItem {
|
||||
readonly title: string = ''
|
||||
readonly type: 'usage' | 'definition' = 'usage'
|
||||
readonly raw: string = ''
|
||||
}
|
||||
|
||||
export const enterNode = (
|
||||
state: EditorState,
|
||||
node: SyntaxNodeRef,
|
||||
items: Environment[],
|
||||
nodeIntersectsChange: NodeIntersectsChangeFn
|
||||
): any => {
|
||||
if (node.type.is('EnvNameGroup')) {
|
||||
if (!nodeIntersectsChange(node.node)) {
|
||||
return false
|
||||
}
|
||||
if (!node.node.prevSibling?.type.is('Begin')) {
|
||||
return false
|
||||
}
|
||||
const openBraceNode = node.node.getChild('OpenBrace')
|
||||
if (!openBraceNode) {
|
||||
return false
|
||||
}
|
||||
const envNameNode = openBraceNode.node.nextSibling
|
||||
if (!envNameNode) {
|
||||
return false
|
||||
}
|
||||
const envNameText = state.doc.sliceString(envNameNode.from, envNameNode.to)
|
||||
|
||||
if (envNameText.length < 1) {
|
||||
return false
|
||||
}
|
||||
|
||||
const thisEnvironmentName: Environment = {
|
||||
title: envNameText,
|
||||
from: envNameNode.from,
|
||||
to: envNameNode.to,
|
||||
line: state.doc.lineAt(envNameNode.from).number,
|
||||
type: 'usage',
|
||||
raw: state.sliceDoc(node.from, node.to),
|
||||
}
|
||||
|
||||
items.push(thisEnvironmentName)
|
||||
} else if (
|
||||
node.type.is('NewEnvironment') ||
|
||||
node.type.is('RenewEnvironment')
|
||||
) {
|
||||
if (!nodeIntersectsChange(node.node)) {
|
||||
// This should already be in `items`
|
||||
return false
|
||||
}
|
||||
|
||||
const envNameNode = node.node.getChild('LiteralArgContent')
|
||||
if (!envNameNode) {
|
||||
return
|
||||
}
|
||||
const envNameText = state.doc.sliceString(envNameNode.from, envNameNode.to)
|
||||
|
||||
if (!envNameText) {
|
||||
return
|
||||
}
|
||||
|
||||
const thisEnvironmentName: Environment = {
|
||||
title: envNameText,
|
||||
from: envNameNode.from,
|
||||
to: envNameNode.to,
|
||||
line: state.doc.lineAt(envNameNode.from).number,
|
||||
type: 'definition',
|
||||
raw: state.sliceDoc(node.from, node.to),
|
||||
}
|
||||
|
||||
items.push(thisEnvironmentName)
|
||||
}
|
||||
}
|
||||
|
||||
export const cursorIsAtBeginEnvironment = (
|
||||
state: EditorState,
|
||||
pos: number
|
||||
): boolean | undefined => {
|
||||
const tree = ensureSyntaxTree(state, pos, HUNDRED_MS)
|
||||
if (!tree) {
|
||||
return
|
||||
}
|
||||
let thisNode = tree.resolve(pos)
|
||||
if (!thisNode) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
thisNode.type.is('EnvNameGroup') &&
|
||||
previousSiblingIs(state, pos, 'Begin')
|
||||
) {
|
||||
return true
|
||||
} else if (
|
||||
thisNode.type.is('$Environment') ||
|
||||
(thisNode.type.is('LaTeX') && pos === state.doc.length) // We're at the end of the document
|
||||
) {
|
||||
// We're at a malformed `\begin{`, resolve leftward
|
||||
thisNode = tree.resolve(pos, -1)
|
||||
if (!thisNode) {
|
||||
return
|
||||
}
|
||||
// TODO: may need to handle various envnames
|
||||
if (thisNode.type.is('OpenBrace') || thisNode.type.is('$EnvName')) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const cursorIsAtEndEnvironment = (
|
||||
state: EditorState,
|
||||
pos: number
|
||||
): boolean | undefined => {
|
||||
const tree = ensureSyntaxTree(state, pos, HUNDRED_MS)
|
||||
if (!tree) {
|
||||
return
|
||||
}
|
||||
let thisNode = tree.resolve(pos)
|
||||
if (!thisNode) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
thisNode.type.is('EnvNameGroup') &&
|
||||
previousSiblingIs(state, pos, 'End')
|
||||
) {
|
||||
return true
|
||||
} else if (thisNode.type.is('$Environment') || thisNode.type.is('Content')) {
|
||||
// We're at a malformed `\end{`, resolve leftward
|
||||
thisNode = tree.resolve(pos, -1)
|
||||
if (!thisNode) {
|
||||
return
|
||||
}
|
||||
// TODO: may need to handle various envnames
|
||||
if (thisNode.type.is('OpenBrace') || thisNode.type.is('EnvName')) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @param node A node of type `$Environment`, `BeginEnv`, or `EndEnv`
|
||||
* @param state The editor state to read the name from
|
||||
* @returns The editor name or null if a name cannot be found
|
||||
*/
|
||||
export function getEnvironmentName(
|
||||
node: SyntaxNode | null,
|
||||
state: EditorState
|
||||
): string | null {
|
||||
if (node?.type.is('$Environment')) {
|
||||
node = node.getChild('BeginEnv')
|
||||
}
|
||||
|
||||
if (!node?.type.is('BeginEnv') && !node?.type.is('EndEnv')) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nameNode = node
|
||||
?.getChild('EnvNameGroup')
|
||||
?.getChild('OpenBrace')?.nextSibling
|
||||
if (!nameNode) {
|
||||
return null
|
||||
}
|
||||
// the name node is a parameter in the grammar, so we have no good way to
|
||||
// target the specific type
|
||||
if (nameNode.type.is('CloseBrace')) {
|
||||
return null
|
||||
}
|
||||
return state.sliceDoc(nameNode.from, nameNode.to)
|
||||
}
|
||||
|
||||
export const getUnstarredEnvironmentName = (
|
||||
node: SyntaxNode | null,
|
||||
state: EditorState
|
||||
): string | undefined => getEnvironmentName(node, state)?.replace(/\*$/, '')
|
||||
|
||||
export function getEnvironmentArguments(environmentNode: SyntaxNode) {
|
||||
return environmentNode.getChild('BeginEnv')?.getChildren('TextArgument')
|
||||
}
|
||||
|
||||
export function parseFigureData(
|
||||
figureEnvironmentNode: SyntaxNode,
|
||||
state: EditorState
|
||||
): FigureData | null {
|
||||
let caption: FigureData['caption'] = null
|
||||
let label: FigureData['label'] = null
|
||||
let file: FigureData['file'] | undefined
|
||||
let width: FigureData['width']
|
||||
let unknownGraphicsArguments: FigureData['unknownGraphicsArguments']
|
||||
let graphicsCommand: FigureData['graphicsCommand'] | undefined
|
||||
let graphicsCommandArguments: FigureData['graphicsCommandArguments'] = null
|
||||
|
||||
const from = figureEnvironmentNode.from
|
||||
const to = figureEnvironmentNode.to
|
||||
|
||||
let error = false
|
||||
figureEnvironmentNode.cursor().iterate((node: SyntaxNodeRef) => {
|
||||
if (error) {
|
||||
return false
|
||||
}
|
||||
if (node.type.is('Caption')) {
|
||||
if (caption) {
|
||||
// Multiple captions
|
||||
error = true
|
||||
return false
|
||||
}
|
||||
caption = {
|
||||
from: node.from,
|
||||
to: node.to,
|
||||
}
|
||||
}
|
||||
if (node.type.is('Label')) {
|
||||
if (label) {
|
||||
// Multiple labels
|
||||
error = true
|
||||
return false
|
||||
}
|
||||
label = {
|
||||
from: node.from,
|
||||
to: node.to,
|
||||
}
|
||||
}
|
||||
if (node.type.is('IncludeGraphics')) {
|
||||
if (file) {
|
||||
// Multiple figure
|
||||
error = true
|
||||
return false
|
||||
}
|
||||
graphicsCommand = {
|
||||
from: node.from,
|
||||
to: node.to,
|
||||
}
|
||||
const content = node.node
|
||||
.getChild('IncludeGraphicsArgument')
|
||||
?.getChild('FilePathArgument')
|
||||
?.getChild('LiteralArgContent')
|
||||
if (!content) {
|
||||
error = true
|
||||
return false
|
||||
}
|
||||
file = {
|
||||
from: content.from,
|
||||
to: content.to,
|
||||
path: state.sliceDoc(content.from, content.to),
|
||||
}
|
||||
const optionalArgs = node.node
|
||||
.getChild('OptionalArgument')
|
||||
?.getChild('ShortOptionalArg')
|
||||
if (!optionalArgs) {
|
||||
width = undefined
|
||||
return false
|
||||
}
|
||||
graphicsCommandArguments = {
|
||||
from: optionalArgs.from,
|
||||
to: optionalArgs.to,
|
||||
}
|
||||
const optionalArgContent = state.sliceDoc(
|
||||
optionalArgs.from,
|
||||
optionalArgs.to
|
||||
)
|
||||
const widthMatch = optionalArgContent.match(
|
||||
/^width=([0-9]|(?:[0-9]*\.[0-9]+)|(?:[0-9]+\.))\\(linewidth|pagewidth|textwidth|hsize|columnwidth)$/
|
||||
)
|
||||
if (widthMatch) {
|
||||
width = parseFloat(widthMatch[1])
|
||||
if (widthMatch[2] !== 'linewidth') {
|
||||
// We shouldn't edit any width other that linewidth
|
||||
unknownGraphicsArguments = optionalArgContent
|
||||
}
|
||||
} else {
|
||||
unknownGraphicsArguments = optionalArgContent
|
||||
}
|
||||
}
|
||||
})
|
||||
if (error) {
|
||||
return null
|
||||
}
|
||||
if (graphicsCommand === undefined || file === undefined) {
|
||||
return null
|
||||
}
|
||||
return new FigureData({
|
||||
caption,
|
||||
label,
|
||||
file,
|
||||
from,
|
||||
to,
|
||||
width,
|
||||
unknownGraphicsArguments,
|
||||
graphicsCommand,
|
||||
graphicsCommandArguments,
|
||||
})
|
||||
}
|
||||
|
||||
export const getBeginEnvSuffix = (state: EditorState, node: SyntaxNode) => {
|
||||
const argumentNode = node
|
||||
.getChild('OptionalArgument')
|
||||
?.getChild('ShortOptionalArg')
|
||||
|
||||
return argumentNode && state.sliceDoc(argumentNode.from, argumentNode.to)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { SyntaxNode, SyntaxNodeRef } from '@lezer/common'
|
||||
import { CenteringCtrlSeq } from '../../lezer-latex/latex.terms.mjs'
|
||||
|
||||
export function centeringNodeForEnvironment(
|
||||
environmentNodeRef: SyntaxNodeRef
|
||||
): SyntaxNode | null {
|
||||
let centeringNode: SyntaxNode | null = null
|
||||
const cursor = environmentNodeRef.node.cursor()
|
||||
cursor.iterate(nodeRef => {
|
||||
if (centeringNode) {
|
||||
return false
|
||||
}
|
||||
if (nodeRef.type.is(CenteringCtrlSeq)) {
|
||||
centeringNode = nodeRef.node
|
||||
return false
|
||||
}
|
||||
// don't descend into nested environments
|
||||
if (
|
||||
nodeRef.node !== environmentNodeRef.node &&
|
||||
nodeRef.type.is('$Environment')
|
||||
) {
|
||||
return false
|
||||
}
|
||||
})
|
||||
return centeringNode
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { EditorState, SelectionRange } from '@codemirror/state'
|
||||
import { syntaxTree } from '@codemirror/language'
|
||||
import {
|
||||
commonAncestor,
|
||||
matchingAncestor,
|
||||
} from '@/features/source-editor/utils/tree-operations/ancestors'
|
||||
|
||||
export type FormattingCommand = '\\textbf' | '\\textit'
|
||||
export type FormattingNodeType = string | number
|
||||
|
||||
export const formattingCommandMap: Record<
|
||||
FormattingCommand,
|
||||
FormattingNodeType
|
||||
> = {
|
||||
'\\textbf': 'TextBoldCommand',
|
||||
'\\textit': 'TextItalicCommand',
|
||||
}
|
||||
|
||||
export const withinFormattingCommand = (state: EditorState) => {
|
||||
const tree = syntaxTree(state)
|
||||
|
||||
return (command: FormattingCommand): boolean => {
|
||||
const nodeType = formattingCommandMap[command]
|
||||
|
||||
const isFormattedText = (range: SelectionRange): boolean => {
|
||||
const nodeLeft = tree.resolveInner(range.from, -1)
|
||||
const formattingCommandLeft = matchingAncestor(nodeLeft, node =>
|
||||
node.type.is(nodeType)
|
||||
)
|
||||
if (!formattingCommandLeft) {
|
||||
return false
|
||||
}
|
||||
|
||||
// We need to check the other end of the selection, and ensure that they
|
||||
// share a common formatting command ancestor
|
||||
const nodeRight = tree.resolveInner(range.to, 1)
|
||||
const ancestor = commonAncestor(formattingCommandLeft, nodeRight)
|
||||
if (!ancestor) {
|
||||
return false
|
||||
}
|
||||
|
||||
const formattingAncestor = matchingAncestor(ancestor, node =>
|
||||
node.type.is(nodeType)
|
||||
)
|
||||
return Boolean(formattingAncestor)
|
||||
}
|
||||
|
||||
return state.selection.ranges.every(isFormattedText)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { getEnvironmentName } from './environments'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { SyntaxNode, SyntaxNodeRef } from '@lezer/common'
|
||||
import { ancestorNodeOfType } from './ancestors'
|
||||
|
||||
export type MathContainer = {
|
||||
content: string
|
||||
displayMode: boolean
|
||||
passToMathJax: boolean
|
||||
pos: number
|
||||
}
|
||||
|
||||
export const mathAncestorNode = (state: EditorState, pos: number) =>
|
||||
ancestorNodeOfType(state, pos, '$MathContainer') ||
|
||||
ancestorNodeOfType(state, pos, 'EquationEnvironment') ||
|
||||
// NOTE: EquationArrayEnvironment can be nested inside EquationEnvironment
|
||||
ancestorNodeOfType(state, pos, 'EquationArrayEnvironment')
|
||||
|
||||
export const parseMathContainer = (
|
||||
state: EditorState,
|
||||
nodeRef: SyntaxNodeRef,
|
||||
ancestorNode: SyntaxNode
|
||||
): MathContainer | null => {
|
||||
// the content of the Math element, without braces
|
||||
const innerContent = state.doc.sliceString(nodeRef.from, nodeRef.to).trim()
|
||||
|
||||
if (!innerContent.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
let content = innerContent
|
||||
let displayMode = false
|
||||
let passToMathJax = true
|
||||
let pos = nodeRef.from
|
||||
|
||||
if (ancestorNode.type.is('$Environment')) {
|
||||
const environmentName = getEnvironmentName(ancestorNode, state)
|
||||
if (environmentName) {
|
||||
// use the outer content of environments that MathJax supports
|
||||
// https://docs.mathjax.org/en/latest/input/tex/macros/index.html#environments
|
||||
if (environmentName === 'tikzcd') {
|
||||
passToMathJax = false
|
||||
}
|
||||
if (environmentName !== 'math' && environmentName !== 'displaymath') {
|
||||
content = state.doc
|
||||
.sliceString(ancestorNode.from, ancestorNode.to)
|
||||
.trim()
|
||||
pos = ancestorNode.from
|
||||
}
|
||||
|
||||
if (environmentName !== 'math') {
|
||||
displayMode = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (
|
||||
ancestorNode.type.is('BracketMath') ||
|
||||
Boolean(ancestorNode.getChild('DisplayMath'))
|
||||
) {
|
||||
displayMode = true
|
||||
}
|
||||
}
|
||||
|
||||
return { content, displayMode, passToMathJax, pos }
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { SyntaxNode, SyntaxNodeRef } from '@lezer/common'
|
||||
import { NodeIntersectsChangeFn, ProjectionItem } from './projection'
|
||||
import * as tokens from '../../lezer-latex/latex.terms.mjs'
|
||||
import { getEnvironmentArguments, getEnvironmentName } from './environments'
|
||||
import { PartialFlatOutline } from '@/features/ide-react/context/outline-context'
|
||||
import { texOrPdfString } from './commands'
|
||||
|
||||
export type Outline = {
|
||||
line: number
|
||||
title: string
|
||||
level: number
|
||||
children?: Outline[]
|
||||
}
|
||||
|
||||
/**
|
||||
* A projection of a part of the file outline, typically a (sub)section heading
|
||||
*/
|
||||
export class FlatOutlineItem extends ProjectionItem {
|
||||
readonly level: number = 0
|
||||
readonly title: string = ''
|
||||
}
|
||||
|
||||
export type FlatOutline = FlatOutlineItem[]
|
||||
|
||||
/* eslint-disable no-unused-vars */
|
||||
enum NestingLevel {
|
||||
Book = 1,
|
||||
Part = 2,
|
||||
Chapter = 3,
|
||||
Section = 4,
|
||||
SubSection = 5,
|
||||
SubSubSection = 6,
|
||||
Paragraph = 7,
|
||||
SubParagraph = 8,
|
||||
Frame = 9,
|
||||
Invalid = -1,
|
||||
}
|
||||
|
||||
const fallbackSectionNames: { [index: string]: NestingLevel } = {
|
||||
book: NestingLevel.Book,
|
||||
part: NestingLevel.Part,
|
||||
chapter: NestingLevel.Part,
|
||||
section: NestingLevel.Section,
|
||||
subsection: NestingLevel.SubSection,
|
||||
subsubsection: NestingLevel.SubSubSection,
|
||||
paragraph: NestingLevel.Paragraph,
|
||||
subparagraph: NestingLevel.SubParagraph,
|
||||
frame: NestingLevel.Frame,
|
||||
}
|
||||
|
||||
export const getNestingLevel = (token: number | string): NestingLevel => {
|
||||
if (typeof token === 'string') {
|
||||
return fallbackSectionNames[token] ?? NestingLevel.Invalid
|
||||
}
|
||||
switch (token) {
|
||||
case tokens.Book:
|
||||
return NestingLevel.Book
|
||||
case tokens.Part:
|
||||
return NestingLevel.Part
|
||||
case tokens.Chapter:
|
||||
return NestingLevel.Chapter
|
||||
case tokens.Section:
|
||||
return NestingLevel.Section
|
||||
case tokens.SubSection:
|
||||
return NestingLevel.SubSection
|
||||
case tokens.SubSubSection:
|
||||
return NestingLevel.SubSubSection
|
||||
case tokens.Paragraph:
|
||||
return NestingLevel.Paragraph
|
||||
case tokens.SubParagraph:
|
||||
return NestingLevel.SubParagraph
|
||||
default:
|
||||
return NestingLevel.Invalid
|
||||
}
|
||||
}
|
||||
|
||||
const getEntryText = (state: EditorState, node: SyntaxNodeRef): string => {
|
||||
const titleParts: string[] = []
|
||||
node.node.cursor().iterate(token => {
|
||||
// For some reason, iterate can possibly visit sibling nodes as well as
|
||||
// child nodes
|
||||
if (token.from >= node.to) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Hide label definitions within the sectioning command
|
||||
if (token.type.is('Label')) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Handle the texorpdfstring command
|
||||
if (token.type.is('UnknownCommand')) {
|
||||
const pdfString = texOrPdfString(state, token.node, 'pdf')
|
||||
if (pdfString) {
|
||||
titleParts.push(pdfString)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Only add text from leaf nodes
|
||||
if (token.node.firstChild) {
|
||||
return true
|
||||
}
|
||||
|
||||
titleParts.push(state.doc.sliceString(token.from, token.to))
|
||||
})
|
||||
return titleParts.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts FlatOutlineItem instances from the syntax tree
|
||||
*/
|
||||
export const enterNode = (
|
||||
state: EditorState,
|
||||
node: SyntaxNodeRef,
|
||||
items: FlatOutlineItem[],
|
||||
nodeIntersectsChange: NodeIntersectsChangeFn
|
||||
): any => {
|
||||
if (node.type.is('SectioningCommand')) {
|
||||
const command = node.node
|
||||
const parent = command.parent
|
||||
|
||||
if (!nodeIntersectsChange(command)) {
|
||||
// This should already be in `items`
|
||||
return
|
||||
}
|
||||
const name = command.getChild('SectioningArgument')?.getChild('LongArg')
|
||||
|
||||
if (!name) {
|
||||
return
|
||||
}
|
||||
|
||||
// Filter out descendants of newcommand/renewcommand
|
||||
for (
|
||||
let ancestor: SyntaxNode | null = parent;
|
||||
ancestor;
|
||||
ancestor = ancestor.parent
|
||||
) {
|
||||
if (ancestor.type.is('NewCommand') || ancestor.type.is('RenewCommand')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const getCommandName = () => {
|
||||
const ctrlSeq = command.firstChild
|
||||
if (!ctrlSeq) return ''
|
||||
// Ignore the \
|
||||
return state.doc.sliceString(ctrlSeq.from + 1, ctrlSeq.to)
|
||||
}
|
||||
|
||||
const nestingLevel = parent?.type.is('$Section')
|
||||
? getNestingLevel(parent.type.id)
|
||||
: getNestingLevel(getCommandName())
|
||||
|
||||
const thisNode = {
|
||||
line: state.doc.lineAt(command.from).number,
|
||||
title: getEntryText(state, name),
|
||||
from: command.from,
|
||||
to: command.to,
|
||||
level: nestingLevel,
|
||||
}
|
||||
|
||||
items.push(thisNode)
|
||||
}
|
||||
if (node.type.is('$Environment')) {
|
||||
const environmentNode = node.node
|
||||
if (getEnvironmentName(environmentNode, state) === 'frame') {
|
||||
const beginEnv = environmentNode.getChild('BeginEnv')!
|
||||
if (!nodeIntersectsChange(beginEnv)) {
|
||||
// This should already be in `items`
|
||||
return
|
||||
}
|
||||
const args = getEnvironmentArguments(environmentNode)?.map(textArg =>
|
||||
textArg.getChild('LongArg')
|
||||
)
|
||||
if (args?.length) {
|
||||
const titleNode = args[0]
|
||||
const title = titleNode
|
||||
? state.sliceDoc(titleNode.from, titleNode.to)
|
||||
: ''
|
||||
const thisNode = {
|
||||
line: state.doc.lineAt(beginEnv.from).number,
|
||||
title,
|
||||
from: beginEnv.from,
|
||||
to: beginEnv.to,
|
||||
level: NestingLevel.Frame,
|
||||
}
|
||||
items.push(thisNode)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const flatItemToOutline = (item: {
|
||||
title: string
|
||||
line: number
|
||||
level: number
|
||||
}): Outline => ({
|
||||
title: item.title,
|
||||
line: item.line,
|
||||
level: item.level,
|
||||
})
|
||||
|
||||
export const nestOutline = (flatOutline: PartialFlatOutline): Outline[] => {
|
||||
const parentStack: Outline[] = []
|
||||
const outline = []
|
||||
|
||||
for (const item of flatOutline) {
|
||||
const outlineItem = flatItemToOutline(item)
|
||||
|
||||
// Pop all higher-leveled potential parents from the parent stack
|
||||
while (
|
||||
parentStack.length &&
|
||||
parentStack[parentStack.length - 1].level >= outlineItem.level
|
||||
) {
|
||||
parentStack.pop()
|
||||
}
|
||||
|
||||
// Append to parent if any, and otherwise add root element
|
||||
if (!parentStack.length) {
|
||||
parentStack.push(outlineItem)
|
||||
outline.push(outlineItem)
|
||||
} else {
|
||||
const parent = parentStack[parentStack.length - 1]
|
||||
if (!parent.children) {
|
||||
parent.children = [outlineItem]
|
||||
} else {
|
||||
parent.children.push(outlineItem)
|
||||
}
|
||||
parentStack.push(outlineItem)
|
||||
}
|
||||
}
|
||||
return outline
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { ensureSyntaxTree } from '@codemirror/language'
|
||||
import { EditorState, Transaction } from '@codemirror/state'
|
||||
import { IterMode, SyntaxNodeRef } from '@lezer/common'
|
||||
|
||||
const TWENTY_MS = 20
|
||||
const FIVE_HUNDRED_MS = 500
|
||||
|
||||
/**
|
||||
* A single item in the projection
|
||||
*/
|
||||
export abstract class ProjectionItem {
|
||||
readonly from: number = 0
|
||||
readonly to: number = 0
|
||||
readonly line: number = 0
|
||||
}
|
||||
|
||||
/* eslint-disable no-unused-vars */
|
||||
export enum ProjectionStatus {
|
||||
Pending,
|
||||
Partial,
|
||||
Complete,
|
||||
}
|
||||
/* eslint-enable no-unused-vars */
|
||||
|
||||
/*
|
||||
* Result of extracting a projection from the document.
|
||||
* Holds the list of ProjectionItems and the status of
|
||||
* the projection
|
||||
*/
|
||||
export interface ProjectionResult<T extends ProjectionItem> {
|
||||
items: T[]
|
||||
status: ProjectionStatus
|
||||
}
|
||||
|
||||
const intersects = (fromA: number, toA: number, fromB: number, toB: number) => {
|
||||
return !(toA < fromB || fromA > toB)
|
||||
}
|
||||
|
||||
export type NodeIntersectsChangeFn = (node: SyntaxNodeRef) => boolean
|
||||
|
||||
export function updatePosition<T extends ProjectionItem>(
|
||||
item: T,
|
||||
transaction?: Transaction
|
||||
): T {
|
||||
if (!transaction) {
|
||||
return item
|
||||
}
|
||||
const { from, to } = item
|
||||
const newFrom = transaction.changes.mapPos(from)
|
||||
const newTo = transaction.changes.mapPos(to)
|
||||
const lineNumber = transaction.state.doc.lineAt(newFrom).number
|
||||
|
||||
if (newFrom === from && newTo === to && lineNumber === item.line) {
|
||||
// Optimisation - if the item hasn't moved, don't create a new object
|
||||
// If items are not immutable this can introduce problems
|
||||
return item
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
from: newFrom,
|
||||
to: newTo,
|
||||
line: lineNumber,
|
||||
}
|
||||
}
|
||||
|
||||
export type EnterNodeFn<T> = (
|
||||
state: EditorState,
|
||||
node: SyntaxNodeRef,
|
||||
items: T[],
|
||||
nodeIntersectsChange: NodeIntersectsChangeFn
|
||||
) => any
|
||||
|
||||
/**
|
||||
* Calculates an updated projection of an editor state. Passing a previous ProjectionResult
|
||||
* will reuse the existing projection elements (though updating their position to
|
||||
* point correctly into the latest EditorState), outside of the changed range.
|
||||
*
|
||||
* @param state The current editor state
|
||||
* @param fromA The start of the modified range in the previous state.
|
||||
* Ignored if `previousResult` is not provided
|
||||
* @param toA The end of the modified range in the previous state.
|
||||
* Ignored if `previousResult` is not provided
|
||||
* @param fromB The start of the modified range in the `state`
|
||||
* @param toB The end of the modified range in the `state`
|
||||
* @param initialParse If this is the intial parse of the document. If that's
|
||||
* the case, we allow 500ms parse time instead of 20ms
|
||||
* @param enterNode A function to call when 'enter'ing a node while traversing the syntax tree,
|
||||
* used to identify nodes we are interested in.
|
||||
* @param transaction Optional, used to update item positions in `previousResult`
|
||||
* @param previousResult A previous ProjectionResult that will be reused for
|
||||
* projection elements outside of the range of [fromA; toA]
|
||||
* @returns A ProjectionResult<T> pointing to locations in `state`
|
||||
*/
|
||||
export function getUpdatedProjection<T extends ProjectionItem>(
|
||||
state: EditorState,
|
||||
fromA: number,
|
||||
toA: number,
|
||||
fromB: number,
|
||||
toB: number,
|
||||
initialParse = false,
|
||||
enterNode: EnterNodeFn<T>,
|
||||
transaction?: Transaction,
|
||||
previousResult: ProjectionResult<T> = {
|
||||
items: [],
|
||||
status: ProjectionStatus.Pending,
|
||||
}
|
||||
): ProjectionResult<T> {
|
||||
// Only reuse results from a Complete parse, otherwise we may drop entries.
|
||||
// We keep items that lie outside the change range, and update their positions.
|
||||
const items: T[] =
|
||||
previousResult.status === ProjectionStatus.Complete
|
||||
? previousResult
|
||||
.items!.filter(item => !intersects(item.from, item.to, fromA, toA))
|
||||
.map(x => updatePosition(x, transaction))
|
||||
: []
|
||||
|
||||
if (previousResult.status !== ProjectionStatus.Complete) {
|
||||
// We have previously tried to compute the projection, but unsuccessfully,
|
||||
// so we should try to parse the whole file again.
|
||||
toB = state.doc.length
|
||||
fromB = 0
|
||||
}
|
||||
const tree = ensureSyntaxTree(
|
||||
state,
|
||||
toB,
|
||||
initialParse ? FIVE_HUNDRED_MS : TWENTY_MS
|
||||
)
|
||||
if (tree) {
|
||||
tree.iterate({
|
||||
from: fromB,
|
||||
to: toB,
|
||||
enter(node) {
|
||||
const nodeIntersectsChange = (n: SyntaxNodeRef) => {
|
||||
return intersects(n.from, n.to, fromB, toB)
|
||||
}
|
||||
return enterNode(state, node, items, nodeIntersectsChange)
|
||||
},
|
||||
mode: IterMode.IgnoreMounts | IterMode.IgnoreOverlays,
|
||||
})
|
||||
// We know the exact projection. Return it.
|
||||
return {
|
||||
status: ProjectionStatus.Complete,
|
||||
items: items.sort((a, b) => a.from - b.from),
|
||||
}
|
||||
} else if (previousResult.status !== ProjectionStatus.Pending) {
|
||||
// We don't know the latest projection, but we have an idea of a previous
|
||||
// projection.
|
||||
return {
|
||||
status: ProjectionStatus.Partial,
|
||||
items: previousResult.items,
|
||||
}
|
||||
} else {
|
||||
// We have no previous projection, and no idea of the current projection.
|
||||
// Return pending.
|
||||
return {
|
||||
items: [],
|
||||
status: ProjectionStatus.Pending,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { syntaxTree } from '@codemirror/language'
|
||||
import { Line } from '@codemirror/state'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { SyntaxNodeRef } from '@lezer/common'
|
||||
import OError from '@overleaf/o-error'
|
||||
import { noSpellCheckProp } from '@/features/source-editor/utils/node-props'
|
||||
|
||||
/* A convenient wrapper around 'Normal' tokens */
|
||||
export class NormalTextSpan {
|
||||
public from: number
|
||||
public to: number
|
||||
public lineNumber: number
|
||||
public text: string
|
||||
public node: SyntaxNodeRef
|
||||
|
||||
constructor(options: {
|
||||
from: number
|
||||
to: number
|
||||
lineNumber: number
|
||||
text: string
|
||||
node: SyntaxNodeRef
|
||||
}) {
|
||||
const { from, to, lineNumber, text, node } = options
|
||||
if (
|
||||
text == null ||
|
||||
from == null ||
|
||||
to == null ||
|
||||
lineNumber == null ||
|
||||
node == null
|
||||
) {
|
||||
throw new OError('TreeQuery: invalid NormalTextSpan').withInfo({
|
||||
options,
|
||||
})
|
||||
}
|
||||
this.from = from
|
||||
this.to = to
|
||||
this.text = text
|
||||
this.node = node
|
||||
this.lineNumber = lineNumber
|
||||
}
|
||||
}
|
||||
|
||||
export const getNormalTextSpansFromLine = (
|
||||
view: EditorView,
|
||||
line: Line
|
||||
): Array<NormalTextSpan> => {
|
||||
const lineNumber = line.number
|
||||
const lineStart = line.from
|
||||
const lineEnd = line.to
|
||||
const tree = syntaxTree(view.state)
|
||||
const normalTextSpans: Array<NormalTextSpan> = []
|
||||
tree?.iterate({
|
||||
from: lineStart,
|
||||
to: lineEnd,
|
||||
enter: (node: SyntaxNodeRef) => {
|
||||
if (
|
||||
node.type.prop(noSpellCheckProp)?.some(context => {
|
||||
return node.matchContext(context)
|
||||
})
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (node.type.name === 'Normal') {
|
||||
normalTextSpans.push(
|
||||
new NormalTextSpan({
|
||||
from: node.from,
|
||||
to: node.to,
|
||||
text: view.state.doc.sliceString(node.from, node.to),
|
||||
lineNumber,
|
||||
node,
|
||||
})
|
||||
)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
})
|
||||
return normalTextSpans
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { SyntaxNode, Tree } from '@lezer/common'
|
||||
import {
|
||||
LongArg,
|
||||
ShortArg,
|
||||
ShortTextArgument,
|
||||
TextArgument,
|
||||
} from '../../lezer-latex/latex.terms.mjs'
|
||||
|
||||
export const parseTheoremArguments = (
|
||||
state: EditorState,
|
||||
node: SyntaxNode
|
||||
): { name: string; label: string } | undefined => {
|
||||
const nameArgumentNode = node.getChild(ShortTextArgument)?.getChild(ShortArg)
|
||||
const labelArgumentNode = node.getChild(TextArgument)?.getChild(LongArg)
|
||||
|
||||
if (nameArgumentNode && labelArgumentNode) {
|
||||
const name = state
|
||||
.sliceDoc(nameArgumentNode.from, nameArgumentNode.to)
|
||||
.trim()
|
||||
|
||||
const label = state
|
||||
.sliceDoc(labelArgumentNode.from, labelArgumentNode.to)
|
||||
.trim()
|
||||
|
||||
if (name && label) {
|
||||
return { name, label }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const parseTheoremStyles = (state: EditorState, tree: Tree) => {
|
||||
// TODO: only scan for styles if amsthm is present?
|
||||
let currentTheoremStyle = 'plain'
|
||||
const theoremStyles = new Map<string, string>()
|
||||
const topNode = tree.topNode
|
||||
if (topNode && topNode.name === 'LaTeX') {
|
||||
const textNode = topNode.getChild('Text')
|
||||
const topLevelCommands = textNode
|
||||
? textNode.getChildren('Command')
|
||||
: topNode.getChildren('Command')
|
||||
for (const command of topLevelCommands) {
|
||||
const node = command.getChild('KnownCommand')?.getChild('$Command')
|
||||
if (node) {
|
||||
if (node.type.is('TheoremStyleCommand')) {
|
||||
const theoremStyle = argumentNodeContent(state, node)
|
||||
if (theoremStyle) {
|
||||
currentTheoremStyle = theoremStyle
|
||||
}
|
||||
} else if (node.type.is('NewTheoremCommand')) {
|
||||
const theoremEnvironmentName = argumentNodeContent(state, node)
|
||||
if (theoremEnvironmentName) {
|
||||
theoremStyles.set(theoremEnvironmentName, currentTheoremStyle)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return theoremStyles
|
||||
}
|
||||
|
||||
const argumentNodeContent = (
|
||||
state: EditorState,
|
||||
node: SyntaxNode
|
||||
): string | null => {
|
||||
const argumentNode = node.getChild(ShortTextArgument)?.getChild(ShortArg)
|
||||
|
||||
return argumentNode
|
||||
? state.sliceDoc(argumentNode.from, argumentNode.to)
|
||||
: null
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import * as termsModule from '../../lezer-latex/latex.terms.mjs'
|
||||
|
||||
export const tokenNames: Array<string> = Object.keys(termsModule)
|
||||
|
||||
export const Tokens: Record<string, Array<string>> = {
|
||||
ctrlSeq: tokenNames.filter(name => name.match(/^(Begin|End|.*CtrlSeq)$/)),
|
||||
ctrlSym: tokenNames.filter(name => name.match(/^.*CtrlSym$/)),
|
||||
envName: tokenNames.filter(name => name.match(/^.*EnvName$/)),
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
export { getNestingLevel, nestOutline } from './tree-operations/outline'
|
||||
|
||||
export type {
|
||||
FlatOutline,
|
||||
FlatOutlineItem,
|
||||
Outline,
|
||||
} from './tree-operations/outline'
|
||||
|
||||
export {
|
||||
iterateDescendantsOf,
|
||||
previousSiblingIs,
|
||||
nextSiblingIs,
|
||||
} from './tree-operations/common'
|
||||
|
||||
export {
|
||||
cursorIsAtBeginEnvironment,
|
||||
cursorIsAtEndEnvironment,
|
||||
getEnvironmentArguments,
|
||||
} from './tree-operations/environments'
|
||||
|
||||
export {
|
||||
getAncestorStack,
|
||||
ancestorNodeOfType,
|
||||
ancestorOfNodeWithType,
|
||||
getBibkeyArgumentNode,
|
||||
descendantsOfNodeWithType,
|
||||
matchingAncestor,
|
||||
} from './tree-operations/ancestors'
|
||||
|
||||
export {
|
||||
NormalTextSpan,
|
||||
getNormalTextSpansFromLine,
|
||||
} from './tree-operations/text'
|
||||
|
||||
export {
|
||||
ifInType,
|
||||
isInEmptyArgumentNodeForAutocomplete,
|
||||
} from './tree-operations/completions'
|
||||
|
||||
export { tokenNames, Tokens } from './tree-operations/tokens'
|
||||
|
||||
export {
|
||||
commentIsOpenFold,
|
||||
commentIsCloseFold,
|
||||
findClosingFoldComment,
|
||||
getFoldRange,
|
||||
} from './tree-operations/comments'
|
||||
11
services/web/frontend/js/features/source-editor/utils/url.ts
Normal file
11
services/web/frontend/js/features/source-editor/utils/url.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
const ALLOWED_PROTOCOLS = ['https:', 'http:']
|
||||
|
||||
export const openURL = (content: string) => {
|
||||
const url = new URL(content, document.location.href)
|
||||
|
||||
if (!ALLOWED_PROTOCOLS.includes(url.protocol)) {
|
||||
throw new Error(`Not opening URL with protocol ${url.protocol}`)
|
||||
}
|
||||
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
Reference in New Issue
Block a user