Move source editor out of module (#12457)

* Update Copybara options in preparation for open-sourcing the source editor

* Move files

* Update paths

* Remove source-editor module and checks for its existence

* Explicitly mention CM6 license in files that contain code adapted from CM6

GitOrigin-RevId: 89b7cc2b409db01ad103198ccbd1b126ab56349b
This commit is contained in:
Tim Down
2023-04-13 10:21:25 +02:00
committed by Copybot
parent 3f992f9453
commit 7f37ba737c
296 changed files with 33268 additions and 54 deletions
+2 -2
View File
@@ -4,5 +4,5 @@ modules/**/scripts
frontend/js/vendor
modules/**/frontend/js/vendor
/public/
modules/source-editor/frontend/js/lezer-latex/latex.mjs
modules/source-editor/frontend/js/lezer-latex/latex.terms.mjs
frontend/js/features/source-editor/lezer-latex/latex.mjs
frontend/js/features/source-editor/lezer-latex/latex.terms.mjs
+3 -3
View File
@@ -92,10 +92,10 @@ cypress/results/
!modules/history-migration/test/unit/src/data/track-changes-project.zip
# Ace themes for conversion
modules/source-editor/frontend/js/themes/ace/
frontend/js/features/source-editor/themes/ace/
# Compiled parser files
modules/source-editor/frontend/js/lezer-latex/latex.mjs
modules/source-editor/frontend/js/lezer-latex/latex.terms.mjs
frontend/js/features/source-editor/lezer-latex/latex.mjs
frontend/js/features/source-editor/lezer-latex/latex.terms.mjs
!**/fixtures/**/*.log
+2 -2
View File
@@ -6,5 +6,5 @@ modules/**/frontend/js/vendor
public/js
public/minjs
frontend/stylesheets/components/nvd3.less
modules/source-editor/frontend/js/lezer-latex/latex.mjs
modules/source-editor/frontend/js/lezer-latex/latex.terms.mjs
frontend/js/features/source-editor/lezer-latex/latex.mjs
frontend/js/features/source-editor/lezer-latex/latex.terms.mjs
@@ -49,13 +49,10 @@
else
.toolbar.toolbar-editor
if !moduleIncludesAvailable('editor:source-editor')
div(ng-if="editor.newSourceEditor")
include ../../source-editor/source-editor
div(ng-if="!editor.newSourceEditor")
include ./source-editor
else
div(ng-if="editor.newSourceEditor")
!= moduleIncludes('editor:source-editor', locals)
div(ng-if="!editor.newSourceEditor")
include ./source-editor
if !isRestrictedTokenMember
include ./review-panel
@@ -23,7 +23,6 @@ meta(name="ol-wsRetryHandshake" data-type="json" content=settings.wsRetryHandsha
meta(name="ol-pdfjsVariant" content=pdfjsVariant)
meta(name="ol-debugPdfDetach" data-type="boolean" content=debugPdfDetach)
meta(name="ol-showLegacySourceEditor", data-type="boolean" content=showLegacySourceEditor)
meta(name="ol-hasNewSourceEditor", data-type="boolean" content=moduleIncludesAvailable('editor:source-editor'))
meta(name="ol-showSymbolPalette" data-type="boolean" content=showSymbolPalette)
meta(name="ol-galileoEnabled" data-type="string" content=galileoEnabled)
meta(name="ol-galileoPromptWords" data-type="string" content=galileoPromptWords)
@@ -0,0 +1,4 @@
source-editor#editor(
ng-if="!editor.showRichText"
ng-show="!!editor.sharejs_doc && !editor.opening && multiSelectedCount === 0 && !editor.error_state"
)
@@ -31,7 +31,7 @@ const buildConfig = () => {
// add entrypoint under '/' for latex-linter worker
addWorker(
'latex-linter-worker',
'../../modules/source-editor/frontend/js/languages/latex/linter/latex-linter.worker.js'
'../../frontend/js/features/source-editor/languages/latex/linter/latex-linter.worker.js'
)
// add entrypoints under '/' for pdfjs workers
@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'
import { FontFamily } from '../../../../../../modules/source-editor/frontend/js/extensions/theme'
import { FontFamily } from '../../../source-editor/extensions/theme'
import { useProjectSettingsContext } from '../../context/project-settings-context'
import SettingsMenuSelect from './settings-menu-select'
@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'
import type { LineHeight } from '../../../../../../modules/source-editor/frontend/js/extensions/theme'
import type { LineHeight } from '../../../source-editor/extensions/theme'
import { useProjectSettingsContext } from '../../context/project-settings-context'
import SettingsMenuSelect from './settings-menu-select'
@@ -5,7 +5,7 @@ import getMeta from '../../../../utils/meta'
import SettingsMenuSelect, { Option } from './settings-menu-select'
import { useProjectSettingsContext } from '../../context/project-settings-context'
import type { OverallThemeMeta } from '../../../../../../types/project-settings'
import type { OverallTheme } from '../../../../../../modules/source-editor/frontend/js/extensions/theme'
import type { OverallTheme } from '../../../source-editor/extensions/theme'
export default function SettingsOverallTheme() {
const { t } = useTranslation()
@@ -2,7 +2,7 @@ import type {
FontFamily,
LineHeight,
OverallTheme,
} from '../../../../../modules/source-editor/frontend/js/extensions/theme'
} from '../../source-editor/extensions/theme'
import type {
Keybindings,
PdfViewer,
@@ -0,0 +1,27 @@
import { EditorView } from '@codemirror/view'
import { EditorSelection } from '@codemirror/state'
export const cloneSelectionVertically =
(forward: boolean, cumulative: boolean) => (view: EditorView) => {
const { main, ranges, mainIndex } = view.state.selection
const { anchor, head, goalColumn } = main
const start = EditorSelection.range(anchor, head, goalColumn)
const nextRange = view.moveVertically(start, forward)
let filteredRanges = [...ranges]
if (!cumulative && filteredRanges.length > 1) {
// remove the current main range
filteredRanges.splice(mainIndex, 1)
}
// prevent duplication when going in the opposite direction
filteredRanges = filteredRanges.filter(
item => item.from !== nextRange.from && item.to !== nextRange.to
)
const selection = EditorSelection.create(
filteredRanges.concat(nextRange),
filteredRanges.length
)
view.dispatch({ selection })
return true
}
@@ -0,0 +1,47 @@
import { EditorSelection } from '@codemirror/state'
import { getIndentUnit, indentString, indentUnit } from '@codemirror/language'
import { EditorView } from '@codemirror/view'
export const indentMore = (view: EditorView) => {
view.dispatch(
view.state.changeByRange(range => {
const doc = view.state.doc
const changes = []
if (range.empty) {
// insert space(s) at the cursor
const line = doc.lineAt(range.from)
const unit = getIndentUnit(view.state)
const offset = range.from - line.from
const cols = unit - (offset % unit)
const insert = indentString(view.state, cols)
changes.push({ from: range.from, insert })
} else {
// indent selected lines
const insert = view.state.facet(indentUnit)
let previousLineNumber = -1
for (let pos = range.from; pos <= range.to; pos++) {
const line = doc.lineAt(pos)
if (previousLineNumber === line.number) {
continue
}
changes.push({ from: line.from, insert })
previousLineNumber = line.number
}
}
const changeSet = view.state.changes(changes)
return {
changes: changeSet,
range: EditorSelection.range(
changeSet.mapPos(range.anchor, 1),
changeSet.mapPos(range.head, 1)
),
}
})
)
return true
}
@@ -0,0 +1,744 @@
import { EditorView } from '@codemirror/view'
import { EditorSelection, EditorState, SelectionRange } from '@codemirror/state'
import {
ensureSyntaxTree,
foldedRanges,
foldEffect,
syntaxTree,
} from '@codemirror/language'
import { SyntaxNode } from '@lezer/common'
import {
ancestorOfNodeWithType,
isUnknownCommandWithName,
} from '../utils/tree-query'
export const wrapRanges =
(
prefix: string,
suffix: string,
wrapWholeLine = false,
selection?: (range: SelectionRange) => SelectionRange
) =>
(view: EditorView): boolean => {
if (view.state.readOnly) {
return false
}
view.dispatch(
view.state.changeByRange(range => {
const insert = { prefix, suffix }
if (wrapWholeLine) {
const line = view.state.doc.lineAt(range.anchor)
if (range.empty) {
// expand range to cover the whole line
range = EditorSelection.range(line.from, line.to)
}
// add a newline at the start if needed
if (range.from !== line.from) {
insert.prefix = `\n${prefix}`
}
// add a newline at the end if needed
if (range.to !== line.to) {
insert.suffix = `${suffix}\n`
}
}
const content = view.state.sliceDoc(range.from, range.to)
// map through the prefix only
const changedRange = range.map(
view.state.changes([
{ from: range.from, insert: `${insert.prefix}` },
]),
1
)
return {
range: selection ? selection(changedRange) : changedRange,
// create a single change, including the content
changes: [
{
from: range.from,
to: range.to,
insert: `${insert.prefix}${content}${insert.suffix}`,
},
],
}
})
)
return true
}
export const changeCase =
(upper = true) =>
(view: EditorView) => {
if (view.state.readOnly) {
return false
}
view.dispatch(
view.state.changeByRange(range => {
// ignore empty ranges
if (range.empty) {
return { range }
}
const text = view.state.doc.sliceString(range.from, range.to)
return {
range,
changes: [
{
from: range.from,
to: range.to,
insert: upper ? text.toUpperCase() : text.toLowerCase(),
},
],
}
})
)
return true
}
export const duplicateSelection = (view: EditorView) => {
if (view.state.readOnly) {
return false
}
const foldedRangesInDocument = foldedRanges(view.state)
view.dispatch(
view.state.changeByRange(range => {
const folds: { offset: number; base: number; length: number }[] = []
if (range.empty) {
const line = view.state.doc.lineAt(range.from)
let lineStart = line.from
let lineEnd = line.to
// Calculate line start/end including folded ranges
//
// Note that at each iteration of the while loop, new folded ranges
// can be included. This happens when there are multiple folded ranges
// on a single editor line (but spanning multiple actual lines)
//
// For example, the following document:
// 1: \begin{document}
// 2: test
// 3: \end{document}\begin{document}
// 4: test
// 5: \end{document}
//
// Can be folded to:
// \begin{document}<...>\end{document}\begin{document}<...>\end{document}
//
// In this case, the first iterations of the while loop below will only
// include lines 3-5, since the overlapping folded range for line 5
// is only the fold on lines 3-5. Hence in the while loop, we expand the
// selection until we include all the ranges.
let changed
do {
changed = false
foldedRangesInDocument.between(lineStart, lineEnd, (from, to) => {
const newLineStart = Math.min(
view.state.doc.lineAt(from).from,
lineStart
)
const newLineEnd = Math.max(view.state.doc.lineAt(to).to, lineEnd)
if (newLineStart !== lineStart || newLineEnd !== lineEnd) {
changed = true
lineStart = newLineStart
lineEnd = newLineEnd
}
})
} while (changed)
// Collect information needed to fold duplicated lines
foldedRangesInDocument.between(lineStart, lineEnd, (from, to) => {
folds.push({
offset: from - lineStart,
base: lineEnd + view.state.lineBreak.length,
length: to - from,
})
})
// Duplicate the selected lines downwards
return {
range,
changes: [
{
from: lineEnd,
insert:
view.state.lineBreak + view.state.doc.slice(lineStart, lineEnd),
},
],
// Add a fold effect for each fold in the original line
effects: folds.map(fold =>
foldEffect.of({
from: fold.base + fold.offset,
to: fold.base + fold.offset + fold.length,
})
),
}
} else {
// Duplicate selected text at head of selection
let newSelectionRange = range
if (range.head > range.anchor) {
// Duplicating to the right, so we need to update the selected range
newSelectionRange = EditorSelection.range(
range.head,
range.head + (range.to - range.from)
)
}
return {
// The new range is the duplicated section, placed at the head of the
// original selection
range: newSelectionRange,
changes: [
{
from: range.head,
insert: view.state.doc.slice(range.from, range.to),
},
],
}
}
})
)
return true
}
function getParentNode(
position: number | SyntaxNode,
state: EditorState,
assoc: 0 | 1 | -1 = 1
): SyntaxNode | undefined {
const tree = ensureSyntaxTree(state, 1000)
let node: SyntaxNode | undefined | null =
typeof position === 'number'
? tree?.resolveInner(position, assoc)
: position
node = node?.parent
while (
['LongArg', 'TextArgument', 'OpenBrace', 'CloseBrace'].includes(
node?.type.name || ''
)
) {
node = node!.parent
}
return node || undefined
}
function wrapRangeInCommand(
state: EditorState,
range: SelectionRange,
command: string
) {
const content = state.sliceDoc(range.from, range.to)
const changes = state.changes([
{
from: range.from,
to: range.to,
insert: `${command}{${content}}`,
},
])
return {
changes,
range: moveRange(
range,
range.from + command.length + 1,
range.from + command.length + content.length + 1
),
}
}
function moveRange(range: SelectionRange, newFrom: number, newTo: number) {
const forwards = range.from === range.anchor
return forwards
? EditorSelection.range(newFrom, newTo)
: EditorSelection.range(newTo, newFrom)
}
function validateReplacement(expected: string, actual: string) {
if (expected !== actual) {
throw new Error(
`Replacement in toggleRange failed validation. Expected ${expected} got ${actual}`
)
}
}
function getWrappingAncestor(
node: SyntaxNode,
command: string,
state: EditorState
): SyntaxNode | null {
for (
let ancestor: SyntaxNode | null = node;
ancestor;
ancestor = ancestor.parent
) {
if (isUnknownCommandWithName(ancestor, command, state)) {
return ancestor
}
if (ancestor.type.is('UnknownCommand')) {
// We could multiple levels deep in bold/non-bold. So bail out in this case
return null
}
}
return null
}
function adjustRangeIfNeeded(
command: string,
range: SelectionRange,
state: EditorState
) {
// Try to adjust the selection, if it is either
// 1. \textbf<{test>}
// 2. \textbf{<test}>
// 3. \textbf<{test}>
// 4. <\textbf{test}>
// 4. \textbf<>{test}
const tree = syntaxTree(state)
if (tree.length < range.to) {
return range
}
const nodeLeft = tree.resolveInner(range.from, 1)
const nodeRight = tree.resolveInner(range.to, -1)
const parentLeft = getWrappingAncestor(nodeLeft, command, state)
const parentRight = getWrappingAncestor(nodeRight, command, state)
const parent = getParentNode(nodeLeft, state)
if (parent?.type.is('UnknownCommand') && spansWholeArgument(parent, range)) {
return bubbleUpRange(
command,
ancestorOfNodeWithType(nodeLeft, 'UnknownCommand'),
range,
state
)
}
if (!parentLeft) {
// We're not trying to unbold, so don't bother adjusting range
return bubbleUpRange(
command,
ancestorOfNodeWithType(nodeLeft, 'UnknownCommand'),
range,
state
)
}
if (nodeLeft.type.is('CtrlSeq') && range.from === range.to) {
const command = nodeLeft.parent?.parent
if (!command) {
return range
}
return EditorSelection.cursor(command.from)
}
let { from, to } = range
if (nodeLeft.type.is('CtrlSeq')) {
from = nodeLeft.to + 1
}
if (nodeLeft.type.is('OpenBrace')) {
from = nodeLeft.to
}
// We know that parentLeft is the UnknownCommand, so now we check if we're
// to the right of the closing brace. (parent is TextArgument, grandparent is
// UnknownCommand)
if (parentLeft === parentRight && nodeRight.type.is('CloseBrace')) {
to = nodeRight.from
}
return bubbleUpRange(command, parentLeft, moveRange(range, from, to), state)
}
function spansWholeArgument(
commandNode: SyntaxNode | null,
range: SelectionRange
): boolean {
const argument = commandNode?.getChild('TextArgument')?.getChild('LongArg')
const res = Boolean(
argument && argument.from === range.from && argument.to === range.to
)
return res
}
function bubbleUpRange(
command: string,
node: SyntaxNode | null,
range: SelectionRange,
state: EditorState
) {
let currentRange = range
for (
let ancestorCommand: SyntaxNode | null = ancestorOfNodeWithType(
node,
'UnknownCommand'
);
spansWholeArgument(ancestorCommand, currentRange);
ancestorCommand = ancestorOfNodeWithType(
ancestorCommand.parent,
'UnknownCommand'
)
) {
if (!ancestorCommand) {
break
}
currentRange = moveRange(
currentRange,
ancestorCommand.from,
ancestorCommand.to
)
if (isUnknownCommandWithName(ancestorCommand, command, state)) {
const argumentNode = ancestorCommand
.getChild('TextArgument')
?.getChild('LongArg')
if (!argumentNode) {
return range
}
return moveRange(range, argumentNode.from, argumentNode.to)
}
}
return range
}
export function toggleRanges(command: string) {
/* There are a number of situations we need to handle in this function.
* In the following examples, the selection range is marked within <>
* 1. If the parent node at start and end of selection is different -> do
* nothing & show error. Case 8 is an exception to this.
* -> For good and bad, this disallows \textbf{this <is} weird
* \textit{to> do}
* 2. If selection contains a BlankLine (i.e. two newlines in a row) -> do
* nothing & show error
* -> \textbf doesn't allow paragraph breaks).
* 3. If the selection is not within a \textbf -> wrap it in a \textbf
* 4. If selection is at the beginning of a \textbf -> shrink the \textbf
* command
* -> e.g. \textbf{<this is> a test} → <this is>\textbf{ a test}
* 5. Similarly for selection at end of \textbf command
* 6. If selection is in the middle of a \textbf command -> split the command
* into two
* -> e.g. \textbf{this <is a> test} → \textbf{this }<is a>\textbf{ test}
* 7. If the selection is a whole \textbf command → remove the wrapping
* command.
* 8. If the selection spans two \textbf's with the same parent then join the
* two
* -> e.g. \textbf{this <is} a \textbf{te>st} → \textbf{this <is a te>st}
* 9. If the selection spans the end of a \textbf, into the parent of the
* command, then extend the \textbf.
* -> e.g. \textbf{this <is} a test> → \textbf{this <is a test>}
* 10. Similarly for selections spanning the beginning of the selection
*/
return (view: EditorView): boolean => {
if (view.state.readOnly) {
return false
}
view.dispatch(
view.state.changeByRange(initialRange => {
const range = adjustRangeIfNeeded(command, initialRange, view.state)
const content = view.state.sliceDoc(range.from, range.to)
const ancestorAtStartOfRange = getParentNode(
range.from,
view.state,
range.from === 0 ? 1 : -1
)
const ancestorAtEndOfRange = range.empty
? ancestorAtStartOfRange
: getParentNode(
range.to,
view.state,
range.to === view.state.doc.length ? -1 : 1
)
if (ancestorAtStartOfRange !== ancestorAtEndOfRange) {
// But handle the exception of case 8
const ancestorAtStartIsWrappingCommand =
ancestorAtStartOfRange &&
isUnknownCommandWithName(
ancestorAtStartOfRange,
command,
view.state
)
const ancestorAtEndIsWrappingCommand =
ancestorAtEndOfRange &&
isUnknownCommandWithName(ancestorAtEndOfRange, command, view.state)
if (
ancestorAtStartIsWrappingCommand &&
ancestorAtEndIsWrappingCommand &&
ancestorAtStartOfRange?.parent?.parent &&
ancestorAtEndOfRange?.parent?.parent
) {
// Test for case 8
const nextAncestorAtStartOfRange =
ancestorAtStartOfRange.parent.parent
const nextAncestorAtEndOfRange = ancestorAtEndOfRange.parent.parent
if (nextAncestorAtStartOfRange === nextAncestorAtEndOfRange) {
// Join the two ranges
const textBetweenRanges = view.state.sliceDoc(
ancestorAtStartOfRange.to,
ancestorAtEndOfRange.from
)
const ancestorStartArgumentNode =
ancestorAtStartOfRange.lastChild?.getChild('LongArg')
const ancestorEndArgumentNode =
ancestorAtEndOfRange.lastChild?.getChild('LongArg')
if (!ancestorStartArgumentNode || !ancestorEndArgumentNode) {
throw new Error("Can't find argument node")
}
const actualContent = view.state.sliceDoc(
ancestorAtStartOfRange.from,
ancestorAtEndOfRange.to
)
const firstCommandArgument = view.state.sliceDoc(
ancestorStartArgumentNode.from,
ancestorStartArgumentNode.to
)
const secondCommandArgument = view.state.sliceDoc(
ancestorEndArgumentNode.from,
ancestorEndArgumentNode.to
)
validateReplacement(
`${command}{${firstCommandArgument}}${textBetweenRanges}${command}{${secondCommandArgument}}`,
actualContent
)
const changes = view.state.changes([
{
from: ancestorAtStartOfRange.from,
to: ancestorAtEndOfRange.to,
insert: `${command}{${firstCommandArgument}${textBetweenRanges}${secondCommandArgument}}`,
},
])
return {
changes,
range: moveRange(
range,
range.from,
range.to - command.length - 1 - 1
),
}
}
}
if (
ancestorAtEndIsWrappingCommand &&
ancestorAtEndOfRange.parent?.parent === ancestorAtStartOfRange
) {
// Extend to the left. Case 10
const contentUpToCommand = view.state.sliceDoc(
range.from,
ancestorAtEndOfRange.from
)
const ancestorEndArgumentNode =
ancestorAtEndOfRange.lastChild?.getChild('LongArg')
if (!ancestorEndArgumentNode) {
throw new Error("Can't find argument node")
}
const commandContent = view.state.sliceDoc(
ancestorEndArgumentNode.from,
ancestorEndArgumentNode.to
)
const actualContent = view.state.sliceDoc(
range.from,
ancestorAtEndOfRange.to
)
validateReplacement(
`${contentUpToCommand}${command}{${commandContent}}`,
actualContent
)
const changes = view.state.changes([
{
from: range.from,
to: ancestorAtEndOfRange.to,
insert: `${command}{${contentUpToCommand}${commandContent}}`,
},
])
return {
changes,
range: moveRange(
range,
range.from + command.length + 1,
range.to
),
}
}
if (
ancestorAtStartIsWrappingCommand &&
ancestorAtStartOfRange.parent?.parent === ancestorAtEndOfRange
) {
// Extend to the right. Case 9
const contentAfterCommand = view.state.sliceDoc(
ancestorAtStartOfRange.to,
range.to
)
const ancestorStartArgumentNode =
ancestorAtStartOfRange.lastChild?.getChild('LongArg')
if (!ancestorStartArgumentNode) {
throw new Error("Can't find argument node")
}
const commandContent = view.state.sliceDoc(
ancestorStartArgumentNode.from,
ancestorStartArgumentNode.to
)
const actualContent = view.state.sliceDoc(
ancestorAtStartOfRange.from,
range.to
)
validateReplacement(
`${command}{${commandContent}}${contentAfterCommand}`,
actualContent
)
const changes = view.state.changes([
{
from: ancestorAtStartOfRange.from,
to: range.to,
insert: `${command}{${commandContent}${contentAfterCommand}}`,
},
])
return {
changes,
range: moveRange(range, range.from, range.to - 1),
}
}
// Bail out in case 1
// TODO: signal error to the user
return { range }
}
const ancestor = ancestorAtStartOfRange
// Bail out in case 2
if (content.includes('\n\n')) {
// TODO: signal error to the user
return { range }
}
const isCursorBeforeAncestor =
range.empty &&
ancestor &&
range.from === ancestor.from &&
isUnknownCommandWithName(ancestor, command, view.state)
// If we can't find an ancestor node, or if the parent is not an exsting
// \textbf, then we just wrap it in a range. Case 3.
if (
isCursorBeforeAncestor ||
!ancestor ||
!isUnknownCommandWithName(ancestor, command, view.state)
) {
return wrapRangeInCommand(view.state, range, command)
}
const argumentNode = ancestor.lastChild?.getChild('LongArg')
if (!argumentNode) {
throw new Error("Can't find argument node")
}
// We should trim from the beginning. Case 4
if (range.from === argumentNode.from && range.to !== argumentNode.to) {
const textAfterSelection = view.state.sliceDoc(
range.to,
argumentNode.to
)
const wholeCommand = view.state.sliceDoc(ancestor.from, ancestor.to)
validateReplacement(
`${command}{${content}${textAfterSelection}}`,
wholeCommand
)
const changes = view.state.changes([
{
from: ancestor.from,
to: ancestor.to,
insert: `${content}${command}{${textAfterSelection}}`,
},
])
return {
range: moveRange(
range,
range.from - command.length - 1,
range.to - command.length - 1
),
changes,
}
}
// We should trim from the end. Case 5
if (range.to === argumentNode.to && range.from !== argumentNode.from) {
const textBeforeSelection = view.state.sliceDoc(
ancestor.from,
range.from
)
const wholeCommand = view.state.sliceDoc(ancestor.from, ancestor.to)
validateReplacement(`${textBeforeSelection}${content}}`, wholeCommand)
const changes = view.state.changes([
{
from: ancestor.from,
to: ancestor.to,
insert: `${textBeforeSelection}}${content}`,
},
])
// We should shift selection forward by the } we insert
return {
range: moveRange(range, range.from + 1, range.to + 1),
changes,
}
}
// We should split the command in two. Case 6
if (range.from !== argumentNode.from && range.to !== argumentNode.to) {
const textBeforeSelection = view.state.sliceDoc(
ancestor.from,
range.from
)
const textAfterSelection = view.state.sliceDoc(range.to, ancestor.to)
const wholeCommand = view.state.sliceDoc(ancestor.from, ancestor.to)
validateReplacement(
`${textBeforeSelection}${content}${textAfterSelection}`,
wholeCommand
)
const changes = view.state.changes([
{
from: ancestor.from,
to: ancestor.to,
insert: `${textBeforeSelection}}${content}${command}{${textAfterSelection}`,
},
])
return {
range: moveRange(range, range.from + 1, range.to + 1),
changes,
}
}
// Remove the wrapping command. Case 7
if (spansWholeArgument(ancestor, range)) {
const argumentContent = view.state.sliceDoc(
argumentNode.from,
argumentNode.to
)
const wholeCommand = view.state.sliceDoc(ancestor.from, ancestor.to)
validateReplacement(`${command}{${content}}`, wholeCommand)
const changes = view.state.changes([
{ from: ancestor.from, to: ancestor.to, insert: argumentContent },
])
return {
range: moveRange(
range,
range.from - command.length - 1,
range.to - command.length - 1
),
changes,
}
}
// Shouldn't happen, but default to just wrapping the content
return wrapRangeInCommand(view.state, range, command)
})
)
return true
}
}
@@ -0,0 +1,120 @@
import { EditorView } from '@codemirror/view'
import { EditorSelection, Text } from '@codemirror/state'
import { selectNextOccurrence, SearchCursor } from '@codemirror/search'
type Spec = {
caseSensitive?: boolean
unquoted: string
}
const stringCursor = (spec: Spec, doc: Text, from: number, to: number) => {
return new SearchCursor(
doc,
spec.unquoted,
from,
to,
spec.caseSensitive ? undefined : x => x.toLowerCase()
)
}
class QueryType {
protected spec
constructor(spec: Spec) {
this.spec = spec
}
}
class StringQuery extends QueryType {
// Searching in reverse is, rather than implementing inverted search
// cursor, done by scanning chunk after chunk forward.
prevMatchInRange(doc: Text, from: number, to: number) {
for (let pos = to; ; ) {
const start = Math.max(
from,
pos - 10000 /* ChunkSize */ - this.spec.unquoted.length
)
const cursor = stringCursor(this.spec, doc, start, pos)
let range = null
while (!cursor.nextOverlapping().done) {
range = cursor.value
}
if (range) {
return range
}
if (start === from) {
return null
}
pos -= 10000 /* ChunkSize */
}
}
prevMatch(doc: Text, curFrom: number, curTo: number) {
return (
this.prevMatchInRange(doc, 0, curFrom) ||
this.prevMatchInRange(doc, curTo, doc.length)
)
}
}
const selectWord = (view: EditorView) => {
const { selection } = view.state
const newSelection = EditorSelection.create(
selection.ranges.map(
range =>
view.state.wordAt(range.head) || EditorSelection.cursor(range.head)
),
selection.mainIndex
)
if (newSelection.eq(selection)) {
return false
}
view.dispatch(view.state.update({ selection: newSelection }))
return true
}
const selectPrevOccurrence = (view: EditorView) => {
const { state } = view
const { ranges } = state.selection
if (ranges.some(range => range.from === range.to)) {
return selectWord(view)
}
const searchedText = state.sliceDoc(ranges[0].from, ranges[0].to)
if (
state.selection.ranges.some(
range => state.sliceDoc(range.from, range.to) !== searchedText
)
) {
return false
}
const query = new StringQuery({ unquoted: searchedText })
const { main } = state.selection
const range = query.prevMatch(state.doc, main.from, main.to)
if (!range) {
return false
}
view.dispatch({
selection: state.selection.addRange(
EditorSelection.range(range.from, range.to)
),
effects: EditorView.scrollIntoView(range.to),
})
return true
}
export const selectOccurrence = (forward: boolean) => (view: EditorView) =>
forward ? selectNextOccurrence(view) : selectPrevOccurrence(view)
@@ -0,0 +1,96 @@
import {
createContext,
ElementType,
memo,
useContext,
useRef,
useState,
} from 'react'
import useIsMounted from '../../../shared/hooks/use-is-mounted'
import { EditorView } from '@codemirror/view'
import { EditorState } from '@codemirror/state'
import CodeMirrorView from './codemirror-view'
import CodeMirrorSearch from './codemirror-search'
import { CodeMirrorToolbar } from './codemirror-toolbar'
import { CodemirrorOutline } from './codemirror-outline'
import { dispatchTimer } from '../../../infrastructure/cm6-performance'
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
const sourceEditorComponents = importOverleafModules(
'sourceEditorComponents'
) as { import: { default: ElementType }; path: string }[]
function CodeMirrorEditor() {
// create the initial state
const [state, setState] = useState(() => {
return EditorState.create()
})
const isMounted = useIsMounted()
// create the view using the initial state and intercept transactions
const viewRef = useRef<EditorView | null>(null)
if (viewRef.current === null) {
const timer = dispatchTimer()
const view = new EditorView({
state,
dispatch: tr => {
timer.start(tr)
view.update([tr])
if (isMounted.current) {
setState(view.state)
}
timer.end(tr, view)
},
})
viewRef.current = view
}
return (
<CodeMirrorStateContext.Provider value={state}>
<CodeMirrorViewContext.Provider value={viewRef.current}>
<CodemirrorOutline />
<CodeMirrorView />
<CodeMirrorSearch />
<CodeMirrorToolbar />
{sourceEditorComponents.map(
({ import: { default: Component }, path }) => (
<Component key={path} />
)
)}
</CodeMirrorViewContext.Provider>
</CodeMirrorStateContext.Provider>
)
}
export default memo(CodeMirrorEditor)
const CodeMirrorStateContext = createContext<EditorState | undefined>(undefined)
export const useCodeMirrorStateContext = (): EditorState => {
const context = useContext(CodeMirrorStateContext)
if (!context) {
throw new Error(
'useCodeMirrorStateContext is only available inside CodeMirrorEditor'
)
}
return context
}
const CodeMirrorViewContext = createContext<EditorView | undefined>(undefined)
export const useCodeMirrorViewContext = (): EditorView => {
const context = useContext(CodeMirrorViewContext)
if (!context) {
throw new Error(
'useCodeMirrorViewContext is only available inside CodeMirrorEditor'
)
}
return context
}
@@ -0,0 +1,156 @@
import { createPortal } from 'react-dom'
import { useCodeMirrorStateContext } from './codemirror-editor'
import React, { useCallback, useMemo, useState } from 'react'
import OutlinePane from '../../outline/components/outline-pane'
import { documentOutline } from '../languages/latex/document-outline'
import isValidTeXFile from '../../../main/is-valid-tex-file'
import useScopeValue from '../../../shared/hooks/use-scope-value'
import useScopeEventEmitter from '../../../shared/hooks/use-scope-event-emitter'
import * as eventTracking from '../../../infrastructure/event-tracking'
import { nestOutline } from '../utils/tree-query'
import { ProjectionStatus } from '../utils/tree-operations/projection'
import useEventListener from '../../../shared/hooks/use-event-listener'
import useDeepCompareMemo from '../../../shared/hooks/use-deep-compare-memo'
import useDebounce from '../../../shared/hooks/use-debounce'
const closestSectionLineNumber = (
outline: { line: number }[] | undefined,
lineNumber: number
): number => {
if (!outline) {
return -1
}
let highestLine = -1
for (const section of outline) {
if (section.line > lineNumber) {
return highestLine
}
highestLine = section.line
}
return highestLine
}
export const CodemirrorOutline = React.memo(function CodemirrorOutline() {
const state = useCodeMirrorStateContext()
const debouncedState = useDebounce(state, 100)
const [docName] = useScopeValue<string>('editor.open_doc_name')
const goToLineEmitter = useScopeEventEmitter('editor:gotoLine', true)
const outlineToggledEmitter = useScopeEventEmitter('outline-toggled')
const [currentlyHighlightedLine, setCurrentlyHighlightedLine] =
useState<number>(-1)
const isTexFile = useMemo(() => isValidTeXFile(docName), [docName])
const [ignoreNextCursorUpdate, setIgnoreNextCursorUpdate] =
useState<boolean>(false)
const [ignoreNextScroll, setIgnoreNextScroll] = useState<boolean>(false)
const [binaryFileOpened, setBinaryFileOpened] = useState<boolean>(false)
useEventListener(
'file-view:file-opened',
useCallback(_ => {
setBinaryFileOpened(true)
}, [])
)
useEventListener(
'scroll:editor:update',
useCallback(
evt => {
if (ignoreNextScroll) {
setIgnoreNextScroll(false)
return
}
setCurrentlyHighlightedLine(evt.detail + 1)
},
[ignoreNextScroll]
)
)
useEventListener(
'cursor:editor:update',
useCallback(
evt => {
if (ignoreNextCursorUpdate) {
setIgnoreNextCursorUpdate(false)
return
}
setCurrentlyHighlightedLine(evt.detail.row + 1)
},
[ignoreNextCursorUpdate]
)
)
useEventListener(
'doc:after-opened',
useCallback(evt => {
if (evt.detail) {
setIgnoreNextCursorUpdate(true)
}
setBinaryFileOpened(false)
setIgnoreNextScroll(true)
}, [])
)
const outlineStatus = useMemo(
() =>
debouncedState.field(documentOutline, false)?.status ||
ProjectionStatus.Pending,
[debouncedState]
)
const flatOutline = useMemo(() => {
const outlineResult = debouncedState.field(documentOutline, false)
if (outlineResult?.status !== ProjectionStatus.Pending) {
// We have a (potentially partial) outline.
return outlineResult?.items!.map(element => {
// Remove {from, to} to not trip up deep comparison
const { level, title, line } = element
return { level, title, line }
})
}
return undefined
}, [debouncedState])
const outline = useDeepCompareMemo(() => {
return flatOutline ? nestOutline(flatOutline) : []
}, [flatOutline])
const jumpToLine = useCallback(
(lineNumber, syncToPdf) => {
setIgnoreNextScroll(true)
goToLineEmitter(lineNumber, 0, syncToPdf)
eventTracking.sendMB('outline-jump-to-line')
},
[goToLineEmitter]
)
const onToggle = useCallback(
isOpen => {
outlineToggledEmitter(isOpen)
},
[outlineToggledEmitter]
)
const highlightedLine = useMemo(
() => closestSectionLineNumber(flatOutline, currentlyHighlightedLine),
[flatOutline, currentlyHighlightedLine]
)
const outlineDomElement = document.querySelector('.outline-container')
if (!outlineDomElement) {
return null
}
return createPortal(
<OutlinePane
outline={outline}
onToggle={onToggle}
eventTracking={eventTracking}
isTexFile={isTexFile && !binaryFileOpened}
jumpToLine={jumpToLine}
highlightedLine={highlightedLine}
show
isPartial={outlineStatus === ProjectionStatus.Partial}
/>,
outlineDomElement
)
})
@@ -0,0 +1,422 @@
import {
useCodeMirrorStateContext,
useCodeMirrorViewContext,
} from './codemirror-editor'
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { runScopeHandlers } from '@codemirror/view'
import {
closeSearchPanel,
setSearchQuery,
SearchQuery,
findPrevious,
findNext,
replaceNext,
replaceAll,
getSearchQuery,
SearchCursor,
} from '@codemirror/search'
import { Button, ButtonGroup, FormControl, InputGroup } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import Tooltip from '../../../shared/components/tooltip'
import Icon from '../../../shared/components/icon'
import classnames from 'classnames'
import useScopeValue from '../../../shared/hooks/use-scope-value'
const MAX_MATCH_COUNT = 1000
type ActiveSearchOption = 'caseSensitive' | 'regexp' | 'wholeWord' | null
const CodeMirrorSearchForm: FC = () => {
const view = useCodeMirrorViewContext()
const state = useCodeMirrorStateContext()
const [keybindings] = useScopeValue<string>('settings.mode')
const emacsKeybindingsActive = keybindings === 'emacs'
const [activeSearchOption, setActiveSearchOption] =
useState<ActiveSearchOption>(null)
// Generate random ID for option buttons. This is necessary because the label
// for each checkbox is separated from it in the DOM so that the buttons can
// be outside the natural tab order
const idSuffix = useMemo(() => Math.random().toString(16).slice(2), [])
const caseSensitiveId = 'caseSensitive' + idSuffix
const regexpId = 'regexp' + idSuffix
const wholeWordId = 'wholeWord' + idSuffix
const { t } = useTranslation()
const [position, setPosition] = useState<{
current: number
total: number
} | null>(null)
const formRef = useRef<HTMLFormElement | null>(null)
const inputRef = useRef<HTMLInputElement | null>(null)
const replaceRef = useRef<HTMLInputElement | null>(null)
const handleInputRef = useCallback(node => {
inputRef.current = node
// focus the search input when the panel opens
if (node) {
node.select()
node.focus()
}
}, [])
const handleReplaceRef = useCallback(node => {
replaceRef.current = node
}, [])
const handleSubmit = useCallback(event => {
event.preventDefault()
}, [])
useEffect(() => {
const { from, to } = state.selection.main
const query = getSearchQuery(state)
if (query.valid) {
const cursor = query.getCursor(state.doc) as SearchCursor
let total = 0
let current = 0
while (!cursor.next().done) {
total++
if (total >= MAX_MATCH_COUNT) {
break
}
const item = cursor.value
if (current === 0 && item.from === from && item.to === to) {
current = total
}
}
setPosition({ current, total })
} else {
setPosition(null)
}
}, [state])
const handleChange = useCallback(() => {
if (formRef.current) {
const data = Object.fromEntries(new FormData(formRef.current))
const query = new SearchQuery({
search: data.search as string,
replace: data.replace as string,
caseSensitive: data.caseSensitive === 'on',
regexp: data.regexp === 'on',
literal: true,
wholeWord: data.wholeWord === 'on',
})
view.dispatch({ effects: setSearchQuery.of(query) })
}
}, [view])
const handleFormKeyDown = useCallback(
event => {
if (runScopeHandlers(view, event, 'search-panel')) {
event.preventDefault()
}
},
[view]
)
// Returns true if the event was handled, false otherwise
const handleEmacsNavigation = useCallback(
event => {
const emacsCtrlSeq =
emacsKeybindingsActive &&
event.ctrlKey &&
!event.altKey &&
!event.shiftKey
if (!emacsCtrlSeq) {
return false
}
switch (event.key) {
case 's': {
event.stopPropagation()
event.preventDefault()
findNext(view)
return true
}
case 'r': {
event.stopPropagation()
event.preventDefault()
findPrevious(view)
return true
}
case 'g': {
event.stopPropagation()
event.preventDefault()
closeSearchPanel(view)
document.dispatchEvent(new CustomEvent('cm:emacs-close-search-panel'))
return true
}
default: {
return false
}
}
},
[view, emacsKeybindingsActive]
)
const handleSearchKeyDown = useCallback(
event => {
switch (event.key) {
case 'Enter':
event.preventDefault()
if (event.shiftKey) {
findPrevious(view)
} else {
findNext(view)
}
break
}
handleEmacsNavigation(event)
},
[view, handleEmacsNavigation]
)
const handleReplaceKeyDown = useCallback(
event => {
switch (event.key) {
case 'Enter':
event.preventDefault()
replaceNext(view)
break
case 'Tab': {
if (event.shiftKey) {
event.preventDefault()
inputRef.current?.focus()
}
}
}
handleEmacsNavigation(event)
},
[view, handleEmacsNavigation]
)
const focusSearchBox = useCallback(() => {
inputRef.current?.focus()
}, [])
const query = useMemo(() => {
return getSearchQuery(state)
}, [state])
return (
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
<form
ref={formRef}
onSubmit={handleSubmit}
onKeyDown={handleFormKeyDown}
className="ol-cm-search-form"
role="search"
>
<div className="ol-cm-search-controls">
<InputGroup bsSize="small" className="ol-cm-search-input-group">
<FormControl
type="text"
name="search"
// IMPORTANT: CodeMirror uses this attribute to focus the input
// when the panel opens and when the panel is refocused
main-field="true"
placeholder={t('search_search_for')}
autoComplete="off"
value={query.search || ''}
onChange={handleChange}
onKeyDown={handleSearchKeyDown}
className="ol-cm-search-form-input"
bsSize="small"
inputRef={handleInputRef}
aria-label={t('search_command_find')}
/>
<InputGroup.Button>
<Tooltip
id="search-match-case"
description={t('search_match_case')}
>
<label
className={classnames(
'btn btn-sm btn-default ol-cm-search-input-button',
{
checked: query.caseSensitive,
focused: activeSearchOption === 'caseSensitive',
}
)}
htmlFor={caseSensitiveId}
aria-label={t('search_match_case')}
>
Aa
</label>
</Tooltip>
</InputGroup.Button>
<InputGroup.Button>
<Tooltip id="search-regexp" description={t('search_regexp')}>
<label
className={classnames(
'btn btn-sm btn-default ol-cm-search-input-button',
{
checked: query.regexp,
focused: activeSearchOption === 'regexp',
}
)}
htmlFor={regexpId}
aria-label={t('search_regexp')}
>
[.*]
</label>
</Tooltip>
</InputGroup.Button>
<InputGroup.Button>
<Tooltip
id="search-whole-word"
description={t('search_whole_word')}
>
<label
className={classnames(
'btn btn-sm btn-default ol-cm-search-input-button',
{
checked: query.wholeWord,
focused: activeSearchOption === 'wholeWord',
}
)}
htmlFor={wholeWordId}
aria-label={t('search_whole_word')}
>
W
</label>
</Tooltip>
</InputGroup.Button>
</InputGroup>
<InputGroup
bsSize="small"
className="ol-cm-search-input-group ol-cm-search-replace-input"
>
<FormControl
type="text"
name="replace"
placeholder={t('search_replace_with')}
autoComplete="off"
value={query.replace || ''}
onChange={handleChange}
onKeyDown={handleReplaceKeyDown}
className="ol-cm-search-form-input"
bsSize="small"
inputRef={handleReplaceRef}
aria-label={t('search_command_replace')}
/>
</InputGroup>
<div className="ol-cm-search-hidden-inputs">
<input
id={caseSensitiveId}
name="caseSensitive"
type="checkbox"
checked={query.caseSensitive}
onChange={handleChange}
onClick={focusSearchBox}
onFocus={() => setActiveSearchOption('caseSensitive')}
onBlur={() => setActiveSearchOption(null)}
/>
<input
id={regexpId}
name="regexp"
type="checkbox"
checked={query.regexp}
onChange={handleChange}
onClick={focusSearchBox}
onFocus={() => setActiveSearchOption('regexp')}
onBlur={() => setActiveSearchOption(null)}
/>
<input
id={wholeWordId}
name="wholeWord"
type="checkbox"
checked={query.wholeWord}
onChange={handleChange}
onClick={focusSearchBox}
onFocus={() => setActiveSearchOption('wholeWord')}
onBlur={() => setActiveSearchOption(null)}
/>
</div>
<div className="ol-cm-search-form-group ol-cm-search-next-previous">
<ButtonGroup className="ol-cm-search-form-button-group">
<Button type="button" bsSize="small" onClick={() => findNext(view)}>
<Icon
type="chevron-down"
fw
accessibilityLabel={t('search_next')}
/>
</Button>
<Button
type="button"
bsSize="small"
onClick={() => findPrevious(view)}
>
<Icon
type="chevron-up"
fw
accessibilityLabel={t('search_previous')}
/>
</Button>
</ButtonGroup>
{position !== null && (
<div className="ol-cm-search-form-position">
{position.total === MAX_MATCH_COUNT
? `${position.current} ${t('of')} ${MAX_MATCH_COUNT}+`
: `${position.current} ${t('of')} ${position.total}`}
</div>
)}
</div>
<div className="ol-cm-search-form-group ol-cm-search-replace-buttons">
<Button
type="button"
bsSize="small"
onClick={() => replaceNext(view)}
>
{t('search_replace')}
</Button>
<Button type="button" bsSize="small" onClick={() => replaceAll(view)}>
{t('search_replace_all')}
</Button>
</div>
</div>
<div className="ol-cm-search-form-close">
<button
className="close"
onClick={() => closeSearchPanel(view)}
type="button"
aria-label={t('close')}
>
<span aria-hidden="true">&times;</span>
</button>
</div>
</form>
)
}
export default CodeMirrorSearchForm
@@ -0,0 +1,17 @@
import { createPortal } from 'react-dom'
import CodeMirrorSearchForm from './codemirror-search-form'
import { useCodeMirrorViewContext } from './codemirror-editor'
function CodeMirrorSearch() {
const view = useCodeMirrorViewContext()
const dom = view.dom.querySelector('.ol-cm-search')
if (!dom) {
return null
}
return createPortal(<CodeMirrorSearchForm />, dom)
}
export default CodeMirrorSearch
@@ -0,0 +1,121 @@
import { memo, useCallback, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import {
useCodeMirrorStateContext,
useCodeMirrorViewContext,
} from './codemirror-editor'
import { searchPanelOpen } from '@codemirror/search'
import { useResizeObserver } from '../../../shared/hooks/use-resize-observer'
import { ToolbarButton } from './toolbar/toolbar-button'
import { ToolbarItems } from './toolbar/toolbar-items'
import * as commands from '../extensions/toolbar/commands'
import { ToolbarOverflow } from './toolbar/overflow'
import useDropdown from '../../../shared/hooks/use-dropdown'
import { getPanel } from '@codemirror/view'
import { createToolbarPanel } from '../extensions/toolbar/toolbar-panel'
export const CodeMirrorToolbar = () => {
const view = useCodeMirrorViewContext()
const panel = getPanel(view, createToolbarPanel)
if (!panel) {
return null
}
return createPortal(<Toolbar />, panel.dom)
}
const Toolbar = memo(function Toolbar() {
const state = useCodeMirrorStateContext()
const [overflowed, setOverflowed] = useState(false)
const [collapsed, setCollapsed] = useState(false)
const overflowBeforeRef = useRef<HTMLDivElement>(null)
const overflowedItemsRef = useRef<Set<string>>(new Set())
const {
open: overflowOpen,
onToggle: setOverflowOpen,
ref: overflowRef,
} = useDropdown()
const buildOverflow = useCallback(
(element: Element) => {
setOverflowOpen(false)
setOverflowed(false)
if (overflowBeforeRef.current) {
overflowedItemsRef.current = new Set()
const buttonGroups = [
...element.querySelectorAll<HTMLDivElement>('[data-overflow]'),
].reverse()
// restore all the overflowed items
for (const buttonGroup of buttonGroups) {
buttonGroup.classList.remove('overflow-hidden')
}
// find all the available items
for (const buttonGroup of buttonGroups) {
if (element.scrollWidth <= element.clientWidth) {
break
}
// add this item to the overflow
overflowedItemsRef.current.add(buttonGroup.dataset.overflow!)
buttonGroup.classList.add('overflow-hidden')
}
setOverflowed(overflowedItemsRef.current.size > 0)
}
},
[setOverflowOpen]
)
// build when the container resizes
const resizeRef = useResizeObserver(buildOverflow)
const toggleToolbar = useCallback(() => {
setCollapsed(value => !value)
}, [])
if (collapsed) {
return null
}
return (
<div className="ol-cm-toolbar" ref={resizeRef}>
<ToolbarItems state={state} />
<div className="ol-cm-toolbar-button-group" ref={overflowBeforeRef}>
<ToolbarOverflow
overflowed={overflowed}
target={overflowBeforeRef.current ?? undefined}
overflowOpen={overflowOpen}
setOverflowOpen={setOverflowOpen}
overflowRef={overflowRef}
>
<ToolbarItems state={state} overflowed={overflowedItemsRef.current} />
</ToolbarOverflow>
</div>
<div className="ol-cm-toolbar-button-group ol-cm-toolbar-end">
<ToolbarButton
id="toolbar-toggle-search"
label="Toggle Search"
command={commands.toggleSearch}
active={searchPanelOpen(state)}
icon="search"
/>
</div>
<div className="ol-cm-toolbar-button-group hidden">
<ToolbarButton
id="toolbar-expand-less"
label="Hide Toolbar"
command={toggleToolbar}
icon="caret-up"
hidden // enable this once there's a way to show the toolbar again
/>
</div>
</div>
)
})
@@ -0,0 +1,30 @@
import { memo, useCallback, useEffect } from 'react'
import { useCodeMirrorViewContext } from './codemirror-editor'
import useCodeMirrorScope from '../hooks/use-codemirror-scope'
function CodeMirrorView() {
const view = useCodeMirrorViewContext()
// append the editor view dom to the container node when mounted
const containerRef = useCallback(
node => {
if (node) {
node.appendChild(view.dom)
}
},
[view]
)
// destroy the editor when unmounted
useEffect(() => {
return () => {
view.destroy()
}
}, [view])
useCodeMirrorScope(view)
return <div ref={containerRef} style={{ height: '100%' }} />
}
export default memo(CodeMirrorView)
@@ -41,7 +41,6 @@ function Badge() {
}
const showLegacySourceEditor: boolean = getMeta('ol-showLegacySourceEditor')
const hasNewSourceEditor: boolean = getMeta('ol-hasNewSourceEditor')
function EditorSwitch() {
const [newSourceEditor, setNewSourceEditor] = useScopeValue(
@@ -97,22 +96,18 @@ function EditorSwitch() {
<fieldset className="toggle-switch">
<legend className="sr-only">Editor mode.</legend>
{hasNewSourceEditor && (
<>
<input
type="radio"
name="editor"
value="cm6"
id="editor-switch-cm6"
className="toggle-switch-input"
checked={!richTextOrVisual && !!newSourceEditor}
onChange={handleChange}
/>
<label htmlFor="editor-switch-cm6" className="toggle-switch-label">
<span>Source</span>
</label>
</>
)}
<input
type="radio"
name="editor"
value="cm6"
id="editor-switch-cm6"
className="toggle-switch-input"
checked={!richTextOrVisual && !!newSourceEditor}
onChange={handleChange}
/>
<label htmlFor="editor-switch-cm6" className="toggle-switch-label">
<span>Source</span>
</label>
{showLegacySourceEditor ? (
<>
@@ -126,7 +121,7 @@ function EditorSwitch() {
onChange={handleChange}
/>
<label htmlFor="editor-switch-ace" className="toggle-switch-label">
<span>{hasNewSourceEditor ? 'Source (legacy)' : 'Source'}</span>
<span>Source (legacy)</span>
</label>
</>
) : null}
@@ -0,0 +1,25 @@
import { lazy, memo, Suspense } from 'react'
import LoadingSpinner from '../../../shared/components/loading-spinner'
import withErrorBoundary from '../../../infrastructure/error-boundary'
import { ErrorBoundaryFallback } from '../../../shared/components/error-boundary-fallback'
const CodeMirrorEditor = lazy(
() =>
import(/* webpackChunkName: "codemirror-editor" */ './codemirror-editor')
)
function SourceEditor() {
return (
<Suspense
fallback={
<div className="pdf-loading-spinner-container">
<LoadingSpinner delay={500} />
</div>
}
>
<CodeMirrorEditor />
</Suspense>
)
}
export default withErrorBoundary(memo(SourceEditor), ErrorBoundaryFallback)
@@ -0,0 +1,62 @@
import { FC, LegacyRef, memo } from 'react'
import { Button, Overlay, Popover } from 'react-bootstrap'
import classnames from 'classnames'
import Icon from '../../../../shared/components/icon'
export const ToolbarOverflow: FC<{
overflowed: boolean
target?: HTMLDivElement
overflowOpen: boolean
setOverflowOpen: (open: boolean) => void
overflowRef?: LegacyRef<Popover>
}> = memo(function ToolbarOverflow({
overflowed,
target,
overflowOpen,
setOverflowOpen,
overflowRef,
children,
}) {
const className = classnames(
'ol-cm-toolbar-button',
'ol-cm-toolbar-overflow-toggle',
{
'ol-cm-toolbar-overflow-toggle-visible': overflowed,
}
)
return (
<>
<Button
type="button"
id="toolbar-more"
className={className}
aria-label="More"
bsStyle={null}
onMouseDown={event => {
event.preventDefault()
event.stopPropagation()
}}
onClick={() => {
setOverflowOpen(!overflowOpen)
}}
>
<Icon type="ellipsis-h" fw />
</Button>
<Overlay
show={overflowOpen}
target={target}
placement="bottom"
container={document.querySelector('.cm-editor')}
containerPadding={0}
animation
onHide={() => setOverflowOpen(false)}
>
<Popover id="popover-toolbar-overflow" ref={overflowRef}>
<div className="ol-cm-toolbar-overflow">{children}</div>
</Popover>
</Overlay>
</>
)
})
@@ -0,0 +1,115 @@
import classnames from 'classnames'
import {
useCodeMirrorStateContext,
useCodeMirrorViewContext,
} from '../codemirror-editor'
import {
findCurrentSectionHeadingLevel,
setSectionHeadingLevel,
} from '../../extensions/toolbar/sections'
import { useCallback, useRef } from 'react'
import { Overlay, Popover } from 'react-bootstrap'
import useEventListener from '../../../../shared/hooks/use-event-listener'
import useDropdown from '../../../../shared/hooks/use-dropdown'
import { emitCommandEvent } from '../../extensions/toolbar/utils/analytics'
import Icon from '../../../../shared/components/icon'
import { useTranslation } from 'react-i18next'
const levels = new Map([
['text', 'Normal text'],
['section', 'Section'],
['subsection', 'Subsection'],
['subsubsection', 'Subsubsection'],
['paragraph', 'Paragraph'],
['subparagraph', 'Subparagraph'],
])
const levelsEntries = [...levels.entries()]
export const SectionHeadingDropdown = () => {
const state = useCodeMirrorStateContext()
const view = useCodeMirrorViewContext()
const { t } = useTranslation()
const { open: overflowOpen, onToggle: setOverflowOpen } = useDropdown()
useEventListener(
'resize',
useCallback(() => {
setOverflowOpen(false)
}, [setOverflowOpen])
)
const toggleButtonRef = useRef<HTMLButtonElement | null>(null)
const currentLevel = findCurrentSectionHeadingLevel(state)
const currentLabel = currentLevel
? levels.get(currentLevel.level) ?? currentLevel.level
: '---'
return (
<>
<button
ref={toggleButtonRef}
type="button"
id="section-heading-menu-button"
aria-haspopup="true"
aria-controls="section-heading-menu"
aria-label={t('toolbar_choose_section_heading_level')}
className="ol-cm-toolbar-menu-toggle"
onMouseDown={event => event.preventDefault()}
onClick={() => setOverflowOpen(!overflowOpen)}
>
<span>{currentLabel}</span>
<Icon type="caret-down" fw />
</button>
<Overlay
show={overflowOpen}
onHide={() => setOverflowOpen(false)}
animation={false}
container={document.querySelector('.cm-editor')}
containerPadding={0}
placement="bottom"
rootClose
target={toggleButtonRef.current ?? undefined}
>
<Popover
id="popover-toolbar-section-heading"
className="ol-cm-toolbar-menu-popover"
>
<div
className="ol-cm-toolbar-menu"
id="section-heading-menu"
role="menu"
aria-labelledby="section-heading-menu-button"
>
{levelsEntries.map(([level, label]) => (
<button
type="button"
role="menuitem"
key={level}
onClick={() => {
emitCommandEvent(view, 'section-level-change')
setSectionHeadingLevel(view, level)
view.focus()
setOverflowOpen(false)
}}
className={classnames(
'ol-cm-toolbar-menu-item',
`section-level-${level}`,
{
'ol-cm-toolbar-menu-item-active':
level === currentLevel?.level,
}
)}
>
{label}
</button>
))}
</div>
</Popover>
</Overlay>
</>
)
}
@@ -0,0 +1,86 @@
import { memo, useCallback } from 'react'
import { EditorView } from '@codemirror/view'
import { useCodeMirrorViewContext } from '../codemirror-editor'
import { Button } from 'react-bootstrap'
import classnames from 'classnames'
import Tooltip from '../../../../shared/components/tooltip'
import { emitCommandEvent } from '../../extensions/toolbar/utils/analytics'
import Icon from '../../../../shared/components/icon'
export const ToolbarButton = memo<{
id: string
className?: string
label: string
command?: (view: EditorView) => void
active?: boolean
disabled?: boolean
icon: string
textIcon?: boolean
hidden?: boolean
shortcut?: string
}>(function ToolbarButton({
id,
className,
label,
command,
active = false,
disabled,
icon,
textIcon = false,
hidden = false,
shortcut,
}) {
const view = useCodeMirrorViewContext()
const handleMouseDown = useCallback(event => {
event.preventDefault()
}, [])
const handleClick = useCallback(
event => {
emitCommandEvent(view, id)
if (command) {
event.preventDefault()
command(view)
view.focus()
}
},
[command, view, id]
)
const button = (
<Button
className={classnames('ol-cm-toolbar-button', className, { hidden })}
aria-label={label}
onMouseDown={handleMouseDown}
onClick={handleClick}
bsStyle={null}
active={active}
disabled={disabled}
type="button"
>
{textIcon ? icon : <Icon type={icon} fw accessibilityLabel={label} />}
</Button>
)
if (!label) {
return button
}
const description = (
<>
<div>{label}</div>
{shortcut && <div>{shortcut}</div>}
</>
)
return (
<Tooltip
id={id}
description={description}
overlayProps={{ placement: 'bottom' }}
>
{button}
</Tooltip>
)
})
@@ -0,0 +1,189 @@
import { FC, memo, useCallback } from 'react'
import { EditorSelection, EditorState } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import { useEditorContext } from '../../../../shared/context/editor-context'
import useScopeEventEmitter from '../../../../shared/hooks/use-scope-event-emitter'
import { useLayoutContext } from '../../../../shared/context/layout-context'
import {
minimumListDepthForSelection,
withinFormattingCommand,
} from '../../utils/tree-operations/ancestors'
import { ToolbarButton } from './toolbar-button'
import { redo, undo } from '@codemirror/commands'
import * as commands from '../../extensions/toolbar/commands'
import { SectionHeadingDropdown } from './section-heading-dropdown'
import { canAddComment } from '../../extensions/toolbar/comments'
import { useTranslation } from 'react-i18next'
const isMac = /Mac/.test(window.navigator?.platform)
export const ToolbarItems: FC<{
state: EditorState
overflowed?: Set<string>
}> = memo(function ToolbarItems({ state, overflowed }) {
const { t } = useTranslation()
const { toggleSymbolPalette, showSymbolPalette } = useEditorContext()
const isActive = withinFormattingCommand(state)
const listDepth = minimumListDepthForSelection(state)
const addCommentEmitter = useScopeEventEmitter('comment:start_adding')
const { setReviewPanelOpen } = useLayoutContext()
const addComment = useCallback(
(view: EditorView) => {
const range = view.state.selection.main
if (range.empty) {
const line = view.state.doc.lineAt(range.head)
view.dispatch({
selection: EditorSelection.range(line.from, line.to),
})
}
setReviewPanelOpen(true)
addCommentEmitter()
},
[addCommentEmitter, setReviewPanelOpen]
)
const showGroup = (group: string) => !overflowed || overflowed.has(group)
return (
<>
{showGroup('group-history') && (
<div className="ol-cm-toolbar-button-group">
<ToolbarButton
id="toolbar-undo"
label={t('toolbar_undo')}
command={undo}
icon="undo"
shortcut={isMac ? '⌘Z' : 'Ctrl+Z'}
/>
<ToolbarButton
id="toolbar-redo"
label={t('toolbar_redo')}
command={redo}
icon="repeat"
shortcut={isMac ? '⇧⌘Z' : 'Ctrl+Y'}
/>
</div>
)}
{showGroup('group-section') && (
<div
className="ol-cm-toolbar-button-group"
data-overflow="group-section"
>
<SectionHeadingDropdown />
</div>
)}
{showGroup('group-format') && (
<div className="ol-cm-toolbar-button-group">
<ToolbarButton
id="toolbar-format-bold"
label={t('toolbar_format_bold')}
command={commands.toggleBold}
active={isActive('\\textbf')}
icon="bold"
shortcut={isMac ? '⌘B' : 'Ctrl+B'}
/>
<ToolbarButton
id="toolbar-format-italic"
label={t('toolbar_format_italic')}
command={commands.toggleItalic}
active={isActive('\\textit')}
icon="italic"
shortcut={isMac ? '⌘I' : 'Ctrl+I'}
/>
</div>
)}
{showGroup('group-math') && (
<div className="ol-cm-toolbar-button-group" data-overflow="group-math">
<ToolbarButton
id="toolbar-inline-math"
label={t('toolbar_insert_inline_math')}
command={commands.wrapInInlineMath}
icon="π"
textIcon
className="ol-cm-toolbar-button-math"
/>
<ToolbarButton
id="toolbar-display-math"
label={t('toolbar_insert_display_math')}
command={commands.wrapInDisplayMath}
icon="Σ"
textIcon
className="ol-cm-toolbar-button-math"
/>
<ToolbarButton
id="toolbar-toggle-symbol-palette"
label={t('toolbar_toggle_symbol_palette')}
active={showSymbolPalette}
command={toggleSymbolPalette}
icon="Ω"
textIcon
className="ol-cm-toolbar-button-math"
/>
</div>
)}
{showGroup('group-misc') && (
<div className="ol-cm-toolbar-button-group" data-overflow="group-misc">
<ToolbarButton
id="toolbar-href"
label={t('toolbar_insert_link')}
command={commands.wrapInHref}
icon="link"
/>
<ToolbarButton
id="toolbar-add-comment"
label={t('toolbar_add_comment')}
command={addComment}
disabled={!canAddComment(state)}
icon="comment"
hidden // enable this if an alternative to the floating "Add Comment" button is needed
/>
<ToolbarButton
id="toolbar-figure"
label={t('toolbar_insert_figure')}
command={commands.insertFigure}
icon="picture-o"
/>
<ToolbarButton
id="toolbar-table"
label={t('toolbar_insert_table')}
command={commands.insertTable}
icon="table"
hidden
/>
</div>
)}
{showGroup('group-list') && (
<div className="ol-cm-toolbar-button-group" data-overflow="group-list">
<ToolbarButton
id="toolbar-bullet-list"
label={t('toolbar_bullet_list')}
command={commands.toggleBulletList}
icon="list-ul"
/>
<ToolbarButton
id="toolbar-numbered-list"
label={t('toolbar_numbered_list')}
command={commands.toggleNumberedList}
icon="list-ol"
/>
<ToolbarButton
id="toolbar-format-indent-decrease"
label={t('toolbar_decrease_indent')}
command={commands.indentDecrease}
icon="outdent"
shortcut={isMac ? '⌘[' : 'Ctrl+['}
disabled={listDepth < 2}
/>
<ToolbarButton
id="toolbar-format-indent-increase"
label={t('toolbar_increase_indent')}
command={commands.indentIncrease}
icon="indent"
shortcut={isMac ? '⌘]' : 'Ctrl+]'}
disabled={listDepth < 1}
/>
</div>
)}
</>
)
})
@@ -0,0 +1,6 @@
import { react2angular } from 'react2angular'
import SourceEditor from '../components/source-editor'
import App from '../../../base'
import { rootContext } from '../../../shared/context/root-context'
App.component('sourceEditor', react2angular(rootContext.use(SourceEditor), []))
@@ -0,0 +1,139 @@
import { EditorView } from '@codemirror/view'
import { Diagnostic, linter, lintGutter } from '@codemirror/lint'
import {
Compartment,
RangeSet,
RangeValue,
StateEffect,
StateField,
Text,
} from '@codemirror/state'
import { Annotation } from '../../../../../types/annotation'
const compileLintSourceConf = new Compartment()
export const annotations = () => [
compileDiagnosticsState,
compileLintSourceConf.of(compileLogLintSource()),
lintGutter({
hoverTime: 0,
}),
// move the lint gutter outside the line numbers
EditorView.baseTheme({
'.cm-gutter-lint': {
order: -1,
},
}),
]
export const lintSourceConfig = {
delay: 100,
// Show highlights only for errors
markerFilter(diagnostics: readonly Diagnostic[]) {
return diagnostics.filter(d => d.severity === 'error')
},
// Do not show any tooltips for highlights within the editor content
tooltipFilter() {
return []
},
}
const compileLogLintSource = () =>
linter(view => {
const items: Diagnostic[] = []
const cursor = view.state.field(compileDiagnosticsState).iter()
while (cursor.value !== null) {
items.push({
...cursor.value.diagnostic,
from: cursor.from,
to: cursor.to,
})
cursor.next()
}
return items
}, lintSourceConfig)
class DiagnosticRangeValue extends RangeValue {
constructor(public diagnostic: Diagnostic) {
super()
}
}
const setCompileDiagnosticsEffect = StateEffect.define<Diagnostic[]>()
export const compileDiagnosticsState = StateField.define<
RangeSet<DiagnosticRangeValue>
>({
create() {
return RangeSet.empty
},
update(value, transaction) {
for (const effect of transaction.effects) {
if (effect.is(setCompileDiagnosticsEffect)) {
return RangeSet.of(
effect.value.map(diagnostic =>
new DiagnosticRangeValue(diagnostic).range(
diagnostic.from,
diagnostic.to
)
),
true
)
}
}
if (transaction.docChanged) {
value = value.map(transaction.changes)
}
return value
},
})
export const setAnnotations = (doc: Text, annotations: Annotation[]) => {
const diagnostics: Diagnostic[] = []
for (const annotation of annotations) {
// ignore "whole document" (row: -1) annotations
if (annotation.row !== -1) {
try {
diagnostics.push(convertAnnotationToDiagnostic(doc, annotation))
} catch (error) {
// ignore invalid annotations
console.debug('invalid annotation position', error)
}
}
}
return {
effects: setCompileDiagnosticsEffect.of(diagnostics),
}
}
export const showCompileLogDiagnostics = (show: boolean) => {
return {
effects: [
// reconfigure the compile log lint source
compileLintSourceConf.reconfigure(show ? compileLogLintSource() : []),
],
}
}
const convertAnnotationToDiagnostic = (
doc: Text,
annotation: Annotation
): Diagnostic => {
if (annotation.row < 0) {
throw new Error(`Invalid annotation row ${annotation.row}`)
}
const line = doc.line(annotation.row + 1)
return {
from: line.from,
to: line.to, // NOTE: highlight whole line as synctex doesn't output column number
severity: annotation.type,
message: annotation.text,
// source: annotation.source, // NOTE: the source is displayed in the tooltip
}
}
@@ -0,0 +1,151 @@
import {
acceptCompletion,
autocompletion,
closeCompletion,
moveCompletionSelection,
startCompletion,
Completion,
} from '@codemirror/autocomplete'
import { EditorView, keymap } from '@codemirror/view'
import { Compartment, Prec, TransactionSpec } from '@codemirror/state'
const autoCompleteConf = new Compartment()
export const autoComplete = ({ autoComplete }: { autoComplete: boolean }) =>
autoCompleteConf.of(createAutoComplete(autoComplete))
export const setAutoComplete = (autoComplete: boolean): TransactionSpec => {
return {
effects: autoCompleteConf.reconfigure(createAutoComplete(autoComplete)),
}
}
const createAutoComplete = (enabled: boolean) => {
if (!enabled) {
return []
}
return [
[
autocompleteTheme,
autocompletion({
closeOnBlur: false,
icons: false,
defaultKeymap: false,
addToOptions: [
// display the completion "type" at the end of the suggestion
{
render: completion => {
const span = document.createElement('span')
span.classList.add('ol-cm-completionType')
if (completion.type) {
span.textContent = completion.type
}
return span
},
position: 400,
},
],
optionClass: (completion: Completion) => {
return `ol-cm-completion-${completion.type}`
},
interactionDelay: 0,
}),
Prec.highest(
keymap.of([
{ key: 'Escape', run: closeCompletion },
{ key: 'ArrowDown', run: moveCompletionSelection(true) },
{ key: 'ArrowUp', run: moveCompletionSelection(false) },
{ key: 'PageDown', run: moveCompletionSelection(true, 'page') },
{ key: 'PageUp', run: moveCompletionSelection(false, 'page') },
{ key: 'Enter', run: acceptCompletion },
{ key: 'Tab', run: acceptCompletion },
])
),
// NOTE: must be lower precedence than Ctrl-Space and Alt-Space in references-search
Prec.high(
keymap.of([
{ key: 'Ctrl-Space', run: startCompletion },
{ key: 'Alt-Space', run: startCompletion },
])
),
],
]
}
const autocompleteTheme = EditorView.baseTheme({
'.cm-tooltip.cm-tooltip-autocomplete': {
// shift the tooltip, so the completion aligns with the text
marginLeft: '-4px',
},
'&light .cm-tooltip.cm-tooltip-autocomplete': {
border: '1px lightgray solid',
background: '#fefefe',
color: '#111',
boxShadow: '2px 3px 5px rgb(0 0 0 / 20%)',
},
'&dark .cm-tooltip.cm-tooltip-autocomplete': {
border: '1px #484747 solid',
boxShadow: '2px 3px 5px rgba(0, 0, 0, 0.51)',
background: '#25282c',
color: '#c1c1c1',
},
// match editor font family and font size, so the completion aligns with the text
'.cm-tooltip.cm-tooltip-autocomplete > ul': {
fontFamily: 'var(--source-font-family)',
fontSize: 'var(--font-size)',
},
'.cm-tooltip.cm-tooltip-autocomplete li[role="option"]': {
display: 'flex',
justifyContent: 'space-between',
lineHeight: 1.4, // increase the line height from default 1.2, for a larger target area
outline: '1px solid transparent',
},
'&light .cm-tooltip.cm-tooltip-autocomplete li[role="option"]:hover': {
outlineColor: '#abbffe',
backgroundColor: 'rgba(233, 233, 253, 0.4)',
},
'&dark .cm-tooltip.cm-tooltip-autocomplete li[role="option"]:hover': {
outlineColor: 'rgba(109, 150, 13, 0.8)',
backgroundColor: 'rgba(58, 103, 78, 0.62)',
},
'.cm-tooltip.cm-tooltip-autocomplete ul li[aria-selected]': {
color: 'inherit',
},
'&light .cm-tooltip.cm-tooltip-autocomplete ul li[aria-selected]': {
background: '#cad6fa',
},
'&dark .cm-tooltip.cm-tooltip-autocomplete ul li[aria-selected]': {
background: '#3a674e',
},
'.cm-completionMatchedText': {
textDecoration: 'none', // remove default underline,
fontWeight: 'bold',
},
'&light .cm-completionMatchedText': {
color: '#2d69c7',
},
'&dark .cm-completionMatchedText': {
color: '#93ca12',
},
'.ol-cm-completionType': {
paddingLeft: '1em',
paddingRight: 0,
width: 'auto',
fontSize: '90%',
fontFamily: 'var(--source-font-family)',
opacity: '0.5',
},
'.cm-completionInfo .ol-cm-symbolCompletionInfo': {
margin: 0,
whiteSpace: 'normal',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
textAlign: 'center',
},
'.cm-completionInfo .ol-cm-symbolCharacter': {
fontSize: '32px',
},
})
@@ -0,0 +1,31 @@
import { keymap } from '@codemirror/view'
import { Compartment, Prec, TransactionSpec } from '@codemirror/state'
import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete'
import { closePrefixedBrackets } from './close-prefixed-brackets'
const autoPairConf = new Compartment()
export const autoPair = ({
autoPairDelimiters,
}: {
autoPairDelimiters: boolean
}) => autoPairConf.of(createAutoPair(autoPairDelimiters))
export const setAutoPair = (autoPairDelimiters: boolean): TransactionSpec => {
return {
effects: autoPairConf.reconfigure(createAutoPair(autoPairDelimiters)),
}
}
const createAutoPair = (enabled: boolean) => {
if (!enabled) {
return []
}
return [
closePrefixedBrackets(),
closeBrackets(),
// NOTE: using Prec.highest as this needs to run before the default Backspace handler
Prec.highest(keymap.of(closeBracketsKeymap)),
]
}
@@ -0,0 +1,134 @@
import {
bracketMatching as bracketMatchingExtension,
matchBrackets,
type MatchResult,
} from '@codemirror/language'
import { Decoration, EditorView } from '@codemirror/view'
import {
EditorSelection,
Extension,
SelectionRange,
type Range,
} from '@codemirror/state'
const matchingMark = Decoration.mark({ class: 'cm-matchingBracket' })
const nonmatchingMark = Decoration.mark({ class: 'cm-nonmatchingBracket' })
const FORWARDS = 1
const BACKWARDS = -1
type Direction = 1 | -1
export const bracketMatching = () => {
return bracketMatchingExtension({
renderMatch: match => {
const decorations: Range<Decoration>[] = []
if (matchedAdjacent(match)) {
// combine an adjacent pair of matching markers into a single decoration
decorations.push(
matchingMark.range(
Math.min(match.start.from, match.end.from),
Math.max(match.start.to, match.end.to)
)
)
} else {
// default match rendering (defaultRenderMatch in @codemirror/matchbrackets)
const mark = match.matched ? matchingMark : nonmatchingMark
decorations.push(mark.range(match.start.from, match.start.to))
if (match.end) {
decorations.push(mark.range(match.end.from, match.end.to))
}
}
return decorations
},
})
}
interface AdjacentMatchResult extends MatchResult {
end: {
from: number
to: number
}
}
const matchedAdjacent = (match: MatchResult): match is AdjacentMatchResult =>
Boolean(
match.matched &&
match.end &&
(match.start.to === match.end.from || match.end.to === match.start.from)
)
export const bracketSelection = (): Extension[] => [
EditorView.domEventHandlers({
dblclick: (evt, view) => {
const pos = view.posAtCoords({
x: evt.pageX,
y: evt.pageY,
})
if (!pos) return false
const search = (direction: Direction, position: number) => {
const match = matchBrackets(view.state, position, direction, {
// Only look at data in the syntax tree, don't scan the text
maxScanDistance: 0,
})
if (match?.matched && match.end) {
const newRange = EditorSelection.range(
Math.min(match.start.from, match.end.from),
Math.max(match.end.to, match.start.to)
)
return newRange
}
return false
}
const dispatchSelection = (range: SelectionRange) => {
view.dispatch({
selection: range,
})
return true
}
// 1. Look forwards, from the character *behind* the cursor
const forwardsExcludingBrackets = search(FORWARDS, pos - 1)
if (forwardsExcludingBrackets) {
return dispatchSelection(
EditorSelection.range(
forwardsExcludingBrackets.from + 1,
forwardsExcludingBrackets.to - 1
)
)
}
// 2. Look forwards, from the character *in front of* the cursor
const forwardsIncludingBrackets = search(FORWARDS, pos)
if (forwardsIncludingBrackets) {
return dispatchSelection(forwardsIncludingBrackets)
}
// 3. Look backwards, from the character *behind* the cursor
const backwardsIncludingBrackets = search(BACKWARDS, pos)
if (backwardsIncludingBrackets) {
return dispatchSelection(backwardsIncludingBrackets)
}
// 4. Look backwards, from the character *in front of* the cursor
const backwardsExcludingBrackets = search(BACKWARDS, pos + 1)
if (backwardsExcludingBrackets) {
return dispatchSelection(
EditorSelection.range(
backwardsExcludingBrackets.from + 1,
backwardsExcludingBrackets.to - 1
)
)
}
return false
},
}),
EditorView.baseTheme({
'.cm-matchingBracket': {
pointerEvents: 'none',
},
}),
]
@@ -0,0 +1,50 @@
// This is copied from CM6, which does not expose it publicly.
// https://github.com/codemirror/view/blob/e7918b607753588a0b2a596e952068fa008bf84c/src/browser.ts
const nav: any =
typeof navigator !== 'undefined'
? navigator
: { userAgent: '', vendor: '', platform: '' }
const doc: any =
typeof document !== 'undefined'
? document
: { documentElement: { style: {} } }
const ieEdge = /Edge\/(\d+)/.exec(nav.userAgent)
const ieUpTo10 = /MSIE \d/.test(nav.userAgent)
const ie11Up = /Trident\/(?:[7-9]|\d{2,})\..*rv:(\d+)/.exec(nav.userAgent)
const ie = !!(ieUpTo10 || ie11Up || ieEdge)
const gecko = !ie && /gecko\/(\d+)/i.test(nav.userAgent)
const chrome = !ie && /Chrome\/(\d+)/.exec(nav.userAgent)
const webkit = 'webkitFontSmoothing' in doc.documentElement.style
const safari = !ie && /Apple Computer/.test(nav.vendor)
const ios =
safari && (/Mobile\/\w+/.test(nav.userAgent) || nav.maxTouchPoints > 2)
export default {
mac: ios || /Mac/.test(nav.platform),
windows: /Win/.test(nav.platform),
linux: /Linux|X11/.test(nav.platform),
ie,
ie_version: ieUpTo10
? doc.documentMode || 6
: ie11Up
? +ie11Up[1]
: ieEdge
? +ieEdge[1]
: 0,
gecko,
gecko_version: gecko
? +(/Firefox\/(\d+)/.exec(nav.userAgent) || [0, 0])[1]
: 0,
chrome: !!chrome,
chrome_version: chrome ? +chrome[1] : 0,
ios,
android: /Android\b/.test(nav.userAgent),
webkit,
safari,
webkit_version: webkit
? +(/\bAppleWebKit\/(\d+)/.exec(navigator.userAgent) || [0, 0])[1]
: 0,
tabSize:
doc.documentElement.style.tabSize != null ? 'tab-size' : '-moz-tab-size',
}
@@ -0,0 +1,30 @@
import { syntaxTree } from '@codemirror/language'
import { EditorSelection, StateEffect, StateField } from '@codemirror/state'
import {
Decoration,
EditorView,
hoverTooltip,
keymap,
ViewPlugin,
WidgetType,
} from '@codemirror/view'
import { CodeMirror, Vim, getCM } from '@replit/codemirror-vim'
export default {
Decoration,
EditorSelection,
EditorView,
StateEffect,
StateField,
ViewPlugin,
WidgetType,
hoverTooltip,
keymap,
syntaxTree,
}
export const CodeMirrorVim = {
CodeMirror,
Vim,
getCM,
}
@@ -0,0 +1,482 @@
import { trackChangesAnnotation } from '../realtime'
import { clearChangeMarkers, buildChangeMarkers } from '../track-changes'
import {
setVerticalOverflow,
updateSetsVerticalOverflow,
editorVerticalTopPadding,
} from '../vertical-overflow'
import { EditorSelection, EditorState } from '@codemirror/state'
import { EditorView, ViewUpdate } from '@codemirror/view'
import { CurrentDoc } from '../../../../../../types/current-doc'
import { fullHeightCoordsAtPos } from '../../utils/layer'
import { debounce } from 'lodash'
// With less than this number of entries, don't bother culling to avoid
// little UI jumps when scrolling.
const CULL_AFTER = 100
export const dispatchEditorEvent = (type: string, payload?: unknown) => {
window.setTimeout(() => {
window.dispatchEvent(
new CustomEvent('editor:event', {
detail: { type, payload },
})
)
}, 0)
}
export type ChangeManager = {
initialize: () => void
handleUpdate: (update: ViewUpdate) => void
destroy: () => void
}
export const createChangeManager = (
view: EditorView,
currentDoc: CurrentDoc
): ChangeManager => {
/**
* Calculate the screen coordinates of each entry (change or comment),
* for use in the review panel.
*
* Returns a boolean indicating whether the visibility of any entry has changed
*/
const recalculateScreenPositions = (entries?: Record<string, any>) => {
const contentRect = view.contentDOM.getBoundingClientRect()
const { doc } = view.state
const items = Object.values(entries || {})
const allVisible = items.length <= CULL_AFTER
let visibilityChanged = false
const docLength = doc.length
const editorPaddingTop = editorVerticalTopPadding(view)
for (const entry of items) {
// TODO: clamp to max row and column, account for folding?
const coords = fullHeightCoordsAtPos(
view,
Math.min(entry.offset, docLength) // avoid exception for comments at end of document when deleting text
)
if (coords) {
const y = coords.top - contentRect.top - editorPaddingTop
const height = coords.bottom - coords.top
entry.screenPos = { y, height, editorPaddingTop }
}
if (allVisible) {
if (!entry.visible) {
visibilityChanged = true
}
entry.visible = true
}
}
if (!allVisible) {
const { from, to } = view.viewport
for (const entry of items) {
const previouslyVisible = entry.visible
entry.visible = entry.offset >= from && entry.offset <= to
if (previouslyVisible !== entry.visible) {
visibilityChanged = true
}
}
}
return visibilityChanged
}
/**
* Add a comment (thread) to the ShareJS doc when it's created
*/
const addComment = (offset: number, length: number, threadId: string) => {
currentDoc.submitOp({
c: view.state.doc.sliceString(offset, offset + length),
p: offset,
t: threadId,
})
}
/**
* Remove a comment (thread) from the range tracker when it's deleted
*/
const removeComment = (commentId: string) => {
currentDoc.ranges.removeCommentId(commentId)
}
/**
* Remove tracked changes from the range tracker when they're accepted
*/
const acceptChanges = (changeIds: string[]) => {
currentDoc.ranges.removeChangeIds(changeIds)
}
/**
* Remove tracked changes from the range tracker when they're rejected,
* and restore the original content
*/
const rejectChanges = (changeIds: string[]) => {
const changes: any[] = currentDoc.ranges.getChanges(changeIds)
if (changes.length === 0) {
return {}
}
// When doing bulk rejections, adjacent changes might interact with each other.
// Consider an insertion with an adjacent deletion (which is a common use-case, replacing words):
//
// "foo bar baz" -> "foo quux baz"
//
// The change above will be modeled with two ops, with the insertion going first:
//
// foo quux baz
// |--| -> insertion of "quux", op 1, at position 4
// | -> deletion of "bar", op 2, pushed forward by "quux" to position 8
//
// When rejecting these changes at once, if the insertion is rejected first, we get unexpected
// results. What happens is:
//
// 1) Rejecting the insertion deletes the added word "quux", i.e., it removes 4 chars
// starting from position 4;
//
// "foo quux baz" -> "foo baz"
// |--| -> 4 characters to be removed
//
// 2) Rejecting the deletion adds the deleted word "bar" at position 8 (i.e. it will act as if
// the word "quuux" was still present).
//
// "foo baz" -> "foo bazbar"
// | -> deletion of "bar" is reverted by reinserting "bar" at position 8
//
// While the intended result would be "foo bar baz", what we get is:
//
// "foo bazbar" (note "bar" readded at position 8)
//
// The issue happens because of step 1. To revert the insertion of "quux", 4 characters are deleted
// from position 4. This includes the position where the deletion exists; when that position is
// cleared, the RangesTracker considers that the deletion is gone and stops tracking/updating it.
// As we still hold a reference to it, the code tries to revert it by readding the deleted text, but
// does so at the outdated position (position 8, which was valid when "quux" was present).
//
// To avoid this kind of problem, we need to make sure that reverting operations doesn't affect
// subsequent operations that come after. Reverse sorting the operations based on position will
// achieve it; in the case above, it makes sure that the the deletion is reverted first:
//
// 1) Rejecting the deletion adds the deleted word "bar" at position 8
//
// "foo quux baz" -> "foo quuxbar baz"
// | -> deletion of "bar" is reverted by
// reinserting "bar" at position 8
//
// 2) Rejecting the insertion deletes the added word "quux", i.e., it removes 4 chars
// starting from position 4 and achieves the expected result:
//
// "foo quuxbar baz" -> "foo bar baz"
// |--| -> 4 characters to be removed
changes.sort((a, b) => b.op.p - a.op.p)
const changesToDispatch = changes.map(change => {
const { op } = change
const opType = 'i' in op ? 'i' : 'c' in op ? 'c' : 'd'
switch (opType) {
case 'd': {
return {
from: op.p,
to: op.p,
insert: op.d,
}
}
case 'i': {
const from = op.p
const content = op.i
const to = from + content.length
const text = view.state.doc.sliceString(from, to)
if (text !== content) {
throw new Error(
`Op to be removed (${JSON.stringify(
change.op
)}) does not match editor text '${text}'`
)
}
return { from, to, insert: '' }
}
default: {
throw new Error(`unknown change: ${JSON.stringify(change)}`)
}
}
})
return {
changes: changesToDispatch,
annotations: [trackChangesAnnotation.of('reject')],
}
}
/**
* If the current selection is empty, select the whole line.
*
* Used when adding a comment with no selected range, e.g. with a keyboard shortcut.
*/
const selectCurrentLine = () => {
if (view.state.selection.main.empty) {
const line = view.state.doc.lineAt(view.state.selection.main.from)
view.dispatch({
selection: {
anchor: line.from,
head: line.to === view.state.doc.length ? line.to : line.to + 1,
},
})
}
}
/**
* Collapse the current selection to a single point (after inserting a comment)
*/
const collapseSelection = () => {
view.dispatch({
selection: EditorSelection.cursor(view.state.selection.main.head),
})
}
/**
* Listen for events dispatched from the (Angular) review panel.
*
* These are combined into a single listener, avoiding the need to add and remove event listeners individually.
*/
const reviewPanelEventListener = (event: Event) => {
const { type, payload } = (
event as CustomEvent<{ type: string; payload: any }>
).detail
switch (type) {
// receive review panel scroll events
case 'scroll': {
view.scrollDOM.scrollBy(0, payload)
break
}
case 'overview-closed': {
window.setTimeout(() => {
dispatchScrollEvent()
}, 0)
break
}
case 'recalculate-screen-positions': {
const changed = recalculateScreenPositions(payload)
if (changed) {
dispatchEditorEvent('track-changes:visibility_changed')
}
break
}
case 'changes:accept': {
acceptChanges(payload)
view.dispatch(buildChangeMarkers())
broadcastChange()
dispatchFocusChangedEvent(view.state)
break
}
case 'changes:reject': {
view.dispatch(rejectChanges(payload))
dispatchFocusChangedEvent(view.state)
break
}
case 'comment:select_line': {
selectCurrentLine()
break
}
case 'comment:add': {
addComment(payload.offset, payload.length, payload.threadId)
collapseSelection()
break
}
case 'comment:remove': {
removeComment(payload)
view.dispatch(buildChangeMarkers())
broadcastChange()
break
}
case 'comment:resolve_threads':
case 'comment:unresolve_thread': {
view.dispatch(buildChangeMarkers())
broadcastChange()
break
}
case 'loaded_threads': {
view.dispatch(buildChangeMarkers())
broadcastChange()
window.setTimeout(() => {
dispatchFocusChangedEvent(view.state)
}, 0)
break
}
case 'sizes': {
const { overflowTop, height } = payload
const padding = view.documentPadding
const contentHeight =
view.contentDOM.clientHeight - padding.top - padding.bottom
const paddingNeeded = height - contentHeight
if (overflowTop !== padding.top || paddingNeeded !== padding.bottom) {
view.dispatch(
setVerticalOverflow({
top: overflowTop,
bottom: paddingNeeded,
})
)
}
break
}
}
}
/**
*
*/
const broadcastChange = () => {
dispatchEditorEvent('track-changes:changed')
}
/**
* When the editor content, focus, size, viewport or selection changes,
* tell the review panel to update.
*
* @param state object
*/
const dispatchFocusChangedEvent = debounce((state: EditorState) => {
// TODO: multiple selections?
const { from, to, empty } = state.selection.main
dispatchEditorEvent('focus:changed', { from, to, empty })
}, 50)
/**
* When the editor is scrolled, tell the review panel so it can scroll in sync.
*/
const dispatchScrollEvent = () => {
window.dispatchEvent(
new CustomEvent('editor:scroll', {
detail: {
height: view.scrollDOM.scrollHeight,
scrollTop: view.scrollDOM.scrollTop,
paddingTop: editorVerticalTopPadding(view),
},
})
)
}
/**
* Add event listeners to the ShareJS doc so that change markers are rebuilt when the tracked changes are updated.
*
* Also add event listeners to the editor scroll DOM and window.
*/
const addListeners = () => {
// NOTE: the namespace "cm6" is needed so the listeners can be removed individually
currentDoc.on('ranges:dirty.cm6', () => {
// TODO: use currentDoc.ranges.getDirtyState and only update those which have changed?
window.setTimeout(() => {
view.dispatch(buildChangeMarkers())
broadcastChange()
}, 0)
})
// called on joinDoc
currentDoc.on('ranges:clear.cm6', () => {
window.setTimeout(() => {
view.dispatch(clearChangeMarkers())
broadcastChange()
}, 0)
})
// called on joinDoc
currentDoc.on('ranges:redraw.cm6', () => {
window.setTimeout(() => {
view.dispatch(buildChangeMarkers())
broadcastChange()
}, 0)
})
// sync review panel scroll with editor scroll
view.scrollDOM.addEventListener('scroll', dispatchScrollEvent)
// listen for events from the review panel controller
window.addEventListener('review-panel:event', reviewPanelEventListener)
}
/**
* Remove event listeners
*/
const removeListeners = () => {
currentDoc.off('ranges:clear.cm6')
currentDoc.off('ranges:dirty.cm6')
currentDoc.off('ranges:redraw.cm6')
view.scrollDOM.removeEventListener('scroll', dispatchScrollEvent)
window.removeEventListener('review-panel:event', reviewPanelEventListener)
}
let lastUpdatedTopPaddingAt = 0
const PADDING_GEOMETRY_CHANGE_INTERVAL = 50
return {
initialize() {
addListeners()
dispatchEditorEvent('track-changes:changed')
},
handleUpdate(update: ViewUpdate) {
if (updateSetsVerticalOverflow(update)) {
lastUpdatedTopPaddingAt = Date.now()
}
if (
update.docChanged ||
update.focusChanged ||
update.viewportChanged ||
update.selectionSet ||
update.geometryChanged
) {
// Ignore a change to the editor geometry that occurs immediately after
// an update to the vertical padding because otherwise it triggers
// another update to the padding and so on ad infinitum. This is not an
// ideal way to handle this but I couldn't see another way.
if (
update.geometryChanged &&
Date.now() - lastUpdatedTopPaddingAt <
PADDING_GEOMETRY_CHANGE_INTERVAL
) {
return
}
dispatchFocusChangedEvent(update.state)
}
},
destroy() {
removeListeners()
},
}
}
@@ -0,0 +1,226 @@
import { Range, RangeSet, RangeValue, Transaction } from '@codemirror/state'
import { CurrentDoc } from '../../../../../../types/current-doc'
import {
AnyOperation,
Change,
ChangeOperation,
CommentOperation,
} from '../../../../../../types/change'
export type StoredComment = {
text: string
comments: {
offset: number
text: string
comment: Change<CommentOperation>
}[]
}
/**
* Find tracked comments within the range of the current transaction's changes
*/
export const findCommentsInCut = (
currentDoc: CurrentDoc,
transaction: Transaction
) => {
const items: StoredComment[] = []
transaction.changes.iterChanges((fromA, toA) => {
const comments = currentDoc.ranges.comments
.filter(
comment =>
fromA <= comment.op.p && comment.op.p + comment.op.c.length <= toA
)
.map(comment => ({
offset: comment.op.p - fromA,
text: comment.op.c,
comment,
}))
if (comments.length) {
items.push({
text: transaction.startState.sliceDoc(fromA, toA),
comments,
})
}
})
return items
}
/**
* Find stored comments matching the text of the current transaction's changes
*/
export const findCommentsInPaste = (
storedComments: StoredComment[],
transaction: Transaction
) => {
const ops: ChangeOperation[] = []
transaction.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
const insertedText = inserted.toString()
// note: only using the first match
const matchedComment = storedComments.find(
item => item.text === insertedText
)
if (matchedComment) {
for (const { offset, text, comment } of matchedComment.comments) {
// Resubmitting an existing comment op (by thread id) will move it
ops.push({
c: text,
p: fromB + offset,
t: comment.id,
})
}
}
})
return ops
}
class CommentRangeValue extends RangeValue {
constructor(
public content: string,
public comment: Change<CommentOperation>
) {
super()
}
}
/**
* Find tracked comments with no content with the ranges of a transaction's changes
*/
export const findDetachedCommentsInChanges = (
currentDoc: CurrentDoc,
transaction: Transaction
) => {
const items: Range<CommentRangeValue>[] = []
transaction.changes.iterChanges((fromA, toA) => {
for (const comment of currentDoc.ranges.comments) {
const content = comment.op.c
// TODO: handle comments that were never attached
if (!content.length) {
continue
}
const from = comment.op.p
const to = from + content.length
if (fromA <= from && to <= toA) {
items.push(new CommentRangeValue(content, comment).range(from, to))
}
}
})
return RangeSet.of(items, true)
}
/**
* Submit operations to the ShareJS doc
* (used when restoring comments on paste)
*/
const submitOps = (
currentDoc: CurrentDoc,
ops: AnyOperation[],
transaction: Transaction
) => {
for (const op of ops) {
currentDoc.submitOp(op)
}
// Check that comments still match text. Will throw error if not.
currentDoc.ranges.validate(transaction.state.doc.toString())
}
/**
* Wait for the ShareJS doc to fire an event, then submit the operations.
*/
const submitOpsAfterEvent = (
currentDoc: CurrentDoc,
eventName: string,
ops: AnyOperation[],
transaction: Transaction
) => {
// We have to wait until the change has been processed by the range
// tracker, since if we move the ops into place beforehand, they will be
// moved again when the changes are processed by the range tracker. This
// ranges:dirty event is fired after the doc has applied the changes to
// the range tracker.
// TODO: could put this in an update listener instead, if the ShareJS doc has been updated by then?
currentDoc.on(eventName, () => {
currentDoc.off(eventName)
submitOps(currentDoc, ops, transaction)
})
}
/**
* Look through the comments stored on cut, and restore those in text that matches the pasted text.
*/
export const restoreCommentsOnPaste = (
currentDoc: CurrentDoc,
transaction: Transaction,
storedComments: StoredComment[]
) => {
if (storedComments.length) {
const ops = findCommentsInPaste(storedComments, transaction)
if (ops.length) {
submitOpsAfterEvent(
currentDoc,
'ranges:dirty.paste-cm6',
ops,
transaction
)
}
}
}
/**
* When undoing a change, find comments from the original content and restore them.
*/
export const restoreDetachedComments = (
currentDoc: CurrentDoc,
transaction: Transaction,
storedComments: RangeSet<any>
) => {
const ops: ChangeOperation[] = []
const cursor = storedComments.iter()
while (cursor.value) {
const { id } = cursor.value.comment
const comment = currentDoc.ranges.comments.find(item => item.id === id)
// check that the comment still exists and is detached
if (comment && comment.op.c === '') {
const content = transaction.state.doc.sliceString(
cursor.from,
cursor.from + cursor.value.content.length
)
if (cursor.value.content === content) {
ops.push({
c: cursor.value.content,
p: cursor.from,
t: id,
})
}
}
cursor.next()
}
// FIXME: timing issue with rapid undos
if (ops.length) {
window.setTimeout(() => {
submitOps(currentDoc, ops, transaction)
}, 0)
}
// submitOpsAfterEvent('ranges:dirty.undo-cm6', ops, transaction)
}
@@ -0,0 +1,49 @@
/**
* This file is adapted from Lezer, licensed under the MIT license:
* https://github.com/lezer-parser/highlight/blob/main/src/highlight.ts
*/
import { tagHighlighter, tags } from '@lezer/highlight'
export const classHighlighter = tagHighlighter([
{ tag: tags.link, class: 'tok-link' },
{ tag: tags.heading, class: 'tok-heading' },
{ tag: tags.emphasis, class: 'tok-emphasis' },
{ tag: tags.strong, class: 'tok-strong' },
{ tag: tags.keyword, class: 'tok-keyword' },
{ tag: tags.atom, class: 'tok-atom' },
{ tag: tags.bool, class: 'tok-bool' },
{ tag: tags.url, class: 'tok-url' },
{ tag: tags.labelName, class: 'tok-labelName' },
{ tag: tags.inserted, class: 'tok-inserted' },
{ tag: tags.deleted, class: 'tok-deleted' },
{ tag: tags.literal, class: 'tok-literal' },
{ tag: tags.string, class: 'tok-string' },
{ tag: tags.number, class: 'tok-number' },
{
tag: [tags.regexp, tags.escape, tags.special(tags.string)],
class: 'tok-string2',
},
{ tag: tags.variableName, class: 'tok-variableName' },
{ tag: tags.local(tags.variableName), class: 'tok-variableName tok-local' },
{
tag: tags.definition(tags.variableName),
class: 'tok-variableName tok-definition',
},
{ tag: tags.special(tags.variableName), class: 'tok-variableName2' },
{
tag: tags.definition(tags.propertyName),
class: 'tok-propertyName tok-definition',
},
{ tag: tags.typeName, class: 'tok-typeName' },
{ tag: tags.namespace, class: 'tok-namespace' },
{ tag: tags.className, class: 'tok-className' },
{ tag: tags.macroName, class: 'tok-macroName' },
{ tag: tags.propertyName, class: 'tok-propertyName' },
{ tag: tags.operator, class: 'tok-operator' },
{ tag: tags.comment, class: 'tok-comment' },
{ tag: tags.meta, class: 'tok-meta' },
{ tag: tags.invalid, class: 'tok-invalid' },
{ tag: tags.punctuation, class: 'tok-punctuation' },
// additional
{ tag: tags.attributeValue, class: 'tok-attributeValue' },
])
@@ -0,0 +1,206 @@
/**
* This file is adapted from CodeMirror 6, licensed under the MIT license:
* https://github.com/codemirror/autocomplete/blob/main/src/closebrackets.ts
*/
import { EditorView } from '@codemirror/view'
import {
codePointAt,
codePointSize,
EditorSelection,
Extension,
SelectionRange,
Text,
TransactionSpec,
} from '@codemirror/state'
import { nextChar, prevChar } from '../languages/latex/completions/apply'
import { completionStatus } from '@codemirror/autocomplete'
import { ancestorNodeOfType } from '../utils/tree-query'
import browser from './browser'
const dispatchInput = (view: EditorView, spec: TransactionSpec) => {
// This is consistent with CM6's closebrackets extension and allows other
// extensions that check for user input to be triggered
view.dispatch(spec, {
scrollIntoView: true,
userEvent: 'input.type',
})
return true
}
const insertInput = (view: EditorView, insert: string) => {
const spec = view.state.changeByRange(range => {
return {
changes: [[{ from: range.from, insert }]],
range: EditorSelection.range(range.from + 1, range.to + 1),
}
})
return dispatchInput(view, spec)
}
const insertBracket = (view: EditorView, open: string, close: string) => {
const spec = view.state.changeByRange(range => {
if (range.empty) {
return {
changes: [{ from: range.head, insert: open + close }],
range: EditorSelection.cursor(range.head + open.length),
}
} else {
return {
changes: [
{ from: range.from, insert: open },
{ from: range.to, insert: close },
],
range: EditorSelection.range(
range.anchor + open.length,
range.head + open.length
),
}
}
})
return dispatchInput(view, spec)
}
export const closePrefixedBrackets = (): Extension => {
return EditorView.inputHandler.of((view, from, to, insert) => {
if (
(browser.android ? view.composing : view.compositionStarted) ||
view.state.readOnly
) {
return false
}
// avoid auto-closing curly braces when autocomplete is open
if (insert === '{' && completionStatus(view.state)) {
return insertInput(view, insert)
}
const { doc, selection } = view.state
const sel = selection.main
if (
from !== sel.from ||
to !== sel.to ||
insert.length > 2 ||
(insert.length === 2 && codePointSize(codePointAt(insert, 0)) === 1)
) {
return false
}
const [config] = view.state.languageDataAt<{
brackets?: Record<string, string | false>
}>('closePrefixedBrackets', sel.head)
// no config for this language, don't handle
if (!config?.brackets) {
return false
}
const prevCharacter = prevChar(view.state.doc, sel.from)
const input = `${prevCharacter}${insert}`
const close = config.brackets[input] ?? config.brackets[insert]
// not specified, don't handle
if (close === undefined) {
return false
}
// prevent auto-close, just insert the character
if (close === false) {
return insertInput(view, insert)
}
const nextCharacter = nextChar(doc, sel.from)
if (insert === '$') {
// avoid duplicating a math-closing dollar sign
if (moveOverClosingMathDollar(view, sel)) {
return true
}
// avoid creating an odd number of dollar signs
const count = countSurroundingCharacters(doc, sel.from, insert)
if (count % 2 !== 0) {
return insertInput(view, insert)
}
}
// This is the default set of "before" characters from the closeBrackets extension,
// plus $ (so $$ works as expected)
if (!sel.empty || !nextCharacter || /[\s)\]}:;>$]/.test(nextCharacter)) {
// auto-close
return insertBracket(view, insert, close)
}
return false
})
}
const moveOverClosingMathDollar = (
view: EditorView,
sel: SelectionRange
): boolean => {
if (!sel.empty) {
return false
}
// inside dollar math
const outerNode = ancestorNodeOfType(view.state, sel.from, 'DollarMath')
if (!outerNode) {
return false
}
// not display math
const innerNode = outerNode.getChild('InlineMath')
if (!innerNode) {
return false
}
// the cursor is at the end of the InlineMath node
if (sel.from !== innerNode.to) {
return false
}
// there's already some math content
const content = view.state.doc.sliceString(innerNode.from, innerNode.to)
if (content.length === 0) {
return false
}
// move the cursor outside the DollarMath node
view.dispatch({
selection: EditorSelection.cursor(outerNode.to),
})
return true
}
const countSurroundingCharacters = (doc: Text, pos: number, insert: string) => {
let count = 0
// count backwards
let to = pos
do {
const char = doc.sliceString(to - 1, to)
if (char !== insert) {
break
}
count++
to--
} while (to > 1)
// count forwards
let from = pos
do {
const char = doc.sliceString(from, from + 1)
if (char !== insert) {
break
}
count++
from++
} while (from < doc.length)
return count
}
@@ -0,0 +1,207 @@
import {
RangeSet,
RangeValue,
StateEffect,
StateField,
TransactionSpec,
} from '@codemirror/state'
import {
EditorView,
hoverTooltip,
layer,
RectangleMarker,
Tooltip,
} from '@codemirror/view'
import { findValidPosition } from '../utils/position'
import { Highlight } from '../../../../../types/highlight'
import { fullHeightCoordsAtPos, getBase } from '../utils/layer'
export const cursorHighlights = () => {
return [
cursorHighlightsState,
cursorHighlightsLayer,
hoverTooltip(cursorTooltip, {
hoverTime: 1,
}),
EditorView.theme({
'.ol-cm-cursorHighlightsLayer': {
zIndex: 100,
contain: 'size style',
pointerEvents: 'none',
},
'.ol-cm-cursorHighlight': {
color: 'hsl(var(--hue), 70%, 50%)',
borderLeft: '2px solid hsl(var(--hue), 70%, 50%)',
display: 'inline-block',
height: '1.6em',
position: 'absolute',
pointerEvents: 'none',
},
'.ol-cm-cursorHighlight:before': {
content: "''",
position: 'absolute',
left: '-2px',
top: '-5px',
height: '5px',
width: '5px',
borderWidth: '3px 3px 2px 2px',
borderStyle: 'solid',
borderColor: 'inherit',
},
'.ol-cm-cursorHighlightLabel': {
lineHeight: 1,
backgroundColor: 'hsl(var(--hue), 70%, 50%)',
padding: '1em 1em',
fontSize: '0.8rem',
fontFamily: 'Lato, sans-serif',
color: 'white',
fontWeight: 700,
whiteSpace: 'nowrap',
pointerEvents: 'none',
},
}),
]
}
class HighlightRangeValue extends RangeValue {
constructor(public highlight: Highlight) {
super()
}
}
const cursorHighlightsState = StateField.define<RangeSet<HighlightRangeValue>>({
create() {
return RangeSet.empty
},
update(value, tr) {
for (const effect of tr.effects) {
if (effect.is(setCursorHighlightsEffect)) {
const highlightRanges = []
for (const highlight of effect.value) {
// NOTE: other highlight types could be handled here
if ('cursor' in highlight) {
try {
const { row, column } = highlight.cursor
const pos = findValidPosition(tr.state.doc, row + 1, column)
highlightRanges.push(
new HighlightRangeValue(highlight).range(pos)
)
} catch (error) {
// ignore invalid highlights
console.debug('invalid highlight position', error)
}
}
}
return RangeSet.of(highlightRanges, true)
}
}
if (tr.docChanged) {
value = value.map(tr.changes)
}
return value
},
})
const cursorTooltip = (view: EditorView, pos: number): Tooltip | null => {
const highlights: Highlight[] = []
view.state
.field(cursorHighlightsState)
.between(pos, pos, (from, to, value) => {
highlights.push(value.highlight)
})
if (highlights.length === 0) {
return null
}
return {
pos,
end: pos,
above: true,
create: () => {
const dom = document.createElement('div')
dom.classList.add('ol-cm-cursorTooltip')
for (const highlight of highlights) {
const label = document.createElement('div')
label.classList.add('ol-cm-cursorHighlightLabel')
label.style.setProperty('--hue', highlight.hue)
label.textContent = highlight.label
dom.appendChild(label)
}
return { dom }
},
}
}
const setCursorHighlightsEffect = StateEffect.define<Highlight[]>()
export const setCursorHighlights = (
cursorHighlights: Highlight[] = []
): TransactionSpec => {
return {
effects: setCursorHighlightsEffect.of(cursorHighlights),
}
}
class CursorMarker extends RectangleMarker {
constructor(
private highlight: Highlight,
className: string,
left: number,
top: number,
width: number | null,
height: number
) {
super(className, left, top, width, height)
}
draw(): HTMLDivElement {
const element = super.draw()
element.style.setProperty('--hue', this.highlight.hue)
return element
}
}
// draw the collaborator cursors in a separate layer, so they don't affect word wrapping
const cursorHighlightsLayer = layer({
above: true,
class: 'ol-cm-cursorHighlightsLayer',
update: (update, layer) => {
return (
update.docChanged ||
update.selectionSet ||
update.transactions.some(tr =>
tr.effects.some(effect => effect.is(setCursorHighlightsEffect))
)
)
},
markers(view) {
const markers: CursorMarker[] = []
const highlightRanges = view.state.field(cursorHighlightsState)
const base = getBase(view)
const { from, to } = view.viewport
highlightRanges.between(from, to, (from, to, { highlight }) => {
const pos = fullHeightCoordsAtPos(view, from)
if (pos) {
markers.push(
new CursorMarker(
highlight,
'ol-cm-cursorHighlight',
pos.left - base.left,
pos.top - base.top,
null,
pos.bottom - pos.top
)
)
}
})
return markers
},
})
@@ -0,0 +1,161 @@
import {
EditorSelection,
EditorState,
SelectionRange,
Text,
TransactionSpec,
} from '@codemirror/state'
import { EditorView, ViewPlugin } from '@codemirror/view'
import { findValidPosition } from '../utils/position'
import customLocalStorage from '../../../infrastructure/local-storage'
const buildStorageKey = (docId: string) => `doc.position.${docId}`
export const cursorPosition = ({
currentDoc: { doc_id: docId },
}: {
currentDoc: { doc_id: string }
}) => {
return [
// store cursor position
ViewPlugin.define(view => {
const unloadListener = () => {
storeCursorPosition(view, docId)
}
window.addEventListener('unload', unloadListener)
return {
destroy: () => {
window.removeEventListener('unload', unloadListener)
unloadListener()
},
}
}),
// Asynchronously dispatch cursor position when the selection changes and
// provide a little debouncing. Using requestAnimationFrame postpones it
// until the next CM6 DOM update.
ViewPlugin.define(view => {
let animationFrameRequest: number | null = null
return {
update(update) {
if (update.selectionSet || update.docChanged) {
if (animationFrameRequest) {
window.cancelAnimationFrame(animationFrameRequest)
}
animationFrameRequest = window.requestAnimationFrame(() => {
animationFrameRequest = null
dispatchCursorPosition(update.state)
})
}
},
}
}),
]
}
// convert the selection head to a row and column
const buildCursorPosition = (state: EditorState) => {
const pos = state.selection.main.head
const line = state.doc.lineAt(pos)
const row = line.number - 1 // 0-indexed
const column = pos - line.from
return { row, column }
}
// dispatch the current cursor position for use with synctex
const dispatchCursorPosition = (state: EditorState) => {
const cursorPosition = buildCursorPosition(state)
window.dispatchEvent(
new CustomEvent('cursor:editor:update', { detail: cursorPosition })
)
}
// store the cursor position for restoring on load
const storeCursorPosition = (view: EditorView, docId: string) => {
const key = buildStorageKey(docId)
const data = customLocalStorage.getItem(key)
const cursorPosition = buildCursorPosition(view.state)
customLocalStorage.setItem(key, { ...data, cursorPosition })
}
// restore the stored cursor position on load
export const restoreCursorPosition = (
doc: Text,
docId: string
): TransactionSpec => {
try {
const key = buildStorageKey(docId)
const data = customLocalStorage.getItem(key)
const { row = 0, column = 0 } = data?.cursorPosition || {}
// restore the cursor to its original position, or the end of the document if past the end
const { lines } = doc
const lineNumber = row < lines ? row + 1 : lines
const line = doc.line(lineNumber)
const offset = line.from + column
const pos = Math.min(offset || 0, doc.length)
return {
selection: EditorSelection.cursor(pos),
}
} catch (error) {
// ignore invalid cursor position
console.debug('invalid cursor position', error)
return {}
}
}
const dispatchSelectionAndScroll = (
view: EditorView,
selection: SelectionRange
) => {
view.dispatch({
selection,
effects: EditorView.scrollIntoView(selection, { y: 'center' }),
})
view.focus()
}
export const setCursorLineAndScroll = (
view: EditorView,
lineNumber: number,
columnNumber = 0
) => {
// TODO: map the position through any changes since the previous compile?
let selectionRange
try {
const pos = findValidPosition(view.state.doc, lineNumber, columnNumber)
selectionRange = EditorSelection.cursor(pos)
} catch (error) {
// ignore invalid cursor position
console.debug('invalid cursor position', error)
}
if (selectionRange) {
dispatchSelectionAndScroll(view, selectionRange)
}
}
export const setCursorPositionAndScroll = (view: EditorView, pos: number) => {
let selectionRange
try {
pos = Math.min(pos, view.state.doc.length)
selectionRange = EditorSelection.cursor(pos)
} catch (error) {
// ignore invalid cursor position
console.debug('invalid cursor position', error)
}
if (selectionRange) {
dispatchSelectionAndScroll(view, selectionRange)
}
}
@@ -0,0 +1,101 @@
import { EditorSelection, Prec } from '@codemirror/state'
import { EditorView, layer } from '@codemirror/view'
import { rectangleMarkerForRange } from '../utils/layer'
import { updateHasMouseDownEffect } from './visual/selection'
import browser from './browser'
// This is mostly a copy of CodeMirror's built-in drawSelection extension. We
// have our own copy so that we can use our own implementation of
// rectangleMarkerForRange
export const drawSelection = () => {
return [cursorLayer, selectionLayer, hideNativeSelection]
}
const canHidePrimary = !browser.ios
const hideNativeSelection = Prec.highest(
EditorView.theme({
'.cm-line': {
'caret-color': canHidePrimary ? 'transparent !important' : null,
'& ::selection': {
backgroundColor: 'transparent !important',
},
'&::selection': {
backgroundColor: 'transparent !important',
},
},
})
)
const cursorLayer = layer({
above: true,
markers(view) {
const {
selection: { ranges, main },
} = view.state
const cursors = []
for (const range of ranges) {
const primary = range === main
if (!range.empty || !primary || canHidePrimary) {
const className = primary
? 'cm-cursor cm-cursor-primary'
: 'cm-cursor cm-cursor-secondary'
const cursor = range.empty
? range
: EditorSelection.cursor(
range.head,
range.head > range.anchor ? -1 : 1
)
for (const piece of rectangleMarkerForRange(view, className, cursor)) {
cursors.push(piece)
}
}
}
return cursors
},
update(update, dom) {
if (update.transactions.some(tr => tr.selection)) {
dom.style.animationName =
dom.style.animationName === 'cm-blink' ? 'cm-blink2' : 'cm-blink'
}
return (
update.docChanged ||
update.selectionSet ||
updateHasMouseDownEffect(update)
)
},
mount(dom, view) {
dom.style.animationDuration = '1200ms'
},
class: 'cm-cursorLayer',
})
const selectionLayer = layer({
above: false,
markers(view) {
const markers = []
for (const range of view.state.selection.ranges) {
if (!range.empty) {
markers.push(
...rectangleMarkerForRange(view, 'cm-selectionBackground', range)
)
}
}
return markers
},
update(update, dom) {
return (
update.docChanged ||
update.selectionSet ||
update.viewportChanged ||
updateHasMouseDownEffect(update)
)
},
class: 'cm-selectionLayer',
})
@@ -0,0 +1,13 @@
import { Compartment, EditorState, TransactionSpec } from '@codemirror/state'
const readOnlyConf = new Compartment()
export const editable = () => {
return [readOnlyConf.of(EditorState.readOnly.of(true))]
}
export const setEditable = (value = true): TransactionSpec => {
return {
effects: [readOnlyConf.reconfigure(EditorState.readOnly.of(!value))],
}
}
@@ -0,0 +1,76 @@
import {
Decoration,
DecorationSet,
EditorView,
ViewPlugin,
ViewUpdate,
WidgetType,
} from '@codemirror/view'
import browser from './browser'
class EmptyLineWidget extends WidgetType {
toDOM(view: EditorView): HTMLElement {
const element = document.createElement('span')
element.className = 'ol-cm-filler'
return element
}
eq(widget: EmptyLineWidget) {
return true
}
}
export const emptyLineFiller = () => {
if (browser.ios) {
// disable on iOS as it breaks Backspace across empty lines
// https://github.com/overleaf/internal/issues/12192
return []
}
return [
ViewPlugin.fromClass(
class {
decorations: DecorationSet
constructor(view: EditorView) {
this.decorations = this.buildDecorations(view)
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged)
this.decorations = this.buildDecorations(update.view)
}
buildDecorations(view: EditorView) {
const decorations = []
const { from, to } = view.viewport
const { doc } = view.state
let pos = from
while (pos <= to) {
const line = doc.lineAt(pos)
if (line.length === 0) {
const decoration = Decoration.widget({
widget: new EmptyLineWidget(),
side: 1,
})
decorations.push(decoration.range(pos))
}
pos = line.to + 1
}
return Decoration.set(decorations)
}
},
{
decorations(value) {
return value.decorations
},
}
),
EditorView.baseTheme({
'.ol-cm-filler': {
display: 'inline-block',
width: '4px',
},
}),
]
}
@@ -0,0 +1,11 @@
import { Extension } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import { captureException } from '../../../infrastructure/error-reporter'
export const exceptionLogger = (): Extension => {
return EditorView.exceptionSink.of(exception => {
captureException(exception, {
tags: { handler: 'cm6-exception' },
})
})
}
@@ -0,0 +1,35 @@
import { ChangeSpec, EditorState, Transaction } from '@codemirror/state'
const BAD_CHARS_REGEXP = /[\0\uD800-\uDFFF]/g
const BAD_CHARS_REPLACEMENT_CHAR = '\uFFFD'
export const filterCharacters = () => {
return EditorState.transactionFilter.of(tr => {
if (tr.docChanged && !tr.annotation(Transaction.remote)) {
const changes: ChangeSpec[] = []
tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
const text = inserted.toString()
const newText = text.replaceAll(
BAD_CHARS_REGEXP,
BAD_CHARS_REPLACEMENT_CHAR
)
if (newText !== text) {
changes.push({
from: fromB,
to: toB,
insert: newText,
})
}
})
if (changes.length) {
return [tr, { changes, sequential: true }]
}
}
return tr
})
}
@@ -0,0 +1,19 @@
import { keymap } from '@codemirror/view'
import { foldAll, toggleFold, unfoldAll } from '@codemirror/language'
export function foldingKeymap() {
return keymap.of([
{
key: 'F2',
run: toggleFold,
},
{
key: 'Alt-Shift-1',
run: foldAll,
},
{
key: 'Alt-Shift-0',
run: unfoldAll,
},
])
}
@@ -0,0 +1,33 @@
import { ViewPlugin } from '@codemirror/view'
import { StateEffect } from '@codemirror/state'
import { updateHasEffect } from '../utils/effects'
const fontLoadEffect = StateEffect.define<readonly FontFace[]>()
export const hasFontLoadedEffect = updateHasEffect(fontLoadEffect)
const plugin = ViewPlugin.define(view => {
function listener(this: FontFaceSet, event: FontFaceSetLoadEvent) {
view.dispatch({ effects: fontLoadEffect.of(event.fontfaces) })
}
const fontLoadSupport = 'fonts' in document
if (fontLoadSupport) {
// TypeScript doesn't appear to know the correct type for the listener
document.fonts.addEventListener('loadingdone', listener as EventListener)
}
return {
destroy() {
if (fontLoadSupport) {
document.fonts.removeEventListener(
'loadingdone',
listener as EventListener
)
}
},
}
})
export function fontLoad() {
return plugin
}
@@ -0,0 +1,61 @@
import { Prec } from '@codemirror/state'
import { EditorView, keymap } from '@codemirror/view'
import { gotoLine } from '@codemirror/search'
export const goToLinePanel = () => {
return [
Prec.high(
keymap.of([
{
key: 'Mod-Shift-l',
preventDefault: true,
run: gotoLine,
},
])
),
EditorView.baseTheme({
'.cm-panel.cm-gotoLine': {
padding: '10px',
fontSize: '14px',
'& label': {
margin: 0,
fontSize: '14px',
'& .cm-textfield': {
margin: '0 10px',
maxWidth: '100px',
height: '34px',
padding: '5px 16px',
fontSize: '14px',
fontWeight: 'normal',
lineHeight: 'var(--line-height-base)',
color: 'var(--input-color)',
backgroundColor: '#fff',
backgroundImage: 'none',
borderRadius: 'var(--input-border-radius)',
boxShadow: 'inset 0 1px 1px rgb(0 0 0 / 8%)',
transition:
'border-color ease-in-out .15s, box-shadow ease-in-out .15s',
'&:focus-visible': {
outline: 'none',
},
'&:focus': {
borderColor: 'var(--input-border-focus)',
},
},
},
'& .cm-button': {
padding: '4px 16px 5px',
textTransform: 'capitalize',
fontSize: '14px',
lineHeight: 'var(--line-height-base)',
userSelect: 'none',
backgroundImage: 'none',
backgroundColor: 'var(--btn-default-bg)',
borderRadius: 'var(--btn-border-radius-base)',
border: '0 solid transparent',
color: '#fff',
},
},
}),
]
}
@@ -0,0 +1,116 @@
import {
Decoration,
DecorationSet,
EditorView,
layer,
LayerMarker,
RectangleMarker,
ViewPlugin,
ViewUpdate,
} from '@codemirror/view'
import { sourceOnly } from './visual/visual'
import { fullHeightCoordsAtPos } from '../utils/layer'
export const highlightActiveLine = (visual: boolean) => {
// this extension should only be active in the source editor
return sourceOnly(visual, [
activeLineLayer,
singleLineHighlighter,
EditorView.baseTheme({
'.ol-cm-activeLineLayer': {
pointerEvents: 'none',
},
}),
])
}
/**
* Line decoration approach used for non-wrapped lines, adapted from built-in
* CodeMirror 6 highlightActiveLine, licensed under the MIT license:
* https://github.com/codemirror/view/blob/main/src/active-line.ts
*/
const lineDeco = Decoration.line({ class: 'cm-activeLine' })
const singleLineHighlighter = ViewPlugin.fromClass(
class {
decorations: DecorationSet
constructor(view: EditorView) {
this.decorations = this.getDeco(view)
}
update(update: ViewUpdate) {
if (update.docChanged || update.selectionSet) {
this.decorations = this.getDeco(update.view)
}
}
getDeco(view: EditorView) {
const deco = []
// NOTE: only highlighting the active line for the main selection
const { main } = view.state.selection
// No active line highlight when text is selected
if (main.empty) {
const line = view.lineBlockAt(main.head)
if (line.height <= view.defaultLineHeight) {
deco.push(lineDeco.range(line.from))
}
}
return Decoration.set(deco)
}
},
{
decorations: v => v.decorations,
}
)
// Custom layer approach, used only for wrapped lines
const activeLineLayer = layer({
above: false,
class: 'ol-cm-activeLineLayer',
markers(view: EditorView): readonly LayerMarker[] {
const markers: LayerMarker[] = []
// NOTE: only highlighting the active line for the main selection
const { main } = view.state.selection
// no active line highlight when text is selected
if (!main.empty) {
return markers
}
// Use line decoration when line doesn't wrap
if (view.lineBlockAt(main.head).height <= view.defaultLineHeight) {
return markers
}
const coords = fullHeightCoordsAtPos(
view,
main.head,
main.assoc || undefined
)
if (coords) {
const scrollRect = view.scrollDOM.getBoundingClientRect()
const contentRect = view.contentDOM.getBoundingClientRect()
const scrollTop = view.scrollDOM.scrollTop
const top = coords.top - scrollRect.top + scrollTop
const left = contentRect.left - scrollRect.left
const width = contentRect.right - contentRect.left
const height = coords.bottom - coords.top
markers.push(
new RectangleMarker('cm-activeLine', left, top, width, height)
)
}
return markers
},
update(update: ViewUpdate): boolean {
return update.geometryChanged || update.selectionSet
},
})
@@ -0,0 +1,128 @@
/**
* This file is adapted from CodeMirror 6, licensed under the MIT license:
* https://github.com/codemirror/search/blob/main/src/selection-match.ts
*/
import { EditorView, layer, RectangleMarker } from '@codemirror/view'
import {
CharCategory,
EditorSelection,
EditorState,
Extension,
} from '@codemirror/state'
import { SearchCursor } from '@codemirror/search'
import { rectangleMarkerForRange } from '../utils/layer'
/*
This extension highlights text that matches the selection.
It uses the `"cm-selectionMatch"` class for the highlighting.
*/
export const highlightSelectionMatches = (): Extension => [
layer({
above: false,
markers(view) {
return buildMarkers(view, view.state)
},
update(update) {
return update.docChanged || update.selectionSet || update.viewportChanged
},
class: 'ol-cm-selectionMatchesLayer',
}),
EditorView.baseTheme({
'.ol-cm-selectionMatchesLayer': {
contain: 'size style',
pointerEvents: 'none',
},
'.cm-selectionMatch': {
position: 'absolute',
},
}),
]
// Whether the characters directly outside the given positions are non-word characters
function insideWordBoundaries(
check: (char: string) => CharCategory,
state: EditorState,
from: number,
to: number
): boolean {
return (
(from === 0 ||
check(state.sliceDoc(from - 1, from)) !== CharCategory.Word) &&
(to === state.doc.length ||
check(state.sliceDoc(to, to + 1)) !== CharCategory.Word)
)
}
// Whether the characters directly at the given positions are word characters
function insideWord(
check: (char: string) => CharCategory,
state: EditorState,
from: number,
to: number
): boolean {
return (
check(state.sliceDoc(from, from + 1)) === CharCategory.Word &&
check(state.sliceDoc(to - 1, to)) === CharCategory.Word
)
}
const buildMarkers = (
view: EditorView,
state: EditorState
): RectangleMarker[] => {
const sel = state.selection
if (sel.ranges.length > 1) {
return []
}
const range = sel.main
if (range.empty) {
return []
}
const len = range.to - range.from
if (len < 3 || len > 200) {
return []
}
const query = state.sliceDoc(range.from, range.to) // TODO: allow and include leading/trailing space?
if (query === '') {
return []
}
const check = state.charCategorizer(range.head)
if (
!(
insideWordBoundaries(check, state, range.from, range.to) &&
insideWord(check, state, range.from, range.to)
)
) {
return []
}
const markers: RectangleMarker[] = []
for (const part of view.visibleRanges) {
const cursor = new SearchCursor(state.doc, query, part.from, part.to)
while (!cursor.next().done) {
const { from, to } = cursor.value
if (!check || insideWordBoundaries(check, state, from, to)) {
markers.push(
...rectangleMarkerForRange(
view,
'cm-selectionMatch',
EditorSelection.range(from, to)
)
)
if (markers.length > 100) {
return []
}
}
}
}
return markers
}
@@ -0,0 +1,8 @@
import { Extension } from '@codemirror/state'
import { indentationMarkers as markers } from '@replit/codemirror-indentation-markers'
import { sourceOnly } from './visual/visual'
export const indentationMarkers = (visual: boolean): Extension =>
sourceOnly(visual, [
markers({ hideFirstIndent: true, highlightActiveBlock: false }),
])
@@ -0,0 +1,138 @@
import {
EditorView,
highlightSpecialChars,
keymap,
rectangularSelection,
tooltips,
crosshairCursor,
dropCursor,
highlightActiveLineGutter,
} from '@codemirror/view'
import { EditorState, Extension } from '@codemirror/state'
import { foldGutter, indentOnInput } from '@codemirror/language'
import { history, historyKeymap, defaultKeymap } from '@codemirror/commands'
import { lintKeymap } from '@codemirror/lint'
import { language } from './language'
import { lineWrappingIndentation } from './line-wrapping-indentation'
import { theme } from './theme'
import { realtime } from './realtime'
import { cursorPosition } from './cursor-position'
import { scrollPosition } from './scroll-position'
import { annotations } from './annotations'
import { cursorHighlights } from './cursor-highlights'
import { autoComplete } from './auto-complete'
import { editable } from './editable'
import { autoPair } from './auto-pair'
import { phrases } from './phrases'
import { spelling } from './spelling'
import { shortcuts } from './shortcuts'
import { symbolPalette } from './symbol-palette'
import { trackChanges } from './track-changes'
import { search } from './search'
import { filterCharacters } from './filter-characters'
import { keybindings } from './keybindings'
import { bracketMatching, bracketSelection } from './bracket-matching'
import { verticalOverflow } from './vertical-overflow'
import { exceptionLogger } from './exception-logger'
import { thirdPartyExtensions } from './third-party-extensions'
import { lineNumbers } from './line-numbers'
import { highlightActiveLine } from './highlight-active-line'
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
import { emptyLineFiller } from './empty-line-filler'
import { goToLinePanel } from './go-to-line'
import { parserWatcher } from './wait-for-parser'
import { drawSelection } from './draw-selection'
import { visual } from './visual/visual'
import { scrollOneLine } from './scroll-one-line'
import { foldingKeymap } from './folding-keymap'
import { inlineBackground } from './inline-background'
import { fontLoad } from './font-load'
import { indentationMarkers } from './indentation-markers'
const ignoredDefaultKeybindings = new Set([
// NOTE: disable "Mod-Enter" as it's used for "Compile"
'Mod-Enter',
// Disable Alt+Arrow as we have special behaviour on Windows / Linux
'Alt-ArrowLeft',
'Alt-ArrowRight',
// This keybinding causes issues on some keyboard layouts where \ is entered
// using AltGr. Windows treats Ctrl-Alt as AltGr, so trying to insert a \
// with Ctrl-Alt would trigger this keybinding, rather than inserting a \
'Mod-Alt-\\',
])
const moduleExtensions: Array<() => Extension> = importOverleafModules(
'sourceEditorExtensions'
).map((item: { import: { extension: Extension } }) => item.import.extension)
export const createExtensions = (options: Record<string, any>): Extension[] => [
lineNumbers(),
highlightSpecialChars(),
history({ newGroupDelay: 250 }),
foldGutter({
openText: '▾',
closedText: '▸',
}),
drawSelection(),
EditorState.allowMultipleSelections.of(true),
EditorView.lineWrapping,
indentOnInput(),
lineWrappingIndentation(options.visual.visual),
indentationMarkers(options.visual.visual),
bracketMatching(),
bracketSelection(),
rectangularSelection(),
crosshairCursor(),
dropCursor(),
tooltips({
parent: document.body,
}),
keymap.of([
...defaultKeymap.filter(
// We only filter on keys, so if the keybinding doesn't have a key,
// allow it
item => !item.key || !ignoredDefaultKeybindings.has(item.key)
),
...historyKeymap,
...lintKeymap,
]),
foldingKeymap(),
goToLinePanel(),
filterCharacters(),
// `autoComplete` needs to be before `keybindings` so that arrow key handling
// in the autocomplete pop-up takes precedence over Vim/Emacs key bindings
autoComplete(options.settings),
// `keybindings` needs to be before `language` so that Vim/Emacs bindings take
// precedence over language-specific keyboard shortcuts
keybindings(),
annotations(), // NOTE: must be before `language`
language(options.currentDoc, options.metadata, options.settings),
theme(options.theme),
realtime(options.currentDoc, options.handleError),
cursorPosition(options.currentDoc),
scrollPosition(options.currentDoc),
cursorHighlights(),
autoPair(options.settings),
editable(),
search(),
phrases(options.phrases),
parserWatcher(),
spelling(options.spelling),
shortcuts(),
symbolPalette(),
emptyLineFiller(), // NOTE: must be before `trackChanges`
trackChanges(options.currentDoc, options.changeManager),
visual(options.currentDoc, options.visual),
verticalOverflow(),
highlightActiveLine(options.visual.visual),
highlightActiveLineGutter(),
scrollOneLine(),
fontLoad(),
inlineBackground(options.visual.visual),
exceptionLogger(),
moduleExtensions.map(extension => extension()),
thirdPartyExtensions(),
]
@@ -0,0 +1,96 @@
import { Annotation, Compartment } from '@codemirror/state'
import { EditorView, ViewPlugin } from '@codemirror/view'
import { themeOptionsChange } from './theme'
import { sourceOnly } from './visual/visual'
import { round } from 'lodash'
import { hasLanguageLoadedEffect } from './language'
import { hasFontLoadedEffect } from './font-load'
const themeConf = new Compartment()
const changeHalfLeadingAnnotation = Annotation.define<boolean>()
function firstVisibleNonSpacePos(view: EditorView) {
for (const range of view.visibleRanges) {
const match = /\S/.exec(view.state.sliceDoc(range.from, range.to))
if (match) {
return range.from + match.index
}
}
return null
}
function measureHalfLeading(view: EditorView) {
const pos = firstVisibleNonSpacePos(view)
if (pos === null) {
return 0
}
const coords = view.coordsAtPos(pos)
if (!coords) {
return 0
}
const inlineBoxHeight = coords.bottom - coords.top
// Rounding prevents gaps appearing in some situations
return round((view.defaultLineHeight - inlineBoxHeight) / 2, 2)
}
function createTheme(halfLeading: number) {
return EditorView.theme({
'.cm-content': {
'--half-leading': halfLeading + 'px',
},
})
}
const plugin = ViewPlugin.define(
view => {
let halfLeading = 0
const measureRequest = {
read: () => {
return measureHalfLeading(view)
},
write: (newHalfLeading: number) => {
if (newHalfLeading !== halfLeading) {
halfLeading = newHalfLeading
window.setTimeout(() =>
view.dispatch({
effects: themeConf.reconfigure(createTheme(newHalfLeading)),
annotations: changeHalfLeadingAnnotation.of(true),
})
)
}
},
}
return {
update(update) {
// Ignore any update triggered by this plugin
if (
update.transactions.some(tr =>
tr.annotation(changeHalfLeadingAnnotation)
)
) {
return
}
if (
hasFontLoadedEffect(update) ||
(update.geometryChanged && !update.docChanged) ||
update.transactions.some(tr => tr.annotation(themeOptionsChange)) ||
hasLanguageLoadedEffect(update)
) {
view.requestMeasure(measureRequest)
}
},
}
},
{
provide: () => [themeConf.of(createTheme(0))],
}
)
export const inlineBackground = (visual: boolean) => {
return sourceOnly(visual, plugin)
}
@@ -0,0 +1,186 @@
import { openSearchPanel } from '@codemirror/search'
import {
Compartment,
EditorSelection,
Prec,
TransactionSpec,
} from '@codemirror/state'
import { EmacsHandler } from '@replit/codemirror-emacs'
import { CodeMirror } from '@replit/codemirror-vim'
import { foldCode, toggleFold, unfoldCode } from '@codemirror/language'
const hasNonEmptySelection = (cm: CodeMirror): boolean => {
const selections = cm.getSelections()
return selections.some(selection => selection.length)
}
let customisedVim = false
const customiseVimOnce = (Vim: any, CodeMirror: any) => {
if (customisedVim) {
return
}
// Allow copy via Ctrl-C in insert mode
Vim.unmap('<C-c>', 'insert')
Vim.defineAction(
'insertModeCtrlC',
(cm: CodeMirror, actionArgs: object, state: any) => {
if (hasNonEmptySelection(cm)) {
navigator.clipboard.writeText(cm.getSelection())
cm.setSelection(cm.getCursor(), cm.getCursor())
} else {
Vim.exitInsertMode(cm)
}
}
)
// Overwrite the moveByCharacters command with a decoration-aware version
Vim.defineMotion(
'moveByCharacters',
function (
cm: CodeMirror,
head: { line: number; ch: number },
motionArgs: Record<string, unknown>
) {
const { cm6: view } = cm
const repeat = Math.min(Number(motionArgs.repeat), view.state.doc.length)
const forward = Boolean(motionArgs.forward)
// head.line is 0-indexed
const startLine = view.state.doc.line(head.line + 1)
let cursor = EditorSelection.cursor(startLine.from + head.ch)
for (let i = 0; i < repeat; ++i) {
cursor = view.moveByChar(cursor, forward)
}
const finishLine = view.state.doc.lineAt(cursor.head)
return new CodeMirror.Pos(
finishLine.number - 1,
cursor.head - finishLine.from
)
}
)
Vim.mapCommand('<C-c>', 'action', 'insertModeCtrlC', undefined, {
context: 'insert',
})
// Code folding commands
Vim.defineAction('toggleFold', function (cm: CodeMirror) {
toggleFold(cm.cm6)
})
Vim.mapCommand('za', 'action', 'toggleFold')
Vim.defineAction('foldCode', function (cm: CodeMirror) {
foldCode(cm.cm6)
})
Vim.mapCommand('zc', 'action', 'foldCode')
Vim.defineAction('unfoldCode', function (cm: CodeMirror) {
unfoldCode(cm.cm6)
})
Vim.mapCommand('zo', 'action', 'unfoldCode')
// Make the Vim 'write' command start a compile
CodeMirror.commands.save = () => {
window.dispatchEvent(new Event('pdf:recompile'))
}
customisedVim = true
}
// Used to ensure that only one listener is active
let emacsSearchCloseListener: (() => void) | undefined
let customisedEmacs = false
const customiseEmacsOnce = () => {
if (customisedEmacs) {
return
}
customisedEmacs = true
const jumpToLastMark = (handler: EmacsHandler) => {
const mark = handler.popEmacsMark()
if (!mark || !mark.length) {
return
}
let selection = null
if (mark.length >= 2) {
selection = EditorSelection.range(mark[0], mark[1])
} else {
selection = EditorSelection.cursor(mark[0])
}
handler.view.dispatch({ selection, scrollIntoView: true })
}
EmacsHandler.addCommands({
openSearch(handler: EmacsHandler) {
const mark = handler.view.state.selection.main
handler.pushEmacsMark([mark.anchor, mark.head])
openSearchPanel(handler.view)
if (emacsSearchCloseListener) {
document.removeEventListener(
'cm:emacs-close-search-panel',
emacsSearchCloseListener
)
}
emacsSearchCloseListener = () => {
jumpToLastMark(handler)
}
document.addEventListener(
'cm:emacs-close-search-panel',
emacsSearchCloseListener
)
},
})
EmacsHandler.bindKey('C-s', 'openSearch')
EmacsHandler.bindKey('C-r', 'openSearch')
}
const options = [
{
name: 'default',
load: async () => {
// TODO: load default keybindings?
return []
},
},
{
name: 'vim',
load: () =>
import('@replit/codemirror-vim').then(m => {
customiseVimOnce(m.Vim, m.CodeMirror)
return m.vim()
}),
},
{
name: 'emacs',
load: () =>
import('@replit/codemirror-emacs').then(m => {
customiseEmacsOnce()
return m.emacs()
}),
},
]
const keybindingsConf = new Compartment()
export const keybindings = () => {
return keybindingsConf.of(Prec.highest([]))
}
export const setKeybindings = async (
selectedKeybindings = 'default'
): Promise<TransactionSpec> => {
const selectedOption = options.find(
option => option.name === selectedKeybindings
)
if (!selectedOption) {
throw new Error(`No key bindings found with name ${selectedKeybindings}`)
}
const support = await selectedOption.load()
return {
// NOTE: use Prec.highest as this keybinding must be above the default keymap(s)
effects: keybindingsConf.reconfigure(Prec.highest(support)),
}
}
@@ -0,0 +1,92 @@
import {
Compartment,
StateEffect,
StateField,
TransactionSpec,
} from '@codemirror/state'
import { languages } from '../languages'
import { ViewPlugin } from '@codemirror/view'
import { indentUnit, LanguageDescription } from '@codemirror/language'
import { Metadata } from '../../../../../types/metadata'
import { CurrentDoc } from '../../../../../types/current-doc'
import { updateHasEffect } from '../utils/effects'
export const languageLoadedEffect = StateEffect.define()
export const hasLanguageLoadedEffect = updateHasEffect(languageLoadedEffect)
const languageConf = new Compartment()
type Options = {
syntaxValidation: boolean
}
export const metadataState = StateField.define<Metadata | undefined>({
create: () => undefined,
update: (value, transaction) => {
for (const effect of transaction.effects) {
if (effect.is(setMetadataEffect)) {
return effect.value
}
}
return value
},
})
export const language = (
currentDoc: CurrentDoc,
metadata: Metadata,
{ syntaxValidation }: Options
) => {
const languageDescription = LanguageDescription.matchFilename(
languages,
currentDoc.docName
)
if (!languageDescription) {
return []
}
return [
// Default to four-space indentation, which prevents a shift in line
// indentation markers when LaTeX loads
languageConf.of(indentUnit.of(' ')),
metadataState,
ViewPlugin.define(view => {
// load the language asynchronously
languageDescription.load().then(support => {
view.dispatch({
effects: [
languageConf.reconfigure(support),
languageLoadedEffect.of(null),
],
})
// Wait until the previous effects have been processed
view.dispatch({
effects: [
setMetadataEffect.of(metadata),
setSyntaxValidationEffect.of(syntaxValidation),
],
})
})
return {}
}),
metadataState,
]
}
export const setMetadataEffect = StateEffect.define<Metadata>()
export const setMetadata = (values: Metadata): TransactionSpec => {
return {
effects: setMetadataEffect.of(values),
}
}
export const setSyntaxValidationEffect = StateEffect.define<boolean>()
export const setSyntaxValidation = (value: boolean): TransactionSpec => {
return {
effects: setSyntaxValidationEffect.of(value),
}
}
@@ -0,0 +1,85 @@
import { EditorSelection, Extension } from '@codemirror/state'
import {
BlockInfo,
EditorView,
lineNumbers as cmLineNumbers,
} from '@codemirror/view'
import { DebouncedFunc, throttle } from 'lodash'
export function lineNumbers(): Extension {
let listener: DebouncedFunc<(event: MouseEvent) => boolean> | null
function disableListener() {
if (listener) {
document.removeEventListener('mousemove', listener)
listener = null
}
}
// Creates a selection range capped within the document bounds. The range is
// anchored at the beginning so that it is a full line that is selected
function selection(view: EditorView, start: BlockInfo, end: BlockInfo) {
const clamp = (num: number) =>
Math.max(0, Math.min(view.state.doc.length, num))
let startPos = start.from
let endPos = end.to + 1
if (start.from === end.from) {
// Selecting one line
startPos = end.to + 1
endPos = start.from
} else if (end.from < start.from) {
// End is prior to start
endPos = end.from
startPos = start.to + 1
}
return EditorSelection.range(clamp(startPos), clamp(endPos))
}
// Wrapper around the built-in codemirror lineNumbers() extension
return cmLineNumbers({
domEventHandlers: {
mousedown: (view, line, event) => {
// Disable default focusing of line number
event.preventDefault()
// If we already have a listener, disable it
disableListener()
view.dispatch({
selection: selection(view, line, line),
})
// Focus the editor
view.contentDOM.focus()
// Set up new listener to track the mouse position
listener = throttle((event: MouseEvent) => {
// Check if we've missed a mouseup event by validating that the
// primary mouse button is still being held
if (event.buttons !== 1) {
disableListener()
return false
}
// Map the mouse cursor to the document, and select the lines matched
const documentPosition = view.posAtCoords({
x: event.pageX,
y: event.pageY,
})
if (documentPosition) {
const endLine = view.lineBlockAt(documentPosition)
view.dispatch({
selection: selection(view, line, endLine),
})
}
}, 50)
document.addEventListener('mousemove', listener)
return false
},
mouseup: () => {
disableListener()
return false
},
},
})
}
@@ -0,0 +1,144 @@
import {
Decoration,
DecorationSet,
EditorView,
ViewPlugin,
ViewUpdate,
} from '@codemirror/view'
import { type Range, StateEffect, StateField } from '@codemirror/state'
import { sourceOnly } from './visual/visual'
const MAX_INDENT_FRACTION = 0.9
const setMaxIndentEffect = StateEffect.define<number>()
export const lineWrappingIndentation = (visual: boolean) => {
// this extension should only be active in the source editor
return sourceOnly(visual, [
// store the current maxIndent, based on the clientWidth
StateField.define<number>({
create() {
return 0
},
update(value, tr) {
for (const effect of tr.effects) {
if (effect.is(setMaxIndentEffect)) {
value = effect.value
}
}
return value
},
provide(field) {
return [
// calculate the max indent when the geometry changes
ViewPlugin.define(view => {
const measure = {
key: 'line-wrapping-indentation-max-indent',
read(view: EditorView) {
return (
(view.contentDOM.clientWidth / view.defaultCharacterWidth) *
MAX_INDENT_FRACTION
)
},
write(value: number, view: EditorView) {
if (view.state.field(field) !== value) {
window.setTimeout(() => {
view.dispatch({
effects: setMaxIndentEffect.of(value),
})
})
}
},
}
return {
update(update: ViewUpdate) {
if (update.geometryChanged) {
view.requestMeasure(measure)
}
},
}
}),
// rebuild the decorations when needed
ViewPlugin.define<{ decorations: DecorationSet }>(
view => {
let previousMaxIndent = 0
const value = {
decorations: buildDecorations(view, view.state.field(field)),
update(update: ViewUpdate) {
const maxIndent = view.state.field(field)
if (
maxIndent !== previousMaxIndent ||
update.geometryChanged ||
update.viewportChanged
) {
value.decorations = buildDecorations(view, maxIndent)
}
previousMaxIndent = maxIndent
},
}
return value
},
{
decorations: value => value.decorations,
}
),
]
},
}),
])
}
export const buildDecorations = (view: EditorView, maxIndent: number) => {
const { state } = view
const { doc, tabSize } = state
const decorations: Range<Decoration>[] = []
let from = 0
for (const line of doc.iterLines()) {
// const indent = line.match(/^(\s*)/)[1].length
const indent = calculateIndent(line, tabSize, maxIndent)
if (indent) {
decorations.push(lineIndentDecoration(indent).range(from))
}
from += line.length + 1
}
return Decoration.set(decorations)
}
const lineIndentDecoration = (indent: number) =>
Decoration.line({
attributes: {
// style: `text-indent: ${indent}ch hanging`, // "hanging" would be ideal, when browsers support it
style: `text-indent: -${indent}ch; padding-left: calc(${indent}ch + 4px)`, // add 4px to account for existing padding-left
},
})
// calculate the character width of whitespace at the start of a line
const calculateIndent = (line: string, tabSize: number, maxIndent: number) => {
let indent = 0
for (const char of line) {
if (char === ' ') {
indent++
} else if (char === '\t') {
indent += tabSize - (indent % tabSize)
} else {
break
}
}
return Math.min(indent, maxIndent)
}
@@ -0,0 +1,13 @@
import { Compartment, EditorState, TransactionSpec } from '@codemirror/state'
const phrasesConf = new Compartment()
export const phrases = (phrases: Record<string, string>) => {
return phrasesConf.of(EditorState.phrases.of(phrases))
}
export const setPhrases = (value: Record<string, string>): TransactionSpec => {
return {
effects: phrasesConf.reconfigure(EditorState.phrases.of(value)),
}
}
@@ -0,0 +1,222 @@
import { Prec, Transaction, Annotation } from '@codemirror/state'
import { EditorView, ViewPlugin } from '@codemirror/view'
import { EventEmitter } from 'events'
import { CurrentDoc } from '../../../../../types/current-doc'
import { ShareDoc } from '../../../../../types/share-doc'
/*
* Integrate CodeMirror 6 with the real-time system, via ShareJS.
*
* Changes from CodeMirror are passed to the shareDoc
* via `handleTransaction`, while changes arriving from
* real-time are passed to CodeMirror via the EditorFacade.
*
* We use an `EditorFacade` to integrate with the rest of
* the IDE, providing an interface the other systems can work with.
*
* Related files:
* - frontend/js/ide/editor/Document.js
* - frontend/js/ide/editor/ShareJsDoc.js
* - frontend/js/ide/connection/EditorWatchdogManager.js
*/
export const realtime = (
{ currentDoc }: { currentDoc: CurrentDoc },
handleError: (error: Error) => void
) => {
const realtimePlugin = ViewPlugin.define(view => {
const editor = new EditorFacade(view)
currentDoc.attachToCM6(editor)
return {
update(update) {
if (update.docChanged) {
editor.handleUpdateFromCM(update.transactions)
}
},
destroy() {
// TODO: wrap in a timeout so processing can finish?
// window.setTimeout(() => {
currentDoc.detachFromCM6()
// }, 0)
},
}
})
// NOTE: not a view plugin, so shouldn't get removed
const ensureRealtimePlugin = EditorView.updateListener.of(update => {
if (!update.view.plugin(realtimePlugin)) {
const message = 'The realtime extension has been destroyed!!'
console.log(message)
if (currentDoc.doc) {
// display the "out of sync" modal
currentDoc.doc.emit('error', message)
} else {
// display the error boundary
handleError(new Error(message))
}
}
})
return Prec.highest([realtimePlugin, ensureRealtimePlugin])
}
export class EditorFacade extends EventEmitter {
public shareDoc: ShareDoc | null
public events: EventEmitter
private maxDocLength?: number
constructor(public view: EditorView) {
super()
this.view = view
this.shareDoc = null
this.events = new EventEmitter()
}
getValue() {
return this.view.state.doc.toString()
}
// Dispatch changes to CodeMirror view
cmInsert(position: number, text: string, origin?: string) {
this.view.dispatch({
changes: { from: position, insert: text },
annotations: [
Transaction.remote.of(origin === 'remote'),
Transaction.addToHistory.of(origin !== 'remote'),
],
})
}
cmDelete(position: number, text: string, origin?: string) {
this.view.dispatch({
changes: { from: position, to: position + text.length },
annotations: [
Transaction.remote.of(origin === 'remote'),
Transaction.addToHistory.of(origin !== 'remote'),
],
})
}
// Connect to ShareJS, passing changes to the CodeMirror view
// as new transactions.
// This is a broad immitation of helper functions supplied in
// the sharejs library. (See vendor/libs/sharejs, in particular
// the 'attach_cm' and 'attach_ace' helpers)
attachShareJs(shareDoc: ShareDoc, maxDocLength?: number) {
this.shareDoc = shareDoc
this.maxDocLength = maxDocLength
const check = () => {
// run in a timeout so it checks the editor content once this update has been applied
window.setTimeout(() => {
const editorText = this.getValue()
const otText = shareDoc.getText()
if (editorText !== otText) {
shareDoc.emit('error', 'Text does not match in CodeMirror 6')
console.error('Text does not match!')
console.error('editor: ' + editorText)
return console.error('ot: ' + otText)
}
}, 0)
}
const onInsert = (pos: number, text: string) => {
this.cmInsert(pos, text, 'remote')
check()
}
const onDelete = (pos: number, text: string) => {
this.cmDelete(pos, text, 'remote')
check()
}
check()
shareDoc.on('insert', onInsert)
shareDoc.on('delete', onDelete)
shareDoc.detach_cm6 = () => {
shareDoc.removeListener('insert', onInsert)
shareDoc.removeListener('delete', onDelete)
delete shareDoc.detach_cm6
this.shareDoc = null
}
}
// Process an update from CodeMirror, applying changes to the
// ShareJs doc if appropriate
handleUpdateFromCM(transactions: readonly Transaction[]) {
const shareDoc = this.shareDoc
if (!shareDoc) {
throw new Error('Trying to process updates with no shareDoc')
}
for (const transaction of transactions) {
if (transaction.docChanged) {
const origin = chooseOrigin(transaction)
if (origin === 'remote') {
return
}
if (
this.maxDocLength &&
transaction.changes.desc.newLength >= this.maxDocLength
) {
shareDoc.emit(
'error',
new Error('document length is greater than maxDocLength')
)
return
}
let positionShift = 0
transaction.changes.iterChanges(
(fromA, toA, fromB, toB, insertedText) => {
const fromUndo = origin === 'undo' || origin === 'reject'
const insertedLength = insertedText.length
const removedLength = toA - fromA
const inserted = insertedLength > 0
const removed = removedLength > 0
const pos = fromA + positionShift
if (removed) {
shareDoc.del(pos, removedLength, fromUndo)
}
if (inserted) {
shareDoc.insert(pos, insertedText.toString(), fromUndo)
}
// TODO: mapPos instead?
positionShift = positionShift - removedLength + insertedLength
this.emit('change', this, { origin, inserted, removed })
}
)
}
}
}
}
export const trackChangesAnnotation = Annotation.define()
const chooseOrigin = (transaction: Transaction) => {
if (transaction.annotation(Transaction.remote)) {
return 'remote'
}
if (transaction.annotation(Transaction.userEvent) === 'undo') {
return 'undo'
}
if (transaction.annotation(trackChangesAnnotation) === 'reject') {
return 'reject'
}
}
@@ -0,0 +1,35 @@
import { Command, EditorView, keymap } from '@codemirror/view'
function scrollByLine(view: EditorView, lineCount: number) {
view.scrollDOM.scrollTop += view.defaultLineHeight * lineCount
}
const scrollUpOneLine: Command = (view: EditorView) => {
scrollByLine(view, -1)
// Always consume the keypress to prevent the cursor going up a line when the
// editor is scrolled to the top
return true
}
const scrollDownOneLine: Command = (view: EditorView) => {
scrollByLine(view, 1)
// Always consume the keypress to prevent the cursor going down a line when
// the editor is scrolled to the bottom
return true
}
export function scrollOneLine() {
// Applied to Windows and Linux only
return keymap.of([
{
linux: 'Ctrl-ArrowUp',
win: 'Ctrl-ArrowUp',
run: scrollUpOneLine,
},
{
linux: 'Ctrl-ArrowDown',
win: 'Ctrl-ArrowDown',
run: scrollDownOneLine,
},
])
}
@@ -0,0 +1,157 @@
import { BlockInfo, EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view'
import { throttle } from 'lodash'
import customLocalStorage from '../../../infrastructure/local-storage'
import {
EditorSelection,
StateEffect,
Text,
TransactionSpec,
} from '@codemirror/state'
import { toggleVisualEffect } from './visual/visual'
const buildStorageKey = (docId: string) => `doc.position.${docId}`
type LineInfo = {
first: BlockInfo
middle: BlockInfo
}
export const scrollPosition = ({
currentDoc: { doc_id: docId },
}: {
currentDoc: { doc_id: string }
}) => {
// store lineInfo for use on unload, when the DOM has already been unmounted
let lineInfo: LineInfo
const scrollHandler = throttle(
(event, view) => {
// exclude a scroll event with no target, which happens when switching docs
if (event.target === view.scrollDOM) {
lineInfo = calculateLineInfo(view)
dispatchScrollPosition(lineInfo, view)
}
},
// long enough to capture intent, but short enough that the selected heading in the outline appears current
120,
{ trailing: true }
)
return [
// store/dispatch scroll position
ViewPlugin.define(
view => {
const unloadListener = () => {
if (lineInfo) {
storeScrollPosition(lineInfo, view, docId)
}
}
window.addEventListener('unload', unloadListener)
return {
update: (update: ViewUpdate) => {
for (const tr of update.transactions) {
for (const effect of tr.effects) {
if (effect.is(toggleVisualEffect)) {
// store the scroll position when switching between source and rich text
if (lineInfo) {
storeScrollPosition(lineInfo, view, docId)
}
} else if (effect.is(restoreScrollPositionEffect)) {
// restore the scroll position
window.setTimeout(() => {
view.dispatch(scrollStoredLineToTop(tr.state.doc, docId))
window.dispatchEvent(
new Event('editor:scroll-position-restored')
)
})
}
}
}
},
destroy: () => {
scrollHandler.cancel()
window.removeEventListener('unload', unloadListener)
unloadListener()
},
}
},
{
eventHandlers: {
scroll: scrollHandler,
},
}
),
]
}
const restoreScrollPositionEffect = StateEffect.define()
export const restoreScrollPosition = () => {
return {
effects: restoreScrollPositionEffect.of(null),
}
}
const calculateLineInfo = (view: EditorView) => {
// the top of the scrollDOM element relative to the top of the document
const { top, height } = view.scrollDOM.getBoundingClientRect()
const distanceFromDocumentTop = top - view.documentTop
return {
first: view.lineBlockAtHeight(distanceFromDocumentTop),
// top plus half the height of the scrollDOM element
middle: view.lineBlockAtHeight(distanceFromDocumentTop + height / 2),
}
}
// dispatch the middle visible line number (for the outline)
const dispatchScrollPosition = (lineInfo: LineInfo, view: EditorView) => {
const middleVisibleLine = view.state.doc.lineAt(lineInfo.middle.from).number
window.dispatchEvent(
new CustomEvent('scroll:editor:update', {
detail: middleVisibleLine,
})
)
}
// store the scroll position (first visible line number, for restoring on load)
const storeScrollPosition = (
lineInfo: LineInfo,
view: EditorView,
docId: string
) => {
const key = buildStorageKey(docId)
const data = customLocalStorage.getItem(key)
const firstVisibleLine = view.state.doc.lineAt(lineInfo.first.from).number
customLocalStorage.setItem(key, { ...data, firstVisibleLine })
}
// restore the scroll position using the stored first visible line number
const scrollStoredLineToTop = (doc: Text, docId: string): TransactionSpec => {
try {
const key = buildStorageKey(docId)
const data = customLocalStorage.getItem(key)
// restore the scroll position to its original position, or the last line of the document
const firstVisibleLine = Math.min(data?.firstVisibleLine ?? 1, doc.lines)
const line = doc.line(firstVisibleLine)
const selectionRange = EditorSelection.cursor(line.from)
return {
effects: EditorView.scrollIntoView(selectionRange, {
y: 'start',
yMargin: 0,
}),
}
} catch (e) {
// ignore invalid line number
console.error(e)
return {}
}
}
@@ -0,0 +1,255 @@
import {
searchKeymap,
search as searchExtension,
setSearchQuery,
getSearchQuery,
openSearchPanel,
SearchQuery,
searchPanelOpen,
} from '@codemirror/search'
import { EditorView, keymap, ViewPlugin } from '@codemirror/view'
import { Annotation, EditorState, TransactionSpec } from '@codemirror/state'
import { highlightSelectionMatches } from './highlight-selection-matches'
const restoreSearchQueryAnnotation = Annotation.define<boolean>()
const ignoredSearchKeybindings = new Set([
// This keybinding causes issues with entering @ on certain keyboard layouts
// https://github.com/overleaf/internal/issues/12119
'Alt-g',
])
const selectNextMatch = (query: SearchQuery, state: EditorState) => {
if (!query.valid) {
return false
}
let cursor = query.getCursor(state.doc, state.selection.main.from)
let result = cursor.next()
if (result.done) {
cursor = query.getCursor(state.doc)
result = cursor.next()
}
return result.done ? null : result.value
}
let searchQuery: SearchQuery | null
export const search = () => {
let open = false
return [
// keymap for search
keymap.of(
searchKeymap.filter(
item => !item.key || !ignoredSearchKeybindings.has(item.key)
)
),
// highlight text which matches the current selection
highlightSelectionMatches(),
// a wrapper round `search`, which creates a custom panel element and passes it to React by dispatching an event
searchExtension({
literal: true,
createPanel: () => {
const dom = document.createElement('div')
dom.className = 'ol-cm-search'
return {
dom,
mount() {
open = true
// focus the search input when the panel is already open
const searchInput =
dom.querySelector<HTMLInputElement>('[main-field]')
if (searchInput) {
searchInput.focus()
searchInput.select()
}
},
destroy() {
window.setTimeout(() => {
open = false // in a timeout, so the view plugin below can run its destroy method first
}, 0)
},
}
},
}),
// restore a stored search and re-open the search panel
ViewPlugin.define(view => {
if (searchQuery) {
const _searchQuery = searchQuery
window.setTimeout(() => {
openSearchPanel(view)
view.dispatch({
effects: setSearchQuery.of(_searchQuery),
annotations: restoreSearchQueryAnnotation.of(true),
})
}, 0)
}
return {
destroy() {
// persist the current search query if the panel is open
searchQuery = open ? getSearchQuery(view.state) : null
},
}
}),
// select a match while searching
EditorView.updateListener.of(update => {
for (const tr of update.transactions) {
// avoid changing the selection and viewport when switching between files
if (tr.annotation(restoreSearchQueryAnnotation)) {
continue
}
for (const effect of tr.effects) {
if (effect.is(setSearchQuery)) {
const query = effect.value
if (!query) return
// The rest of this messes up searching in Vim, which is handled by
// the Vim extension, so bail out here in Vim mode. Happily, the
// Vim extension sticks an extra property on the query value that
// can be checked
if ('forVim' in query) return
const next = selectNextMatch(query, tr.state)
if (next) {
// select a match if possible
const spec: TransactionSpec = {
selection: { anchor: next.from, head: next.to },
userEvent: 'select.search',
}
// scroll into view if not opening the panel
if (searchPanelOpen(tr.startState)) {
spec.effects = EditorView.scrollIntoView(next.from, {
y: 'center',
})
}
update.view.dispatch(spec)
} else {
// clear the selection if the query became invalid
const prevQuery = getSearchQuery(tr.startState)
if (prevQuery.valid) {
const { from } = tr.startState.selection.main
update.view.dispatch({
selection: { anchor: from },
})
}
}
}
}
}
}),
// search form theme
EditorView.theme({
'.ol-cm-search-form': {
padding: '10px',
display: 'flex',
gap: '10px',
background: 'var(--ol-blue-gray-1)',
'--ol-cm-search-form-focus-shadow':
'inset 0 1px 1px rgb(0 0 0 / 8%), 0 0 8px rgb(102 175 233 / 60%)',
},
'.ol-cm-search-controls': {
display: 'grid',
gridTemplateColumns: 'auto auto',
gridTemplateRows: 'auto auto',
gap: '10px',
},
'.ol-cm-search-form-row': {
display: 'flex',
gap: '10px',
justifyContent: 'space-between',
},
'.ol-cm-search-form-group': {
display: 'flex',
gap: '10px',
alignItems: 'center',
},
'.ol-cm-search-input-group': {
border: '1px solid var(--input-border)',
borderRadius: '20px',
background: 'white',
width: '100%',
maxWidth: '25em',
'& input[type="text"]': {
background: 'none',
boxShadow: 'none',
},
'& input[type="text"]:focus': {
outline: 'none',
boxShadow: 'none',
},
'& .btn.btn': {
background: 'var(--ol-blue-gray-0)',
color: 'var(--ol-blue-gray-3)',
borderRadius: '50%',
height: '2em',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: '2em',
marginRight: '3px',
'&.checked': {
color: '#fff',
backgroundColor: 'var(--ol-blue)',
},
'&:active': {
boxShadow: 'none',
},
},
'&:focus-within': {
borderColor: 'var(--input-border-focus)',
boxShadow: 'var(--ol-cm-search-form-focus-shadow)',
},
},
'.input-group .ol-cm-search-form-input': {
border: 'none',
},
'.ol-cm-search-input-button': {
background: '#fff',
color: 'inherit',
border: 'none',
},
'.ol-cm-search-input-button.focused': {
borderColor: 'var(--input-border-focus)',
boxShadow: 'var(--ol-cm-search-form-focus-shadow)',
},
'.ol-cm-search-form-button-group': {
flexShrink: 0,
},
'.ol-cm-search-form-position': {
flexShrink: 0,
color: 'var(--ol-blue-gray-4)',
},
'.ol-cm-search-hidden-inputs': {
position: 'absolute',
left: '-10000px',
},
'.ol-cm-search-form-close': {
flex: 1,
},
'.ol-cm-search-replace-input': {
order: 3,
},
'.ol-cm-search-replace-buttons': {
order: 4,
},
}),
]
}
@@ -0,0 +1,174 @@
import { type KeyBinding, keymap } from '@codemirror/view'
import { Prec } from '@codemirror/state'
import { indentMore } from '../commands/indent'
import {
indentLess,
redo,
deleteLine,
toggleLineComment,
cursorLineBoundaryBackward,
selectLineBoundaryBackward,
cursorLineBoundaryForward,
selectLineBoundaryForward,
cursorSyntaxLeft,
selectSyntaxLeft,
cursorSyntaxRight,
selectSyntaxRight,
} from '@codemirror/commands'
import { changeCase, duplicateSelection } from '../commands/ranges'
import { selectOccurrence } from '../commands/select'
import { cloneSelectionVertically } from '../commands/cursor'
import { dispatchEditorEvent } from './changes/change-manager'
export const shortcuts = () => {
const toggleReviewPanel = () => {
dispatchEditorEvent('toggle-review-panel')
return true
}
const addNewCommentFromKbdShortcut = () => {
dispatchEditorEvent('add-new-comment')
return true
}
const toggleTrackChangesFromKbdShortcut = () => {
dispatchEditorEvent('toggle-track-changes')
return true
}
const keyBindings: KeyBinding[] = [
{
key: 'Tab',
preventDefault: true,
run: indentMore,
},
{
key: 'Shift-Tab',
preventDefault: true,
run: indentLess,
},
{
key: 'Ctrl-y',
mac: 'Mod-y',
preventDefault: true,
run: redo,
},
{
key: 'Ctrl-Shift-z',
preventDefault: true,
run: redo,
},
{
key: 'Ctrl-Shift-/',
mac: 'Mod-Shift-/',
preventDefault: true,
run: toggleLineComment,
},
{
key: 'Ctrl-ß',
mac: 'Mod-ß',
preventDefault: true,
run: toggleLineComment,
},
{
key: 'Ctrl-#',
preventDefault: true,
run: toggleLineComment,
},
{
key: 'Ctrl-u',
preventDefault: true,
run: changeCase(true), // uppercase
},
{
key: 'Ctrl-Shift-u',
preventDefault: true,
run: changeCase(false), // lowercase
},
{
key: 'Ctrl-d',
mac: 'Mod-d',
preventDefault: true,
run: deleteLine,
},
{
key: 'Ctrl-j',
mac: 'Mod-j',
preventDefault: true,
run: toggleReviewPanel,
},
{
key: 'Ctrl-Shift-c',
mac: 'Mod-Shift-c',
preventDefault: true,
run: addNewCommentFromKbdShortcut,
},
{
key: 'Ctrl-Shift-a',
mac: 'Mod-Shift-a',
preventDefault: true,
run: toggleTrackChangesFromKbdShortcut,
},
{
key: 'Ctrl-Alt-ArrowUp',
preventDefault: true,
run: cloneSelectionVertically(false, true),
},
{
key: 'Ctrl-Alt-ArrowDown',
preventDefault: true,
run: cloneSelectionVertically(true, true),
},
{
key: 'Ctrl-Alt-Shift-ArrowUp',
preventDefault: true,
run: cloneSelectionVertically(false, false),
},
{
key: 'Ctrl-Alt-Shift-ArrowDown',
preventDefault: true,
run: cloneSelectionVertically(true, false),
},
{
key: 'Ctrl-Alt-ArrowLeft',
preventDefault: true,
run: selectOccurrence(false),
},
{
key: 'Ctrl-Alt-ArrowRight',
preventDefault: true,
run: selectOccurrence(true),
},
{
key: 'Ctrl-Shift-d',
mac: 'Mod-Shift-d',
run: duplicateSelection,
},
{
win: 'Alt-ArrowLeft',
linux: 'Alt-ArrowLeft',
run: cursorLineBoundaryBackward,
shift: selectLineBoundaryBackward,
preventDefault: true,
},
{
win: 'Alt-ArrowRight',
linux: 'Alt-ArrowRight',
run: cursorLineBoundaryForward,
shift: selectLineBoundaryForward,
preventDefault: true,
},
{
mac: 'Ctrl-ArrowLeft',
run: cursorSyntaxLeft,
shift: selectSyntaxLeft,
},
{
mac: 'Ctrl-ArrowRight',
run: cursorSyntaxRight,
shift: selectSyntaxRight,
},
]
return Prec.high(keymap.of(keyBindings))
}
@@ -0,0 +1,35 @@
import { postJSON } from '../../../../infrastructure/fetch-json'
import { Word } from './spellchecker'
const apiUrl = (path: string) => {
return `/spelling${path}`
}
export async function learnWordRequest(word?: Word) {
if (!word || !word.text) {
throw new Error(`Invalid word supplied: ${word}`)
}
return await postJSON(apiUrl('/learn'), {
body: {
word: word.text,
},
})
}
export function spellCheckRequest(
language: string,
words: Word[],
controller: AbortController
) {
const signal = controller.signal
const textWords = words.map(w => w.text)
return postJSON<{
misspellings: { index: number; suggestions: string[] }[]
}>(apiUrl('/check'), {
body: {
language,
words: textWords,
},
signal,
})
}
@@ -0,0 +1,105 @@
import { StateField, StateEffect } from '@codemirror/state'
import LRU from 'lru-cache'
import { Word } from './spellchecker'
const CACHE_MAX = 15000
export const cacheKey = (lang: string, wordText: string) => {
return `${lang}:${wordText}`
}
export type WordCacheValue = string[] | boolean | number
export class WordCache {
private _cache: LRU<string, WordCacheValue>
constructor() {
this._cache = new LRU({ max: CACHE_MAX })
}
set(lang: string, wordText: string, value: WordCacheValue) {
const key = cacheKey(lang, wordText)
this._cache.set(key, value)
}
get(lang: string, wordText: string) {
const key = cacheKey(lang, wordText)
return this._cache.get(key)
}
remove(lang: string, wordText: string) {
const key = cacheKey(lang, wordText)
this._cache.delete(key)
}
/*
* Given a language and a list of words,
* check the cache and sort the words into two categories:
* - words we know to be misspelled
* - words that are presently unknown to us
*/
checkWords(
lang: string,
wordsToCheck: Word[]
): {
knownMisspelledWords: Word[]
unknownWords: Word[]
} {
const knownMisspelledWords = []
const unknownWords = []
const seen: Record<string, WordCacheValue | undefined> = {}
for (const word of wordsToCheck) {
const wordText = word.text
if (seen[wordText] == null) {
seen[wordText] = this.get(lang, wordText)
}
const cached = seen[wordText]
if (cached == null) {
// Word is not known
unknownWords.push(word)
} else if (cached === true) {
// Word is known to be correct
} else {
// Word is known to be misspelled
word.suggestions = cached
knownMisspelledWords.push(word)
}
}
return {
knownMisspelledWords,
unknownWords,
}
}
}
export const addWordToCache = StateEffect.define<{
lang: string
wordText: string
value: string[] | boolean
}>()
export const removeWordFromCache = StateEffect.define<{
lang: string
wordText: string
}>()
// Share a single instance of WordCache between all instances of the CM6 source editor. This means that cached words are
// retained when switching away from CM6 and then back to it.
const wordCache = new WordCache()
export const cacheField = StateField.define<WordCache>({
create() {
return wordCache
},
update(cache, transaction) {
for (const effect of transaction.effects) {
if (effect.is(addWordToCache)) {
const { lang, wordText, value } = effect.value
cache.set(lang, wordText, value)
} else if (effect.is(removeWordFromCache)) {
const { lang, wordText } = effect.value
cache.remove(lang, wordText)
}
}
return cache
},
})
@@ -0,0 +1,241 @@
import {
StateField,
StateEffect,
Range,
RangeValue,
EditorSelection,
} from '@codemirror/state'
import { EditorView, showTooltip, Tooltip } from '@codemirror/view'
import { misspelledWordsField } from './misspelled-words'
import { addIgnoredWord } from './ignored-words'
import { learnWordRequest } from './backend'
import { Word } from './spellchecker'
const ITEMS_TO_SHOW = 8
type Mark = Range<RangeValue & { spec: { word: Word } }>
/*
* The time until which a click event will be ignored, so it doesn't immediately close the spelling menu.
* Safari emits an additional "click" event when event.preventDefault() is called in the "contextmenu" event listener.
*/
let openingUntil = 0
/*
* Hide the spelling menu on click
*/
const handleClickEvent = (event: MouseEvent, view: EditorView) => {
if (Date.now() < openingUntil) {
return
}
if (view.state.field(spellingMenuField, false)) {
view.dispatch({
effects: hideSpellingMenu.of(null),
})
}
}
/*
* Detect when the user right-clicks on a misspelled word,
* and show a menu of suggestions
*/
const handleContextMenuEvent = (event: MouseEvent, view: EditorView) => {
const position = view.posAtCoords(
{
x: event.pageX,
y: event.pageY,
},
false
)
const marks = view.state.field(misspelledWordsField)
let targetMark: Mark | null = null
marks.between(view.viewport.from, view.viewport.to, (from, to, value) => {
if (position >= from && position <= to) {
targetMark = { from, to, value }
return false
}
})
if (!targetMark) {
return
}
const { from, to, value } = targetMark as Mark
const targetWord = value.spec.word
if (!targetWord) {
console.debug(
'>> spelling no word associated with decorated range, stopping'
)
return
}
event.preventDefault()
openingUntil = Date.now() + 100
view.dispatch({
selection: EditorSelection.range(from, to),
effects: showSpellingMenu.of({
mark: targetMark,
word: targetWord,
}),
})
}
/*
* Spelling menu "tooltip" field.
* Manages the menu of suggestions shown on right-click
*/
export const spellingMenuField = StateField.define<Tooltip | null>({
create() {
return null
},
update(menu, transaction) {
for (const effect of transaction.effects) {
if (effect.is(hideSpellingMenu)) {
return null
} else if (effect.is(showSpellingMenu)) {
const { mark, word } = effect.value
// Build a "Tooltip" showing the suggestions
return {
pos: mark.from,
above: false,
strictSide: false,
create: view => {
return createSpellingSuggestionList(mark, word, view)
},
}
}
}
return menu
},
provide: field => {
return [
showTooltip.from(field),
EditorView.domEventHandlers({
contextmenu: handleContextMenuEvent,
click: handleClickEvent,
}),
]
},
})
const showSpellingMenu = StateEffect.define<{ mark: Mark; word: Word }>()
const hideSpellingMenu = StateEffect.define()
/*
* Creates the suggestion menu dom, to be displayed in the
* spelling menu "tooltip"
* */
const createSpellingSuggestionList = (
mark: Mark,
word: Word,
view: EditorView
) => {
// Wrapper div.
// Note, CM6 doesn't like showing complex elements
// 'inside' its Tooltip element, so we style this
// wrapper div to be basically invisible, and allow
// the dropdown list to hang off of it, giving the illusion that
// the list _is_ the tooltip.
// See the theme in spelling/index for styling that makes this work.
const dom = document.createElement('div')
dom.classList.add('ol-cm-spelling-context-menu-tooltip')
// List
const list = document.createElement('ul')
list.classList.add('dropdown-menu', 'dropdown-menu-unpositioned')
// List items, with links inside
if (Array.isArray(word.suggestions)) {
for (const suggestion of word.suggestions.slice(0, ITEMS_TO_SHOW)) {
const li = makeLinkItem(suggestion, event => {
const text = (event.target as HTMLElement).innerText
handleCorrectWord(mark, word, text, view)
event.preventDefault()
})
list.appendChild(li)
}
}
// Divider
const divider = document.createElement('li')
divider.classList.add('divider')
list.append(divider)
// Add to Dictionary
const addToDictionary = makeLinkItem(
'Add to Dictionary',
async function (event: Event) {
await handleLearnWord(word, view)
event.preventDefault()
}
)
list.append(addToDictionary)
dom.appendChild(list)
return { dom }
}
const makeLinkItem = (suggestion: string, handler: EventListener) => {
const li = document.createElement('li')
const button = document.createElement('button')
button.classList.add('btn-link', 'text-left', 'dropdown-menu-button')
button.onclick = handler
button.textContent = suggestion
li.appendChild(button)
return li
}
/*
* Learn a word, adding it to the local cache
* and sending it to the spelling backend
*/
const handleLearnWord = async function (word: Word, view: EditorView) {
try {
await learnWordRequest(word)
view.dispatch({
effects: [addIgnoredWord.of(word), hideSpellingMenu.of(null)],
})
} catch (err) {
console.error(err)
}
}
/*
* Correct a word, removing the marked range
* and replacing it with the chosen text
*/
const handleCorrectWord = (
mark: Mark,
word: Word,
text: string,
view: EditorView
) => {
const existingText = view.state.doc.sliceString(mark.from, mark.to)
// Defend against erroneous replacement, if the word at this
// position is not actually what we think it is
if (existingText !== word.text) {
console.debug(
'>> spelling word-to-correct does not match, stopping',
mark,
existingText,
word
)
return
}
view.dispatch({
changes: [
{
from: mark.from,
to: mark.to,
insert: text,
},
],
effects: [hideSpellingMenu.of(null)],
})
}
@@ -0,0 +1 @@
export const WORD_REGEX = /\\?['\p{L}]+/gu
@@ -0,0 +1,25 @@
import { StateField, StateEffect } from '@codemirror/state'
import ignoredWords, { IgnoredWords } from '../../../dictionary/ignored-words'
export const ignoredWordsField = StateField.define<IgnoredWords>({
create() {
return ignoredWords
},
update(ignoredWords, transaction) {
for (const effect of transaction.effects) {
if (effect.is(addIgnoredWord)) {
const newWord = effect.value
ignoredWords.add(newWord.text)
}
}
return ignoredWords
},
})
export const addIgnoredWord = StateEffect.define<{
text: string
}>()
export const updateAfterAddingIgnoredWord = StateEffect.define<string>()
export const resetSpellChecker = StateEffect.define()
@@ -0,0 +1,140 @@
import { EditorView, ViewPlugin } from '@codemirror/view'
import {
Compartment,
Facet,
StateEffect,
StateField,
TransactionSpec,
} from '@codemirror/state'
import { misspelledWordsField, resetMisspelledWords } from './misspelled-words'
import {
ignoredWordsField,
resetSpellChecker,
updateAfterAddingIgnoredWord,
} from './ignored-words'
import { addWordToCache, cacheField, removeWordFromCache } from './cache'
import { spellingMenuField } from './context-menu'
import { SpellChecker } from './spellchecker'
const spellCheckLanguageConf = new Compartment()
const spellCheckLanguageFacet = Facet.define<string | undefined>()
type Options = { spellCheckLanguage?: string }
/*
* Create the spelling extensions array, based on options passed.
*/
export const spelling = ({ spellCheckLanguage }: Options) => {
return [
EditorView.baseTheme({
'.ol-cm-spelling-error': {
textDecorationColor: 'red',
textDecorationLine: 'underline',
textDecorationStyle: 'dotted',
textDecorationThickness: '2px',
textDecorationSkipInk: 'none',
textUnderlineOffset: '0.2em',
},
'.cm-tooltip.ol-cm-spelling-context-menu-tooltip': {
borderWidth: '0',
},
}),
spellCheckLanguageConf.of(spellCheckLanguageFacet.of(spellCheckLanguage)),
spellCheckField,
misspelledWordsField,
ignoredWordsField,
cacheField,
spellingMenuField,
]
}
const spellCheckField = StateField.define<SpellChecker | null>({
create(state) {
const [spellCheckLanguage] = state.facet(spellCheckLanguageFacet)
return spellCheckLanguage ? new SpellChecker(spellCheckLanguage) : null
},
update(value, tr) {
for (const effect of tr.effects) {
if (effect.is(setSpellCheckLanguageEffect)) {
value?.destroy()
return effect.value ? new SpellChecker(effect.value) : null
}
}
return value
},
provide(field) {
return [
ViewPlugin.define(view => {
return {
destroy: () => {
view.state.field(field)?.destroy()
},
}
}),
EditorView.domEventHandlers({
focus: (event, view) => {
if (view.state.facet(EditorView.editable)) {
view.state.field(field)?.spellCheckAsap(view)
}
},
}),
EditorView.updateListener.of(update => {
if (update.state.facet(EditorView.editable)) {
update.state.field(field)?.handleUpdate(update)
}
}),
]
},
})
const setSpellCheckLanguageEffect = StateEffect.define<string | undefined>()
export const setSpelling = ({
spellCheckLanguage,
}: Options): TransactionSpec => {
return {
effects: [
resetMisspelledWords.of(null),
spellCheckLanguageConf.reconfigure(
spellCheckLanguageFacet.of(spellCheckLanguage)
),
setSpellCheckLanguageEffect.of(spellCheckLanguage),
],
}
}
export const addLearnedWord = (
spellCheckLanguage: string,
word: string
): TransactionSpec => {
return {
effects: [
addWordToCache.of({
lang: spellCheckLanguage,
wordText: word,
value: true,
}),
updateAfterAddingIgnoredWord.of(word),
],
}
}
export const removeLearnedWord = (
spellCheckLanguage: string,
word: string
): TransactionSpec => {
return {
effects: [
removeWordFromCache.of({
lang: spellCheckLanguage,
wordText: word,
}),
],
}
}
export const resetLearnedWords = (): TransactionSpec => {
return {
effects: [resetSpellChecker.of(null)],
}
}
@@ -0,0 +1,127 @@
import OError from '@overleaf/o-error'
import { Text } from '@codemirror/state'
import { Word } from './spellchecker'
import { ViewUpdate } from '@codemirror/view'
export class LineTracker {
private _lines: boolean[]
constructor(doc: Text) {
/*
* Maintain an array of booleans, one for each line of the document.
* `true` means a line has changed
*/
this._lines = new Array(doc.lines).fill(true)
}
dump() {
return [...this._lines]
}
count() {
return this._lines.length
}
lineHasChanged(lineNumber: number) {
return this._lines[lineNumber - 1] === true
}
/*
* Given a list of words, clear the 'changed' mark
* on the lines the words are on
*/
clearChangedLinesForWords(words: Word[]) {
words.forEach(word => {
this.clearLine(word.lineNumber)
})
}
clearLine(lineNumber: number) {
this._lines[lineNumber - 1] = false
}
clearAllLines() {
this._lines = this._lines.map(() => false)
}
resetAllLines() {
this._lines = this._lines.map(() => true)
}
markLineAsUpdated(lineNumber: number) {
this._lines[lineNumber - 1] = true
}
/*
* On update, for all changes, mark the affected lines
* as changed
*/
applyUpdate(update: ViewUpdate) {
for (const transaction of update.transactions) {
if (transaction.docChanged) {
let positionShift = 0
transaction.changes.iterChanges(
(fromA, toA, fromB, toB, insertedText) => {
const insertedLength = insertedText.length
const removedLength = toA - fromA
const hasInserted = insertedLength > 0
const hasRemoved = removedLength > 0
const doc = update.state.doc
const oldDoc = transaction.startState.doc
if (hasRemoved) {
const startLine = oldDoc.lineAt(fromA).number
const endLine = oldDoc.lineAt(toA).number
/* Mark start line as changed, and remove deleted lines
* Example:
* with this text:
* |1|aaaa|
* |2|bbbb| => [false, false, false, false]
* |3|cccc|
* |4|dddd|
*
* with a selection covering 'bbcccc' across lines 2 and 3,
* press backspace,
* resulting in:
* |1|aaaa|
* |2|bb| => [false, true, false]
* |3|dddd|
*/
this._lines.splice(startLine - 1, endLine - startLine + 1, true)
}
if (hasInserted) {
const startLine = doc.lineAt(fromB).number
/* Mark start line as changed, and insert new (changed) lines after.
* Example:
* with this text:
* |1|aaaa|
* |2|bbbb| => [false, false, false]
* |3|cccc|
*
* with the cursor at the end of line 2,
* insert the following text:
* |1|xx|
* |2|yy|
*
* results in:
* |1|aaaa|
* |2|bbbbxx| => [false, true, true, false]
* |3|yy|
* |4|cccc|
*/
const changes = new Array(insertedText.lines).fill(true)
this._lines.splice(startLine - 1, 1, ...changes)
}
positionShift = positionShift - removedLength + insertedLength
}
)
if (update.state.doc.lines !== this._lines.length) {
throw new OError(
'LineTracker length does not match document line count'
).withInfo({
documentLines: update.state.doc.lines,
trackerLines: this._lines.length,
})
}
}
}
}
}
@@ -0,0 +1,100 @@
import { StateField, StateEffect, Transaction } from '@codemirror/state'
import { EditorView, Decoration, DecorationSet } from '@codemirror/view'
import { updateAfterAddingIgnoredWord } from './ignored-words'
import _ from 'lodash'
import { Word } from './spellchecker'
export const addMisspelledWords = StateEffect.define<Word[]>()
export const resetMisspelledWords = StateEffect.define()
const createMark = (word: Word) => {
const mark = Decoration.mark({
class: 'ol-cm-spelling-error',
word,
})
return mark.range(word.from, word.to)
}
/*
* State for misspelled words, the results of a
* spellcheck request. Misspelled words are marked
* with a red wavy underline.
*/
export const misspelledWordsField = StateField.define<DecorationSet>({
create() {
return Decoration.none
},
update(marks, transaction) {
marks = marks.map(transaction.changes)
marks = removeMarksUnderEdit(marks, transaction)
for (const effect of transaction.effects) {
if (effect.is(addMisspelledWords)) {
// We're setting a new list of misspelled words
const misspelledWords = effect.value
marks = mergeMarks(marks, misspelledWords)
} else if (effect.is(updateAfterAddingIgnoredWord)) {
// Remove a misspelled word, all instances that match text
const word = effect.value
marks = removeAllMarksMatchingWordText(marks, word)
} else if (effect.is(resetMisspelledWords)) {
marks = Decoration.none
}
}
return marks
},
provide: field => {
return EditorView.decorations.from(field)
},
})
/*
* Remove any marks whos text has just been edited
*/
const removeMarksUnderEdit = (
marks: DecorationSet,
transaction: Transaction
) => {
transaction.changes.iterChanges((fromA, toA) => {
marks = marks.update({
// Filter out marks that overlap the change span
filter: (from, to, mark) => {
const changeStartWithinMark = from <= fromA && to >= fromA
const changeEndWithinMark = from <= toA && to >= toA
const markHasBeenEdited = changeStartWithinMark || changeEndWithinMark
return !markHasBeenEdited
},
})
})
return marks
}
/*
* Given the set of marks, and a list of new misspelled-words,
* merge these together into a new set of marks
*/
const mergeMarks = (marks: DecorationSet, words: Word[]) => {
const affectedLines = new Set(words.map(w => w.lineNumber))
marks = marks
.update({
filter: (from, to, mark) => {
return !affectedLines.has(mark.spec.word.lineNumber)
},
})
.update({
add: _.sortBy(words, ['from']).map(w => createMark(w)),
sort: true,
})
return marks
}
/*
* Remove existing marks matching the text of a supplied word
*/
const removeAllMarksMatchingWordText = (marks: DecorationSet, word: string) => {
return marks.update({
filter: (from, to, mark) => {
return mark.spec.word.text !== word
},
})
}
@@ -0,0 +1,387 @@
import { addMisspelledWords } from './misspelled-words'
import { ignoredWordsField, resetSpellChecker } from './ignored-words'
import { LineTracker } from './line-tracker'
import { cacheField, addWordToCache, WordCacheValue } from './cache'
import { WORD_REGEX } from './helpers'
import OError from '@overleaf/o-error'
import { spellCheckRequest } from './backend'
import { EditorView, ViewUpdate } from '@codemirror/view'
import { Line } from '@codemirror/state'
import { IgnoredWords } from '../../../dictionary/ignored-words'
import {
getNormalTextSpansFromLine,
NormalTextSpan,
} from '../../utils/tree-query'
import { waitForParser } from '../wait-for-parser'
const DEBUG = window ? window.sl_debugging : false
const _log = (...args: any) => {
if (DEBUG) {
console.debug('[SpellChecker]: ', ...args)
}
}
/*
* Spellchecker, handles updates, schedules spelling checks
*/
export class SpellChecker {
private abortController?: AbortController | null = null
private timeout: number | null = null
private firstCheck = true
private lineTracker: LineTracker | null = null
private waitingForParser = false
private firstCheckPending = false
// eslint-disable-next-line no-useless-constructor
constructor(private readonly language: string) {
this.language = language
}
destroy() {
_log('destroy')
this._clearPendingSpellCheck()
}
_abortRequest() {
if (this.abortController) {
_log('abort request')
this.abortController.abort()
this.abortController = null
}
}
handleUpdate(update: ViewUpdate) {
if (!this.lineTracker) {
this.lineTracker = new LineTracker(update.state.doc)
}
if (update.docChanged) {
this.lineTracker.applyUpdate(update)
this.scheduleSpellCheck(update.view)
} else if (update.viewportChanged) {
this.scheduleSpellCheck(update.view)
} else if (
update.transactions.some(tr => {
return tr.effects.some(effect => effect.is(resetSpellChecker))
})
) {
this.lineTracker.resetAllLines()
this.spellCheckAsap(update.view)
}
// At the point that the spellchecker is initialized, the editor may not
// yet be editable, and the parser may not be ready. Therefore, to do the
// initial spellcheck, watch for changes in the editability of the editor
// and kick off the process that performs a spellcheck once the parser is
// ready. CM6 dispatches a transaction after every chunk of parser work and
// when the editability changes, which means the spell checker is
// initialized as soon as possible.
else if (
this.firstCheck &&
!this.firstCheckPending &&
update.state.facet(EditorView.editable)
) {
this.firstCheckPending = true
_log('Scheduling initial spellcheck')
this.spellCheckAsap(update.view)
}
}
_performSpellCheck(view: EditorView) {
_log('Begin ---------------->')
const wordsToCheck = this.getWordsToCheck(view)
if (wordsToCheck.length === 0) {
return
}
_log(
'- words to check',
wordsToCheck.map(w => w.text)
)
const cache = view.state.field(cacheField)
const { knownMisspelledWords, unknownWords } = cache.checkWords(
this.language,
wordsToCheck
)
const processResult = (
misspellings: { index: number; suggestions: string[] }[]
) => {
this.lineTracker?.clearChangedLinesForWords(wordsToCheck)
if (this.firstCheck) {
this.firstCheck = false
this.firstCheckPending = false
}
const result = buildSpellCheckResult(
knownMisspelledWords,
unknownWords,
misspellings
)
_log('- result', result)
window.setTimeout(() => {
view.dispatch({
effects: compileEffects(result),
})
}, 0)
_log('<---------------- End')
}
_log('- before spellcheck request')
_log(
' - unknownWords',
unknownWords.map(w => w.text)
)
_log(
' - knownMisspelledWords',
knownMisspelledWords.map(w => w.text)
)
if (unknownWords.length === 0) {
_log('- skip request')
processResult([])
} else {
this._abortRequest()
this.abortController = new AbortController()
spellCheckRequest(this.language, unknownWords, this.abortController)
.then(result => {
this.abortController = null
processResult(result.misspellings)
})
.catch(error => {
this.abortController = null
_log('>> error in spellcheck request', error)
})
}
}
_spellCheckWhenParserReady(view: EditorView) {
if (this.waitingForParser) {
return
}
this.waitingForParser = true
waitForParser(
view,
view => viewportRangeToCheck(this.firstCheck, view).to
).then(() => {
this.waitingForParser = false
this._performSpellCheck(view)
})
}
_clearPendingSpellCheck() {
if (this.timeout) {
window.clearTimeout(this.timeout)
this.timeout = null
}
this._abortRequest()
}
_asyncSpellCheck(view: EditorView, delay: number) {
this._clearPendingSpellCheck()
this.timeout = window.setTimeout(() => {
this._spellCheckWhenParserReady(view)
this.timeout = null
}, delay)
}
spellCheckAsap(view: EditorView) {
this._asyncSpellCheck(view, 0)
}
scheduleSpellCheck(view: EditorView) {
this._asyncSpellCheck(view, 1000)
}
getWordsToCheck(view: EditorView) {
const lang = this.language
const ignoredWords = view.state.field(ignoredWordsField)
_log('- ignored words', ignoredWords)
if (!this.lineTracker) {
this.lineTracker = new LineTracker(view.state.doc)
}
let wordsToCheck: Word[] = []
for (const line of viewportLinesToCheck(
this.lineTracker,
this.firstCheck,
view
)) {
wordsToCheck = wordsToCheck.concat(
getWordsFromLine(view, line, ignoredWords, lang)
)
}
return wordsToCheck
}
}
export class Word {
public text: string
public from: number
public to: number
public lineNumber: number
public lang: string
public suggestions?: WordCacheValue
constructor(options: {
text: string
from: number
to: number
lineNumber: number
lang: string
}) {
const { text, from, to, lineNumber, lang } = options
if (
text == null ||
from == null ||
to == null ||
lineNumber == null ||
lang == null
) {
throw new OError('Spellcheck: invalid word').withInfo({ options })
}
this.text = text
this.from = from
this.to = to
this.lineNumber = lineNumber
this.lang = lang
}
}
export const buildSpellCheckResult = (
knownMisspelledWords: Word[],
unknownWords: Word[],
misspellings: { index: number; suggestions: string[] }[]
) => {
const cacheAdditions: [Word, string[] | boolean][] = []
// Put known misspellings into cache
const misspelledWords = misspellings.map(item => {
const word = { ...unknownWords[item.index] }
word.suggestions = item.suggestions
if (word.suggestions) {
cacheAdditions.push([word, word.suggestions])
}
return word
})
// if word was not misspelled, put it in the cache
for (const word of unknownWords) {
if (!misspelledWords.find(mw => mw.text === word.text)) {
cacheAdditions.push([word, true])
}
}
const finalMisspellings = misspelledWords.concat(knownMisspelledWords)
_log('- result')
_log(
' - finalMisspellings',
finalMisspellings.map(w => w.text)
)
_log(
' - cacheAdditions',
cacheAdditions.map(([w, v]) => `'${w.text}'=>${v}`)
)
return {
cacheAdditions,
misspelledWords: finalMisspellings,
}
}
export const compileEffects = (results: {
cacheAdditions: [Word, string[] | boolean][]
misspelledWords: Word[]
}) => {
const { cacheAdditions, misspelledWords } = results
return [
addMisspelledWords.of(misspelledWords),
...cacheAdditions.map(([word, value]) => {
return addWordToCache.of({
lang: word.lang,
wordText: word.text,
value,
})
}),
]
}
const viewportRangeToCheck = (firstCheck: boolean, view: EditorView) => {
const doc = view.state.doc
let { from, to } = view.viewport
let firstLineNumber = doc.lineAt(from).number
let lastLineNumber = doc.lineAt(to).number
/*
* On first check, we scan the viewport plus some padding on either side.
* Then on subsequent checks we just scan the viewport
*/
if (firstCheck) {
const visibleLineCount = lastLineNumber - firstLineNumber + 1
const padding = Math.floor(visibleLineCount * 2)
firstLineNumber = Math.max(firstLineNumber - padding, 1)
lastLineNumber = Math.min(lastLineNumber + padding, doc.lines)
from = doc.line(firstLineNumber).from
to = doc.line(lastLineNumber).to
}
return { from, to, firstLineNumber, lastLineNumber }
}
export const viewportLinesToCheck = (
lineTracker: LineTracker,
firstCheck: boolean,
view: EditorView
) => {
const { firstLineNumber, lastLineNumber } = viewportRangeToCheck(
firstCheck,
view
)
_log('- viewport lines', firstLineNumber, lastLineNumber)
const lines = []
for (
let lineNumber = firstLineNumber;
lineNumber <= lastLineNumber;
lineNumber++
) {
if (!lineTracker.lineHasChanged(lineNumber)) {
continue
}
lines.push(view.state.doc.line(lineNumber))
}
_log(
'- lines to check',
lines.map(l => l.number)
)
return lines
}
export const getWordsFromLine = (
view: EditorView,
line: Line,
ignoredWords: IgnoredWords,
lang: string
): Word[] => {
const lineNumber = line.number
const normalTextSpans: Array<NormalTextSpan> = getNormalTextSpansFromLine(
view,
line
)
const words: Word[] = []
let regexResult
normalTextSpans.forEach(span => {
WORD_REGEX.lastIndex = 0 // reset global stateful regexp for this usage
while ((regexResult = WORD_REGEX.exec(span.text))) {
let word = regexResult[0]
if (word.startsWith("'")) {
word = word.slice(1)
}
if (word.endsWith("'")) {
word = word.slice(0, -1)
}
if (!ignoredWords.has(word)) {
words.push(
new Word({
text: word,
from: span.from + regexResult.index,
to: span.from + regexResult.index + word.length,
lineNumber,
lang,
})
)
}
}
})
return words
}
@@ -0,0 +1,39 @@
import { ViewPlugin } from '@codemirror/view'
import { EditorSelection } from '@codemirror/state'
export const symbolPalette = () => {
return ViewPlugin.define(view => {
const listener = (event: Event) => {
const symbol = (event as CustomEvent<{ command: string }>).detail
view.focus()
const spec = view.state.changeByRange(range => {
const changeSet = view.state.changes([
{
from: range.from,
to: range.to,
insert: symbol.command,
},
])
return {
range: EditorSelection.cursor(changeSet.mapPos(range.to, 1)),
changes: changeSet,
}
})
view.dispatch(spec, {
scrollIntoView: true,
})
}
window.addEventListener('editor:insert-symbol', listener)
return {
destroy() {
window.removeEventListener('editor:insert-symbol', listener)
},
}
})
}
@@ -0,0 +1,270 @@
import { EditorView } from '@codemirror/view'
import { Annotation, Compartment, TransactionSpec } from '@codemirror/state'
import { defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language'
import { classHighlighter } from './class-highlighter'
const optionsThemeConf = new Compartment()
const selectedThemeConf = new Compartment()
export const themeOptionsChange = Annotation.define<boolean>()
export type FontFamily = 'monaco' | 'lucida'
export type LineHeight = 'compact' | 'normal' | 'wide'
export type OverallTheme = '' | 'light-'
type Options = {
fontSize: number
fontFamily: FontFamily
lineHeight: LineHeight
overallTheme: OverallTheme
}
export const theme = (options: Options) => [
baseTheme,
staticTheme,
syntaxHighlighting(classHighlighter),
optionsThemeConf.of(createThemeFromOptions(options)),
selectedThemeConf.of([]),
]
export const setOptionsTheme = (options: Options): TransactionSpec => {
return {
effects: optionsThemeConf.reconfigure(createThemeFromOptions(options)),
annotations: themeOptionsChange.of(true),
}
}
export const setEditorTheme = async (
editorTheme: string
): Promise<TransactionSpec> => {
const theme = await loadSelectedTheme(editorTheme)
return {
effects: selectedThemeConf.reconfigure(theme),
}
}
const svgUrl = (content: string) =>
`url('data:image/svg+xml,${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">${content}</svg>`
)}')`
export const lineHeights: Record<LineHeight, number> = {
compact: 1.33,
normal: 1.6,
wide: 2,
}
const fontFamilies: Record<FontFamily, string[]> = {
monaco: ['Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'monospace'],
lucida: ['Lucida Console', 'Source Code Pro', 'monospace'],
}
const createThemeFromOptions = ({
fontSize = 12,
fontFamily = 'monaco',
lineHeight = 'normal',
overallTheme = '',
}: Options) => {
// theme styles that depend on settings
return [
EditorView.editorAttributes.of({
class: overallTheme === '' ? 'overall-theme-dark' : 'overall-theme-light',
}),
EditorView.theme({
'&.cm-editor': {
// set variables
'--font-size': `${fontSize}px`,
'--source-font-family': fontFamilies[fontFamily]?.join(', '),
'--line-height': lineHeights[lineHeight],
},
'.cm-content': {
fontSize: 'var(--font-size)',
fontFamily: 'var(--source-font-family)',
lineHeight: 'var(--line-height)',
},
'.cm-cursor-primary': {
fontSize: 'var(--font-size)',
fontFamily: 'var(--source-font-family)',
lineHeight: 'var(--line-height)',
},
'.cm-gutters': {
fontSize: 'var(--font-size)',
lineHeight: 'var(--line-height)',
},
'.cm-tooltip': {
// set variables for tooltips, which are outside the editor
'--font-size': `${fontSize}px`,
'--source-font-family': fontFamilies[fontFamily]?.join(', '),
'--line-height': lineHeights[lineHeight],
// NOTE: fontFamily is not set here, as most tooltips use the UI font
fontSize: 'var(--font-size)',
},
'.cm-panel': {
fontSize: 'var(--font-size)',
},
'.cm-foldGutter .cm-gutterElement > span': {
height: 'calc(var(--font-size) * var(--line-height))',
},
'.cm-lineNumbers': {
fontFamily: 'var(--source-font-family)',
},
}),
]
}
// base styles that can have &dark and &light variants
const baseTheme = EditorView.baseTheme({
// use a background color for lint error ranges
'.cm-lintRange-error': {
padding: 'var(--half-leading, 0) 0',
background: 'rgba(255, 0, 0, 0.2)',
// avoid highlighting nested error ranges
'& .cm-lintRange-error': {
background: 'none',
},
},
'.cm-widgetBuffer': {
height: '1.3em',
},
'.cm-snippetFieldPosition': {
display: 'inline-block',
height: '1.3em',
},
// style the gutter fold button on hover
'&dark .cm-foldGutter .cm-gutterElement > span:hover': {
boxShadow: '0 1px 1px rgba(255, 255, 255, 0.2)',
backgroundColor: 'rgba(255, 255, 255, 0.1)',
},
'&light .cm-foldGutter .cm-gutterElement > span:hover': {
borderColor: 'rgba(0, 0, 0, 0.3)',
boxShadow: '0 1px 1px rgba(255, 255, 255, 0.7)',
backgroundColor: 'rgba(255, 255, 255, 0.2)',
},
})
// theme styles that don't depend on settings
// TODO: move some/all of these into baseTheme?
const staticTheme = EditorView.theme({
// make the editor fill the available height
'&': {
height: '100%',
textRendering: 'optimizeSpeed',
},
// remove the outline from the focused editor
'&.cm-editor.cm-focused:not(:focus-visible)': {
outline: 'none',
},
// override default styles for the search panel
'.cm-panel.cm-search label': {
display: 'inline-flex',
alignItems: 'center',
fontWeight: 'normal',
},
'.cm-selectionLayer': {
zIndex: -10,
},
// remove the right-hand border from the gutter
// ensure the gutter doesn't shrink
'.cm-gutters': {
borderRight: 'none',
flexShrink: 0,
},
// style the gutter fold button
// TODO: add a class to this element for easier theming
'.cm-foldGutter .cm-gutterElement > span': {
border: '1px solid transparent',
borderRadius: '3px',
display: 'inline-flex',
flexDirection: 'column',
justifyContent: 'center',
color: 'rgba(109, 109, 109, 0.7)',
},
// reduce the padding around line numbers
'.cm-lineNumbers .cm-gutterElement': {
padding: '0',
userSelect: 'none',
},
// make cursor visible with reduced opacity when the editor is not focused
'&:not(.cm-focused) .cm-cursor': {
display: 'block',
opacity: 0.2,
},
// make the cursor wider, and use the themed color
'.cm-cursor, .cm-dropCursor': {
borderWidth: '2px',
marginLeft: '-1px', // half the border width
borderLeftColor: 'inherit',
},
// set the default "selection match" style
'.cm-selectionMatch, .cm-searchMatch': {
backgroundColor: 'transparent',
outlineOffset: '-1px',
},
// make sure selectionMatch inside searchMatch doesn't have a background colour
'.cm-searchMatch .cm-selectionMatch': {
backgroundColor: 'transparent !important',
},
// Match the height of search matches to selection matches
'.cm-searchMatch': {
paddingTop: 'var(--half-leading)',
paddingBottom: 'var(--half-leading)',
},
// remove border from hover tooltips (e.g. cursor highlights)
'.cm-tooltip-hover': {
border: 'none',
},
// use the same style as Ace for snippet fields
'.cm-snippetField': {
background: 'rgba(194, 193, 208, 0.09)',
border: '1px dotted rgba(211, 208, 235, 0.62)',
},
// style the fold placeholder
'.cm-foldPlaceholder': {
boxSizing: 'border-box',
display: 'inline-block',
height: '11px',
width: '1.8em',
marginTop: '-2px',
verticalAlign: 'middle',
backgroundImage:
'url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABEAAAAJCAYAAADU6McMAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAJpJREFUeNpi/P//PwOlgAXGYGRklAVSokD8GmjwY1wasKljQpYACtpCFeADcHVQfQyMQAwzwAZI3wJKvCLkfKBaMSClBlR7BOQikCFGQEErIH0VqkabiGCAqwUadAzZJRxQr/0gwiXIal8zQQPnNVTgJ1TdawL0T5gBIP1MUJNhBv2HKoQHHjqNrA4WO4zY0glyNKLT2KIfIMAAQsdgGiXvgnYAAAAASUVORK5CYII="),url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAA3CAYAAADNNiA5AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAACJJREFUeNpi+P//fxgTAwPDBxDxD078RSX+YeEyDFMCIMAAI3INmXiwf2YAAAAASUVORK5CYII=")',
backgroundRepeat: 'no-repeat, repeat-x',
backgroundPosition: 'center center, top left',
color: 'transparent',
border: '1px solid black',
borderRadius: '2px',
},
// align the lint icons with the line numbers
'.cm-gutter-lint .cm-gutterElement': {
padding: '0.3em',
},
// reset the default style for the lint gutter error marker, which uses :before
'.cm-lint-marker-error:before': {
content: 'normal',
},
// set a new icon for the lint gutter error marker
'.cm-lint-marker-error': {
content: svgUrl(
`<circle cx="20" cy="20" r="15" fill="#f87" stroke="#f43" stroke-width="6"/>`
),
},
// set a new icon for the lint gutter warning marker
'.cm-lint-marker-warning': {
content: svgUrl(
`<path fill="#FCC483" stroke="#DE8014" stroke-width="6" stroke-linejoin="round" d="M20 6L37 35L3 35Z"/>`
),
},
})
const loadSelectedTheme = async (editorTheme: string) => {
const { theme, highlightStyle, dark } = await import(
/* webpackChunkName: "cm6-theme" */ `../themes/cm6/${editorTheme}.json`
)
return [
EditorView.theme(theme, { dark }),
highlightStyle
? EditorView.theme(highlightStyle, { dark })
: syntaxHighlighting(defaultHighlightStyle, { fallback: true }), // use the default highlight style if none is provided
]
}
@@ -0,0 +1,42 @@
import type { Extension } from '@codemirror/state'
import CodeMirror, { CodeMirrorVim } from './bundle'
export const thirdPartyExtensions = (): Extension => {
const extensions: Extension[] = []
window.dispatchEvent(
new CustomEvent('UNSTABLE_editor:extensions', {
detail: { CodeMirror, CodeMirrorVim, extensions },
})
)
Object.defineProperty(window, 'UNSTABLE_editorHelp', {
writable: false,
enumerable: true,
value: `
Listen for the UNSTABLE_editor:extensions event to add your CodeMirror 6
extension(s) to the extensions array. Use the exported objects to avoid
instanceof comparison errors.
Open an issue on http://github.com/overleaf/overleaf if you think more
should be exported.
This API is **unsupported** and subject to change without warning.
Example:
window.addEventListener("UNSTABLE_editor:extensions", function(evt) {
const { CodeMirror, extensions } = evt.detail;
// CodeMirror contains exported objects from the CodeMirror instance
const { EditorSelection, ViewPlugin } = CodeMirror;
// ...
// Any custom extensions should be pushed to the \`extensions\` array
extensions.push(myCustomExtension)
});`,
})
return extensions
}
@@ -0,0 +1,106 @@
import { EditorSelection, EditorState, SelectionRange } from '@codemirror/state'
import { Command } from '@codemirror/view'
import {
closeSearchPanel,
openSearchPanel,
searchPanelOpen,
} from '@codemirror/search'
import { toggleRanges, wrapRanges } from '../../commands/ranges'
import {
ancestorListType,
toggleListForRanges,
unwrapBulletList,
unwrapNumberedList,
wrapInBulletList,
wrapInNumberedList,
} from './lists'
import { snippet } from '@codemirror/autocomplete'
import { snippets } from './snippets'
import { minimumListDepthForSelection } from '../../utils/tree-operations/ancestors'
export const toggleBold = toggleRanges('\\textbf')
export const toggleItalic = toggleRanges('\\textit')
export const wrapInHref = wrapRanges('\\href{}{', '}', false, range =>
EditorSelection.cursor(range.from - 2)
)
export const toggleBulletList = toggleListForRanges('itemize')
export const toggleNumberedList = toggleListForRanges('enumerate')
export const wrapInInlineMath = wrapRanges('\\(', '\\)')
export const wrapInDisplayMath = wrapRanges('\n\\[', '\\]\n')
const ensureEmptyLine = (state: EditorState, range: SelectionRange) => {
let pos = range.anchor
let suffix = ''
const line = state.doc.lineAt(pos)
if (line.length) {
pos = Math.min(line.to + 1, state.doc.length)
const nextLine = state.doc.lineAt(pos)
if (nextLine.length) {
suffix = '\n'
}
}
return { pos, suffix }
}
export const insertFigure: Command = view => {
const { state, dispatch } = view
const { pos, suffix } = ensureEmptyLine(state, state.selection.main)
const template = `\n${snippets.figure}\n${suffix}`
snippet(template)({ state, dispatch }, { label: 'Figure' }, pos, pos)
return true
}
export const insertTable: Command = view => {
const { state, dispatch } = view
const { pos, suffix } = ensureEmptyLine(state, state.selection.main)
const template = `\n${snippets.table}\n${suffix}`
snippet(template)({ state, dispatch }, { label: 'Table' }, pos, pos)
return true
}
export const indentDecrease: Command = view => {
if (minimumListDepthForSelection(view.state) < 2) {
return false
}
switch (ancestorListType(view.state)) {
case 'itemize':
return unwrapBulletList(view)
case 'enumerate':
return unwrapNumberedList(view)
default:
return false
}
}
export const cursorIsAtStartOfListItem = (state: EditorState) => {
return state.selection.ranges.every(range => {
const line = state.doc.lineAt(range.from)
const prefix = state.sliceDoc(line.from, range.from)
return /\\item\s*$/.test(prefix)
})
}
export const indentIncrease: Command = view => {
if (minimumListDepthForSelection(view.state) < 1) {
return false
}
switch (ancestorListType(view.state)) {
case 'itemize':
return wrapInBulletList(view)
case 'enumerate':
return wrapInNumberedList(view)
default:
return false
}
}
export const toggleSearch: Command = view => {
if (searchPanelOpen(view.state)) {
closeSearchPanel(view)
} else {
openSearchPanel(view)
}
return true
}
@@ -0,0 +1,14 @@
import { EditorState } from '@codemirror/state'
export const canAddComment = (state: EditorState) => {
// TODO: permissions.comment
// allow an empty selection if there's content on the line
const range = state.selection.main
if (range.empty) {
return state.doc.lineAt(range.head).text.length > 0
}
// always allow a non-empty selection
return true
}
@@ -0,0 +1,316 @@
import { EditorView } from '@codemirror/view'
import {
ChangeSpec,
EditorSelection,
EditorState,
SelectionRange,
} from '@codemirror/state'
import {
getIndentation,
getIndentUnit,
IndentContext,
indentString,
syntaxTree,
} from '@codemirror/language'
import {
ancestorNodeOfType,
ancestorOfNodeWithType,
ancestorWithType,
descendantsOfNodeWithType,
} from '../../utils/tree-operations/ancestors'
import { getEnvironmentName } from '../../utils/tree-operations/environments'
import { ListEnvironment } from '../../lezer-latex/latex.terms.mjs'
export const ancestorListType = (state: EditorState): string | null => {
const ancestorNode = ancestorWithType(state, ListEnvironment)
if (!ancestorNode) {
return null
}
return getEnvironmentName(ancestorNode, state)
}
const wrapRangeInList = (
state: EditorState,
range: SelectionRange,
environment: string,
prefix = ''
) => {
const cx = new IndentContext(state)
const columns = getIndentation(cx, range.from) ?? 0
const unit = getIndentUnit(state)
const indent = indentString(state, columns)
const itemIndent = indentString(state, columns + unit)
const fromLine = state.doc.lineAt(range.from)
const toLine = state.doc.lineAt(range.to)
// TODO: merge with existing list at the same level?
const lines: string[] = [`${indent}\\begin{${environment}}`]
for (const line of state.doc.iterLines(fromLine.number, toLine.number + 1)) {
let content = line.trim()
if (content.endsWith('\\item')) {
content += ' ' // ensure a space after \item
}
lines.push(`${itemIndent}${prefix}${content}`)
}
if (lines.length === 1) {
lines.push(`${itemIndent}${prefix}`)
}
const changes = [
{
from: fromLine.from,
to: toLine.to,
insert: lines.join('\n'),
},
]
// map through the prefix
range = EditorSelection.cursor(range.to).map(state.changes(changes), 1)
changes.push({
from: toLine.to,
to: toLine.to,
insert: `\n${indent}\\end{${environment}}`,
})
return {
range,
changes,
}
}
const wrapRangesInList =
(environment: string) =>
(view: EditorView): boolean => {
view.dispatch(
view.state.changeByRange(range =>
wrapRangeInList(view.state, range, environment)
)
)
return true
}
const unwrapRangeFromList = (
state: EditorState,
range: SelectionRange,
environment: string
) => {
const node = syntaxTree(state).resolveInner(range.from)
const list = ancestorOfNodeWithType(node, ListEnvironment)
if (!list) {
return { range }
}
const fromLine = state.doc.lineAt(range.from)
const toLine = state.doc.lineAt(range.to)
const listFromLine = state.doc.lineAt(list.from)
const listToLine = state.doc.lineAt(list.to)
const cx = new IndentContext(state)
const columns = getIndentation(cx, range.from) ?? 0
const unit = getIndentUnit(state)
const indent = indentString(state, columns - unit) // decrease indent depth
// TODO: only move lines that are list items
const changes: ChangeSpec[] = []
if (listFromLine.number === fromLine.number - 1) {
// remove \begin if there are no items before this one
changes.push({
from: listFromLine.from,
to: listFromLine.to + 1,
insert: '',
})
} else {
// finish the previous list for the previous items
changes.push({
from: fromLine.from,
insert: `${indent}\\end{${environment}}\n`,
})
}
const ensureSpace = (state: EditorState, from: number, to: number) => {
return /^\s*$/.test(state.doc.sliceString(from, to))
}
for (
let lineNumber = fromLine.number;
lineNumber <= toLine.number;
lineNumber++
) {
const line = state.doc.line(lineNumber)
const to = line.from + unit
if (to <= line.to && ensureSpace(state, line.from, to)) {
// remove indent
changes.push({
from: line.from,
to,
insert: '',
})
}
}
if (listToLine.number === toLine.number + 1) {
// remove \end if there are no items after this one
changes.push({
from: listToLine.from,
to: listToLine.to + 1,
insert: '',
})
} else {
// start a new list for the remaining items
changes.push({
from: toLine.to,
insert: `\n${indent}\\begin{${environment}}`,
})
}
// map the range through these changes
range = range.map(state.changes(changes), -1)
return { range, changes }
}
const unwrapRangesFromList =
(environment: string) =>
(view: EditorView): boolean => {
view.dispatch(
view.state.changeByRange(range =>
unwrapRangeFromList(view.state, range, environment)
)
)
return true
}
const toggleListForRange = (
view: EditorView,
range: SelectionRange,
environment: string
) => {
const ancestorNode = ancestorNodeOfType(
view.state,
range.head,
ListEnvironment
)
if (ancestorNode) {
const beginEnvNode = ancestorNode.getChild('BeginEnv')
const endEnvNode = ancestorNode.getChild('EndEnv')
if (beginEnvNode && endEnvNode) {
const beginEnvNameNode = beginEnvNode
?.getChild('EnvNameGroup')
?.getChild('ListEnvName')
const endEnvNameNode = endEnvNode
?.getChild('EnvNameGroup')
?.getChild('ListEnvName')
if (beginEnvNameNode && endEnvNameNode) {
const envName = view.state
.sliceDoc(beginEnvNameNode.from, beginEnvNameNode.to)
.trim()
if (envName === environment) {
const beginLine = view.state.doc.lineAt(beginEnvNode.from)
const endLine = view.state.doc.lineAt(endEnvNode.from)
// whether the command is the only content on this line, apart from whitespace
const emptyBeginLine = /^\s*\\begin\{[^}]*}\s*$/.test(beginLine.text)
const emptyEndLine = /^\s*\\end\{[^}]*}\s*$/.test(endLine.text)
// toggle list off
const changeSpec: ChangeSpec[] = [
{
from: emptyBeginLine ? beginLine.from - 1 : beginEnvNode.from,
to: emptyBeginLine ? beginLine.to : beginEnvNode.to,
insert: '',
},
{
from: emptyEndLine ? endLine.from : endEnvNode.from,
to: emptyEndLine ? endLine.to + 1 : endEnvNode.to,
insert: '',
},
]
const commandNodes = descendantsOfNodeWithType(
ancestorNode,
'Item'
).filter(
commandNode =>
view.state.sliceDoc(commandNode.from, commandNode.to) === '\\item'
)
if (commandNodes.length > 0) {
// whether the command is the only content on this line, apart from whitespace
const emptyLineBeforeItem = /^\s*\\item\{/.test(beginLine.text)
const indentUnit = emptyLineBeforeItem
? getIndentUnit(view.state)
: 0
for (const commandNode of commandNodes) {
changeSpec.push({
from: commandNode.from - indentUnit,
to: commandNode.to + 1,
insert: '',
})
}
}
const changes = view.state.changes(changeSpec)
return {
range: range.map(changes),
changes,
}
} else {
// change list type
const changeSpec: ChangeSpec[] = [
{
from: beginEnvNameNode.from,
to: beginEnvNameNode.to,
insert: environment,
},
{
from: endEnvNameNode.from,
to: endEnvNameNode.to,
insert: environment,
},
]
const changes = view.state.changes(changeSpec)
return {
range: range.map(changes),
changes,
}
}
}
}
} else {
// create a new list
return wrapRangeInList(view.state, range, environment, '\\item ')
}
return { range }
}
export const toggleListForRanges =
(environment: string) => (view: EditorView) => {
view.dispatch(
view.state.changeByRange(range =>
toggleListForRange(view, range, environment)
)
)
}
export const wrapInBulletList = wrapRangesInList('itemize')
export const wrapInNumberedList = wrapRangesInList('enumerate')
export const unwrapBulletList = unwrapRangesFromList('itemize')
export const unwrapNumberedList = unwrapRangesFromList('enumerate')
@@ -0,0 +1,139 @@
import { EditorSelection, EditorState, SelectionRange } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import { syntaxTree } from '@codemirror/language'
import { ancestorsOfNodeWithType } from '../../utils/tree-operations/ancestors'
import { SyntaxNode } from '@lezer/common'
export const findCurrentSectionHeadingLevel = (state: EditorState) => {
const selections = state.selection.ranges.map(range =>
rangeInfo(state, range)
)
const currentLevels = new Set(selections.map(item => item.level))
return currentLevels.size === 1 ? selections[0] : null
}
type RangeInfo = {
range: SelectionRange
command?: SyntaxNode
ctrlSeq?: SyntaxNode
level: string
}
export const rangeInfo = (
state: EditorState,
range: SelectionRange
): RangeInfo => {
const tree = syntaxTree(state)
const node = tree.resolveInner(range.anchor)
const command = ancestorsOfNodeWithType(node, 'SectioningCommand').next()
.value
const ctrlSeq = command?.firstChild
const level = ctrlSeq
? state.sliceDoc(ctrlSeq.from + 1, ctrlSeq.to).trim()
: 'text'
return { command, ctrlSeq, level, range }
}
export const setSectionHeadingLevel = (view: EditorView, level: string) => {
view.dispatch(
view.state.changeByRange(range => {
const info = rangeInfo(view.state, range)
if (level === info.level) {
return { range }
}
if (level === 'text' && info.command) {
// remove
const argument = info.command.getChild('SectioningArgument')
if (argument) {
const content = view.state.sliceDoc(
argument.from + 1,
argument.to - 1
)
// map through the prefix only
const changedRange = range.map(
view.state.changes([
{ from: info.command.from, to: argument.from + 1, insert: '' },
]),
1
)
return {
range: changedRange,
changes: [
{
from: info.command.from,
to: info.command.to,
insert: content,
},
],
}
}
return { range }
} else if (info.level === 'text') {
// add
const insert = {
prefix: `\\${level}{`,
suffix: '}',
}
const originalRange = range
const line = view.state.doc.lineAt(range.anchor)
if (range.empty) {
// expand range to cover the whole line
range = EditorSelection.range(line.from, line.to)
} else {
if (range.from !== line.from) {
insert.prefix = '\n' + insert.prefix
}
if (range.to !== line.to) {
insert.suffix += '\n'
}
}
const content = view.state.sliceDoc(range.from, range.to)
// map through the prefix only
const changedRange = originalRange.map(
view.state.changes([
{ from: range.from, insert: `${insert.prefix}` },
]),
1
)
return {
range: changedRange,
// create a single change, including the content
changes: [
{
from: range.from,
to: range.to,
insert: `${insert.prefix}${content}${insert.suffix}`,
},
],
}
} else {
// change
if (!info.ctrlSeq) {
return { range }
}
const changes = view.state.changes([
{
from: info.ctrlSeq.from + 1,
to: info.ctrlSeq.to,
insert: level,
},
])
return {
range: range.map(changes),
changes,
}
}
})
)
}
@@ -0,0 +1,7 @@
import { environments } from '../../languages/latex/completions/data/environments'
import { prepareSnippetTemplate } from '../../languages/latex/snippets'
export const snippets = {
figure: prepareSnippetTemplate(environments.get('figure') as string),
table: prepareSnippetTemplate(environments.get('table') as string),
}
@@ -0,0 +1,221 @@
import { StateEffect, StateField } from '@codemirror/state'
import { EditorView, showPanel } from '@codemirror/view'
const toggleToolbarEffect = StateEffect.define<boolean>()
const toolbarState = StateField.define<boolean>({
create: () => true,
update: (value, tr) => {
if (tr.state.readOnly) {
return false
}
for (const effect of tr.effects) {
if (effect.is(toggleToolbarEffect)) {
value = effect.value
}
}
return value
},
provide: f => showPanel.from(f, on => (on ? createToolbarPanel : null)),
})
export function createToolbarPanel() {
const dom = document.createElement('div')
dom.classList.add('ol-cm-toolbar-portal')
return { dom, top: true }
}
export const toolbarPanel = () => [
toolbarState,
EditorView.theme({
'.ol-cm-toolbar': {
backgroundColor: 'var(--editor-toolbar-bg)',
color: 'var(--toolbar-btn-color)',
flex: 1,
display: 'flex',
overflowX: 'hidden',
},
'&.overall-theme-dark .ol-cm-toolbar': {
'& img': {
filter: 'invert(1)',
},
},
'.ol-cm-toolbar-overflow': {
display: 'flex',
flexWrap: 'wrap',
},
'#popover-toolbar-overflow': {
padding: 0,
borderColor: 'rgba(125, 125, 125, 0.2)',
backgroundColor: 'var(--editor-toolbar-bg)',
color: 'var(--toolbar-btn-color)',
'& .popover-content': {
padding: 0,
},
'& .arrow': {
borderBottomColor: 'rgba(125, 125, 125, 0.2)',
'&:after': {
borderBottomColor: 'var(--editor-toolbar-bg)',
},
},
},
'.ol-cm-toolbar-button-group': {
display: 'flex',
alignItems: 'center',
whiteSpace: 'nowrap',
flexWrap: 'nowrap',
padding: '0 4px',
margin: '4px 0',
lineHeight: '1',
'&:not(:first-of-type)': {
borderLeft: '1px solid rgba(125, 125, 125, 0.3)',
'&.ol-cm-toolbar-end': {
borderLeft: 'none',
},
'&.overflow-hidden': {
borderLeft: 'none',
},
},
'&.overflow-hidden': {
width: 0,
padding: 0,
},
},
'.ol-cm-toolbar-button': {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0',
margin: '0 1px',
backgroundColor: 'transparent',
border: 'none',
borderRadius: '1px',
lineHeight: '1',
width: '24px',
height: '24px',
overflow: 'hidden',
'&:hover, &:focus, &:active, &.active': {
backgroundColor: 'rgba(125, 125, 125, 0.1)',
color: 'inherit',
boxShadow: 'none',
'&[disabled]': {
opacity: '0.2',
},
},
'&.active, &:active': {
backgroundColor: 'rgba(125, 125, 125, 0.2)',
},
'&[disabled]': {
opacity: '0.2',
},
'.overflow-hidden &': {
display: 'none',
},
'&.ol-cm-toolbar-button-math': {
fontFamily: '"Noto Serif", serif',
fontSize: '16px',
fontWeight: 700,
},
},
'&.overall-theme-dark .ol-cm-toolbar-button': {
opacity: 0.8,
'&:hover, &:focus, &:active, &.active': {
backgroundColor: 'rgba(125, 125, 125, 0.2)',
},
'&.active, &:active': {
backgroundColor: 'rgba(125, 125, 125, 0.4)',
},
'&[disabled]': {
opacity: 0.2,
},
},
'.ol-cm-toolbar-end': {
flex: 1,
justifyContent: 'flex-end',
},
'.ol-cm-toolbar-overflow-toggle': {
display: 'none',
'&.ol-cm-toolbar-overflow-toggle-visible': {
display: 'flex',
},
},
'.ol-cm-toolbar-menu-toggle': {
background: 'transparent',
boxShadow: 'none !important',
border: 'none',
whiteSpace: 'nowrap',
color: 'inherit',
borderRadius: '0',
opacity: 0.8,
width: '120px',
fontSize: '13px',
fontFamily: 'Lato',
fontWeight: '700',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '5px 6px',
'&:hover, &:focus, &.active': {
backgroundColor: 'rgba(125, 125, 125, 0.1)',
opacity: '1',
color: 'inherit',
},
'& .caret': {
marginTop: '0',
},
},
'.ol-cm-toolbar-menu-popover': {
border: 'none',
borderRadius: '0',
borderBottomLeftRadius: '4px',
borderBottomRightRadius: '4px',
boxShadow: '0 2px 5px rgb(0 0 0 / 20%)',
backgroundColor: 'var(--editor-toolbar-bg)',
color: 'var(--toolbar-btn-color)',
padding: '0',
'&.bottom': {
marginTop: '1px',
},
'&.top': {
marginBottom: '1px',
},
'& .arrow': {
display: 'none',
},
'& .popover-content': {
padding: '0',
},
'& .ol-cm-toolbar-menu': {
width: '120px',
display: 'flex',
flexDirection: 'column',
boxSizing: 'border-box',
fontSize: '14px',
},
'& .ol-cm-toolbar-menu-item': {
border: 'none',
background: 'none',
padding: '4px 12px',
height: '40px',
display: 'flex',
alignItems: 'center',
fontWeight: 'bold',
'&.ol-cm-toolbar-menu-item-active': {
backgroundColor: 'rgba(125, 125, 125, 0.1)',
},
'&:hover': {
backgroundColor: 'rgba(125, 125, 125, 0.2)',
color: 'inherit',
},
'&.section-level-section': {
fontSize: '1.44em',
},
'&.section-level-subsection': {
fontSize: '1.2em',
},
'&.section-level-body': {
fontWeight: 'normal',
},
},
},
}),
]
@@ -0,0 +1,8 @@
import { EditorView } from '@codemirror/view'
import { sendMB } from '../../../../../infrastructure/event-tracking'
import { isVisual } from '../../visual/visual'
export function emitCommandEvent(view: EditorView, command: string) {
const mode = isVisual(view) ? 'visual' : 'source'
sendMB('codemirror-toolbar-event', { command, mode })
}
@@ -0,0 +1,355 @@
import {
EditorState,
RangeSet,
StateEffect,
StateField,
Transaction,
} from '@codemirror/state'
import {
Decoration,
type DecorationSet,
EditorView,
type PluginValue,
ViewPlugin,
WidgetType,
} from '@codemirror/view'
import {
findCommentsInCut,
findDetachedCommentsInChanges,
restoreCommentsOnPaste,
restoreDetachedComments,
StoredComment,
} from './changes/comments'
import { invertedEffects } from '@codemirror/commands'
import { CurrentDoc } from '../../../../../types/current-doc'
import {
Change,
ChangeOperation,
CommentOperation,
DeleteOperation,
Operation,
} from '../../../../../types/change'
import { ChangeManager } from './changes/change-manager'
const clearChangesEffect = StateEffect.define()
const buildChangesEffect = StateEffect.define()
const restoreDetachedCommentsEffect = StateEffect.define<RangeSet<any>>({
map: (value, mapping) => {
return value
.update({
filter: (from, to) => {
return from <= mapping.length && to <= mapping.length
},
})
.map(mapping)
},
})
type Options = {
currentDoc: CurrentDoc
loadingThreads: boolean
}
export const trackChanges = (
{ currentDoc, loadingThreads }: Options,
changeManager: ChangeManager
) => {
// A state field that stored any comments found within the ranges of a "cut" transaction,
// to be restored when pasting matching text.
const cutCommentsState = StateField.define<StoredComment[]>({
create: () => {
return []
},
update: (value, transaction) => {
if (transaction.annotation(Transaction.remote)) {
return value
}
if (!transaction.docChanged) {
return value
}
if (transaction.isUserEvent('delete.cut')) {
return findCommentsInCut(currentDoc, transaction)
}
if (transaction.isUserEvent('input.paste')) {
restoreCommentsOnPaste(currentDoc, transaction, value)
return []
}
return value
},
})
return [
// attach any comments detached by the transaction as an inverted effect, to be applied on undo
invertedEffects.of(transaction => {
if (
transaction.docChanged &&
!transaction.annotation(Transaction.remote)
) {
const detachedComments = findDetachedCommentsInChanges(
currentDoc,
transaction
)
if (detachedComments.size) {
return [restoreDetachedCommentsEffect.of(detachedComments)]
}
}
return []
}),
// restore any detached comments on undo
EditorState.transactionExtender.of(transaction => {
for (const effect of transaction.effects) {
if (effect.is(restoreDetachedCommentsEffect)) {
// send the comments to the ShareJS doc
restoreDetachedComments(currentDoc, transaction, effect.value)
// return a transaction spec to rebuild the change markers
return buildChangeMarkers()
}
}
return null
}),
cutCommentsState,
// initialize/destroy the change manager, and handle any updates
ViewPlugin.define(() => {
changeManager.initialize()
return {
update: update => {
changeManager.handleUpdate(update)
},
destroy: () => {
changeManager.destroy()
},
}
}),
// draw change decorations
ViewPlugin.define<
PluginValue & {
decorations: DecorationSet
}
>(
() => {
return {
decorations: loadingThreads
? Decoration.none
: buildChangeDecorations(currentDoc),
update(update) {
for (const transaction of update.transactions) {
this.decorations = this.decorations.map(transaction.changes)
for (const effect of transaction.effects) {
if (effect.is(clearChangesEffect)) {
this.decorations = Decoration.none
} else if (effect.is(buildChangesEffect)) {
this.decorations = buildChangeDecorations(currentDoc)
}
}
}
},
}
},
{
decorations: value => value.decorations,
}
),
// styles for change decorations
trackChangesTheme,
]
}
export const clearChangeMarkers = () => {
return {
effects: clearChangesEffect.of(null),
}
}
export const buildChangeMarkers = () => {
return {
effects: buildChangesEffect.of(null),
}
}
const buildChangeDecorations = (currentDoc: CurrentDoc) => {
const changes = [...currentDoc.ranges.changes, ...currentDoc.ranges.comments]
const decorations = []
for (const change of changes) {
try {
decorations.push(...createChangeRange(change, currentDoc))
} catch (error) {
// ignore invalid changes
console.debug('invalid change position', error)
}
}
return Decoration.set(decorations, true)
}
class ChangeDeletedWidget extends WidgetType {
constructor(public change: Change<DeleteOperation>) {
super()
}
toDOM() {
const widget = document.createElement('span')
widget.classList.add('ol-cm-change')
widget.classList.add('ol-cm-change-d')
return widget
}
eq() {
return true
}
}
class ChangeCalloutWidget extends WidgetType {
constructor(public change: Change, public opType: string) {
super()
}
toDOM() {
const widget = document.createElement('span')
widget.className = 'ol-cm-change-callout'
widget.classList.add(`ol-cm-change-callout-${this.opType}`)
const inner = document.createElement('span')
inner.classList.add('ol-cm-change-callout-inner')
widget.appendChild(inner)
return widget
}
eq(widget: ChangeCalloutWidget) {
return widget.opType === this.opType
}
updateDOM(element: HTMLElement) {
element.className = 'ol-cm-change-callout'
element.classList.add(`ol-cm-change-callout-${this.opType}`)
return true
}
}
// const isInsertOperation = (op: Operation): op is InsertOperation => 'i' in op
const isChangeOperation = (op: Operation): op is ChangeOperation =>
'c' in op && 't' in op
const isCommentOperation = (op: Operation): op is CommentOperation =>
'c' in op && !('t' in op)
const isDeleteOperation = (op: Operation): op is DeleteOperation => 'd' in op
const createChangeRange = (change: Change, currentDoc: CurrentDoc) => {
const { id, metadata, op } = change
const from = op.p
// TODO: find valid positions?
if (isDeleteOperation(op)) {
const opType = 'd'
const changeWidget = Decoration.widget({
widget: new ChangeDeletedWidget(change as Change<DeleteOperation>),
opType,
id,
metadata,
})
const calloutWidget = Decoration.widget({
widget: new ChangeCalloutWidget(change, opType),
opType,
id,
metadata,
})
return [calloutWidget.range(from, from), changeWidget.range(from, from)]
}
if (isChangeOperation(op) && currentDoc.ranges.resolvedThreadIds[op.t]) {
return []
}
const isChangeOrCommentOperation =
isChangeOperation(op) || isCommentOperation(op)
const opType = isChangeOrCommentOperation ? 'c' : 'i'
const changedText = isChangeOrCommentOperation ? op.c : op.i
const to = from + changedText.length
// Mark decorations must not be empty
if (from === to) {
return []
}
const changeMark = Decoration.mark({
tagName: 'span',
class: `ol-cm-change ol-cm-change-${opType}`,
opType,
id,
metadata,
})
const calloutWidget = Decoration.widget({
widget: new ChangeCalloutWidget(change, opType),
opType,
id,
metadata,
})
return [calloutWidget.range(from, from), changeMark.range(from, to)]
}
const trackChangesTheme = EditorView.baseTheme({
'.cm-line': {
overflowX: 'hidden', // needed so the callout elements don't overflow (requires line wrapping to be on)
},
'&light .ol-cm-change-i': {
backgroundColor: '#2c8e304d',
},
'&dark .ol-cm-change-i': {
backgroundColor: 'rgba(37, 107, 41, 0.15)',
},
'&light .ol-cm-change-c': {
backgroundColor: '#f3b1114d',
},
'&dark .ol-cm-change-c': {
backgroundColor: 'rgba(194, 93, 11, 0.15)',
},
'.ol-cm-change': {
padding: 'var(--half-leading, 0) 0',
},
'.ol-cm-change-d': {
borderLeft: '2px dotted #c5060b',
marginLeft: '-1px',
},
'.ol-cm-change-callout': {
position: 'relative',
pointerEvents: 'none',
padding: 'var(--half-leading, 0) 0',
},
'.ol-cm-change-callout-inner': {
display: 'inline-block',
position: 'absolute',
left: 0,
bottom: 0,
width: '10000px',
borderBottom: '1px dashed black',
},
'.ol-cm-change-callout-i .ol-cm-change-callout-inner': {
borderColor: '#2c8e30',
},
'.ol-cm-change-callout-c .ol-cm-change-callout-inner': {
borderColor: '#f3b111',
},
'.ol-cm-change-callout-d .ol-cm-change-callout-inner': {
borderColor: '#c5060b',
},
})
@@ -0,0 +1,165 @@
import {
Extension,
StateEffect,
StateField,
TransactionSpec,
} from '@codemirror/state'
import { EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view'
export function verticalOverflow(): Extension {
return [
overflowPaddingState,
minimumBottomPaddingState,
contentAttributes,
bottomPaddingPlugin,
topPaddingPlugin,
]
}
type VerticalPadding = { top: number; bottom: number }
const setOverflowPaddingEffect = StateEffect.define<VerticalPadding>()
// Store extra padding needed at the top and bottom of the editor to match the height of the review panel.
// The padding needs to allow enough space for tracked changes/comments at the top and/or bottom of the review panel.
const overflowPaddingState = StateField.define<VerticalPadding>({
create() {
return { top: 0, bottom: 0 }
},
update(value, tr) {
for (const effect of tr.effects) {
if (effect.is(setOverflowPaddingEffect)) {
const { top, bottom } = effect.value
// only update the state when the values actually change
if (top !== value.top || bottom !== value.bottom) {
value = { top, bottom }
}
}
}
return value
},
})
const setMinimumBottomPaddingEffect = StateEffect.define<number>()
// Store extra padding needed at the bottom of the editor content.
// The content must have a space at the bottom equivalent to the
// height of the editor content minus one line, so that the last
// line in the document can be scrolled to the top of the editor.
const minimumBottomPaddingState = StateField.define<number>({
create() {
return 0
},
update(value, tr) {
for (const effect of tr.effects) {
if (effect.is(setMinimumBottomPaddingEffect)) {
value = effect.value
}
}
return value
},
})
// Set scrollTop to counteract changes to the top padding.
// This view plugin is needed because the overflowPaddingState StateField doesn't have access to the view.
const topPaddingPlugin = ViewPlugin.define(view => {
let previousTop = 0
return {
update: update => {
const { top } = update.state.field(overflowPaddingState)
if (top !== previousTop) {
const diff = top - previousTop
if (diff < 0) {
// padding is decreasing, scroll now
view.scrollDOM.scrollTop += diff
} else {
// padding is increasing, scroll after it has been applied
view.requestMeasure({
key: 'vertical-overflow-scroll-top',
read() {
// do nothing
},
write(measure, view) {
view.scrollDOM.scrollTop += diff
},
})
}
previousTop = top
}
},
}
})
/**
* When the editor geometry changes, recalculate the amount of padding needed at
* the end of the doc: (the scrollDOM height - 1 line height).
* Adapted from the CodeMirror 6 scrollPastEnd extension, licensed under the MIT
* license:
* https://github.com/codemirror/view/blob/main/src/scrollpastend.ts
*/
const bottomPaddingPlugin = ViewPlugin.define(view => {
let previousHeight = 0
const measure = {
key: 'vertical-overflow-bottom-padding',
read(view: EditorView) {
return view.scrollDOM.clientHeight - view.defaultLineHeight
},
write(height: number, view: EditorView) {
if (height !== previousHeight) {
// dispatch must be wrapped in a timeout to avoid clashing with the current update
window.setTimeout(() =>
view.dispatch({
effects: setMinimumBottomPaddingEffect.of(height),
})
)
previousHeight = height
}
},
}
view.requestMeasure(measure)
return {
update: update => {
if (update.geometryChanged) {
update.view.requestMeasure(measure)
}
},
}
})
// Set a style attribute on the contentDOM containing the calculated top and bottom padding.
// This value will be concatenated with style values from any other extensions.
const contentAttributes = EditorView.contentAttributes.compute(
[overflowPaddingState, minimumBottomPaddingState],
state => {
const overflowPadding = state.field(overflowPaddingState)
const minimumBottomPadding = state.field(minimumBottomPaddingState)
const bottomPadding = Math.max(minimumBottomPadding, overflowPadding.bottom)
return {
style: `padding-top: ${overflowPadding.top}px; padding-bottom: ${bottomPadding}px;`,
}
}
)
export function setVerticalOverflow(padding: VerticalPadding): TransactionSpec {
return {
effects: [setOverflowPaddingEffect.of(padding)],
}
}
export function updateSetsVerticalOverflow(update: ViewUpdate): boolean {
return update.transactions.some(tr => {
return tr.effects.some(effect => effect.is(setOverflowPaddingEffect))
})
}
export function editorVerticalTopPadding(view: EditorView): number {
return view.state.field(overflowPaddingState).top
}
@@ -0,0 +1,927 @@
import { EditorState, Range, StateField } from '@codemirror/state'
import {
Decoration,
DecorationSet,
EditorView,
WidgetType,
} from '@codemirror/view'
import { SyntaxNode, Tree } from '@lezer/common'
import { syntaxTree } from '@codemirror/language'
import {
hasMouseDownEffect,
mouseDownEffect,
selectionIntersects,
extendBackwardsOverEmptyLines,
extendForwardsOverEmptyLines,
} from './selection'
import { ItemWidget } from './visual-widgets/item'
import { LaTeXWidget } from './visual-widgets/latex'
import { BraceWidget } from './visual-widgets/brace'
import { ancestorNodeOfType } from '../../utils/tree-query'
import { MakeTitleWidget } from './visual-widgets/maketitle'
import { BeginWidget } from './visual-widgets/begin'
import { EndWidget } from './visual-widgets/end'
import {
getEnvironmentArguments,
getEnvironmentName,
} from '../../utils/tree-operations/environments'
import { MathWidget } from './visual-widgets/math'
import { GraphicsWidget } from './visual-widgets/graphics'
import { IconBraceWidget } from './visual-widgets/icon-brace'
import { TeXWidget } from './visual-widgets/tex'
import {
createCharacterCommand,
hasCharacterSubstitution,
} from './visual-widgets/character'
import { centeringNodeForEnvironment } from '../../utils/tree-operations/figure'
import { Frame, FrameWidget } from './visual-widgets/frame'
import { DividerWidget } from './visual-widgets/divider'
import { PreambleWidget } from './visual-widgets/preamble'
import { EndDocumentWidget } from './visual-widgets/end-document'
import { EnvironmentLineWidget } from './visual-widgets/environment-line'
import { ListEnvironmentName } from '../../utils/tree-operations/ancestors'
import { InlineGraphicsWidget } from './visual-widgets/inline-graphics'
type Options = {
fileTreeManager: {
getPreviewByPath: (
path: string
) => { url: string; extension: string } | null
}
}
function shouldDecorate(
state: EditorState,
extents: { from: number; to: number }
) {
return state.readOnly || !selectionIntersects(state.selection, extents)
}
function shouldDecorateFromLineEdges(
state: EditorState,
extents: { from: number; to: number }
) {
return shouldDecorate(state, {
from: state.doc.lineAt(extents.from).from,
to: state.doc.lineAt(extents.to).to,
})
}
function decorateArgumentBraces(
startWidget: WidgetType,
argumentNode: SyntaxNode | null | undefined,
start: number,
decorateEmptyArguments = false,
endWidget?: WidgetType
): Range<Decoration>[] {
if (!argumentNode) {
return []
}
const openBrace = argumentNode.getChild('OpenBrace')
const closeBrace = argumentNode.getChild('CloseBrace')
if (openBrace && closeBrace) {
if (
// Make sure that decoration ranges are non-empty
openBrace.to > start &&
(decorateEmptyArguments || argumentNode.to - argumentNode.from > 2)
) {
return [
Decoration.replace({
widget: startWidget,
}).range(start, openBrace.to),
Decoration.replace({ widget: endWidget }).range(
closeBrace.from,
closeBrace.to
),
]
}
}
return []
}
const hasClosingBrace = (node: SyntaxNode) =>
node.getChild('EnvNameGroup')?.getChild('CloseBrace')
/**
* Atomic decorations replace a range of content with an uneditable widget.
* Decorations that span multiple lines must be contained in a StateField, not a ViewPlugin.
*/
export const atomicDecorations = (options: Options) => {
const getPreviewByPath = (path: string) =>
options.fileTreeManager.getPreviewByPath(path)
const createDecorations = (state: EditorState, tree: Tree): DecorationSet => {
const decorations: Range<Decoration>[] = []
const listEnvironmentStack: ListEnvironmentName[] = []
let currentListEnvironment: ListEnvironmentName | undefined
const ordinalStack: number[] = []
let currentOrdinal = 0
let listDepth = 0
const preamble: {
from: number
to: number
title?: {
node: SyntaxNode
content: string
}
author?: {
node: SyntaxNode
content: string
}
} = { from: 0, to: 0 }
// find the positions of the title and author in the preamble
tree.iterate({
enter(nodeRef) {
if (nodeRef.type.is('DocumentEnvironment')) {
// Attempt to include \begin{document} in the preamble
preamble.to = nodeRef.node.getChild('Content')?.from ?? nodeRef.from
return false
} else if (nodeRef.type.is('Title')) {
const node = nodeRef.node.getChild('TextArgument')
if (node) {
const content = state.sliceDoc(node.from, node.to)
preamble.title = { node, content }
}
} else if (nodeRef.type.is('Author')) {
const node = nodeRef.node.getChild('TextArgument')
if (node) {
const content = state.sliceDoc(node.from, node.to)
preamble.author = { node, content }
}
}
},
})
if (preamble.to > 0) {
// hide the preamble. We use selectionIntersects directly, so that it also
// expands in readOnly mode.
const endLine = state.doc.lineAt(preamble.to).number
for (let lineNumber = 1; lineNumber <= endLine; ++lineNumber) {
const line = state.doc.line(lineNumber)
const classes = ['ol-cm-preamble-line']
if (lineNumber === 1) {
classes.push('ol-cm-environment-first-line')
}
if (lineNumber === endLine) {
classes.push('ol-cm-environment-last-line')
}
decorations.push(
Decoration.line({
class: classes.join(' '),
}).range(line.from)
)
}
const isExpanded = selectionIntersects(state.selection, preamble)
if (!isExpanded) {
decorations.push(
Decoration.replace({
widget: new PreambleWidget(preamble.to, isExpanded),
block: true,
}).range(0, preamble.to)
)
} else {
decorations.push(
Decoration.widget({
widget: new PreambleWidget(preamble.to, isExpanded),
block: true,
side: -1,
}).range(0)
)
}
}
const startListEnvironment = (envName: ListEnvironmentName) => {
if (currentListEnvironment) {
listEnvironmentStack.push(currentListEnvironment)
ordinalStack.push(currentOrdinal)
}
currentListEnvironment = envName
currentOrdinal = 0
}
const endListEnvironment = () => {
currentListEnvironment = listEnvironmentStack.pop()
currentOrdinal = ordinalStack.pop() ?? 0
}
tree.iterate({
enter(nodeRef) {
if (nodeRef.type.is('$Environment')) {
if (shouldDecorate(state, nodeRef)) {
const envName = getEnvironmentName(nodeRef.node, state)
const hideInEnvironmentTypes = ['figure', 'table']
if (envName && hideInEnvironmentTypes.includes(envName)) {
const beginNode = nodeRef.node.getChild('BeginEnv')
const endNode = nodeRef.node.getChild('EndEnv')
if (
beginNode &&
endNode &&
hasClosingBrace(beginNode) &&
hasClosingBrace(endNode)
) {
const beginLine = state.doc.lineAt(beginNode.from)
const endLine = state.doc.lineAt(endNode.from)
const begin = {
from: beginLine.from,
to: extendForwardsOverEmptyLines(state.doc, beginLine),
}
const end = {
from: extendBackwardsOverEmptyLines(state.doc, endLine),
to: endLine.to,
}
if (shouldDecorate(state, { from: begin.from, to: end.to })) {
decorations.push(
Decoration.replace({
widget: new EnvironmentLineWidget(envName, 'begin'),
block: true,
}).range(begin.from, begin.to),
Decoration.replace({
widget: new EnvironmentLineWidget(envName, 'end'),
block: true,
}).range(end.from, end.to)
)
const centeringNode = centeringNodeForEnvironment(nodeRef)
if (centeringNode) {
const line = state.doc.lineAt(centeringNode.from)
const from = extendBackwardsOverEmptyLines(state.doc, line)
const to = extendForwardsOverEmptyLines(state.doc, line)
decorations.push(
Decoration.replace({
block: true,
}).range(from, to)
)
}
}
}
} else if (nodeRef.type.is('ListEnvironment')) {
const beginNode = nodeRef.node.getChild('BeginEnv')
const endNode = nodeRef.node.getChild('EndEnv')
if (
beginNode &&
endNode &&
hasClosingBrace(beginNode) &&
hasClosingBrace(endNode)
) {
const beginLine = state.doc.lineAt(beginNode.from)
const endLine = state.doc.lineAt(endNode.from)
const begin = {
from: beginLine.from,
to: extendForwardsOverEmptyLines(state.doc, beginLine),
}
const end = {
from: extendBackwardsOverEmptyLines(state.doc, endLine),
to: endLine.to,
}
if (
!selectionIntersects(state.selection, begin) &&
!selectionIntersects(state.selection, end)
) {
decorations.push(
Decoration.replace({
block: true,
}).range(begin.from, begin.to),
Decoration.replace({
block: true,
}).range(end.from, end.to)
)
}
}
}
}
} else if (nodeRef.type.is('BeginEnv')) {
// the beginning of an environment, with an environment name argument
const envName = getEnvironmentName(nodeRef.node, state)
if (envName) {
switch (envName) {
case 'itemize':
case 'enumerate':
startListEnvironment(envName)
listDepth++
break
case 'abstract':
if (shouldDecorate(state, nodeRef)) {
decorations.push(
Decoration.replace({
widget: new BeginWidget(envName),
block: true,
}).range(nodeRef.from, nodeRef.to)
)
}
break
case 'frame':
if (shouldDecorate(state, nodeRef)) {
const parent = nodeRef.node.parent
if (parent?.type.is('Environment')) {
const args = getEnvironmentArguments(parent)
if (!args) {
break
}
if (args.length > 0) {
const title = args[0]
if (!title) {
break
}
let to = title.to
const titleTextNode = title.getChild('LongArg')
if (!titleTextNode) {
break
}
const frame: Frame = {
title: {
node: title,
content: state.sliceDoc(
titleTextNode.from,
titleTextNode.to
),
},
}
if (args.length > 1) {
// We have a subtitle too
const subtitle = args[1]
if (subtitle) {
const subtitleTextNode = subtitle.getChild('LongArg')
if (subtitleTextNode) {
to = subtitle.to
frame.subtitle = {
node: subtitle,
content: state.sliceDoc(
subtitleTextNode.from,
subtitleTextNode.to
),
}
}
}
}
decorations.push(
Decoration.replace({
widget: new FrameWidget(frame),
block: true,
}).range(nodeRef.from, to)
)
}
}
}
break
default:
// do nothing
break
}
}
} else if (nodeRef.type.is('EndEnv')) {
// the end of an environment, with an environment name argument
const envName = getEnvironmentName(nodeRef.node, state)
if (envName) {
switch (envName) {
case 'itemize':
case 'enumerate':
if (currentListEnvironment === envName) {
endListEnvironment()
}
listDepth--
break
case 'abstract':
if (shouldDecorate(state, nodeRef)) {
decorations.push(
Decoration.replace({
widget: new EndWidget(),
block: true,
}).range(nodeRef.from, nodeRef.to)
)
}
break
case 'document':
if (shouldDecorate(state, nodeRef)) {
decorations.push(
Decoration.replace({
widget: new EndDocumentWidget(),
block: true,
}).range(nodeRef.from, nodeRef.to)
)
}
break
case 'frame':
if (shouldDecorate(state, nodeRef)) {
decorations.push(
Decoration.replace({
widget: new DividerWidget(),
block: true,
}).range(nodeRef.from, nodeRef.to)
)
}
break
default:
// do nothing
break
}
}
} else if (nodeRef.type.is('$SectioningCommand')) {
const ancestorNode = ancestorNodeOfType(
state,
nodeRef.to,
'SectioningCommand'
)
if (ancestorNode) {
const shouldShowBraces = !shouldDecorate(state, ancestorNode)
// a section (or subsection, etc) command
const argumentNode = ancestorNode.getChild('SectioningArgument')
if (argumentNode) {
const braces = argumentNode.getChildren('$Brace')
if (braces.length !== 2) {
return false
}
const titleNode = argumentNode.getChild('LongArg')
if (!titleNode) {
return false
}
const title = state.sliceDoc(titleNode.from, titleNode.to)
if (!title.trim()) {
return false
}
decorations.push(
Decoration.replace({
widget: new BraceWidget(shouldShowBraces ? '}' : ''),
}).range(braces[1].from, braces[1].to)
)
decorations.push(
Decoration.replace({
widget: new BraceWidget(shouldShowBraces ? '{' : ''),
}).range(nodeRef.from, titleNode.from)
)
return false
}
}
} else if (nodeRef.type.is('VerbCommand')) {
if (shouldDecorate(state, nodeRef)) {
// \verb content (text only)
const contentNode = nodeRef.node.getChild('VerbContent')
if (contentNode) {
if (contentNode.to - contentNode.from > 2) {
decorations.push(
Decoration.replace({}).range(
nodeRef.from,
contentNode.from + 1
)
)
decorations.push(
Decoration.replace({}).range(nodeRef.to - 1, nodeRef.to)
)
}
}
}
return false // no markup in verbatim content
} else if (nodeRef.type.is('Cite')) {
// \cite command with a bibkey argument
if (shouldDecorate(state, nodeRef)) {
const argumentNode = nodeRef.node
.getChild('BibKeyArgument')
?.getChild('ShortTextArgument')
decorations.push(
...decorateArgumentBraces(
new IconBraceWidget('📚'),
argumentNode,
nodeRef.from
)
)
}
return false // no markup in cite content
} else if (nodeRef.type.is('Ref')) {
// \ref command with a ref label argument
if (shouldDecorate(state, nodeRef)) {
const argumentNode = nodeRef.node
.getChild('RefArgument')
?.getChild('ShortTextArgument')
decorations.push(
...decorateArgumentBraces(
new IconBraceWidget('🏷'),
argumentNode,
nodeRef.from
)
)
}
return false // no markup in ref content
} else if (nodeRef.type.is('Label')) {
// \label definition
if (shouldDecorate(state, nodeRef)) {
const argumentNode = nodeRef.node
.getChild('LabelArgument')
?.getChild('ShortTextArgument')
decorations.push(
...decorateArgumentBraces(
new IconBraceWidget('🏷'),
argumentNode,
nodeRef.from
)
)
}
return false // no markup in label content
} else if (nodeRef.type.is('Include')) {
// \include (a file path)
if (shouldDecorate(state, nodeRef)) {
const argumentNode = nodeRef.node
.getChild('IncludeArgument')
?.getChild('FilePathArgument')
decorations.push(
...decorateArgumentBraces(
new IconBraceWidget('🔗'),
argumentNode,
nodeRef.from
)
)
}
return false // no markup in include content
} else if (nodeRef.type.is('Input')) {
// \input (a file path)
// TODO: Ensure this works with BareFilePathArgument
if (shouldDecorate(state, nodeRef)) {
const contentNode = nodeRef.node.getChild('InputArgument')
if (contentNode) {
if (contentNode.to - contentNode.from > 2) {
decorations.push(
Decoration.replace({
widget: new IconBraceWidget('🔗'),
}).range(nodeRef.from, contentNode.from + 1)
)
decorations.push(
Decoration.replace({
widget: new BraceWidget(),
}).range(nodeRef.to - 1, nodeRef.to)
)
}
}
}
return false // no markup in input content
} else if (nodeRef.type.is('Math')) {
// math equations
const ancestorNode =
ancestorNodeOfType(state, nodeRef.from, '$MathContainer') ||
ancestorNodeOfType(state, nodeRef.from, 'EquationEnvironment') ||
// NOTE: EquationArrayEnvironment can be nested inside EquationEnvironment
ancestorNodeOfType(state, nodeRef.from, 'EquationArrayEnvironment')
if (
ancestorNode &&
(ancestorNode.type.is('$Environment')
? shouldDecorateFromLineEdges(state, ancestorNode)
: shouldDecorate(state, ancestorNode))
) {
// the content of the Math element, without braces
const innerContent = state.doc
.sliceString(nodeRef.from, nodeRef.to)
.trim()
// only replace when there's content inside the braces
if (innerContent.length) {
let content = innerContent
let displayMode = false
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 !== 'math' &&
environmentName !== 'displaymath'
) {
content = state.doc
.sliceString(ancestorNode.from, ancestorNode.to)
.trim()
}
if (environmentName !== 'math') {
displayMode = true
}
}
} else {
if (
ancestorNode.type.is('BracketMath') ||
Boolean(ancestorNode.getChild('DisplayMath'))
) {
displayMode = true
}
}
decorations.push(
Decoration.replace({
widget: new MathWidget(content, displayMode),
block: displayMode,
}).range(ancestorNode.from, ancestorNode.to)
)
return false
}
}
} else if (nodeRef.type.is('HrefCommand')) {
// a hyperlink with URL and content arguments
if (shouldDecorate(state, nodeRef)) {
const urlArgument = nodeRef.node.getChild('UrlArgument')
const textArgument = nodeRef.node.getChild('ShortTextArgument')
if (urlArgument) {
decorations.push(
...decorateArgumentBraces(
new BraceWidget(),
textArgument,
nodeRef.from
)
)
}
}
} else if (nodeRef.type.is('Caption')) {
if (shouldDecorate(state, nodeRef)) {
// a caption
const argumentNode = nodeRef.node.getChild('TextArgument')
decorations.push(
...decorateArgumentBraces(
new BraceWidget(),
argumentNode,
nodeRef.from
)
)
}
} else if (nodeRef.type.is('IncludeGraphics')) {
// \includegraphics with a file path argument
if (shouldDecorate(state, nodeRef)) {
const filePathArgument = nodeRef.node
.getChild('IncludeGraphicsArgument')
?.getChild('FilePathArgument')
?.getChild('LiteralArgContent')
if (filePathArgument) {
const filePath = state.doc.sliceString(
filePathArgument.from,
filePathArgument.to
)
if (filePath) {
const environmentNode = ancestorNodeOfType(
state,
nodeRef.from,
'FigureEnvironment'
)
const centered = Boolean(
environmentNode &&
centeringNodeForEnvironment(environmentNode)
)
const line = state.doc.lineAt(nodeRef.from)
const lineContainsOnlyNode =
line.text.trim().length === nodeRef.to - nodeRef.from
if (lineContainsOnlyNode) {
decorations.push(
Decoration.replace({
widget: new GraphicsWidget(
filePath,
getPreviewByPath,
centered
),
block: true,
}).range(line.from, line.to)
)
} else {
decorations.push(
Decoration.replace({
widget: new InlineGraphicsWidget(
filePath,
getPreviewByPath,
centered
),
}).range(nodeRef.from, nodeRef.to)
)
}
}
return false
}
}
} else if (nodeRef.type.is('Item')) {
// only decorate \item inside a list
if (currentListEnvironment) {
currentOrdinal++
const line = state.doc.lineAt(nodeRef.from)
const onlySpaceBeforeNode = /^\s*$/.test(
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
}
} else if (nodeRef.type.is('UnknownCommand')) {
// a command that's not defined separately by the grammar
const commandNode = nodeRef.node
const commandNameNode = commandNode.getChild('$CtrlSeq')
const textArgumentNode = commandNode.getChild('TextArgument')
if (commandNameNode) {
const commandName = state.doc
.sliceString(commandNameNode.from, commandNameNode.to)
.trim()
if (commandName.length > 0) {
if (
// markup that can be toggled using toolbar buttons/keyboard shortcuts
['\\textbf', '\\textit', '\\underline'].includes(commandName)
) {
const argumentText = textArgumentNode?.getChild('LongArg')
const shouldShowBraces =
!shouldDecorate(state, nodeRef) ||
argumentText?.from === argumentText?.to
decorations.push(
...decorateArgumentBraces(
new BraceWidget(shouldShowBraces ? '{' : ''),
textArgumentNode,
nodeRef.from,
true,
new BraceWidget(shouldShowBraces ? '}' : '')
)
)
} else if (
// markup that can't be toggled using toolbar buttons/keyboard shortcuts
['\\textsc', '\\texttt', '\\sout', '\\emph'].includes(
commandName
)
) {
if (shouldDecorate(state, nodeRef)) {
decorations.push(
...decorateArgumentBraces(
new BraceWidget(),
textArgumentNode,
nodeRef.from
)
)
}
} else if (commandName === '\\url') {
if (shouldDecorate(state, nodeRef)) {
// command name and opening brace
decorations.push(
...decorateArgumentBraces(
new BraceWidget(),
textArgumentNode,
nodeRef.from
)
)
return false
}
} else if (commandName === '\\LaTeX') {
if (shouldDecorate(state, nodeRef)) {
decorations.push(
Decoration.replace({
widget: new LaTeXWidget(),
}).range(nodeRef.from, nodeRef.to)
)
return false
}
} else if (commandName === '\\TeX') {
if (shouldDecorate(state, nodeRef)) {
decorations.push(
Decoration.replace({
widget: new TeXWidget(),
}).range(nodeRef.from, nodeRef.to)
)
return false
}
} else if (commandName === '\\maketitle') {
if (shouldDecorate(state, nodeRef)) {
const line = state.doc.lineAt(nodeRef.from)
const from = extendBackwardsOverEmptyLines(state.doc, line)
const to = extendForwardsOverEmptyLines(state.doc, line)
if (shouldDecorate(state, { from, to })) {
decorations.push(
Decoration.replace({
widget: new MakeTitleWidget(preamble),
block: true,
}).range(from, to)
)
}
return false
}
} else if (hasCharacterSubstitution(commandName)) {
if (shouldDecorate(state, nodeRef)) {
const replacement = createCharacterCommand(commandName)
if (replacement) {
decorations.push(
Decoration.replace({
widget: replacement,
}).range(nodeRef.from, nodeRef.to)
)
return false
}
}
}
}
}
}
},
})
return Decoration.set(decorations, true)
}
let previousTree: Tree
return [
StateField.define<{
mousedown: boolean
decorations: DecorationSet
}>({
create(state) {
previousTree = syntaxTree(state)
return {
mousedown: false,
decorations: createDecorations(state, previousTree),
}
},
update(value, tr) {
for (const effect of tr.effects) {
// store the "mousedown" value when it changes
if (effect.is(mouseDownEffect)) {
value = {
mousedown: effect.value,
decorations: value.decorations, // unchanged
}
}
}
const tree = syntaxTree(tr.state)
if (
tree.type === previousTree.type &&
tree.length < tr.state.doc.length
) {
// still parsing
value = {
mousedown: value.mousedown, // unchanged
decorations: value.decorations.map(tr.changes),
}
} else if (
// only update the decorations when the mouse is not making a selection
!value.mousedown &&
(tree !== previousTree || tr.selection || hasMouseDownEffect(tr))
) {
// tree changed
previousTree = tree
// TODO: update the existing decorations for the changed range(s)?
value = {
mousedown: value.mousedown, // unchanged
decorations: createDecorations(tr.state, tree),
}
}
return value
},
provide(field) {
return [
EditorView.decorations.from(field, field => field.decorations),
EditorView.atomicRanges.from(field, value => () => value.decorations),
]
},
}),
]
}
@@ -0,0 +1,70 @@
import {
EditorSelection,
EditorState,
SelectionRange,
Transaction,
} from '@codemirror/state'
import { syntaxTree } from '@codemirror/language'
import { SyntaxNode } from '@lezer/common'
// avoid placing the cursor in front of a list item marker
export const listItemMarker = EditorState.transactionFilter.of(tr => {
if (tr.selection) {
let selection = tr.selection
for (const [index, range] of tr.selection.ranges.entries()) {
if (range.empty) {
const node = syntaxTree(tr.state).resolveInner(range.anchor, 1)
const pos = chooseTargetPosition(node, tr, range)
if (pos !== null) {
selection = selection.replaceRange(
EditorSelection.cursor(
pos,
range.assoc,
range.bidiLevel ?? undefined, // workaround for inconsistent types
range.goalColumn
),
index
)
}
}
}
if (selection !== tr.selection) {
return [tr, { selection }]
}
}
return tr
})
const chooseTargetPosition = (
node: SyntaxNode,
tr: Transaction,
range: SelectionRange
) => {
let targetNode
if (node.type.is('Item')) {
targetNode = node
} else if (node.type.is('ItemCtrlSeq')) {
targetNode = node.parent
} else if (node.type.is('Whitespace')) {
targetNode = node.nextSibling?.firstChild?.firstChild
}
if (!targetNode?.type.is('Item')) {
return null
}
// mouse click
if (tr.isUserEvent('select.pointer')) {
// jump to after the item
return targetNode.to
}
// keyboard navigation
if (range.assoc === 1 && !range.goalColumn) {
// moving backwards: jump to end of the previous line
return Math.max(tr.state.doc.lineAt(range.anchor).from - 1, 1)
} else {
// moving forwards: jump to after the item
return targetNode.to
}
}
@@ -0,0 +1,180 @@
import {
Decoration,
DecorationSet,
ViewPlugin,
ViewUpdate,
} from '@codemirror/view'
import { EditorState, Range } from '@codemirror/state'
import { syntaxTree } from '@codemirror/language'
import { getEnvironmentName } from '../../utils/tree-operations/environments'
import { centeringNodeForEnvironment } from '../../utils/tree-operations/figure'
import { Tree } from '@lezer/common'
/**
* Mark decorations add attributes to elements within a range.
*/
export const markDecorations = ViewPlugin.define(
view => {
const createDecorations = (
state: EditorState,
tree: Tree
): DecorationSet => {
const decorations: Range<Decoration>[] = []
for (const { from, to } of view.visibleRanges) {
tree?.iterate({
from,
to,
enter(nodeRef) {
if (
nodeRef.type.is('KnownCommand') ||
nodeRef.type.is('UnknownCommand')
) {
// decorate commands with a class, for optional styling
const ctrlSeq =
nodeRef.node.getChild('$CtrlSeq') ??
nodeRef.node.firstChild?.getChild('$CtrlSeq')
if (ctrlSeq) {
const text = state.doc.sliceString(ctrlSeq.from + 1, ctrlSeq.to)
// a special case for "label" as the whole command needs a space afterwards
if (text === 'label') {
// decorate the whole command
const from = nodeRef.from
const to = nodeRef.to
if (to > from) {
decorations.push(
Decoration.mark({
class: `ol-cm-${text}`,
inclusive: true,
}).range(from, to)
)
}
} else {
// decorate the command content
const from = ctrlSeq.to + 1
const to = nodeRef.to - 1
if (to > from) {
decorations.push(
Decoration.mark({
class: `ol-cm-command-${text}`,
inclusive: true,
}).range(from, to)
)
}
}
}
} else if (nodeRef.type.is('SectioningCommand')) {
// decorate section headings with a class, for styling
const ctrlSeq = nodeRef.node.getChild('$CtrlSeq')
if (ctrlSeq) {
const text = state.doc.sliceString(ctrlSeq.from + 1, ctrlSeq.to)
decorations.push(
Decoration.mark({
class: `ol-cm-heading ol-cm-command-${text}`,
}).range(nodeRef.from, nodeRef.to)
)
}
} else if (nodeRef.type.is('Caption')) {
// decorate caption lines with a class, for styling
const argument = nodeRef.node.getChild('TextArgument')
if (argument) {
const lines = {
start: state.doc.lineAt(nodeRef.from),
end: state.doc.lineAt(nodeRef.to),
}
for (
let lineNumber = lines.start.number;
lineNumber <= lines.end.number;
lineNumber++
) {
const line = state.doc.line(lineNumber)
decorations.push(
Decoration.line({
class: 'ol-cm-caption-line',
}).range(line.from)
)
}
}
} else if (nodeRef.type.is('$Environment')) {
const environmentName = getEnvironmentName(nodeRef.node, state)
switch (environmentName) {
case 'abstract':
case 'figure':
case 'table':
{
const centered = Boolean(
centeringNodeForEnvironment(nodeRef)
)
const lines = {
start: state.doc.lineAt(nodeRef.from),
end: state.doc.lineAt(nodeRef.to),
}
for (
let lineNumber = lines.start.number;
lineNumber <= lines.end.number;
lineNumber++
) {
const line = state.doc.line(lineNumber)
const classNames = [
`ol-cm-environment-${environmentName}`,
'ol-cm-environment-line',
]
if (centered) {
classNames.push('ol-cm-environment-centered')
}
decorations.push(
Decoration.line({
class: classNames.join(' '),
}).range(line.from)
)
}
}
break
}
}
},
})
}
return Decoration.set(decorations, true)
}
let previousTree = syntaxTree(view.state)
return {
decorations: createDecorations(view.state, previousTree),
update(update: ViewUpdate) {
const tree = syntaxTree(update.state)
// still parsing
if (
tree.type === previousTree.type &&
tree.length < update.view.viewport.to
) {
this.decorations = this.decorations.map(update.changes)
} else if (tree !== previousTree || update.viewportChanged) {
// parsed or resized
previousTree = tree
// TODO: update the existing decorations for the changed range(s)?
this.decorations = createDecorations(update.state, tree)
}
},
}
},
{
decorations(value) {
return value.decorations
},
}
)
@@ -0,0 +1,75 @@
import { EditorSelection, StateEffect, Line, Text } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import { hasEffect, updateHasEffect } from '../../utils/effects'
export const selectionIntersects = (
selection: EditorSelection,
extents: { from: number; to: number }
) =>
selection.ranges.some(
range =>
// Case 1: from is inside node
(extents.from <= range.from && extents.to >= range.from) ||
// Case 2: to is inside node
(extents.from <= range.to && extents.to >= range.to)
)
export const placeSelectionInsideBlock = (
view: EditorView,
event: MouseEvent
) => {
const line = view.lineBlockAtHeight(event.pageY - view.documentTop)
const selectionRange = EditorSelection.cursor(line.to)
const selection = event.ctrlKey
? view.state.selection.addRange(selectionRange)
: selectionRange
return { selection, effects: EditorView.scrollIntoView(line.to) }
}
export const extendBackwardsOverEmptyLines = (doc: Text, line: Line) => {
let { number, from } = line
for (let lineNumber = number - 1; lineNumber > 0; lineNumber--) {
const line = doc.line(lineNumber)
if (line.text.trim().length > 0) {
break
}
from = line.from
}
return from
}
export const extendForwardsOverEmptyLines = (doc: Text, line: Line) => {
let { number, to } = line
for (let lineNumber = number + 1; lineNumber <= doc.lines; lineNumber++) {
const line = doc.line(lineNumber)
if (line.text.trim().length > 0) {
break
}
to = line.to
}
return to
}
export const mouseDownEffect = StateEffect.define<boolean>()
export const hasMouseDownEffect = hasEffect(mouseDownEffect)
export const updateHasMouseDownEffect = updateHasEffect(mouseDownEffect)
export const mouseDownListener = EditorView.domEventHandlers({
mousedown: (event, view) => {
// not wrapped in a timeout, so update listeners know that the mouse is down before they process the selection
view.dispatch({
effects: mouseDownEffect.of(true),
})
},
mouseup: (event, view) => {
// wrap in a timeout, so update listeners receive this effect after the new selection has finished being handled
window.setTimeout(() => {
view.dispatch({
effects: mouseDownEffect.of(false),
})
})
},
})
@@ -0,0 +1,35 @@
import { EditorView, ViewPlugin } from '@codemirror/view'
import { EditorSelection } from '@codemirror/state'
import { findDocumentEnvironment } from '../../utils/tree-operations/environments'
import { syntaxTree } from '@codemirror/language'
export const skipPreambleWithCursor = ViewPlugin.define((view: EditorView) => {
let checkedOnce = false
return {
update(update) {
if (
!checkedOnce &&
syntaxTree(update.state).length === update.state.doc.length
) {
checkedOnce = true
// Only move the cursor if we're at the default position (0). Otherwise
// switching back and forth between source/RT while editing the preamble
// would be annoying.
if (
update.view.state.selection.eq(
EditorSelection.create([EditorSelection.cursor(0)])
)
) {
setTimeout(() => {
const position = findDocumentEnvironment(view.state)
view.dispatch({
selection: EditorSelection.cursor(
Math.min(position ? position + 1 : 0, update.state.doc.length)
),
})
}, 0)
}
}
},
}
})
@@ -0,0 +1,10 @@
import { EditorSelection } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import { SyntaxNode } from '@lezer/common'
export const selectNode = (view: EditorView, node: SyntaxNode) => {
view.dispatch({
selection: EditorSelection.single(node.from + 1, node.to - 1),
scrollIntoView: true,
})
}
@@ -0,0 +1,80 @@
import { EditorState } from '@codemirror/state'
import { SyntaxNode, SyntaxNodeRef } from '@lezer/common'
import { isUnknownCommandWithName } from '../../../utils/tree-query'
function isNewline(node: SyntaxNodeRef, state: EditorState) {
if (!node.type.is('CtrlSym')) {
return false
}
const command = state.sliceDoc(node.from, node.to)
return command === '\\\\'
}
/**
* Does a small amount of typesetting of LaTeX content into a DOM element.
* Does **not** typeset math, you **must manually** invoke MathJax after this
* function if you wish to typeset math content.
* @param node The syntax node containing the text to be typeset
* @param element The DOM element to typeset into
* @param state The editor state where `node` is from
*/
export function typesetNodeIntoElement(
node: SyntaxNode,
element: HTMLElement,
state: EditorState
) {
// If we're a TextArgument node, we should skip the braces
const argument = node.getChild('LongArg')
if (argument) {
node = argument
}
const ancestorStack = [element]
const ancestor = () => ancestorStack[ancestorStack.length - 1]
const popAncestor = () => ancestorStack.pop()!
const pushAncestor = (x: HTMLElement) => ancestorStack.push(x)
// NOTE: Quite hack-ish way to omit closing braces from the output
const ignoredRanges: { from: number; to: number }[] = []
let from = node.from
node.cursor().iterate(
function enter(childNodeRef) {
const childNode = childNodeRef.node
if (from < childNode.from) {
ancestor().append(
document.createTextNode(state.sliceDoc(from, childNode.from))
)
from = ignoredRanges.some(
range => range.from <= childNode.from && range.to >= childNode.from
)
? childNode.to
: childNode.from
}
if (isUnknownCommandWithName(childNode, '\\textit', state)) {
pushAncestor(document.createElement('i'))
const argument = childNode.getChild('TextArgument')
from = argument?.getChild('LongArg')?.from ?? childNode.to
const endBrace = argument?.getChild('CloseBrace')
if (endBrace) {
ignoredRanges.push(endBrace)
}
} else if (isNewline(childNode, state)) {
ancestor().appendChild(document.createElement('br'))
from = childNode.to
}
},
function leave(childNodeRef) {
const childNode = childNodeRef.node
if (isUnknownCommandWithName(childNode, '\\textit', state)) {
const typeSetElement = popAncestor()
ancestor().appendChild(typeSetElement)
}
}
)
if (from < node.to) {
ancestor().append(document.createTextNode(state.sliceDoc(from, node.to)))
}
return element
}
@@ -0,0 +1,141 @@
import { keymap } from '@codemirror/view'
import { EditorSelection, Prec } from '@codemirror/state'
import { ancestorNodeOfType } from '../../utils/tree-query'
import { toggleRanges } from '../../commands/ranges'
import {
getIndentation,
IndentContext,
indentString,
} from '@codemirror/language'
import {
cursorIsAtStartOfListItem,
indentDecrease,
indentIncrease,
} from '../toolbar/commands'
export const visualKeymap = Prec.highest(
keymap.of([
// create a new list item with the same indentation
{
key: 'Enter',
run: view => {
const { state } = view
let handled = false
const changes = state.changeByRange(range => {
if (range.empty) {
const { from } = range
const listNode = ancestorNodeOfType(state, from, 'ListEnvironment')
if (listNode) {
const line = state.doc.lineAt(range.from)
const endLine = state.doc.lineAt(listNode.to)
if (line.number === endLine.number - 1) {
// last item line
if (line.text.trim() === '\\item') {
// no content on this line
// delete this line
const changes = state.changes({
from: line.from,
to: line.to + 1,
insert: '',
})
// the start of the line after the list environment
const range = EditorSelection.cursor(endLine.to + 1).map(
changes
)
handled = true
return { changes, range }
}
}
// create a new list item
const cx = new IndentContext(state)
const columns = getIndentation(cx, from) ?? 0
const indent = indentString(state, columns)
const insert = `\n${indent}\\item `
handled = true
return {
changes: { from, insert },
range: EditorSelection.cursor(from + insert.length),
}
}
const sectioningNode = ancestorNodeOfType(
state,
from,
'SectioningCommand'
)
if (sectioningNode) {
// jump out of a section heading to the start of the next line
const nextLineNumber = state.doc.lineAt(from).number + 1
if (nextLineNumber <= state.doc.lines) {
const line = state.doc.line(nextLineNumber)
handled = true
return {
range: EditorSelection.cursor(line.from),
}
}
}
}
return { range }
})
if (handled) {
view.dispatch(changes, {
scrollIntoView: true,
userEvent: 'input',
})
}
return handled
},
},
// Increase list indent
{
key: 'Mod-]',
preventDefault: true,
run: indentIncrease,
},
// Decrease list indent
{
key: 'Mod-[',
preventDefault: true,
run: indentDecrease,
},
// Increase list indent
{
key: 'Tab',
preventDefault: true,
run: view =>
cursorIsAtStartOfListItem(view.state) && indentIncrease(view),
},
// Decrease list indent
{
key: 'Shift-Tab',
preventDefault: true,
run: indentDecrease,
},
// Override bolding in RT mode
{
key: 'Ctrl-b',
mac: 'Mod-b',
preventDefault: true,
run: toggleRanges('\\textbf'),
},
{
key: 'Ctrl-i',
mac: 'Mod-i',
preventDefault: true,
run: toggleRanges('\\textit'),
},
])
)
@@ -0,0 +1,339 @@
import { EditorView } from '@codemirror/view'
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
import { tags } from '@lezer/highlight'
export const visualHighlightStyle = syntaxHighlighting(
HighlightStyle.define([
{ tag: tags.link, class: 'ol-cm-link-text' },
{ tag: tags.url, class: 'ol-cm-url' },
{ tag: tags.typeName, class: 'ol-cm-monospace' },
{ tag: tags.attributeValue, class: 'ol-cm-monospace' },
{ tag: tags.keyword, class: 'ol-cm-monospace' },
{ tag: tags.string, class: 'ol-cm-monospace' },
{ tag: tags.punctuation, class: 'ol-cm-punctuation' },
{ tag: tags.literal, class: 'ol-cm-monospace' },
{
tag: tags.monospace,
fontFamily: 'var(--source-font-family)',
lineHeight: 1,
overflowWrap: 'break-word',
},
])
)
export const visualTheme = EditorView.theme({
'&.cm-editor': {
'--visual-font-family':
"'Noto Serif', 'Palatino Linotype', 'Book Antiqua', Palatino, serif !important",
'--visual-font-size': 'calc(var(--font-size) * 1.15)',
'& .cm-content': {
opacity: 0,
},
'&.ol-cm-parsed .cm-content': {
opacity: 1,
transition: 'opacity 0.1s ease-out',
},
},
'.cm-content.cm-content': {
overflowX: 'hidden', // needed so the callout elements don't overflow (requires line wrapping to be on)
padding: '0 max(calc((100% - 100ch) / 2), 8%)', // max 100 characters per line
fontFamily: 'var(--visual-font-family)',
fontSize: 'var(--visual-font-size)',
},
'.cm-cursor-primary.cm-cursor-primary': {
fontFamily: 'var(--visual-font-family)',
fontSize: 'var(--visual-font-size)',
},
'.cm-line': {
overflowX: 'visible', // needed so the callout elements can overflow when the content has padding
},
'.cm-gutter': {
opacity: '0.5',
},
'.cm-tooltip': {
fontSize: 'calc(var(--font-size) * 1.15) !important',
},
'.ol-cm-link-text': {
textDecoration: 'underline',
fontFamily: 'inherit',
},
'.ol-cm-monospace': {
fontFamily: 'var(--source-font-family)',
lineHeight: 1,
fontWeight: 'normal',
fontStyle: 'normal',
fontVariant: 'normal',
textDecoration: 'none',
},
'.ol-cm-punctuation': {
fontFamily: 'var(--source-font-family)',
lineHeight: 1,
},
'.ol-cm-brace': {
opacity: '0.5',
},
'.ol-cm-math': {
overflow: 'hidden', // stop the margin from the inner math element affecting the block height
},
'.ol-cm-maketitle': {
textAlign: 'center',
paddingBottom: '2em',
},
'.ol-cm-title': {
fontSize: '1.7em',
cursor: 'pointer',
padding: '0.5em',
lineHeight: 'calc(var(--line-height) * 5/6)',
},
'.ol-cm-author': {
display: 'inline-block',
maxWidth: '45%',
minWidth: '200px',
verticalAlign: 'top',
cursor: 'pointer',
},
'.ol-cm-author:not(:first-child)': {
display: 'inline-block',
marginLeft: '5%',
maxWidth: '45%',
},
'.ol-cm-icon-brace': {
filter: 'grayscale(1)',
marginRight: '2px',
},
'.ol-cm-begin': {
fontFamily: 'var(--source-font-family)',
minHeight: '1em',
textAlign: 'center',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
'.ol-cm-end': {
fontFamily: 'var(--source-font-family)',
padding: '0.5em 0 1.5em',
minHeight: '1em',
textAlign: 'center',
justifyContent: 'center',
background: `linear-gradient(180deg, rgba(0,0,0,0) calc(50% - 1px), rgba(192,192,192,1) calc(50%), rgba(0,0,0,0) calc(50% + 1px))`,
},
'.ol-cm-environment-top': {
paddingTop: '1em',
},
'.ol-cm-environment-bottom': {
paddingBottom: '1em',
},
'.ol-cm-environment-first-line': {
paddingTop: '0.5em !important',
borderTopLeftRadius: '8px',
borderTopRightRadius: '8px',
},
'.ol-cm-environment-last-line': {
paddingBottom: '1em !important',
borderBottomLeftRadius: '8px',
borderBottomRightRadius: '8px',
},
'.ol-cm-environment-figure.ol-cm-environment-line, .ol-cm-environment-table.ol-cm-environment-line':
{
backgroundColor: 'rgba(125, 125, 125, 0.05)',
padding: '0 12px',
},
'.ol-cm-environment-figure.ol-cm-environment-last-line, .ol-cm-environment-table.ol-cm-environment-last-line, .ol-cm-preamble-line.ol-cm-environment-last-line':
{
boxShadow: '0 2px 5px -3px rgb(125, 125, 125, 0.5)',
},
'.ol-cm-environment-padding': {
flex: 1,
height: '1px',
background: `linear-gradient(180deg, rgba(0,0,0,0) calc(50% - 1px), rgba(192,192,192,1) calc(50%), rgba(0,0,0,0) calc(50% + 1px))`,
},
'.ol-cm-environment-name': {
padding: '0 1em',
},
'.ol-cm-environment-name-abstract': {
fontFamily: 'var(--visual-font-family)',
fontSize: '1.2em',
fontWeight: 550,
},
'.ol-cm-environment-name-abstract:first-letter': {
textTransform: 'uppercase',
},
'.ol-cm-item': {
paddingInlineStart: 'calc(var(--list-depth) * 2ch)',
},
'.ol-cm-item::before': {
counterReset: 'list-item var(--list-ordinal)',
content: 'counter(list-item, var(--list-type)) var(--list-suffix)',
},
'.ol-cm-heading': {
fontWeight: 550,
lineHeight: '1.35',
color: 'inherit !important',
background: 'inherit !important',
},
'.ol-cm-command-part': {
fontSize: '2em',
},
'.ol-cm-command-chapter': {
fontSize: '1.6em',
},
'.ol-cm-command-section': {
fontSize: '1.44em',
},
'.ol-cm-command-subsection': {
fontSize: '1.2em',
},
'.ol-cm-command-subsubsection': {
fontSize: '1em',
},
'.ol-cm-command-paragraph': {
fontSize: '1em',
},
'.ol-cm-command-subparagraph': {
fontSize: '1em',
},
'.ol-cm-frame-title': {
fontSize: '1.44em',
},
'.ol-cm-frame-subtitle': {
fontSize: '1em',
},
'.ol-cm-divider': {
borderBottom: '1px solid rgba(125, 125, 125, 0.1)',
padding: '0.5em 6px',
'&.ol-cm-frame-widget': {
borderBottom: 'none',
borderTop: '1px solid rgba(125, 125, 125, 0.1)',
},
},
'.ol-cm-command-textbf': {
fontWeight: 700,
},
'.ol-cm-command-textit': {
fontStyle: 'italic',
},
'.ol-cm-command-textsc': {
fontVariant: 'small-caps',
},
'.ol-cm-command-texttt': {
fontFamily: 'monospace',
},
'.ol-cm-command-underline': {
textDecoration: 'underline',
},
'.ol-cm-command-sout': {
textDecoration: 'line-through',
},
'.ol-cm-command-emph': {
fontStyle: 'italic',
'& .ol-cm-command-textit': {
fontStyle: 'normal',
},
'.ol-cm-command-textit &': {
fontStyle: 'normal',
},
},
'.ol-cm-command-url': {
textDecoration: 'underline',
// copied from tags.monospace
fontFamily: 'var(--source-font-family)',
lineHeight: 1,
overflowWrap: 'break-word',
hyphens: 'auto',
},
'.ol-cm-environment-centered.ol-cm-caption-line': {
padding: '0 10%',
textAlign: 'center',
},
'.ol-cm-caption-line .ol-cm-label': {
marginRight: '1ch',
},
'.ol-cm-tex': {
textTransform: 'uppercase',
'& sup': {
position: 'inherit',
fontSize: '0.85em',
verticalAlign: '0.15em',
marginLeft: '-0.36em',
marginRight: '-0.15em',
},
'& sub': {
position: 'inherit',
fontSize: '1em',
verticalAlign: '-0.5ex',
marginLeft: '-0.1667em',
marginRight: '-0.125em',
},
},
'.ol-cm-graphics': {
display: 'block',
maxWidth: 'min(300px, 100%)',
paddingTop: '1em',
paddingBottom: '1em',
cursor: 'pointer',
'.ol-cm-graphics-inline &': {
display: 'inline',
},
},
'.ol-cm-graphics-loading': {
height: '300px', // guess that the height is the same as the max width
},
'.ol-cm-graphics-error': {
border: '1px solid red',
padding: '8px',
},
'.ol-cm-environment-centered .ol-cm-graphics': {
margin: '0 auto',
},
'.ol-cm-command-verb .ol-cm-monospace': {
color: 'inherit', // remove syntax highlighting colour from verbatim content
},
'.ol-cm-preamble-wrapper': {
padding: '0.5em 0',
'&.ol-cm-preamble-expanded': {
paddingBottom: '0',
},
},
'.ol-cm-preamble-widget, .ol-cm-end-document-widget': {
padding: '0.25em 1em',
borderRadius: '8px',
fontFamily: '"Lato", sans-serif',
fontSize: '14px',
'.ol-cm-preamble-expanded &': {
borderBottomLeftRadius: '0',
borderBottomRightRadius: '0',
borderBottom: '1px solid rgba(125, 125, 125, 0.2)',
},
},
'.ol-cm-preamble-widget': {
cursor: 'pointer',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
},
'.ol-cm-preamble-expand-icon': {
width: '32px',
lineHeight: '32px',
textAlign: 'center',
transition: '0.2s ease-out',
opacity: '0.5',
'.ol-cm-preamble-widget:hover &': {
opacity: '1',
},
'.ol-cm-preamble-expanded &': {
transform: 'rotate(180deg)',
},
},
'.ol-cm-preamble-line, .ol-cm-end-document-widget, .ol-cm-preamble-widget': {
backgroundColor: 'rgba(125, 125, 125, 0.05)',
},
'.ol-cm-preamble-line': {
padding: '0 12px',
'&.ol-cm-environment-first-line': {
borderRadius: '0',
},
},
'.ol-cm-end-document-widget': {
textAlign: 'center',
},
})
@@ -0,0 +1,49 @@
import { EditorView, WidgetType } from '@codemirror/view'
import { placeSelectionInsideBlock } from '../selection'
export class BeginWidget extends WidgetType {
constructor(public environment: string) {
super()
}
toDOM(view: EditorView) {
const element = document.createElement('div')
element.classList.add('ol-cm-begin')
element.classList.add(`ol-cm-begin-${this.environment}`)
const leftPadding = document.createElement('span')
leftPadding.classList.add('ol-cm-environment-padding')
element.appendChild(leftPadding)
const name = document.createElement('span')
name.textContent = this.environment
name.classList.add('ol-cm-environment-name')
name.classList.add(`ol-cm-environment-name-${this.environment}`)
element.appendChild(name)
const rightPadding = document.createElement('span')
rightPadding.classList.add('ol-cm-environment-padding')
element.appendChild(rightPadding)
element.addEventListener('mouseup', event => {
event.preventDefault()
view.dispatch(placeSelectionInsideBlock(view, event as MouseEvent))
})
return element
}
eq(widget: BeginWidget) {
return widget.environment === this.environment
}
updateDOM(element: HTMLDivElement) {
element.querySelector('.ol-cm-environment-name')!.textContent =
this.environment
return true
}
ignoreEvent(event: Event): boolean {
return event.type !== 'mouseup'
}
}
@@ -0,0 +1,24 @@
import { WidgetType } from '@codemirror/view'
export class BraceWidget extends WidgetType {
constructor(private content?: string) {
super()
}
toDOM() {
const element = document.createElement('span')
element.classList.add('ol-cm-brace')
if (this.content !== undefined) {
element.textContent = this.content
}
return element
}
ignoreEvent(event: Event) {
return event.type !== 'mousedown' && event.type !== 'mouseup'
}
eq(widget: BraceWidget) {
return widget.content === this.content
}
}
@@ -0,0 +1,160 @@
import { WidgetType } from '@codemirror/view'
export class CharacterWidget extends WidgetType {
constructor(public content: string) {
super()
}
toDOM() {
const element = document.createElement('span')
element.classList.add('ol-cm-character')
element.textContent = this.content
return element
}
eq(widget: CharacterWidget) {
return widget.content === this.content
}
updateDOM(element: HTMLElement): boolean {
element.textContent = this.content
return true
}
ignoreEvent(event: Event) {
return event.type !== 'mousedown' && event.type !== 'mouseup'
}
}
const SUBSTITUTIONS = new Map([
['\\', ' '], // a trimmed \\ '
['\\%', '\u0025'],
['\\_', '\u005F'],
['\\}', '\u007D'],
['\\&', '\u0026'],
['\\#', '\u0023'],
['\\{', '\u007B'],
['\\textasciicircum', '\u005E'],
['\\textless', '\u003C'],
['\\textasciitilde', '\u007E'],
['\\textordfeminine', '\u00AA'],
['\\textasteriskcentered', '\u204E'],
['\\textordmasculine', '\u00BA'],
['\\textbackslash', '\u005C'],
['\\textparagraph', '\u00B6'],
['\\textbar', '\u007C'],
['\\textperiodcentered', '\u00B7'],
['\\textbardbl', '\u2016'],
['\\textpertenthousand', '\u2031'],
['\\textperthousand', '\u2030'],
['\\textbraceleft', '\u007B'],
['\\textquestiondown', '\u00BF'],
['\\textbraceright', '\u007D'],
['\\textquotedblleft', '\u201C'],
['\\textbullet', '\u2022'],
['\\textquotedblright', '\u201D'],
['\\textcopyright', '\u00A9'],
['\\textquoteleft', '\u2018'],
['\\textdagger', '\u2020'],
['\\textquoteright', '\u2019'],
['\\textdaggerdbl', '\u2021'],
['\\textregistered', '\u00AE'],
['\\textdollar', '\u0024'],
['\\textsection', '\u00A7'],
['\\textellipsis', '\u2026'],
['\\textsterling', '\u00A3'],
['\\textemdash', '\u2014'],
['\\texttrademark', '\u2122'],
['\\textendash', '\u2013'],
['\\textunderscore', '\u005F'],
['\\textexclamdown', '\u00A1'],
['\\textvisiblespace', '\u2423'],
['\\textgreater', '\u003E'],
['\\ddag', '\u2021'],
['\\pounds', '\u00A3'],
['\\copyright', '\u00A9'],
['\\dots', '\u2026'],
['\\S', '\u00A7'],
['\\dag', '\u2020'],
['\\P', '\u00B6'],
['\\aa', '\u00E5'],
['\\DH', '\u00D0'],
['\\L', '\u0141'],
['\\o', '\u00F8'],
['\\th', '\u00FE'],
['\\AA', '\u00C5'],
['\\DJ', '\u0110'],
['\\l', '\u0142'],
['\\oe', '\u0153'],
['\\TH', '\u00DE'],
['\\AE', '\u00C6'],
['\\dj', '\u0111'],
['\\NG', '\u014A'],
['\\OE', '\u0152'],
['\\ae', '\u00E6'],
['\\IJ', '\u0132'],
['\\ng', '\u014B'],
['\\ss', '\u00DF'],
['\\dh', '\u00F0'],
['\\ij', '\u0133'],
['\\O', '\u00D8'],
['\\SS', '\u1E9E'],
['\\guillemetleft', '\u00AB'],
['\\guilsinglleft', '\u2039'],
['\\quotedblbase', '\u201E'],
['\\textquotedbl', '\u0022'],
['\\guillemetright', '\u00BB'],
['\\guilsinglright', '\u203A'],
['\\quotesinglbase', '\u201A'],
['\\textbaht', '\u0E3F'],
['\\textdollar', '\u0024'],
['\\textwon', '\u20A9'],
['\\textcent', '\u00A2'],
['\\textlira', '\u20A4'],
['\\textyen', '\u00A5'],
['\\textcentoldstyle', '\u00A2'],
['\\textdong', '\u20AB'],
['\\textnaira', '\u20A6'],
['\\textcolonmonetary', '\u20A1'],
['\\texteuro', '\u20AC'],
['\\textpeso', '\u20B1'],
['\\textcurrency', '\u00A4'],
['\\textflorin', '\u0192'],
['\\textsterling', '\u00A3'],
['\\textcircledP', '\u2117'],
['\\textcopyright', '\u00A9'],
['\\textservicemark', '\u2120'],
['\\textregistered', '\u00AE'],
['\\texttrademark', '\u2122'],
['\\textblank', '\u2422'],
['\\textpilcrow', '\u00B6'],
['\\textbrokenbar', '\u00A6'],
['\\textquotesingle', '\u0027'],
['\\textdblhyphen', '\u2E40'],
['\\textdblhyphenchar', '\u2E40'],
['\\textdiscount', '\u2052'],
['\\textrecipe', '\u211E'],
['\\textestimated', '\u212E'],
['\\textreferencemark', '\u203B'],
['\\textinterrobang', '\u203D'],
['\\textthreequartersemdash', '\u2014'],
['\\textinterrobangdown', '\u2E18'],
['\\texttildelow', '\u02F7'],
['\\textnumero', '\u2116'],
['\\texttwelveudash', '\u2014'],
['\\textopenbullet', '\u25E6'],
['\\ldots', '\u2026'],
])
export function createCharacterCommand(
command: string
): CharacterWidget | undefined {
const substitution = SUBSTITUTIONS.get(command)
if (substitution !== undefined) {
return new CharacterWidget(substitution)
}
}
export function hasCharacterSubstitution(command: string): boolean {
return SUBSTITUTIONS.has(command)
}
@@ -0,0 +1,17 @@
import { WidgetType } from '@codemirror/view'
export class DividerWidget extends WidgetType {
toDOM() {
const element = document.createElement('div')
element.classList.add('ol-cm-divider')
return element
}
eq() {
return true
}
updateDOM(): boolean {
return true
}
}
@@ -0,0 +1,27 @@
import { EditorView, WidgetType } from '@codemirror/view'
import { placeSelectionInsideBlock } from '../selection'
export class EndDocumentWidget extends WidgetType {
toDOM(view: EditorView): HTMLElement {
const element = document.createElement('div')
element.classList.add('ol-cm-end-document-widget')
element.textContent = view.state.phrase('End of document')
element.addEventListener('mouseup', event => {
event.preventDefault()
view.dispatch(placeSelectionInsideBlock(view, event as MouseEvent))
})
return element
}
ignoreEvent(event: Event): boolean {
return event.type !== 'mouseup'
}
eq(): boolean {
return true
}
updateDOM(): boolean {
return true
}
}
@@ -0,0 +1,13 @@
import { WidgetType } from '@codemirror/view'
export class EndWidget extends WidgetType {
toDOM() {
const element = document.createElement('div')
element.classList.add('ol-cm-end')
return element
}
eq(widget: EndWidget) {
return true
}
}
@@ -0,0 +1,39 @@
import { EditorView, WidgetType } from '@codemirror/view'
export class EnvironmentLineWidget extends WidgetType {
constructor(public environment: string, public line?: 'begin' | 'end') {
super()
}
toDOM(view: EditorView) {
const element = document.createElement('div')
element.classList.add(`ol-cm-environment-${this.environment}`)
element.classList.add('ol-cm-environment-edge')
const line = document.createElement('div')
element.append(line)
line.classList.add('ol-cm-environment-line')
line.classList.add(`ol-cm-environment-${this.environment}`)
switch (this.line) {
case 'begin':
element.classList.add('ol-cm-environment-top')
line.classList.add('ol-cm-environment-first-line')
break
case 'end':
element.classList.add('ol-cm-environment-bottom')
line.classList.add('ol-cm-environment-last-line')
break
}
return element
}
eq(widget: EnvironmentLineWidget) {
return widget.environment === this.environment && widget.line === this.line
}
ignoreEvent(event: Event): boolean {
return event.type !== 'mousedown' && event.type !== 'mouseup'
}
}

Some files were not shown because too many files have changed in this diff Show More