mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-06-02 13:49:00 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
-1
@@ -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
-1
@@ -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'
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
)
|
||||
})
|
||||
+422
@@ -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">×</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>
|
||||
</>
|
||||
)
|
||||
})
|
||||
+115
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
+6
@@ -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' },
|
||||
])
|
||||
+206
@@ -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
|
||||
},
|
||||
})
|
||||
+128
@@ -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
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
+144
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+100
@@ -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
|
||||
}
|
||||
+927
@@ -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
|
||||
}
|
||||
}
|
||||
+180
@@ -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),
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
+35
@@ -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)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
+10
@@ -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,
|
||||
})
|
||||
}
|
||||
+80
@@ -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',
|
||||
},
|
||||
})
|
||||
+49
@@ -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'
|
||||
}
|
||||
}
|
||||
+24
@@ -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
|
||||
}
|
||||
}
|
||||
+160
@@ -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)
|
||||
}
|
||||
+17
@@ -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
|
||||
}
|
||||
}
|
||||
+27
@@ -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
|
||||
}
|
||||
}
|
||||
+13
@@ -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
|
||||
}
|
||||
}
|
||||
+39
@@ -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
Reference in New Issue
Block a user