first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

@@ -0,0 +1,270 @@
import { EditorView, ViewUpdate } from '@codemirror/view'
import { Diagnostic, linter, lintGutter } from '@codemirror/lint'
import {
Compartment,
EditorState,
Extension,
Line,
RangeSet,
RangeValue,
StateEffect,
StateField,
} from '@codemirror/state'
import { Annotation } from '../../../../../types/annotation'
import { debugConsole } from '@/utils/debugging'
import { sendMB } from '@/infrastructure/event-tracking'
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
import { syntaxTree } from '@codemirror/language'
interface CompileLogDiagnostic extends Diagnostic {
compile?: true
ruleId?: string
id?: string
entryIndex: number
firstOnLine?: boolean
}
type RenderedDiagnostic = Pick<
CompileLogDiagnostic,
| 'message'
| 'severity'
| 'ruleId'
| 'compile'
| 'source'
| 'id'
| 'firstOnLine'
>
export type DiagnosticAction = (
diagnostic: RenderedDiagnostic
) => HTMLButtonElement | null
const diagnosticActions = importOverleafModules('diagnosticActions') as {
import: { default: DiagnosticAction }
}[]
const compileLintSourceConf = new Compartment()
export const annotations = () => [
compileDiagnosticsState,
compileLintSourceConf.of(compileLogLintSource()),
/**
* The built-in lint gutter extension, configured with zero hover delay.
*/
lintGutter({
hoverTime: 0,
}),
annotationsTheme,
]
/**
* A theme which moves the lint gutter outside the line numbers.
*/
const annotationsTheme = 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 []
},
needsRefresh(update: ViewUpdate) {
return update.selectionSet
},
}
/**
* A lint source using the compile log diagnostics
*/
const compileLogLintSource = (): Extension =>
linter(view => {
const items: CompileLogDiagnostic[] = []
// NOTE: iter() changes the order of diagnostics on the same line
const cursor = view.state.field(compileDiagnosticsState).iter()
while (cursor.value !== null) {
const { diagnostic } = cursor.value
items.push({
...diagnostic,
from: cursor.from,
to: cursor.to,
renderMessage: () => renderMessage(diagnostic),
})
cursor.next()
}
// restore the original order of items
items.sort((a, b) => a.from - b.from || a.entryIndex - b.entryIndex)
return items
}, lintSourceConfig)
class CompileLogDiagnosticRangeValue extends RangeValue {
constructor(public diagnostic: CompileLogDiagnostic) {
super()
}
}
const setCompileDiagnosticsEffect = StateEffect.define<CompileLogDiagnostic[]>()
/**
* A state field for the compile log diagnostics
*/
export const compileDiagnosticsState = StateField.define<
RangeSet<CompileLogDiagnosticRangeValue>
>({
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 CompileLogDiagnosticRangeValue(diagnostic).range(
diagnostic.from,
diagnostic.to
)
),
true
)
}
}
if (transaction.docChanged) {
value = value.map(transaction.changes)
}
return value
},
})
export const setAnnotations = (
state: EditorState,
annotations: Annotation[]
) => {
const diagnostics: CompileLogDiagnostic[] = []
for (const annotation of annotations) {
// ignore "whole document" (row: -1) annotations
if (annotation.row !== -1) {
try {
diagnostics.push(...convertAnnotationToDiagnostic(state, annotation))
} catch (error) {
// ignore invalid annotations
debugConsole.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 commandRanges = (state: EditorState, line: Line, command: string) => {
const ranges: { from: number; to: number }[] = []
syntaxTree(state).iterate({
enter(nodeRef) {
if (nodeRef.type.is('CtrlSeq')) {
const { from, to } = nodeRef
if (command === state.sliceDoc(from, to)) {
ranges.push({ from, to })
}
}
},
from: line.from,
to: line.to,
})
return ranges.slice(0, 1) // NOTE: only highlighting the first match on a line, to avoid duplicate messages
}
const chooseHighlightRanges = (
state: EditorState,
line: Line,
annotation: Annotation
) => {
const ranges: { from: number; to: number }[] = []
if (annotation.command) {
ranges.push(...commandRanges(state, line, annotation.command))
}
// default to highlighting the whole line
if (ranges.length === 0) {
ranges.push(line)
}
return ranges
}
const convertAnnotationToDiagnostic = (
state: EditorState,
annotation: Annotation
): CompileLogDiagnostic[] => {
if (annotation.row < 0) {
throw new Error(`Invalid annotation row ${annotation.row}`)
}
// NOTE: highlight whole line by default, as synctex doesn't output column number
const line = state.doc.line(annotation.row + 1)
const highlightRanges = chooseHighlightRanges(state, line, annotation)
return highlightRanges.map(location => ({
from: location.from,
to: location.to,
severity: annotation.type,
message: annotation.text,
ruleId: annotation.ruleId,
compile: true,
id: annotation.id,
entryIndex: annotation.entryIndex,
source: annotation.source,
firstOnLine: annotation.firstOnLine,
}))
}
export const renderMessage = (diagnostic: RenderedDiagnostic) => {
const { message, severity, ruleId, compile = false } = diagnostic
const div = document.createElement('div')
div.classList.add('ol-cm-diagnostic-message')
div.append(message)
const activeDiagnosticActions = diagnosticActions
.map(m => m.import.default(diagnostic))
.filter(Boolean) as HTMLButtonElement[]
if (activeDiagnosticActions.length) {
const actions = document.createElement('div')
actions.classList.add('ol-cm-diagnostic-actions')
actions.append(...activeDiagnosticActions)
div.append(actions)
}
window.setTimeout(() => {
if (div.isConnected) {
sendMB('lint-gutter-marker-view', { severity, ruleId, compile })
}
}, 500) // 500ms delay to indicate intention, rather than accidental hover
return div
}

View File

@@ -0,0 +1,197 @@
import {
acceptCompletion,
autocompletion,
closeCompletion,
moveCompletionSelection,
startCompletion,
Completion,
} from '@codemirror/autocomplete'
import { EditorView, keymap } from '@codemirror/view'
import {
Compartment,
Extension,
Prec,
TransactionSpec,
} from '@codemirror/state'
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
const moduleExtensions: Array<(options: Record<string, any>) => Extension> =
importOverleafModules('autoCompleteExtensions').map(
(item: { import: { extension: Extension } }) => item.import.extension
)
const autoCompleteConf = new Compartment()
type AutoCompleteOptions = {
enabled: boolean
} & Record<string, any>
export const autoComplete = ({ enabled, ...rest }: AutoCompleteOptions) =>
autoCompleteConf.of(createAutoComplete({ enabled, ...rest }))
export const setAutoComplete = ({
enabled,
...rest
}: AutoCompleteOptions): TransactionSpec => {
return {
effects: autoCompleteConf.reconfigure(
createAutoComplete({ enabled, ...rest })
),
}
}
const createAutoComplete = ({ enabled, ...rest }: AutoCompleteOptions) => {
if (!enabled) {
return []
}
return [
[
autocompleteTheme,
/**
* A built-in extension which provides the autocomplete feature,
* configured with a custom render function and
* a zero interaction delay (so that keypresses are handled after the autocomplete is opened).
*/
autocompletion({
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,
}),
/**
* A keymap which adds Tab for accepting a completion and Ctrl-Space for opening autocomplete.
*/
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 },
])
),
/**
* A keymap which positions Ctrl-Space and Alt-Space below the corresponding bindings for advanced reference search.
*/
Prec.high(
keymap.of([
{ key: 'Ctrl-Space', run: startCompletion },
{ key: 'Alt-Space', run: startCompletion },
])
),
],
moduleExtensions.map(extension => extension({ ...rest })),
]
}
const AUTOCOMPLETE_LINE_HEIGHT = 1.4
/**
* Styles for the autocomplete menu
*/
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, &light .cm-tooltip.cm-completionInfo':
{
border: '1px lightgray solid',
background: '#fefefe',
color: '#111',
boxShadow: '2px 3px 5px rgb(0 0 0 / 20%)',
},
'&dark .cm-tooltip.cm-tooltip-autocomplete, &dark .cm-tooltip.cm-completionInfo':
{
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: AUTOCOMPLETE_LINE_HEIGHT, // increase the line height from default 1.2, for a larger target area
outline: '1px solid transparent',
},
'.cm-tooltip .cm-completionDetail': {
flex: '1 0 auto',
fontSize: 'calc(var(--font-size) * 1.4)',
lineHeight: `calc(var(--font-size) * ${AUTOCOMPLETE_LINE_HEIGHT})`,
overflow: 'hidden',
// By default CodeMirror styles the details as italic
fontStyle: 'normal !important',
// We use this element for the symbol palette, so change the font to the
// symbol palette font
fontFamily: "'Stix Two Math', serif",
},
'&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,
},
'&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',
},
})

View File

@@ -0,0 +1,26 @@
import { keymap } from '@codemirror/view'
import { Compartment, Prec, TransactionSpec } from '@codemirror/state'
import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete'
const autoPairConf = new Compartment()
export const autoPair = ({
autoPairDelimiters,
}: {
autoPairDelimiters: boolean
}) => autoPairConf.of(autoPairDelimiters ? extension : [])
export const setAutoPair = (autoPairDelimiters: boolean): TransactionSpec => {
return {
effects: autoPairConf.reconfigure(autoPairDelimiters ? extension : []),
}
}
/**
* The built-in closeBrackets extension and closeBrackets keymap.
*/
const extension = [
closeBrackets(),
// NOTE: using Prec.highest as this needs to run before the default Backspace handler
Prec.highest(keymap.of(closeBracketsKeymap)),
]

View File

@@ -0,0 +1,168 @@
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'
import browser from '@/features/source-editor/extensions/browser'
const matchingMark = Decoration.mark({ class: 'cm-matchingBracket' })
const nonmatchingMark = Decoration.mark({ class: 'cm-nonmatchingBracket' })
const FORWARDS = 1
const BACKWARDS = -1
type Direction = 1 | -1
/**
* A built-in extension which decorates matching pairs of brackets when focused,
* configured with a custom render function that combines adjacent pairs of matching markers
* into a single decoration so theres no border between them.
*/
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)
)
/**
* A custom extension which handles double-click events on a matched bracket
* and extends the selection to cover the contents of the bracket pair.
*/
export const bracketSelection = (): Extension => {
return [chooseEventHandler(), matchingBracketTheme]
}
const chooseEventHandler = () => {
// Safari doesn't always fire the second "click" event, so use dblclick instead
if (browser.safari) {
return [
EditorView.domEventHandlers({
dblclick: handleDoubleClick,
}),
]
}
// Store and use the previous click event, as the "dblclick" event can have the wrong position
let lastClickEvent: MouseEvent | null = null
return EditorView.domEventHandlers({
click(evt, view) {
if (evt.detail === 1) {
lastClickEvent = evt
} else if (evt.detail === 2 && lastClickEvent) {
return handleDoubleClick(lastClickEvent, view)
}
},
})
}
const handleDoubleClick = (evt: MouseEvent, view: EditorView) => {
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) {
return EditorSelection.range(
Math.min(match.start.from, match.end.from),
Math.max(match.end.to, match.start.to)
)
}
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
}
const matchingBracketTheme = EditorView.baseTheme({
'.cm-matchingBracket': {
pointerEvents: 'none',
},
})

View File

@@ -0,0 +1,41 @@
import { EditorView } from '@codemirror/view'
/**
* A panel which contains the editor breadcrumbs
*/
export function breadcrumbPanel() {
return [
EditorView.editorAttributes.of({
style: '--breadcrumbs-height: 28px;',
}),
EditorView.baseTheme({
'.ol-cm-breadcrumbs-portal': {
display: 'flex',
pointerEvents: 'none !important',
'& > *': {
pointerEvents: 'all',
},
},
'.ol-cm-breadcrumbs': {
height: 'var(--breadcrumbs-height)',
flex: 1,
display: 'flex',
alignItems: 'center',
gap: 'var(--spacing-01)',
fontSize: 'var(--font-size-01)',
padding: 'var(--spacing-02)',
overflow: 'auto',
scrollbarWidth: 'thin',
'& > *': {
flexShrink: '0',
},
},
'&light .ol-cm-breadcrumb-chevron': {
color: 'var(--neutral-30)',
},
'&dark .ol-cm-breadcrumb-chevron': {
color: 'var(--neutral-50)',
},
}),
]
}

View File

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

View File

@@ -0,0 +1,36 @@
import { syntaxTree } from '@codemirror/language'
import {
EditorSelection,
Prec,
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,
Prec,
StateEffect,
StateField,
ViewPlugin,
WidgetType,
hoverTooltip,
keymap,
syntaxTree,
}
export const CodeMirrorVim = {
CodeMirror,
Vim,
getCM,
}

View File

@@ -0,0 +1,226 @@
import { Range, RangeSet, RangeValue, Transaction } from '@codemirror/state'
import {
AnyOperation,
Change,
CommentOperation,
} from '../../../../../../types/change'
import { ThreadId } from '../../../../../../types/review-panel/review-panel'
import { DocumentContainer } from '@/features/ide-react/editor/document-container'
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: DocumentContainer,
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: CommentOperation[] = []
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 as ThreadId,
})
}
}
})
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: DocumentContainer,
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: DocumentContainer,
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: DocumentContainer,
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)
window.setTimeout(() => 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: DocumentContainer,
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: DocumentContainer,
transaction: Transaction,
storedComments: RangeSet<any>
) => {
const ops: CommentOperation[] = []
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)
}

View File

@@ -0,0 +1,106 @@
import { EditorState } from '@codemirror/state'
import { Change, EditOperation } from '../../../../../../types/change'
import { isDeleteOperation, isInsertOperation } from '@/utils/operations'
import { DocumentContainer } from '@/features/ide-react/editor/document-container'
import { trackChangesAnnotation } from '@/features/source-editor/extensions/realtime'
/**
* Remove tracked changes from the range tracker when they're rejected,
* and restore the original content
*/
export const rejectChanges = (
state: EditorState,
ranges: DocumentContainer['ranges'],
changeIds: string[]
) => {
const changes = ranges!.getChanges(changeIds) as Change<EditOperation>[]
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
if (isInsertOperation(op)) {
const from = op.p
const content = op.i
const to = from + content.length
const text = state.doc.sliceString(from, to)
if (text !== content) {
throw new Error(`Op to be removed does not match editor text`)
}
return { from, to, insert: '' }
} else if (isDeleteOperation(op)) {
return {
from: op.p,
to: op.p,
insert: op.d,
}
} else {
throw new Error(`unknown change type: ${JSON.stringify(change)}`)
}
})
return {
changes: changesToDispatch,
annotations: [trackChangesAnnotation.of('reject')],
}
}

View File

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

View File

@@ -0,0 +1,363 @@
import {
Decoration,
EditorView,
getTooltip,
keymap,
showTooltip,
Tooltip,
TooltipView,
ViewUpdate,
} from '@codemirror/view'
import {
EditorSelection,
EditorState,
Prec,
SelectionRange,
StateEffect,
StateField,
} from '@codemirror/state'
import { ancestorOfNodeWithType } from '../utils/tree-query'
import { ensureSyntaxTree, syntaxTree } from '@codemirror/language'
import {
FilePathArgument,
LiteralArgContent,
RefArgument,
ShortArg,
ShortTextArgument,
UrlArgument,
} from '../lezer-latex/latex.terms.mjs'
import {
hasNextSnippetField,
selectedCompletion,
} from '@codemirror/autocomplete'
import { SyntaxNode } from '@lezer/common'
type ActiveTooltip = {
range: SelectionRange
tooltip: Tooltip
command: string
} | null
const createTooltipView = (
update?: (update: ViewUpdate) => void
): TooltipView => {
const dom = document.createElement('div')
dom.role = 'menu'
dom.classList.add('ol-cm-command-tooltip')
return { dom, update }
}
const buildTooltip = (
command: string,
pos: number,
value: ActiveTooltip,
commandNode: SyntaxNode,
argumentNode?: SyntaxNode | null,
update?: (update: ViewUpdate) => void
): ActiveTooltip => {
if (!argumentNode) {
return null
}
const { from, to } = commandNode
// if the node still matches the range (i.e. this is the same node),
// re-use the tooltip by supplying the same create function
if (value && from === value.range.from && to === value.range.to) {
return {
...value,
tooltip: { ...value.tooltip, pos },
}
}
return {
command,
range: EditorSelection.range(from, to),
tooltip: {
create: () => createTooltipView(update), // ensure a new create function
arrow: true,
pos,
},
}
}
const createTooltipState = (
state: EditorState,
value: ActiveTooltip
): ActiveTooltip => {
// NOTE: only handling the main selection
const { main } = state.selection
const pos = main.head
const node = syntaxTree(state).resolveInner(pos, 0)
const commandNode = ancestorOfNodeWithType(node, '$CommandTooltipCommand')
if (!commandNode) {
return null
}
// only show the tooltip when the selection is completely inside the node
if (main.from < commandNode.from || main.to > commandNode.to) {
return null
}
const commandName = commandNode.name
switch (commandName) {
// a hyperlink (\href)
case 'HrefCommand': {
const argumentNode = commandNode
.getChild(UrlArgument)
?.getChild(LiteralArgContent)
if (
argumentNode &&
state.sliceDoc(argumentNode.from, argumentNode.to).includes('\n')
) {
return null
}
const update = (update: ViewUpdate) => {
const tooltipState = commandTooltipState(update.state)
const input =
tooltipState && firstInteractiveElement(update.view, tooltipState)
if (input && document.activeElement !== input) {
const commandNode = resolveCommandNode(update.state)
const argumentNode = commandNode
?.getChild(UrlArgument)
?.getChild(LiteralArgContent)
if (argumentNode) {
const url = update.state.sliceDoc(
argumentNode.from,
argumentNode.to
)
if (url !== input.value) {
input.dispatchEvent(
new CustomEvent('value-update', {
detail: url,
})
)
}
}
}
}
return buildTooltip(
commandName,
pos,
value,
commandNode,
argumentNode,
update
)
}
// a URL (\url)
case 'UrlCommand': {
const argumentNode = commandNode
.getChild(UrlArgument)
?.getChild(LiteralArgContent)
if (argumentNode) {
const content = state
.sliceDoc(argumentNode.from, argumentNode.to)
.trim()
if (
!content ||
content.includes('\n') ||
!/^https?:\/\/\w+/.test(content)
) {
return null
}
}
return buildTooltip(commandName, pos, value, commandNode, argumentNode)
}
// a cross-reference (\ref)
case 'Ref': {
const argumentNode = commandNode
.getChild(RefArgument)
?.getChild(ShortTextArgument)
?.getChild(ShortArg)
return buildTooltip(commandName, pos, value, commandNode, argumentNode)
}
// an included file (\include)
case 'Include': {
const argumentNode = commandNode
.getChild('IncludeArgument')
?.getChild(FilePathArgument)
?.getChild(LiteralArgContent)
return buildTooltip(commandName, pos, value, commandNode, argumentNode)
}
// an input file (\input)
case 'Input': {
const argumentNode = commandNode
.getChild('InputArgument')
?.getChild(FilePathArgument)
?.getChild(LiteralArgContent)
return buildTooltip(commandName, pos, value, commandNode, argumentNode)
}
}
return null
}
const commandTooltipTheme = EditorView.baseTheme({
'&light .cm-tooltip.ol-cm-command-tooltip': {
border: '1px lightgray solid',
background: '#fefefe',
color: '#111',
boxShadow: '2px 3px 5px rgb(0 0 0 / 20%)',
},
'&dark .cm-tooltip.ol-cm-command-tooltip': {
border: '1px #484747 solid',
background: '#25282c',
color: '#c1c1c1',
boxShadow: '2px 3px 5px rgba(0, 0, 0, 0.51)',
},
'.ol-cm-command-tooltip-content': {
padding: '8px 0',
display: 'flex',
flexDirection: 'column',
},
'.btn-link.ol-cm-command-tooltip-link': {
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '4px 12px',
textDecoration: 'none',
color: 'inherit',
},
'.ol-cm-command-tooltip-form': {
padding: '0 8px',
},
})
export const resolveCommandNode = (state: EditorState) => {
const tooltipState = commandTooltipState(state)
if (tooltipState) {
const pos = tooltipState.range.from
const tree = ensureSyntaxTree(state, pos)
if (tree) {
return tree.resolveInner(pos, 1).parent
}
}
}
const closeCommandTooltipEffect = StateEffect.define()
export const closeCommandTooltip = () => {
return {
effects: closeCommandTooltipEffect.of(null),
}
}
export const commandTooltipStateField = StateField.define<ActiveTooltip>({
create(state) {
return createTooltipState(state, null)
},
update(value, tr) {
if (tr.effects.some(effect => effect.is(closeCommandTooltipEffect))) {
// close the tooltip if this effect is present
value = null
} else if (selectedCompletion(tr.state)) {
// don't show tooltip if autocomplete is open
value = null
} else {
if (value) {
// map the stored range through changes
value.range = value.range.map(tr.changes)
}
if (tr.docChanged || tr.selection) {
// create/update the tooltip
value = createTooltipState(tr.state, value)
}
}
return value
},
provide(field) {
return [
// show the tooltip when defined
showTooltip.from(field, field => (field ? field.tooltip : null)),
// set attributes on the node with the popover
EditorView.decorations.from(field, field => {
if (!field) {
return Decoration.none
}
return Decoration.set(
Decoration.mark({
attributes: {
'aria-haspopup': 'menu',
},
}).range(field.range.from, field.range.to)
)
}),
]
},
})
export const commandTooltipState = (
state: EditorState
): ActiveTooltip | undefined => state.field(commandTooltipStateField, false)
const firstInteractiveElement = (
view: EditorView,
tooltipState: NonNullable<ActiveTooltip>
) =>
getTooltip(view, tooltipState.tooltip)?.dom.querySelector<
HTMLInputElement | HTMLButtonElement
>('input, button')
const commandTooltipKeymap = Prec.highest(
keymap.of([
{
key: 'Tab',
// Tab to focus the first element in the tooltip, if open
run(view) {
const tooltipState = commandTooltipState(view.state)
if (tooltipState) {
const element = firstInteractiveElement(view, tooltipState)
if (element) {
// continue to the next snippet if there's already a URL filled in
if (
element.type === 'url' &&
element.value &&
hasNextSnippetField(view.state)
) {
return false
}
element.focus()
return true
}
}
return false
},
},
{
key: 'Escape',
// Escape to close the tooltip, if open
run(view) {
const tooltipState = commandTooltipState(view.state)
if (tooltipState) {
view.dispatch(closeCommandTooltip())
}
return false
},
},
])
)
export const commandTooltip = [
commandTooltipStateField,
commandTooltipKeymap,
commandTooltipTheme,
]

View File

@@ -0,0 +1,221 @@
import {
MapMode,
RangeSet,
RangeValue,
StateEffect,
StateField,
Transaction,
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'
import { debugConsole } from '@/utils/debugging'
/**
* A custom extension that displays collaborator cursors in a separate layer.
*/
export const cursorHighlights = () => {
return [
cursorHighlightsState,
cursorHighlightsLayer,
cursorHighlightsTheme,
hoverTooltip(cursorTooltip, {
hoverTime: 1,
}),
]
}
const cursorHighlightsTheme = 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 {
mapMode = MapMode.Simple
constructor(public highlight: Highlight) {
super()
}
eq(other: HighlightRangeValue) {
return other.highlight === this.highlight
}
}
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
debugConsole.debug('invalid highlight position', error)
}
}
}
return RangeSet.of(highlightRanges, true)
}
}
if (tr.docChanged && !tr.annotation(Transaction.remote)) {
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', String(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', String(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
},
})

View File

@@ -0,0 +1,183 @@
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'
import { debugConsole } from '@/utils/debugging'
const buildStorageKey = (docId: string) => `doc.position.${docId}`
/**
* A custom extension that:
* a) stores the cursor position in localStorage when the view is destroyed or the window is closed.
* b) dispatches the cursor position when it changes, for use with “show position in PDF”.
*/
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
debugConsole.debug('invalid cursor position', error)
return {}
}
}
const dispatchSelectionAndScroll = (
view: EditorView,
selection: SelectionRange
) => {
window.setTimeout(() => {
view.dispatch({
selection,
effects: EditorView.scrollIntoView(selection, { y: 'center' }),
})
view.focus()
})
}
const selectTextIfExists = (doc: Text, pos: number, selectText: string) => {
const selectionLength = pos + selectText.length
const text = doc.sliceString(pos, selectionLength)
return text === selectText
? EditorSelection.range(pos, selectionLength)
: EditorSelection.cursor(doc.lineAt(pos).from)
}
export const setCursorLineAndScroll = (
view: EditorView,
lineNumber: number,
columnNumber = 0,
selectText?: string
) => {
// TODO: map the position through any changes since the previous compile?
let selectionRange
try {
const { doc } = view.state
const pos = findValidPosition(doc, lineNumber, columnNumber)
dispatchSelectionAndScroll(
view,
selectText
? selectTextIfExists(doc, pos, selectText)
: EditorSelection.cursor(pos)
)
} catch (error) {
// ignore invalid cursor position
debugConsole.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
debugConsole.debug('invalid cursor position', error)
}
if (selectionRange) {
dispatchSelectionAndScroll(view, selectionRange)
}
}

View File

@@ -0,0 +1,24 @@
import { StateEffect, StateField } from '@codemirror/state'
export const docName = (docName: string) =>
StateField.define<string>({
create() {
return docName
},
update(value, tr) {
for (const effect of tr.effects) {
if (effect.is(setDocNameEffect)) {
value = effect.value
}
}
return value
},
})
export const setDocNameEffect = StateEffect.define<string>()
export const setDocName = (docName: string) => {
return {
effects: setDocNameEffect.of(docName),
}
}

View File

@@ -0,0 +1,102 @@
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'
/**
* The built-in extension which draws the cursor and selection(s) in layers,
* copied to make use of a custom version of rectangleMarkerForRange which calls
* fullHeightCoordsAtPos when in Source mode, extending the top and bottom
* of the coords to cover the full line height.
*/
export const drawSelection = () => {
return [cursorLayer, selectionLayer, Prec.highest(hideNativeSelection)]
}
const canHidePrimary = !browser.ios
const hideNativeSelection = 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',
})

View File

@@ -0,0 +1,29 @@
import { Compartment, EditorState, TransactionSpec } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
const readOnlyConf = new Compartment()
/**
* A custom extension which determines whether the content is editable, by setting the value of the EditorState.readOnly and EditorView.editable facets.
* Commands and extensions read the EditorState.readOnly facet to decide whether they should be applied.
* EditorView.editable determines whether the DOM can be focused, by changing the value of the contenteditable attribute.
*/
export const editable = () => {
return [
readOnlyConf.of([
EditorState.readOnly.of(true),
EditorView.editable.of(false),
]),
]
}
export const setEditable = (value = true): TransactionSpec => {
return {
effects: [
readOnlyConf.reconfigure([
EditorState.readOnly.of(!value),
EditorView.editable.of(value),
]),
],
}
}

View File

@@ -0,0 +1,77 @@
import { StateEffect, StateEffectType, StateField } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
type EffectListenerOptions = {
once: boolean
}
type EffectListener = {
effect: StateEffectType<any>
callback: (value: any) => any
options?: EffectListenerOptions
}
const addEffectListenerEffect = StateEffect.define<EffectListener>()
const removeEffectListenerEffect = StateEffect.define<EffectListener>()
/**
* A state field that stores a collection of listeners per effect,
* and runs each listener's callback function when the effect is dispatched.
*/
export const effectListeners = () => [effectListenersField]
const effectListenersField = StateField.define<EffectListener[]>({
create: () => [],
update(value, transaction) {
for (const effect of transaction.effects) {
if (effect.is(addEffectListenerEffect)) {
value.push(effect.value)
}
if (effect.is(removeEffectListenerEffect)) {
value = value.filter(
listener =>
!(
listener.effect === effect.value.effect &&
listener.callback === effect.value.callback
)
)
}
for (let i = 0; i < value.length; ++i) {
const listener = value[i]
if (effect.is(listener.effect)) {
// Invoke the callback after the transaction
setTimeout(() => listener.callback(effect.value))
if (listener.options?.once) {
// Remove the effectListener
value.splice(i, 1)
// Keep index the same for the next iteration, since we've removed
// an element
--i
}
}
}
}
return value
},
})
export const addEffectListener = <T>(
view: EditorView,
effect: StateEffectType<T>,
callback: (value: T) => any,
options?: EffectListenerOptions
) => {
view.dispatch({
effects: addEffectListenerEffect.of({ effect, callback, options }),
})
}
export const removeEffectListener = <T>(
view: EditorView,
effect: StateEffectType<T>,
callback: (value: T) => any
) => {
view.dispatch({
effects: removeEffectListenerEffect.of({ effect, callback }),
})
}

View File

@@ -0,0 +1,81 @@
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
}
}
/**
* A custom extension which adds a widget decoration at the start of each empty line in the viewport,
* so that the line is highlighted when part of tracked changes.
*/
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
},
}
),
emptyLineFillerTheme,
]
}
const emptyLineFillerTheme = EditorView.baseTheme({
'.ol-cm-filler': {
padding: '0 2px',
},
})

View File

@@ -0,0 +1,217 @@
import {
ChangeSet,
Extension,
StateEffect,
StateField,
} from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import { addEffectListener, removeEffectListener } from './effect-listeners'
import { setMetadataEffect } from './language'
import { debugConsole } from '@/utils/debugging'
type NestedReadonly<T> = {
readonly [P in keyof T]: NestedReadonly<T[P]>
}
type FigureDataProps = {
from: number
to: number
caption: {
from: number
to: number
} | null
label: { from: number; to: number } | null
width?: number
unknownGraphicsArguments?: string
graphicsCommandArguments: {
from: number
to: number
} | null
graphicsCommand: { from: number; to: number }
file: {
from: number
to: number
path: string
}
}
function mapFromTo<T extends { from: number; to: number } | null>(
position: T,
changes: ChangeSet
) {
if (!position) {
return position
}
return {
...position,
from: changes.mapPos(position.from),
to: changes.mapPos(position.to),
}
}
export class FigureData {
// eslint-disable-next-line no-useless-constructor
constructor(private props: NestedReadonly<FigureDataProps>) {}
public get from() {
return this.props.from
}
public get to() {
return this.props.to
}
public get caption() {
return this.props.caption
}
public get label() {
return this.props.label
}
public get width() {
return this.props.width
}
public get unknownGraphicsArguments() {
return this.props.unknownGraphicsArguments
}
public get graphicsCommandArguments() {
return this.props.graphicsCommandArguments
}
public get graphicsCommand() {
return this.props.graphicsCommand
}
public get file() {
return this.props.file
}
map(changes: ChangeSet): FigureData {
return new FigureData({
from: changes.mapPos(this.from),
to: changes.mapPos(this.to),
caption: mapFromTo(this.caption, changes),
label: mapFromTo(this.label, changes),
graphicsCommand: mapFromTo(this.graphicsCommand, changes),
width: this.width,
file: mapFromTo(this.file, changes),
graphicsCommandArguments: mapFromTo(
this.graphicsCommandArguments,
changes
),
unknownGraphicsArguments: this.unknownGraphicsArguments,
})
}
}
export const editFigureDataEffect = StateEffect.define<FigureData | null>()
export const editFigureData = StateField.define<FigureData | null>({
create: () => null,
update: (current, transaction) => {
let value: FigureData | null | undefined
for (const effect of transaction.effects) {
if (effect.is(editFigureDataEffect)) {
value = effect.value
}
}
// Allow setting to null
if (value !== undefined) {
return value
}
if (!current) {
return current
}
return current.map(transaction.changes)
},
})
export const figureModal = (): Extension => [editFigureData]
export function waitForFileTreeUpdate(view: EditorView) {
const abortController = new AbortController()
const promise = new Promise<void>(resolve => {
const abort = () => {
debugConsole.warn('Aborting wait for file tree update')
removeEffectListener(view, setMetadataEffect, listener)
resolve()
}
function listener() {
if (abortController.signal.aborted) {
// We've already handled this
return
}
abortController.signal.removeEventListener('abort', abort)
resolve()
}
abortController.signal.addEventListener('abort', abort, { once: true })
addEffectListener(view, setMetadataEffect, listener, { once: true })
})
return {
withTimeout(afterMs = 500) {
setTimeout(() => abortController.abort(), afterMs)
return promise
},
promise,
}
}
const ALLOWED_MIME_TYPES = new Set([
'image/jpeg',
'image/png',
'application/pdf',
])
export type PastedImageData = {
name: string
type: string
data: Blob
}
export const figureModalPasteHandler = (): Extension => {
return EditorView.domEventHandlers({
drop: evt => {
if (!evt.dataTransfer || evt.dataTransfer.files.length === 0) {
return
}
const file = evt.dataTransfer.files[0]
if (!ALLOWED_MIME_TYPES.has(file.type)) {
return
}
window.dispatchEvent(
new CustomEvent<PastedImageData>('figure-modal:paste-image', {
detail: {
name: file.name,
type: file.type,
data: file,
},
})
)
},
paste: evt => {
if (!evt.clipboardData || evt.clipboardData.files.length === 0) {
return
}
if (evt.clipboardData.types.includes('text/plain')) {
return // allow pasted text to be handled even if there's also a file on the clipboard
}
const file = evt.clipboardData.files[0]
if (!ALLOWED_MIME_TYPES.has(file.type)) {
return
}
window.dispatchEvent(
new CustomEvent<PastedImageData>('figure-modal:paste-image', {
detail: {
name: file.name,
type: file.type,
data: file,
},
})
)
},
})
}

View File

@@ -0,0 +1,89 @@
import { EditorView } from '@codemirror/view'
import { hasImageExtension } from '@/features/source-editor/utils/file'
import { FigureModalSource } from '@/features/source-editor/components/figure-modal/figure-modal-context'
import { EditorSelection } from '@codemirror/state'
export const fileTreeItemDrop = () =>
EditorView.domEventHandlers({
dragover(event) {
// TODO: detect a drag from the file tree?
if (event.dataTransfer) {
event.preventDefault()
}
},
drop(event, view) {
if (event.dataTransfer) {
const fileId = event.dataTransfer.getData(
'application/x-overleaf-file-id'
)
const filePath = event.dataTransfer.getData(
'application/x-overleaf-file-path'
)
if (fileId && filePath) {
event.preventDefault()
const pos = view.posAtCoords(event)
if (pos !== null) {
handleDroppedFile(view, pos, fileId, filePath)
}
}
}
},
})
const withoutExtension = (filename: string) =>
filename.substring(0, filename.lastIndexOf('.'))
const handleDroppedFile = (
view: EditorView,
pos: number,
fileId: string,
filePath: string
) => {
if (filePath.endsWith('.bib')) {
view.focus()
const insert = `\\bibliography{${withoutExtension(filePath)}}`
view.dispatch({
changes: { from: pos, insert },
selection: EditorSelection.cursor(pos + insert.length),
})
return
}
if (filePath.endsWith('.tex')) {
view.focus()
const insert = `\\input{${withoutExtension(filePath)}}`
view.dispatch({
changes: { from: pos, insert },
selection: EditorSelection.cursor(pos + insert.length),
})
return
}
if (hasImageExtension(filePath)) {
view.focus()
view.dispatch({
selection: EditorSelection.cursor(pos),
})
window.dispatchEvent(
new CustomEvent('figure-modal:open', {
detail: {
source: FigureModalSource.FILE_TREE,
fileId,
filePath,
},
})
)
return
}
return null
}

View File

@@ -0,0 +1,38 @@
import { ChangeSpec, EditorState, Transaction } from '@codemirror/state'
const BAD_CHARS_REGEXP = /[\0\uD800-\uDFFF]/g
const BAD_CHARS_REPLACEMENT_CHAR = '\uFFFD'
/**
* A custom extension that replaces input characters in a Unicode range with a replacement character.
*/
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
})
}

View File

@@ -0,0 +1,19 @@
import { foldAll, toggleFold, unfoldAll } from '@codemirror/language'
/**
* A custom extension that binds keyboard shortcuts to folding actions.
*/
export const foldingKeymap = [
{
key: 'F2',
run: toggleFold,
},
{
key: 'Alt-Shift-1',
run: foldAll,
},
{
key: 'Alt-Shift-0',
run: unfoldAll,
},
]

View File

@@ -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)
/**
* A custom extension that listens for an event indicating that fonts have finished loading,
* then dispatches an effect which other extensions can use to recalculate values.
*/
export const fontLoad = 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
)
}
},
}
})

View File

@@ -0,0 +1,13 @@
import { EditorView } from '@codemirror/view'
/**
* An extension that triggers a custom DOM event whenever the editor geometry
* changes. This is used to synchronize the editor content and review panel
* height in "Current file" mode.
*/
export const geometryChangeEvent = () =>
EditorView.updateListener.of(update => {
if (update.geometryChanged) {
window.dispatchEvent(new CustomEvent('editor:geometry-change'))
}
})

View File

@@ -0,0 +1,67 @@
import { Prec } from '@codemirror/state'
import { EditorView, keymap } from '@codemirror/view'
import { gotoLine } from '@codemirror/search'
/**
* A custom extension that provides a keyboard shortcut
* and panel with UI for jumping to a specific line number.
*/
export const goToLinePanel = () => {
return [
Prec.high(
keymap.of([
{
key: 'Mod-Shift-l',
preventDefault: true,
run: gotoLine,
},
])
),
gotoLineTheme,
]
}
const gotoLineTheme = 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',
},
},
})

View File

@@ -0,0 +1,122 @@
import {
Decoration,
DecorationSet,
EditorView,
layer,
LayerMarker,
RectangleMarker,
ViewPlugin,
ViewUpdate,
} from '@codemirror/view'
import { sourceOnly } from './visual/visual'
import { fullHeightCoordsAtPos } from '../utils/layer'
/**
* An alternative version of the built-in highlightActiveLine extension,
* using a custom approach for highlighting the full height of the active “visual line” of a wrapped line.
*/
export const highlightActiveLine = (visual: boolean) => {
// this extension should only be active in the source editor
return sourceOnly(visual, [
activeLineLayer,
singleLineHighlighter,
highlightActiveLineTheme,
])
}
const highlightActiveLineTheme = 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.geometryChanged || 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
},
})

View File

@@ -0,0 +1,18 @@
import { sourceOnly } from './visual/visual'
import { highlightSpecialChars as _highlightSpecialChars } from '@codemirror/view'
/**
* The built-in extension which highlights unusual whitespace characters,
* configured to highlight additional space characters.
*/
export const highlightSpecialChars = (visual: boolean) =>
sourceOnly(
visual,
_highlightSpecialChars({
addSpecialChars: new RegExp(
// non standard space characters (https://jkorpela.fi/chars/spaces.html)
'[\u00A0\u1680\u180E\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u200B\u202F\u205F\u3000\uFEFF]',
/x/.unicode != null ? 'gu' : 'g'
),
})
)

View File

@@ -0,0 +1,17 @@
import { Extension } from '@codemirror/state'
import { indentationMarkers as markers } from '@replit/codemirror-indentation-markers'
import { sourceOnly } from './visual/visual'
import browser from './browser'
/**
* A third-party extension which adds markers to show the indentation level.
* Configured to omit markers in the first column and to keep the same style for markers in the active block.
*/
export const indentationMarkers = (visual: boolean): Extension => {
// disable indentation markers in Safari due to flicker, ref to git issue: 18263
return browser.safari
? []
: sourceOnly(visual, [
markers({ hideFirstIndent: true, highlightActiveBlock: false }),
])
}

View File

@@ -0,0 +1,166 @@
import {
EditorView,
rectangularSelection,
tooltips,
crosshairCursor,
dropCursor,
highlightActiveLineGutter,
} from '@codemirror/view'
import { EditorState, Extension } from '@codemirror/state'
import { foldGutter, indentOnInput, indentUnit } from '@codemirror/language'
import { history } from '@codemirror/commands'
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 { symbolPalette } from './symbol-palette'
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 { 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 { drawSelection } from './draw-selection'
import { visual } from './visual/visual'
import { inlineBackground } from './inline-background'
import { indentationMarkers } from './indentation-markers'
import { codemirrorDevTools } from '../languages/latex/codemirror-dev-tools'
import { keymaps } from './keymaps'
import { shortcuts } from './shortcuts'
import { effectListeners } from './effect-listeners'
import { highlightSpecialChars } from './highlight-special-chars'
import { toolbarPanel } from './toolbar/toolbar-panel'
import { breadcrumbPanel } from './breadcrumbs-panel'
import { geometryChangeEvent } from './geometry-change-event'
import { docName } from './doc-name'
import { fileTreeItemDrop } from './file-tree-item-drop'
import { mathPreview } from './math-preview'
import { ranges } from './ranges'
import { trackDetachedComments } from './track-detached-comments'
import { reviewTooltip } from './review-tooltip'
const moduleExtensions: Array<(options: Record<string, any>) => Extension> =
importOverleafModules('sourceEditorExtensions').map(
(item: { import: { extension: Extension } }) => item.import.extension
)
export const createExtensions = (options: Record<string, any>): Extension[] => [
lineNumbers(),
highlightSpecialChars(options.visual.visual),
// The built-in extension that manages the history stack,
// configured to increase the maximum delay between adjacent grouped edits
history({ newGroupDelay: 250 }),
// The built-in extension that displays buttons for folding code in a gutter element,
// configured with custom openText and closeText symbols.
foldGutter({
openText: '▾',
closedText: '▸',
}),
drawSelection(),
// A built-in facet that is set to true to allow multiple selections.
// This makes the editor more like a code editor than Google Docs or Microsoft Word,
// which only have single selections.
EditorState.allowMultipleSelections.of(true),
// A built-in extension that enables soft line wrapping.
EditorView.lineWrapping,
// A built-in extension that re-indents input if the language defines an indentOnInput field in its language data.
indentOnInput(),
lineWrappingIndentation(options.visual.visual),
indentationMarkers(options.visual.visual),
bracketMatching(),
bracketSelection(),
// A built-in extension that enables rectangular selections, created by dragging a new selection while holding down Alt.
rectangularSelection(),
// A built-in extension that turns the pointer into a crosshair while Alt is pressed.
crosshairCursor(),
// A built-in extension that shows where dragged content will be dropped.
dropCursor(),
// A built-in extension that is used for configuring tooltip behaviour,
// configured so that the tooltip parent is the document body,
// to avoid cutting off tooltips which overflow the editor.
tooltips({
parent: document.body,
tooltipSpace(view) {
const { top, bottom } = view.scrollDOM.getBoundingClientRect()
return {
top,
left: 0,
bottom,
right: window.innerWidth,
}
},
}),
keymaps,
goToLinePanel(),
filterCharacters(),
// NOTE: `autoComplete` needs to be before `keybindings` so that arrow key handling
// in the autocomplete pop-up takes precedence over Vim/Emacs key bindings
autoComplete({
enabled: options.settings.autoComplete,
projectFeatures: options.projectFeatures,
referencesSearchMode: options.settings.referencesSearchMode,
}),
// NOTE: `keybindings` needs to be before `language` so that Vim/Emacs bindings take
// precedence over language-specific keyboard shortcuts
keybindings(),
docName(options.docName),
// NOTE: `annotations` needs to be before `language`
annotations(),
language(options.docName, options.metadata, options.settings),
indentUnit.of(' '), // 4 spaces
theme(options.theme),
realtime(options.currentDoc, options.handleError),
cursorPosition(options.currentDoc),
scrollPosition(options.currentDoc, options.visual),
cursorHighlights(),
autoPair(options.settings),
editable(),
search(),
phrases(options.phrases),
spelling(options.spelling),
shortcuts,
symbolPalette(),
// NOTE: `emptyLineFiller` needs to be before `trackChanges`,
// so the decorations are added in the correct order.
emptyLineFiller(),
ranges(),
trackDetachedComments(options.currentDoc),
visual(options.visual),
mathPreview(options.settings.mathPreview),
reviewTooltip(),
toolbarPanel(),
breadcrumbPanel(),
verticalOverflow(),
highlightActiveLine(options.visual.visual),
// The built-in extension that highlights the active line in the gutter.
highlightActiveLineGutter(),
inlineBackground(options.visual.visual),
codemirrorDevTools(),
// Send exceptions to Sentry
EditorView.exceptionSink.of(options.handleException),
// CodeMirror extensions provided by modules
moduleExtensions.map(extension => extension(options)),
thirdPartyExtensions(),
effectListeners(),
geometryChangeEvent(),
fileTreeItemDrop(),
]

View File

@@ -0,0 +1,98 @@
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 { fontLoad, 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.contentAttributes.of({
style: `--half-leading: ${halfLeading}px`,
})
}
/**
* A custom extension which measures the height of the first non-space position and provides a CSS variable via an editor theme,
* used for extending elements over the whole line height using padding.
*/
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, [fontLoad, plugin])
}

View File

@@ -0,0 +1,251 @@
import { openSearchPanel } from '@codemirror/search'
import {
Compartment,
EditorSelection,
Prec,
TransactionSpec,
} from '@codemirror/state'
import type { EmacsHandler } from '@replit/codemirror-emacs'
import type { CodeMirror, Vim } from '@replit/codemirror-vim'
import { foldCode, toggleFold, unfoldCode } from '@codemirror/language'
import { EditorView } from '@codemirror/view'
import {
cursorToBeginningOfVisualLine,
cursorToEndOfVisualLine,
selectRestOfVisualLine,
selectToBeginningOfVisualLine,
selectToEndOfVisualLine,
} from './visual-line-selection'
const hasNonEmptySelection = (cm: CodeMirror): boolean => {
const selections = cm.getSelections()
return selections.some(selection => selection.length)
}
type VimCodeMirrorCommands = typeof CodeMirror.commands & {
save: (cm: CodeMirror) => void
}
let customisedVim = false
const customiseVimOnce = (_Vim: typeof Vim, _CodeMirror: typeof CodeMirror) => {
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)
})
// disable tab and shift-tab keys in command (normal) and visual modes
// using "undefined" params because mapCommand signature is:
// mapCommand(keys, type, name, args, extra)
_Vim.mapCommand('<Tab>', undefined, undefined, undefined, {
context: 'normal',
})
_Vim.mapCommand('<Tab>', undefined, undefined, undefined, {
context: 'visual',
})
_Vim.mapCommand('<S-Tab>', undefined, undefined, undefined, {
context: 'normal',
})
_Vim.mapCommand('<S-Tab>', undefined, undefined, undefined, {
context: 'visual',
})
// Make the Vim 'write' command start a compile
;(_CodeMirror.commands as VimCodeMirrorCommands).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 = (_EmacsHandler: typeof EmacsHandler) => {
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
)
},
save() {
window.dispatchEvent(new Event('pdf:recompile'))
},
})
_EmacsHandler.bindKey('C-s', 'openSearch')
_EmacsHandler.bindKey('C-r', 'openSearch')
_EmacsHandler.bindKey('C-x C-s', 'save')
_EmacsHandler.bindKey('C-a', {
command: 'goOrSelect',
args: [cursorToBeginningOfVisualLine, selectToBeginningOfVisualLine],
})
_EmacsHandler.bindKey('C-e', {
command: 'goOrSelect',
args: [cursorToEndOfVisualLine, selectToEndOfVisualLine],
})
_EmacsHandler.bindKey('C-k', {
command: 'killLine',
args: selectRestOfVisualLine,
})
}
const options = [
{
name: 'default',
load: async () => {
// TODO: load default keybindings?
return []
},
},
{
name: 'vim',
load: () =>
import(
/* webpackChunkName: "codemirror-vim" */ '@replit/codemirror-vim'
).then(m => {
customiseVimOnce(m.Vim, m.CodeMirror)
return m.vim()
}),
},
{
name: 'emacs',
load: () =>
import(
/* webpackChunkName: "codemirror-emacs" */ '@replit/codemirror-emacs'
).then(m => {
customiseEmacsOnce(m.EmacsHandler)
return [
m.emacs(),
EditorView.domEventHandlers({
keydown(event) {
if (event.ctrlKey && event.key === 's') {
event.stopPropagation()
}
},
}),
]
}),
},
]
const keybindingsConf = new Compartment()
/**
* Third-party extensions providing Emacs and Vim keybindings,
* implemented as wrappers around the CodeMirror 5 interface,
* with some customisation (particularly related to search).
*/
export const keybindings = () => {
return keybindingsConf.of(Prec.highest([]))
}
export const setKeybindings = async (
selectedKeybindings = 'default'
): Promise<TransactionSpec> => {
if (selectedKeybindings === 'none') {
selectedKeybindings = 'default'
}
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)),
}
}

View File

@@ -0,0 +1,50 @@
import { keymap } from '@codemirror/view'
import { defaultKeymap, historyKeymap } from '@codemirror/commands'
import { lintKeymap } from '@codemirror/lint'
import { scrollOneLineKeymap } from './scroll-one-line'
import { foldingKeymap } from './folding-keymap'
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 ignoredDefaultMacKeybindings = new Set([
// We replace these with our custom visual-line versions
'Mod-Backspace',
'Mod-Delete',
])
const filteredDefaultKeymap = defaultKeymap.filter(
// We only filter on keys, so if the keybinding doesn't have a key,
// allow it
item => {
if (item.key && ignoredDefaultKeybindings.has(item.key)) {
return false
}
if (item.mac && ignoredDefaultMacKeybindings.has(item.mac)) {
return false
}
return true
}
)
export const keymaps = keymap.of([
// The default CodeMirror keymap, with a few key bindings filtered out.
...filteredDefaultKeymap,
// Key bindings for undo/redo/undoSelection/redoSelection
...historyKeymap,
// Key bindings for “open lint panel” and “next diagnostic”
...lintKeymap,
// Key bindings for folding actions
...foldingKeymap,
// Key bindings for scrolling the viewport
...scrollOneLineKeymap,
])

View File

@@ -0,0 +1,133 @@
import {
Compartment,
StateEffect,
StateField,
TransactionSpec,
} from '@codemirror/state'
import { languages } from '../languages'
import { ViewPlugin } from '@codemirror/view'
import { indentUnit, LanguageDescription } from '@codemirror/language'
import { updateHasEffect } from '../utils/effects'
import { Folder } from '../../../../../types/folder'
import { Command } from '@/features/ide-react/context/metadata-context'
export const languageLoadedEffect = StateEffect.define()
export const hasLanguageLoadedEffect = updateHasEffect(languageLoadedEffect)
const languageConf = new Compartment()
type Options = {
syntaxValidation: boolean
}
type Metadata = {
labels: Set<string>
packageNames: Set<string>
commands: Command[]
referenceKeys: Set<string>
fileTreeData: Folder
}
/**
* A state field that stores the metadata parsed from a project on the server.
*/
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
},
})
const languageCompartment = new Compartment()
/**
* The parser and support extensions for each supported language,
* which are loaded dynamically as needed.
*/
export const language = (
docName: string,
metadata: Metadata,
{ syntaxValidation }: Options
) => languageCompartment.of(buildExtension(docName, metadata, syntaxValidation))
const buildExtension = (
docName: string,
metadata: Metadata,
syntaxValidation: boolean
) => {
const languageDescription = LanguageDescription.matchFilename(
languages,
docName
)
if (!languageDescription) {
return []
}
return [
/**
* Default to four-space indentation and set the configuration in advance,
* to prevent a shift in line indentation markers when the LaTeX language loads.
*/
languageConf.of(indentUnit.of(' ')),
metadataState,
/**
* A view plugin which loads the appropriate language for the current file extension,
* then dispatches an effect so other extensions can update accordingly.
*/
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 setLanguage = (
docName: string,
metadata: Metadata,
syntaxValidation: boolean
) => {
return {
effects: languageCompartment.reconfigure(
buildExtension(docName, metadata, syntaxValidation)
),
}
}
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),
}
}

View File

@@ -0,0 +1,90 @@
import { EditorSelection, Extension } from '@codemirror/state'
import {
BlockInfo,
EditorView,
lineNumbers as _lineNumbers,
} from '@codemirror/view'
import { DebouncedFunc, throttle } from 'lodash'
/**
* The built-in extension which displays line numbers in the gutter,
* configured with a mousedown/mouseup handler that selects lines of the document
* when dragging a selection in the gutter.
*/
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 _lineNumbers({
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
},
},
})
}

View File

@@ -0,0 +1,212 @@
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>()
/**
* Calculates the indentation needed for each line based on the spaces and tabs at the start of the line.
* Decorates each line with a `style` attribute, to ensure that wrapped lines share the same indentation.
*
* NOTE: performance increases linearly with document size, but decorating only the viewport causes unacceptable layout shifts when scrolling.
*/
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) {
value.decorations = buildDecorations(view, maxIndent)
} else if (update.geometryChanged || update.viewportChanged) {
value.decorations = updateDecorations(
value.decorations,
update,
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 = calculateIndent(line, tabSize, maxIndent)
if (indent) {
decorations.push(lineIndentDecoration(indent).range(from))
}
from += line.length + 1
}
return Decoration.set(decorations)
}
export const updateDecorations = (
decorations: DecorationSet,
update: ViewUpdate,
maxIndent: number
) => {
const add: Range<Decoration>[] = []
const { doc: startDoc } = update.startState
const { doc, tabSize } = update.state
const changedLinesFrom = new Set()
let filterFrom = doc.length
let filterTo = 0
update.changes.iterChangedRanges((fromA, toA, fromB, toB) => {
// remove changed lines
const fromALineNumber = startDoc.lineAt(fromA).number
const toALineNumber = startDoc.lineAt(toA).number
for (
let lineNumber = fromALineNumber;
lineNumber <= toALineNumber;
lineNumber++
) {
const line = startDoc.line(lineNumber)
changedLinesFrom.add(line.from)
filterFrom = Math.min(line.from, filterFrom)
filterTo = Math.max(line.from, filterTo)
}
// add changed lines
const fromBLineNumber = doc.lineAt(fromB).number
const toBLineNumber = doc.lineAt(toB).number
for (
let lineNumber = fromBLineNumber;
lineNumber <= toBLineNumber;
lineNumber++
) {
const line = doc.line(lineNumber)
const indent = calculateIndent(line.text, tabSize, maxIndent)
if (indent) {
add.push(lineIndentDecoration(indent).range(line.from))
}
}
})
return decorations
.update({
filter(from) {
return !changedLinesFrom.has(from)
},
filterFrom,
filterTo,
})
.map(update.changes)
.update({ add })
}
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 + 6px)`, // add 6px 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)
}

View File

@@ -0,0 +1,26 @@
import { Compartment, EditorState } from '@codemirror/state'
import { setSyntaxValidationEffect } from './language'
import { linter } from '@codemirror/lint'
export const createLinter: typeof linter = (lintSource, config) => {
const linterConfig = new Compartment()
return [
linterConfig.of([]),
// enable/disable the linter to match the syntaxValidation setting
EditorState.transactionExtender.of(tr => {
for (const effect of tr.effects) {
if (effect.is(setSyntaxValidationEffect)) {
return {
effects: linterConfig.reconfigure(
effect.value ? linter(lintSource, config) : []
),
}
}
}
return null
}),
]
}

View File

@@ -0,0 +1,243 @@
import {
EditorView,
repositionTooltips,
showTooltip,
Tooltip,
ViewPlugin,
} from '@codemirror/view'
import {
Compartment,
EditorState,
Extension,
StateEffect,
StateField,
TransactionSpec,
} from '@codemirror/state'
import { loadMathJax } from '../../mathjax/load-mathjax'
import { descendantsOfNodeWithType } from '../utils/tree-query'
import {
MathContainer,
mathAncestorNode,
parseMathContainer,
} from '../utils/tree-operations/math'
import { documentCommands } from '../languages/latex/document-commands'
import { debugConsole } from '@/utils/debugging'
import { nodeHasError } from '../utils/tree-operations/common'
import { documentEnvironments } from '../languages/latex/document-environments'
const REPOSITION_EVENT = 'editor:repositionMathTooltips'
const HIDE_TOOLTIP_EVENT = 'editor:hideMathTooltip'
export const mathPreview = (enabled: boolean): Extension => {
return mathPreviewConf.of(
enabled ? [mathPreviewTheme, mathPreviewStateField] : [mathPreviewTheme]
)
}
export const hideTooltipEffect = StateEffect.define<null>()
const mathPreviewConf = new Compartment()
export const setMathPreview = (enabled: boolean): TransactionSpec => ({
effects: mathPreviewConf.reconfigure(enabled ? mathPreviewStateField : []),
})
export const mathPreviewStateField = StateField.define<{
tooltip: Tooltip | null
mathContent: HTMLDivElement | null
hide: boolean
}>({
create: buildInitialState,
update(state, tr) {
for (const effect of tr.effects) {
if (effect.is(hideTooltipEffect)) {
return { tooltip: null, hide: true, mathContent: null }
}
}
if (tr.docChanged || tr.selection) {
const mathContainer = getMathContainer(tr.state)
if (mathContainer) {
if (state.hide) {
return { tooltip: null, hide: true, mathContent: null }
} else {
const mathContent = buildTooltipContent(tr.state, mathContainer)
return {
tooltip: buildTooltip(mathContainer, mathContent),
mathContent,
hide: false,
}
}
}
return { tooltip: null, hide: false, mathContent: null }
}
return state
},
provide: field => [
showTooltip.compute([field], state => state.field(field).tooltip),
ViewPlugin.define(view => {
const listener = () => repositionTooltips(view)
const hideTooltip = () => {
view.dispatch({
effects: hideTooltipEffect.of(null),
})
}
window.addEventListener(REPOSITION_EVENT, listener)
window.addEventListener(HIDE_TOOLTIP_EVENT, hideTooltip)
return {
destroy() {
window.removeEventListener(REPOSITION_EVENT, listener)
window.removeEventListener(HIDE_TOOLTIP_EVENT, hideTooltip)
},
}
}),
],
})
function buildInitialState(state: EditorState) {
const mathContainer = getMathContainer(state)
if (mathContainer) {
const mathContent = buildTooltipContent(state, mathContainer)
return {
tooltip: buildTooltip(mathContainer, mathContent),
mathContent,
hide: false,
}
}
return { tooltip: null, hide: false, mathContent: null }
}
const renderMath = async (
content: string,
displayMode: boolean,
element: HTMLElement,
definitions: string
) => {
const MathJax = await loadMathJax()
MathJax.texReset([0]) // equation numbering is disabled, but this is still needed
try {
await MathJax.tex2svgPromise(definitions)
} catch {
// ignore errors thrown during parsing command definitions
}
const math = await MathJax.tex2svgPromise(content, {
...MathJax.getMetricsFor(element),
display: displayMode,
})
element.textContent = ''
element.append(math)
}
function buildTooltip(
mathContainer: MathContainer,
mathContent: HTMLDivElement | null
): Tooltip | null {
if (!mathContent || !mathContainer) {
return null
}
return {
pos: mathContainer.pos,
above: true,
strictSide: true,
arrow: false,
create() {
const dom = document.createElement('div')
dom.classList.add('ol-cm-math-tooltip-container')
return { dom, overlap: true, offset: { x: 0, y: 8 } }
},
}
}
const getMathContainer = (state: EditorState) => {
const range = state.selection.main
if (!range.empty) {
return null
}
// if anywhere inside Math, find the whole Math node
const ancestorNode = mathAncestorNode(state, range.from)
if (!ancestorNode) return null
const [node] = descendantsOfNodeWithType(ancestorNode, 'Math', 'Math')
if (!node) return null
if (nodeHasError(ancestorNode)) return null
return parseMathContainer(state, node, ancestorNode)
}
const buildTooltipContent = (
state: EditorState,
math: MathContainer | null
): HTMLDivElement | null => {
if (!math || !math.content.length) return null
const element = document.createElement('div')
element.style.opacity = '0'
element.textContent = math.content
let definitions = ''
const environmentState = state.field(documentEnvironments, false)
if (environmentState?.items) {
for (const environment of environmentState.items) {
if (environment.type === 'definition') {
definitions += `${environment.raw}\n`
}
}
}
const commandState = state.field(documentCommands, false)
if (commandState?.items) {
for (const command of commandState.items) {
if (command.type === 'definition' && command.raw) {
definitions += `${command.raw}\n`
}
}
}
renderMath(math.content, math.displayMode, element, definitions)
.then(() => {
element.style.opacity = '1'
window.dispatchEvent(new Event(REPOSITION_EVENT))
})
.catch(error => {
debugConsole.error(error)
})
return element
}
/**
* Styles for the preview tooltip
*/
const mathPreviewTheme = EditorView.baseTheme({
'&light .ol-cm-math-tooltip': {
boxShadow: '0px 2px 4px 0px #1e253029',
border: '1px solid #e7e9ee !important',
backgroundColor: 'white !important',
},
'&dark .ol-cm-math-tooltip': {
boxShadow: '0px 2px 4px 0px #1e253029',
border: '1px solid #2f3a4c !important',
backgroundColor: '#1b222c !important',
},
})

View File

@@ -0,0 +1,16 @@
import { Compartment, EditorState, TransactionSpec } from '@codemirror/state'
const phrasesConf = new Compartment()
/**
* A built-in extension providing a mapping between translation keys and translations.
*/
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)),
}
}

View File

@@ -0,0 +1,433 @@
import { StateEffect, StateField, TransactionSpec } from '@codemirror/state'
import {
Decoration,
type DecorationSet,
EditorView,
type PluginValue,
ViewPlugin,
WidgetType,
} from '@codemirror/view'
import {
AnyOperation,
Change,
DeleteOperation,
} from '../../../../../types/change'
import { debugConsole } from '@/utils/debugging'
import {
isCommentOperation,
isDeleteOperation,
isInsertOperation,
} from '@/utils/operations'
import { Ranges } from '@/features/review-panel-new/context/ranges-context'
import { Threads } from '@/features/review-panel-new/context/threads-context'
import { isSelectionWithinOp } from '@/features/review-panel-new/utils/is-selection-within-op'
type RangesData = {
ranges: Ranges
threads: Threads
}
const updateRangesEffect = StateEffect.define<RangesData>()
const highlightRangesEffect = StateEffect.define<AnyOperation>()
const clearHighlightRangesEffect = StateEffect.define<AnyOperation>()
export const updateRanges = (data: RangesData): TransactionSpec => {
return {
effects: updateRangesEffect.of(data),
}
}
export const highlightRanges = (op: AnyOperation): TransactionSpec => {
return {
effects: highlightRangesEffect.of(op),
}
}
export const clearHighlightRanges = (op: AnyOperation): TransactionSpec => {
return {
effects: clearHighlightRangesEffect.of(op),
}
}
export const rangesDataField = StateField.define<RangesData | null>({
create() {
return null
},
update(rangesData, tr) {
for (const effect of tr.effects) {
if (effect.is(updateRangesEffect)) {
return effect.value
}
}
return rangesData
},
})
/**
* A custom extension that initialises the change manager, passes any updates to it,
* and produces decorations for tracked changes and comments.
*/
export const ranges = () => [
rangesDataField,
// handle viewportChanged updates
ViewPlugin.define(view => {
let timer: number
return {
update(update) {
if (update.viewportChanged) {
if (timer) {
window.clearTimeout(timer)
}
timer = window.setTimeout(() => {
dispatchEvent(new Event('editor:viewport-changed'))
}, 25)
}
},
}
}),
// draw change decorations
ViewPlugin.define<
PluginValue & {
decorations: DecorationSet
}
>(
() => {
return {
decorations: Decoration.none,
update(update) {
for (const transaction of update.transactions) {
this.decorations = this.decorations.map(transaction.changes)
for (const effect of transaction.effects) {
if (effect.is(updateRangesEffect)) {
this.decorations = buildChangeDecorations(effect.value)
} else if (
effect.is(highlightRangesEffect) &&
isDeleteOperation(effect.value)
) {
this.decorations = updateDeleteWidgetHighlight(
this.decorations,
widget =>
widget.change.op.p === effect.value.p &&
widget.highlightType !== 'focus',
'highlight'
)
} else if (
effect.is(clearHighlightRangesEffect) &&
isDeleteOperation(effect.value)
) {
this.decorations = updateDeleteWidgetHighlight(
this.decorations,
widget =>
widget.change.op.p === effect.value.p &&
widget.highlightType !== 'focus',
null
)
}
}
if (transaction.selection) {
this.decorations = updateDeleteWidgetHighlight(
this.decorations,
({ change }) =>
isSelectionWithinOp(change.op, update.state.selection.main),
'focus'
)
this.decorations = updateDeleteWidgetHighlight(
this.decorations,
({ change }) =>
!isSelectionWithinOp(change.op, update.state.selection.main),
null
)
}
}
},
}
},
{
decorations: value => value.decorations,
}
),
// draw highlight decorations
ViewPlugin.define<
PluginValue & {
decorations: DecorationSet
}
>(
() => {
return {
decorations: Decoration.none,
update(update) {
for (const transaction of update.transactions) {
this.decorations = this.decorations.map(transaction.changes)
for (const effect of transaction.effects) {
if (effect.is(highlightRangesEffect)) {
this.decorations = buildHighlightDecorations(
'ol-cm-change-highlight',
effect.value
)
} else if (effect.is(clearHighlightRangesEffect)) {
this.decorations = Decoration.none
}
}
}
},
}
},
{
decorations: value => value.decorations,
}
),
// draw focus decorations
ViewPlugin.define<
PluginValue & {
decorations: DecorationSet
}
>(
view => {
return {
decorations: Decoration.none,
update(update) {
if (
!update.transactions.some(
tr =>
tr.selection ||
tr.effects.some(effect => effect.is(updateRangesEffect))
)
) {
return
}
this.decorations = Decoration.none
const rangesData = view.state.field(rangesDataField)
if (!rangesData?.ranges) {
return
}
const { changes, comments } = rangesData.ranges
const unresolvedComments = rangesData.threads
? comments.filter(
comment =>
comment.op.t &&
rangesData.threads[comment.op.t] &&
!rangesData.threads[comment.op.t].resolved
)
: []
for (const range of [...changes, ...unresolvedComments]) {
if (isSelectionWithinOp(range.op, update.state.selection.main)) {
this.decorations = buildHighlightDecorations(
'ol-cm-change-focus',
range.op
)
break
}
}
},
}
},
{
decorations: value => value.decorations,
}
),
// styles for change decorations
trackChangesTheme,
]
const buildChangeDecorations = (data: RangesData) => {
if (!data.ranges) {
return Decoration.none
}
const changes = [...data.ranges.changes, ...data.ranges.comments]
const decorations = []
for (const change of changes) {
try {
decorations.push(...createChangeRange(change, data))
} catch (error) {
// ignore invalid changes
debugConsole.debug('invalid change position', error)
}
}
return Decoration.set(decorations, true)
}
const updateDeleteWidgetHighlight = (
decorations: DecorationSet,
predicate: (widget: ChangeDeletedWidget) => boolean,
highlightType?: 'focus' | 'highlight' | null
) => {
const widgetsToReplace: ChangeDeletedWidget[] = []
const cursor = decorations.iter()
while (cursor.value) {
const widget = cursor.value.spec?.widget
if (widget instanceof ChangeDeletedWidget && predicate(widget)) {
widgetsToReplace.push(cursor.value.spec.widget)
}
cursor.next()
}
return decorations.update({
sort: true,
filter: (from, to, decoration) => {
return !widgetsToReplace.includes(decoration.spec?.widget)
},
add: widgetsToReplace.map(({ change }) =>
Decoration.widget({
widget: new ChangeDeletedWidget(change, highlightType),
side: 1,
opType: 'd',
id: change.id,
metadata: change.metadata,
}).range(change.op.p, change.op.p)
),
})
}
const buildHighlightDecorations = (className: string, op: AnyOperation) => {
if (isDeleteOperation(op)) {
// delete indicators are handled in change decorations
return Decoration.none
}
const opFrom = op.p
const opLength = isInsertOperation(op) ? op.i.length : op.c.length
const opType = isInsertOperation(op) ? 'i' : 'c'
if (opLength === 0) {
return Decoration.none
}
return Decoration.set(
Decoration.mark({
class: `${className} ${className}-${opType}`,
}).range(opFrom, opFrom + opLength),
true
)
}
class ChangeDeletedWidget extends WidgetType {
constructor(
public change: Change<DeleteOperation>,
public highlightType: 'highlight' | 'focus' | null = null
) {
super()
}
toDOM() {
const widget = document.createElement('span')
widget.classList.add('ol-cm-change')
widget.classList.add('ol-cm-change-d')
if (this.highlightType) {
widget.classList.add(`ol-cm-change-d-${this.highlightType}`)
}
return widget
}
eq(old: ChangeDeletedWidget) {
return old.highlightType === this.highlightType
}
}
const createChangeRange = (change: Change, data: RangesData) => {
const { id, metadata, op } = change
const from = op.p
if (isDeleteOperation(op)) {
const opType = 'd'
const changeWidget = Decoration.widget({
widget: new ChangeDeletedWidget(change as Change<DeleteOperation>),
side: 1,
opType,
id,
metadata,
})
return [changeWidget.range(from, from)]
}
const _isCommentOperation = isCommentOperation(op)
if (_isCommentOperation) {
const thread = data.threads[op.t]
if (!thread || thread.resolved) {
return []
}
}
const opType = _isCommentOperation ? 'c' : 'i'
const changedText = _isCommentOperation ? 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,
})
return [changeMark.range(from, to)]
}
const trackChangesTheme = EditorView.baseTheme({
'.ol-cm-change-i, .ol-cm-change-highlight-i, .ol-cm-change-focus-i': {
backgroundColor: 'rgba(44, 142, 48, 0.30)',
},
'&light .ol-cm-change-c, &light .ol-cm-change-highlight-c, &light .ol-cm-change-focus-c':
{
backgroundColor: 'rgba(243, 177, 17, 0.30)',
},
'&dark .ol-cm-change-c, &dark .ol-cm-change-highlight-c, &dark .ol-cm-change-focus-c':
{
backgroundColor: 'rgba(194, 93, 11, 0.15)',
},
'.ol-cm-change': {
padding: 'var(--half-leading, 0) 0',
},
'.ol-cm-change-highlight': {
padding: 'var(--half-leading, 0) 0',
},
'.ol-cm-change-focus': {
padding: 'var(--half-leading, 0) 0',
},
'&light .ol-cm-change-d': {
borderLeft: '2px dotted #c5060b',
marginLeft: '-1px',
},
'&dark .ol-cm-change-d': {
borderLeft: '2px dotted #c5060b',
marginLeft: '-1px',
},
'&light .ol-cm-change-d-highlight': {
borderLeft: '3px solid #c5060b',
marginLeft: '-2px',
},
'&dark .ol-cm-change-d-highlight': {
borderLeft: '3px solid #c5060b',
marginLeft: '-2px',
},
'&light .ol-cm-change-d-focus': {
borderLeft: '3px solid #B83A33',
marginLeft: '-2px',
},
'&dark .ol-cm-change-d-focus': {
borderLeft: '3px solid #B83A33',
marginLeft: '-2px',
},
})

View File

@@ -0,0 +1,257 @@
import { Prec, Transaction, Annotation, ChangeSpec } from '@codemirror/state'
import { EditorView, ViewPlugin } from '@codemirror/view'
import { EventEmitter } from 'events'
import RangesTracker from '@overleaf/ranges-tracker'
import { ShareDoc } from '../../../../../types/share-doc'
import { debugConsole } from '@/utils/debugging'
import { DocumentContainer } from '@/features/ide-react/editor/document-container'
/*
* 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
* - frontend/js/features/ide-react/editor/document.ts
* - frontend/js/features/ide-react/editor/share-js-doc.ts
* - frontend/js/features/ide-react/connection/editor-watchdog-manager.js
*/
export type ChangeDescription = {
origin: 'remote' | 'undo' | 'reject' | undefined
inserted: boolean
removed: boolean
}
/**
* A custom extension that connects the CodeMirror 6 editor to the currently open ShareJS document.
*/
export const realtime = (
{ currentDoc }: { currentDoc: DocumentContainer },
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, currentDoc.ranges)
}
},
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!!'
debugConsole.warn(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
cmChange(changes: ChangeSpec, origin?: string) {
const isRemote = origin === 'remote'
this.view.dispatch({
changes,
annotations: [
Transaction.remote.of(isRemote),
Transaction.addToHistory.of(!isRemote),
],
effects:
// if this is a remote change, restore a snapshot of the current scroll position after the change has been applied
isRemote
? this.view.scrollSnapshot().map(this.view.state.changes(changes))
: undefined,
})
}
cmInsert(position: number, text: string, origin?: string) {
this.cmChange({ from: position, insert: text }, origin)
}
cmDelete(position: number, text: string, origin?: string) {
this.cmChange({ from: position, to: position + text.length }, origin)
}
// 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_ace' helper)
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')
debugConsole.error('Text does not match!')
debugConsole.error('editor: ' + editorText)
debugConsole.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[],
ranges?: RangesTracker
) {
const shareDoc = this.shareDoc
const trackedDeletesLength =
ranges != null ? ranges.getTrackedDeletesLength() : 0
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
}
// This is an approximation. Some deletes could have generated new
// tracked deletes since we measured trackedDeletesLength at the top of
// the function. Unfortunately, the ranges tracker is only updated
// after all transactions are processed, so it's not easy to get an
// exact number.
const fullDocLength =
transaction.changes.desc.newLength + trackedDeletesLength
if (this.maxDocLength && fullDocLength >= 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
const changeDescription: ChangeDescription = {
origin,
inserted,
removed,
}
this.emit('change', this, changeDescription)
}
)
}
}
}
}
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'
}
}

View File

@@ -0,0 +1,215 @@
import {
Decoration,
DecorationSet,
EditorView,
showTooltip,
Tooltip,
TooltipView,
} from '@codemirror/view'
import {
Extension,
StateField,
StateEffect,
Range,
SelectionRange,
EditorState,
Transaction,
} from '@codemirror/state'
import { v4 as uuid } from 'uuid'
export const addNewCommentRangeEffect = StateEffect.define<Range<Decoration>>()
export const removeNewCommentRangeEffect = StateEffect.define<string>()
export const textSelectedEffect = StateEffect.define<null>()
export const removeReviewPanelTooltipEffect = StateEffect.define()
const mouseDownEffect = StateEffect.define()
const mouseUpEffect = StateEffect.define()
const mouseDownStateField = StateField.define<boolean>({
create() {
return false
},
update(value, tr) {
for (const effect of tr.effects) {
if (effect.is(mouseDownEffect)) {
return true
} else if (effect.is(mouseUpEffect)) {
return false
}
}
return value
},
})
export const buildAddNewCommentRangeEffect = (range: SelectionRange) => {
return addNewCommentRangeEffect.of(
Decoration.mark({
tagName: 'span',
class: `ol-cm-change ol-cm-change-c`,
opType: 'c',
id: uuid(),
}).range(range.from, range.to)
)
}
export const reviewTooltip = (): Extension => {
let mouseUpListener: null | (() => void) = null
const disableMouseUpListener = () => {
if (mouseUpListener) {
document.removeEventListener('mouseup', mouseUpListener)
}
}
return [
reviewTooltipTheme,
reviewTooltipStateField,
mouseDownStateField,
EditorView.domEventHandlers({
mousedown: (event, view) => {
disableMouseUpListener()
mouseUpListener = () => {
disableMouseUpListener()
view.dispatch({ effects: mouseUpEffect.of(null) })
}
view.dispatch({
effects: mouseDownEffect.of(null),
})
document.addEventListener('mouseup', mouseUpListener)
},
}),
]
}
export const reviewTooltipStateField = StateField.define<{
tooltip: Tooltip | null
addCommentRanges: DecorationSet
}>({
create() {
return { tooltip: null, addCommentRanges: Decoration.none }
},
update(field, tr) {
let { tooltip, addCommentRanges } = field
addCommentRanges = addCommentRanges.map(tr.changes)
for (const effect of tr.effects) {
if (effect.is(removeNewCommentRangeEffect)) {
const threadId = effect.value
addCommentRanges = addCommentRanges.update({
filter: (_from, _to, value) => {
return value.spec.id !== threadId
},
})
}
if (effect.is(addNewCommentRangeEffect)) {
const rangeToAdd = effect.value
addCommentRanges = addCommentRanges.update({
add: [rangeToAdd],
})
}
}
if (tr.state.selection.main.empty) {
return { tooltip: null, addCommentRanges }
}
if (
!tr.effects.some(effect => effect.is(mouseUpEffect)) &&
tr.annotation(Transaction.userEvent) !== 'select' &&
tr.annotation(Transaction.userEvent) !== 'select.pointer'
) {
if (tr.selection) {
// selection was changed, remove the tooltip
return { tooltip: null, addCommentRanges }
}
// for any other update, we keep the tooltip because it could be created in previous transaction
// and we are still waiting for "mouse up" event to show it
return { tooltip, addCommentRanges }
}
const isMouseDown = tr.state.field(mouseDownStateField)
// if "isMouseDown" is true, tooltip will be created but still hidden
// the reason why we cant just create the tooltip on mouse up is because transaction.userEvent is empty at that point
return { tooltip: buildTooltip(tr.state, isMouseDown), addCommentRanges }
},
provide: field => [
EditorView.decorations.from(field, field => field.addCommentRanges),
showTooltip.compute([field], state => state.field(field).tooltip),
],
})
function buildTooltip(state: EditorState, hidden: boolean): Tooltip | null {
const lineAtFrom = state.doc.lineAt(state.selection.main.from)
const lineAtTo = state.doc.lineAt(state.selection.main.to)
const multiLineSelection = lineAtFrom.number !== lineAtTo.number
const column = state.selection.main.head - lineAtTo.from
// If the selection is a multi-line selection and the cursor is at the beginning of the next line
// we want to show the tooltip at the end of the previous line
const pos =
multiLineSelection && column === 0
? state.selection.main.head - 1
: state.selection.main.head
return {
pos,
above: state.selection.main.head !== state.selection.main.to,
create: hidden
? createHiddenReviewTooltipView
: createVisibleReviewTooltipView,
}
}
const createReviewTooltipView = (hidden: boolean): TooltipView => {
const dom = document.createElement('div')
dom.className = 'review-tooltip-menu-container'
dom.style.display = hidden ? 'none' : 'block'
return {
dom,
overlap: true,
offset: { x: 0, y: 8 },
}
}
const createHiddenReviewTooltipView = () => createReviewTooltipView(true)
const createVisibleReviewTooltipView = () => createReviewTooltipView(false)
/**
* Styles for the tooltip
*/
const reviewTooltipTheme = EditorView.baseTheme({
'.review-tooltip-menu-container.cm-tooltip': {
backgroundColor: 'transparent',
border: 'none',
zIndex: 0,
},
'&light': {
'& .review-tooltip-menu': {
backgroundColor: 'white',
},
'& .review-tooltip-menu-button': {
'&:hover': {
backgroundColor: '#2f3a4c14',
},
},
},
'&dark': {
'& .review-tooltip-menu': {
backgroundColor: '#1b222c',
},
'& .review-tooltip-menu-button': {
'&:hover': {
backgroundColor: '#2f3a4c',
},
},
},
})

View File

@@ -0,0 +1,35 @@
import { Command, EditorView } 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
}
/**
* Custom keymap for Windows and Linux for scrolling the viewport up/down one line with Ctrl+ArrowUp/Down.
*/
export const scrollOneLineKeymap = [
{
linux: 'Ctrl-ArrowUp',
win: 'Ctrl-ArrowUp',
run: scrollUpOneLine,
},
{
linux: 'Ctrl-ArrowDown',
win: 'Ctrl-ArrowDown',
run: scrollDownOneLine,
},
]

View File

@@ -0,0 +1,187 @@
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 { sourceOnly, toggleVisualEffect } from './visual/visual'
import { debugConsole } from '@/utils/debugging'
const buildStorageKey = (docId: string) => `doc.position.${docId}`
type LineInfo = {
first: BlockInfo
middle: BlockInfo
}
/**
* A custom extension that:
* a) stores the scroll position (first visible line number) in localStorage when the view is destroyed,
* or the window is closed, or when switching between Source and Rich Text, and
* b) dispatches the scroll position (middle visible line) when it changes, for use in the outline.
*/
export const scrollPosition = (
{
currentDoc: { doc_id: docId },
}: {
currentDoc: { doc_id: string }
},
{ visual }: { visual: boolean }
) => {
// 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,
},
}
),
// restore the scroll position when switching to source mode
sourceOnly(
visual,
EditorView.updateListener.of(update => {
for (const tr of update.transactions) {
for (const effect of tr.effects) {
if (effect.is(toggleVisualEffect)) {
if (!effect.value) {
// switching to the source editor
window.setTimeout(() => {
update.view.dispatch(restoreScrollPosition())
update.view.focus()
})
}
}
}
}
})
),
]
}
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
debugConsole.error(e)
return {}
}
}

View File

@@ -0,0 +1,429 @@
import {
search as _search,
setSearchQuery,
getSearchQuery,
openSearchPanel,
SearchQuery,
searchPanelOpen,
searchKeymap,
highlightSelectionMatches,
togglePanel,
} from '@codemirror/search'
import {
Decoration,
EditorView,
KeyBinding,
keymap,
ViewPlugin,
} from '@codemirror/view'
import {
Annotation,
Compartment,
EditorSelection,
EditorState,
Prec,
SelectionRange,
StateEffect,
StateField,
TransactionSpec,
} from '@codemirror/state'
import { sendSearchEvent } from '@/features/event-tracking/search-events'
import { isVisual } from '@/features/source-editor/extensions/visual/visual'
const restoreSearchQueryAnnotation = Annotation.define<boolean>()
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
}
const storedSelectionEffect = StateEffect.define<EditorSelection | null>()
const storedSelectionState = StateField.define<EditorSelection | null>({
create() {
return null
},
update(value, tr) {
if (value) {
value = value.map(tr.changes)
}
for (const effect of tr.effects) {
if (effect.is(storedSelectionEffect)) {
value = effect.value
} else if (effect.is(togglePanel) && effect.value === false) {
value = null // clear the stored selection when closing the search panel
}
}
return value
},
provide(f) {
return [
EditorView.decorations.from(f, selection => {
if (!selection) {
return Decoration.none
}
const decorations = selection.ranges
.filter(range => !range.empty)
.map(range =>
Decoration.mark({
class: 'ol-cm-stored-selection',
}).range(range.from, range.to)
)
return Decoration.set(decorations)
}),
]
},
})
export const getStoredSelection = (state: EditorState) =>
state.field(storedSelectionState)
export const setStoredSelection = (selection: EditorSelection | null) => {
return {
effects: [
storedSelectionEffect.of(selection),
// TODO: only disable selection highlighting if the current selection is a search match
highlightSelectionMatchesConf.reconfigure(
selection ? [] : highlightSelectionMatchesExtension
),
],
}
}
const highlightSelectionMatchesConf = new Compartment()
const highlightSelectionMatchesExtension = highlightSelectionMatches({
wholeWords: true,
})
// store the search query for use when switching between files
// TODO: move this into EditorContext?
let searchQuery: SearchQuery | null
const scrollToMatch = (range: SelectionRange, view: EditorView) => {
const coords = {
from: view.coordsAtPos(range.from),
to: view.coordsAtPos(range.to),
}
const scrollRect = view.scrollDOM.getBoundingClientRect()
const strategy =
(coords.from && coords.from.top < scrollRect.top) ||
(coords.to && coords.to.bottom > scrollRect.bottom)
? 'center'
: 'nearest'
return EditorView.scrollIntoView(range, {
y: strategy,
})
}
const searchEventKeymap: KeyBinding[] = [
// record an event when the search panel is opened using the keyboard shortcut
{
key: 'Mod-f',
preventDefault: true,
scope: 'editor search-panel',
run(view) {
if (!searchPanelOpen(view.state)) {
sendSearchEvent('search-open', {
searchType: 'document',
method: 'keyboard',
mode: isVisual(view) ? 'visual' : 'source',
})
}
return false // continue with the regular search shortcut
},
},
]
/**
* A collection of extensions related to the search feature.
*/
export const search = () => {
let open = false
return [
// keymap for search events
Prec.high(keymap.of(searchEventKeymap)),
// keymap for search
keymap.of(searchKeymap),
// highlight text which matches the current selection
highlightSelectionMatchesConf.of(highlightSelectionMatchesExtension),
// a stored selection for use in "within selection" searches
storedSelectionState,
/**
* The CodeMirror `search` extension, configured to create a custom panel element
* and to scroll the search match into the centre of the viewport when needed.
*/
_search({
literal: true,
// centre the search match if it was outside the visible area
scrollToMatch,
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 => {
// if the search panel wasn't open, don't select a match
if (!searchPanelOpen(update.startState)) {
return
}
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 = scrollToMatch(
EditorSelection.range(next.from, next.to),
update.view
)
}
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 },
})
}
}
}
}
}
}),
searchFormTheme,
]
}
const searchFormTheme = EditorView.theme({
'.ol-cm-search-form': {
'--ol-cm-search-form-gap': '10px',
'--ol-cm-search-form-button-margin': '3px',
padding: 'var(--ol-cm-search-form-gap)',
display: 'flex',
gap: 'var(--ol-cm-search-form-gap)',
background: 'var(--neutral-20)',
'--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-form-error-shadow':
'inset 0 1px 1px rgb(0 0 0 / 8%), 0 0 8px var(--red-50)',
containerType: 'inline-size',
'& .form-control-sm, & .btn-sm': {
padding: 'var(--spacing-03) var(--spacing-05)',
},
},
'&.ol-cm-search-form': {
'--ol-cm-search-form-gap': 'var(--spacing-05)',
'--ol-cm-search-form-button-margin': 'var(--spacing-02)',
'--input-border': 'var(--border-primary)',
'--input-border-focus': 'var(--border-active)',
},
'.ol-cm-search-controls': {
display: 'grid',
gridTemplateColumns: 'auto auto',
gridTemplateRows: 'auto auto',
gap: 'var(--ol-cm-search-form-gap)',
flex: 1,
},
'@container (max-width: 450px)': {
'.ol-cm-search-controls': {
gridTemplateColumns: 'auto',
},
},
'.ol-cm-search-form-row': {
display: 'flex',
gap: 'var(--ol-cm-search-form-gap)',
justifyContent: 'space-between',
},
'.ol-cm-search-form-group': {
display: 'flex',
gap: 'var(--ol-cm-search-form-gap)',
alignItems: 'center',
},
'.ol-cm-search-input-group': {
border: '1px solid var(--input-border)',
borderRadius: '20px',
background: 'white',
width: '100%',
maxWidth: '50em',
display: 'inline-flex',
alignItems: 'center',
'& input[type="text"]': {
background: 'none',
boxShadow: 'none',
},
'& input[type="text"]:focus': {
outline: 'none',
boxShadow: 'none',
},
'& .btn.btn': {
background: 'var(--neutral-10)',
color: 'var(--neutral-60)',
borderRadius: '50%',
height: '2em',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: '2em',
marginRight: 'var(--ol-cm-search-form-button-margin)',
'&.checked': {
color: 'var(--white)',
backgroundColor: 'var(--blue-50)',
},
'&:active': {
boxShadow: 'none',
},
},
'&:focus-within': {
borderColor: 'var(--input-border-focus)',
boxShadow: 'var(--ol-cm-search-form-focus-shadow)',
},
},
'.ol-cm-search-input-group.ol-cm-search-input-error': {
'&:focus-within': {
borderColor: 'var(--input-border-danger)',
boxShadow: 'var(--ol-cm-search-form-error-shadow)',
},
},
'.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(--content-secondary)',
},
'.ol-cm-search-hidden-inputs': {
position: 'absolute',
left: '-10000px',
},
'.ol-cm-search-form-close': {
marginLeft: 'auto',
display: 'flex',
alignItems: 'start',
},
'.ol-cm-search-replace-input': {
order: 3,
},
'.ol-cm-search-replace-buttons': {
order: 4,
},
'.ol-cm-stored-selection': {
background: 'rgba(125, 125, 125, 0.1)',
paddingTop: 'var(--half-leading)',
paddingBottom: 'var(--half-leading)',
},
// set the default "match" style
'.cm-selectionMatch, .cm-searchMatch': {
backgroundColor: 'transparent',
outlineOffset: '-1px',
paddingTop: 'var(--half-leading)',
paddingBottom: 'var(--half-leading)',
},
// make sure selectionMatch inside searchMatch doesn't have a background colour
'.cm-searchMatch .cm-selectionMatch': {
backgroundColor: 'transparent !important',
},
})

View File

@@ -0,0 +1,224 @@
import { 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 { selectNextOccurrence, selectPrevOccurrence } from '../commands/select'
import { cloneSelectionVertically } from '../commands/cursor'
import {
deleteToVisualLineEnd,
deleteToVisualLineStart,
} from './visual-line-selection'
import { emitShortcutEvent } from '@/features/source-editor/extensions/toolbar/utils/analytics'
const toggleReviewPanel = () => {
window.dispatchEvent(new Event('ui.toggle-review-panel'))
return true
}
const addNewCommentFromKbdShortcut = () => {
window.dispatchEvent(new Event('add-new-review-comment'))
return true
}
const toggleTrackChangesFromKbdShortcut = () => {
window.dispatchEvent(new Event('toggle-track-changes'))
return true
}
/**
* Custom key bindings for motion, transformation, selection, history, etc.
*/
export const shortcuts = Prec.high(
keymap.of([
{
key: 'Tab',
run: indentMore,
},
{
key: 'Shift-Tab',
run: indentLess,
},
{
key: 'Mod-y',
preventDefault: true,
run: redo,
},
{
key: 'Mod-Shift-z',
preventDefault: true,
run: redo,
},
// defaultKeymap maps Mod-/ to toggleLineComment, but
// w3c-keyname has a hard-coded mapping of Shift+key => character
// which uses a US keyboard layout, so we need to add more mappings.
// Mod-/, but Spanish, Portuguese, German and Swedish keyboard layouts have / at Shift+7
// (keyCode 55, mapped with Shift to &)
{
key: 'Mod-&',
preventDefault: true,
run: toggleLineComment,
},
// Mod-/, but German keyboard layouts have / at Cmd+Shift+ß
// Mod-/, but Czech keyboard layouts have / at Shift-ú
// (keyCode 191, mapped with Shift to ?)
{
key: 'Mod-?',
preventDefault: true,
run: toggleLineComment,
},
// German keyboard layouts map 0xBF to #,
// so VS Code on Windows/Linux uses Ctrl-# to toggle line comments.
// This is an additional, undocumented shortcut for compatibility.
{
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: 'Mod-d',
preventDefault: true,
run(view) {
emitShortcutEvent(view, 'delete-line')
return deleteLine(view)
},
},
{
key: 'Mod-j',
preventDefault: true,
run: toggleReviewPanel,
},
{
key: 'Mod-Shift-c',
preventDefault: true,
run: addNewCommentFromKbdShortcut,
},
{
key: 'Mod-Shift-a',
preventDefault: true,
run: toggleTrackChangesFromKbdShortcut,
},
{
key: 'Cmd-Alt-ArrowUp',
preventDefault: true,
run: cloneSelectionVertically(false, true, 'cmd'),
},
{
key: 'Cmd-Alt-ArrowDown',
preventDefault: true,
run: cloneSelectionVertically(true, true, 'cmd'),
},
{
key: 'Cmd-Alt-Shift-ArrowUp',
preventDefault: true,
run: cloneSelectionVertically(false, false, 'cmd'),
},
{
key: 'Cmd-Alt-Shift-ArrowDown',
preventDefault: true,
run: cloneSelectionVertically(true, false, 'cmd'),
},
// Duplicates of the above commands,
// allowing Ctrl instead of Command (but still tracking the events separately).
// Note: both Ctrl and Commmand versions need to work on macOS, for backwards compatibility,
// so the duplicates shouldn't simply be combined to use `Mod-`.
{
key: 'Ctrl-Alt-ArrowUp',
preventDefault: true,
run: cloneSelectionVertically(false, true, 'ctrl'),
},
{
key: 'Ctrl-Alt-ArrowDown',
preventDefault: true,
run: cloneSelectionVertically(true, true, 'ctrl'),
},
{
key: 'Ctrl-Alt-Shift-ArrowUp',
preventDefault: true,
run: cloneSelectionVertically(false, false, 'ctrl'),
},
{
key: 'Ctrl-Alt-Shift-ArrowDown',
preventDefault: true,
run: cloneSelectionVertically(true, false, 'ctrl'),
},
{
key: 'Ctrl-Alt-ArrowLeft',
preventDefault: true,
run(view) {
emitShortcutEvent(view, 'select-prev-occurrence')
return selectPrevOccurrence(view)
},
},
{
key: 'Ctrl-Alt-ArrowRight',
preventDefault: true,
run(view) {
emitShortcutEvent(view, 'select-next-occurrence')
return selectNextOccurrence(view)
},
},
{
key: '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,
},
{
mac: 'Cmd-Backspace',
run: deleteToVisualLineStart,
},
{
mac: 'Cmd-Delete',
run: deleteToVisualLineEnd,
},
])
)

View File

@@ -0,0 +1,104 @@
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 class WordCache {
private _cache: LRU<string, boolean>
constructor() {
this._cache = new LRU({ max: CACHE_MAX })
}
set(lang: string, wordText: string, value: boolean) {
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)
}
reset() {
this._cache = new LRU({ max: CACHE_MAX })
}
/*
* 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: Word[] = []
const unknownWords: Word[] = []
const seen: Record<string, boolean | undefined> = {}
for (const word of wordsToCheck) {
const wordText = word.text
if (seen[wordText] === undefined) {
seen[wordText] = this.get(lang, wordText)
}
const cached = seen[wordText]
if (cached === undefined) {
// Word is not known
unknownWords.push(word)
} else if (!cached) {
// Word is known to be misspelled
knownMisspelledWords.push(word)
}
}
return {
knownMisspelledWords,
unknownWords,
}
}
}
export const addWordToCache = StateEffect.define<{
lang: string
wordText: string
value: 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
},
})

View File

@@ -0,0 +1,247 @@
import {
StateField,
StateEffect,
Prec,
EditorSelection,
} from '@codemirror/state'
import { EditorView, showTooltip, Tooltip, keymap } from '@codemirror/view'
import { Word, Mark, getMarkAtPosition } from './spellchecker'
import { debugConsole } from '@/utils/debugging'
import {
getSpellChecker,
getSpellCheckLanguage,
} from '@/features/source-editor/extensions/spelling/index'
import { sendMB } from '@/infrastructure/event-tracking'
import ReactDOM from 'react-dom'
import { SpellingSuggestions } from '@/features/source-editor/extensions/spelling/spelling-suggestions'
import { SplitTestProvider } from '@/shared/context/split-test-context'
import { addLearnedWord } from '@/features/source-editor/extensions/spelling/learned-words'
import { postJSON } from '@/infrastructure/fetch-json'
/*
* 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 targetMark = getMarkAtPosition(view, position)
if (!targetMark) {
return
}
const { value } = targetMark
const targetWord = value.spec.word
if (!targetWord) {
debugConsole.debug(
'>> spelling no word associated with decorated range, stopping'
)
return
}
event.preventDefault()
openingUntil = Date.now() + 100
view.dispatch({
effects: showSpellingMenu.of({
mark: targetMark,
word: targetWord,
}),
})
}
const handleShortcutEvent = (view: EditorView) => {
const targetMark = getMarkAtPosition(view, view.state.selection.main.from)
if (!targetMark || !targetMark.value) {
return false
}
view.dispatch({
effects: showSpellingMenu.of({
mark: targetMark,
word: targetMark.value.spec.word,
}),
})
return true
}
/*
* Spelling menu "tooltip" field.
* Manages the menu of suggestions shown on right-click
*/
export const spellingMenuField = StateField.define<Tooltip | null>({
create() {
return null
},
update(value, transaction) {
if (value) {
value = {
...value,
pos: transaction.changes.mapPos(value.pos),
end: value.end ? transaction.changes.mapPos(value.end) : undefined,
}
}
for (const effect of transaction.effects) {
if (effect.is(hideSpellingMenu)) {
value = null
} else if (effect.is(showSpellingMenu)) {
const { mark, word } = effect.value
// Build a "Tooltip" showing the suggestions
value = {
pos: mark.from,
end: mark.to,
above: false,
strictSide: false,
create: createSpellingSuggestionList(word),
}
}
}
return value
},
provide: field => {
return [
showTooltip.from(field),
EditorView.domEventHandlers({
contextmenu: handleContextMenuEvent,
click: handleClickEvent,
}),
Prec.highest(
keymap.of([
{ key: 'Ctrl-Space', run: handleShortcutEvent },
{ key: 'Alt-Space', run: handleShortcutEvent },
])
),
]
},
})
const showSpellingMenu = StateEffect.define<{ mark: Mark; word: Word }>()
export const hideSpellingMenu = StateEffect.define()
/*
* Creates the suggestion menu dom, to be displayed in the
* spelling menu "tooltip"
* */
const createSpellingSuggestionList = (word: Word) => (view: EditorView) => {
const dom = document.createElement('div')
dom.classList.add('ol-cm-spelling-context-menu-tooltip')
ReactDOM.render(
<SplitTestProvider>
<SpellingSuggestions
word={word}
spellCheckLanguage={getSpellCheckLanguage(view.state)}
spellChecker={getSpellChecker(view.state)}
handleClose={(focus = true) => {
view.dispatch({
effects: hideSpellingMenu.of(null),
})
if (focus) {
view.focus()
}
}}
handleLearnWord={() => {
const tooltip = view.state.field(spellingMenuField)
if (tooltip) {
window.setTimeout(() => {
view.dispatch({
selection: EditorSelection.cursor(tooltip.end ?? tooltip.pos),
})
})
}
view.focus()
postJSON('/spelling/learn', {
body: {
word: word.text,
},
})
.then(() => {
view.dispatch(addLearnedWord(word.text), {
effects: hideSpellingMenu.of(null),
})
sendMB('spelling-word-added', {
language: getSpellCheckLanguage(view.state),
})
})
.catch(error => {
debugConsole.error(error)
})
}}
handleCorrectWord={(text: string) => {
const tooltip = view.state.field(spellingMenuField)
if (!tooltip) {
throw new Error('No active tooltip')
}
const existingText = view.state.doc.sliceString(
tooltip.pos,
tooltip.end
)
if (existingText !== word.text) {
return
}
window.setTimeout(() => {
const changes = view.state.changes([
{ from: tooltip.pos, to: tooltip.end, insert: text },
])
view.dispatch({
changes,
effects: [hideSpellingMenu.of(null)],
selection: EditorSelection.cursor(tooltip.end ?? tooltip.pos).map(
changes
),
})
})
view.focus()
sendMB('spelling-suggestion-click', {
language: getSpellCheckLanguage(view.state),
})
}}
/>
</SplitTestProvider>,
dom
)
const destroy = () => {
ReactDOM.unmountComponentAtNode(dom)
}
return { dom, destroy }
}

View File

@@ -0,0 +1 @@
export const WORD_REGEX = /\\?['\p{L}]+/gu

View File

@@ -0,0 +1,155 @@
import { EditorView, ViewPlugin } from '@codemirror/view'
import {
EditorState,
StateEffect,
StateField,
TransactionSpec,
} from '@codemirror/state'
import { misspelledWordsField } from './misspelled-words'
import { removeLearnedWord } from './learned-words'
import { cacheField } from './cache'
import { hideSpellingMenu, spellingMenuField } from './context-menu'
import { SpellChecker } from './spellchecker'
import { parserWatcher } from '../wait-for-parser'
import type { HunspellManager } from '@/features/source-editor/hunspell/HunspellManager'
type Options = {
spellCheckLanguage?: string
hunspellManager?: HunspellManager
}
/**
* A custom extension that creates a spell checker for the current language (from the user settings).
* The spell check runs on the server whenever a line changes.
* The mis-spelled words, ignored words and spell-checked words are stored in a state field.
* Mis-spelled words are decorated with a Mark decoration.
* The suggestions menu is displayed in a tooltip, activated with a right-click on the decoration.
*/
export const spelling = ({ spellCheckLanguage, hunspellManager }: Options) => {
return [
spellingTheme,
parserWatcher,
spellCheckLanguageField.init(() => spellCheckLanguage),
spellCheckerField.init(() =>
spellCheckLanguage
? new SpellChecker(spellCheckLanguage, hunspellManager)
: null
),
misspelledWordsField,
cacheField,
spellingMenuField,
dictionary,
]
}
const dictionary = ViewPlugin.define(view => {
const listener = (event: Event) => {
view.dispatch(removeLearnedWord((event as CustomEvent<string>).detail))
}
window.addEventListener('editor:remove-learned-word', listener)
return {
destroy() {
window.removeEventListener('editor:remove-learned-word', listener)
},
}
})
const spellingTheme = 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',
background: 'transparent',
},
})
export const getSpellChecker = (state: EditorState) =>
state.field(spellCheckerField, false)
const spellCheckerField = StateField.define<SpellChecker | null>({
create() {
return null
},
update(value, tr) {
for (const effect of tr.effects) {
if (effect.is(setSpellCheckLanguageEffect)) {
value?.destroy()
value = effect.value.spellCheckLanguage
? new SpellChecker(
effect.value.spellCheckLanguage,
effect.value.hunspellManager
)
: 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)?.scheduleSpellCheck(view)
}
},
}),
EditorView.updateListener.of(update => {
if (update.state.facet(EditorView.editable)) {
update.state.field(field)?.handleUpdate(update)
}
}),
]
},
})
export const getSpellCheckLanguage = (state: EditorState) =>
state.field(spellCheckLanguageField, false)
const spellCheckLanguageField = StateField.define<string | undefined>({
create() {
return undefined
},
update(value, tr) {
for (const effect of tr.effects) {
if (effect.is(setSpellCheckLanguageEffect)) {
value = effect.value.spellCheckLanguage
}
}
return value
},
})
export const setSpellCheckLanguageEffect = StateEffect.define<{
spellCheckLanguage: string | undefined
hunspellManager?: HunspellManager
}>()
export const setSpellCheckLanguage = ({
spellCheckLanguage,
hunspellManager,
}: Options): TransactionSpec => {
return {
effects: [
setSpellCheckLanguageEffect.of({
spellCheckLanguage,
hunspellManager,
}),
hideSpellingMenu.of(null),
],
}
}

View File

@@ -0,0 +1,24 @@
import { StateEffect } from '@codemirror/state'
import getMeta from '@/utils/meta'
export const addLearnedWordEffect = StateEffect.define<string>()
export const removeLearnedWordEffect = StateEffect.define<string>()
export const learnedWords = {
global: new Set(getMeta('ol-learnedWords')),
}
export const addLearnedWord = (text: string) => {
learnedWords.global.add(text)
return {
effects: addLearnedWordEffect.of(text),
}
}
export const removeLearnedWord = (text: string) => {
learnedWords.global.delete(text)
return {
effects: removeLearnedWordEffect.of(text),
}
}

View File

@@ -0,0 +1,85 @@
import { StateField, StateEffect, Line } from '@codemirror/state'
import { EditorView, Decoration, DecorationSet } from '@codemirror/view'
import { addLearnedWordEffect } from './learned-words'
import { Word } from './spellchecker'
import { setSpellCheckLanguageEffect } from '@/features/source-editor/extensions/spelling/index'
export const addMisspelledWords = StateEffect.define<Word[]>()
const createMark = (word: Word) => {
return Decoration.mark({
class: 'ol-cm-spelling-error',
word,
}).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) {
if (transaction.docChanged) {
// Remove any marks whose text has just been edited
marks = marks.update({
filter(from, to) {
return !transaction.changes.touchesRange(from, to)
},
})
}
marks = marks.map(transaction.changes)
for (const effect of transaction.effects) {
if (effect.is(addMisspelledWords)) {
const { doc } = transaction.state
// collect the lines that contained mispelled words, so existing marks can be removed
const affectedLines = new Map<number, Line>()
for (const word of effect.value) {
if (!affectedLines.has(word.lineNumber)) {
affectedLines.set(word.lineNumber, doc.line(word.lineNumber))
}
}
// Merge the new misspelled words into the existing set of marks
marks = marks.update({
filter(from, to) {
for (const line of affectedLines.values()) {
if (to > line.from && from < line.to) {
return false
}
}
return true
},
add: effect.value.map(word => createMark(word)),
sort: true,
})
} else if (effect.is(addLearnedWordEffect)) {
const word = effect.value
// Remove existing marks matching the text of a supplied word
marks = marks.update({
filter(_from, _to, mark) {
return (
mark.spec.word.text !== word &&
mark.spec.word.text !== capitaliseWord(word)
)
},
})
} else if (effect.is(setSpellCheckLanguageEffect)) {
marks = Decoration.none
}
}
return marks
},
provide: field => {
return EditorView.decorations.from(field)
},
})
const capitaliseWord = (word: string) =>
word.charAt(0).toUpperCase() + word.substring(1)

View File

@@ -0,0 +1,440 @@
import { addMisspelledWords, misspelledWordsField } from './misspelled-words'
import { addLearnedWordEffect, removeLearnedWordEffect } from './learned-words'
import { cacheField, addWordToCache } from './cache'
import { WORD_REGEX } from './helpers'
import OError from '@overleaf/o-error'
import { EditorView, ViewUpdate } from '@codemirror/view'
import { ChangeSet, Line, Range, RangeValue } from '@codemirror/state'
import { getNormalTextSpansFromLine } from '../../utils/tree-query'
import { waitForParser } from '../wait-for-parser'
import { debugConsole } from '@/utils/debugging'
import type { HunspellManager } from '../../hunspell/HunspellManager'
import { captureException } from '@/infrastructure/error-reporter'
/*
* Spellchecker, handles updates, schedules spelling checks
*/
export class SpellChecker {
private abortController?: AbortController | null = null
private timeout: number | null = null
private firstCheck = true
private waitingForParser = false
private firstCheckPending = false
private trackedChanges: ChangeSet
private destroyed = false
private readonly segmenter?: Intl.Segmenter
// eslint-disable-next-line no-useless-constructor
constructor(
private readonly language: string,
private hunspellManager?: HunspellManager
) {
debugConsole.log('SpellChecker', language, hunspellManager)
this.trackedChanges = ChangeSet.empty(0)
const locale = language.replace(/_/, '-')
try {
if (Intl.Segmenter) {
const supportedLocales = Intl.Segmenter.supportedLocalesOf([locale], {
localeMatcher: 'lookup',
})
if (supportedLocales.includes(locale)) {
this.segmenter = new Intl.Segmenter(locale, {
localeMatcher: 'lookup',
granularity: 'word',
})
}
}
} catch (error) {
// ignore, not supported for some reason
debugConsole.error(error)
}
if (this.segmenter) {
debugConsole.log(`Using Intl.Segmenter for ${locale}`)
} else {
debugConsole.warn(`Not using Intl.Segmenter for ${locale}`)
}
}
destroy() {
this._clearPendingSpellCheck()
this.destroyed = true
}
_abortRequest() {
if (this.abortController) {
this.abortController.abort()
this.abortController = null
}
}
handleUpdate(update: ViewUpdate) {
if (update.docChanged) {
this.trackedChanges = this.trackedChanges.compose(update.changes)
this.scheduleSpellCheck(update.view)
} else if (update.viewportChanged) {
this.trackedChanges = ChangeSet.empty(0)
this.scheduleSpellCheck(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
this.spellCheckAsap(update.view)
} else {
for (const tr of update.transactions) {
for (const effect of tr.effects) {
if (effect.is(addLearnedWordEffect)) {
this.addWord(effect.value)
.then(() => {
update.view.state.field(cacheField, false)?.reset()
this.trackedChanges = ChangeSet.empty(0)
this.spellCheckAsap(update.view)
})
.catch(error => {
captureException(error)
debugConsole.error(error)
})
} else if (effect.is(removeLearnedWordEffect)) {
this.removeWord(effect.value)
.then(() => {
update.view.state.field(cacheField, false)?.reset()
this.trackedChanges = ChangeSet.empty(0)
this.spellCheckAsap(update.view)
})
.catch(error => {
captureException(error)
debugConsole.error(error)
})
}
}
}
}
}
_performSpellCheck(view: EditorView) {
const wordsToCheck = this.getWordsToCheck(view)
if (wordsToCheck.length === 0) {
this.trackedChanges = ChangeSet.empty(0)
return
}
const cache = view.state.field(cacheField)
const { knownMisspelledWords, unknownWords } = cache.checkWords(
this.language,
wordsToCheck
)
const processResult = (misspellings: { index: number }[]) => {
this.trackedChanges = ChangeSet.empty(0)
if (this.firstCheck) {
this.firstCheck = false
this.firstCheckPending = false
}
const { misspelledWords, cacheAdditions } = buildSpellCheckResult(
knownMisspelledWords,
unknownWords,
misspellings
)
view.dispatch({
effects: [
addMisspelledWords.of(misspelledWords),
...cacheAdditions.map(([word, value]) => {
return addWordToCache.of({
lang: word.lang,
wordText: word.text,
value,
})
}),
],
})
}
if (unknownWords.length === 0) {
processResult([])
} else {
this._abortRequest()
this.abortController = new AbortController()
if (this.hunspellManager) {
const signal = this.abortController.signal
this.hunspellManager.send(
{
type: 'spell',
words: unknownWords.map(word => word.text),
},
result => {
if (!signal.aborted) {
if ('error' in result) {
debugConsole.error(result.error)
captureException(
new Error('Error running spellcheck for word'),
{ tags: { ol_spell_check_language: this.language } }
)
} else {
processResult(result.misspellings)
}
}
}
)
}
}
}
suggest(word: string) {
return new Promise<{ suggestions: string[] }>((resolve, reject) => {
if (this.hunspellManager) {
this.hunspellManager.send({ type: 'suggest', word }, result => {
if ('error' in result) {
reject(new Error('Error finding spelling suggestions for word'))
} else {
resolve(result)
}
})
}
})
}
addWord(word: string) {
return new Promise<void>((resolve, reject) => {
if (this.hunspellManager) {
this.hunspellManager.send({ type: 'add_word', word }, result => {
if ('error' in result) {
reject(new Error('Error adding word to spellcheck'))
} else {
resolve()
}
})
}
})
}
removeWord(word: string) {
return new Promise<void>((resolve, reject) => {
if (this.hunspellManager) {
this.hunspellManager.send({ type: 'remove_word', word }, result => {
if ('error' in result) {
reject(new Error('Error removing word from spellcheck'))
} else {
resolve()
}
})
}
})
}
_spellCheckWhenParserReady(view: EditorView) {
if (this.waitingForParser) {
return
}
this.waitingForParser = true
waitForParser(view, view => view.viewport.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) {
if (this.destroyed) {
debugConsole.warn(
'spellCheckAsap called after spellchecker was destroyed. Ignoring.'
)
return
}
this._asyncSpellCheck(view, 0)
}
scheduleSpellCheck(view: EditorView) {
if (this.destroyed) {
debugConsole.warn(
'scheduleSpellCheck called after spellchecker was destroyed. Ignoring.'
)
return
}
this._asyncSpellCheck(view, 1000)
}
getWordsToCheck(view: EditorView) {
const wordsToCheck: Word[] = []
const { from, to } = view.viewport
const changedLineNumbers = new Set<number>()
if (!this.trackedChanges.empty) {
this.trackedChanges.iterChangedRanges((_fromA, _toA, fromB, toB) => {
if (fromB <= to && toB >= from) {
const fromLine = view.state.doc.lineAt(fromB).number
const toLine = view.state.doc.lineAt(toB).number
for (let i = fromLine; i <= toLine; i++) {
changedLineNumbers.add(i)
}
}
})
} else {
const fromLine = view.state.doc.lineAt(from).number
const toLine = view.state.doc.lineAt(to).number
for (let i = fromLine; i <= toLine; i++) {
changedLineNumbers.add(i)
}
}
for (const i of changedLineNumbers) {
const line = view.state.doc.line(i)
wordsToCheck.push(
...getWordsFromLine(view, line, this.language, this.segmenter)
)
}
return wordsToCheck
}
}
export class Word {
public text: string
public from: number
public to: number
public lineNumber: number
public lang: string
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 }[]
) => {
const cacheAdditions: [Word, boolean][] = []
// Put known misspellings into cache
const misspelledWords = misspellings.map(item => {
const word = {
...unknownWords[item.index],
}
cacheAdditions.push([word, false])
return word
})
const misspelledWordsSet = new Set<string>(
misspelledWords.map(word => word.text)
)
// if word was not misspelled, put it in the cache
for (const word of unknownWords) {
if (!misspelledWordsSet.has(word.text)) {
cacheAdditions.push([word, true])
}
}
return {
cacheAdditions,
misspelledWords: misspelledWords.concat(knownMisspelledWords),
}
}
export function* getWordsFromLine(
view: EditorView,
line: Line,
lang: string,
segmenter?: Intl.Segmenter
) {
for (const span of getNormalTextSpansFromLine(view, line)) {
if (segmenter) {
for (const value of segmenter.segment(span.text)) {
if (value.isWordLike) {
const word = value.segment
const from = span.from + value.index
yield new Word({
text: word,
from,
to: from + word.length,
lineNumber: line.number,
lang,
})
}
}
} else {
for (const match of span.text.matchAll(WORD_REGEX)) {
let word = match[0].replace(/'+$/, '')
let from = span.from + match.index
while (word.startsWith("'")) {
word = word.slice(1)
from++
}
yield new Word({
text: word,
from,
to: from + word.length,
lineNumber: line.number,
lang,
})
}
}
}
}
export type Mark = Range<RangeValue & { spec: { word: Word } }>
export const getMarkAtPosition = (
view: EditorView,
position: number
): Mark | null => {
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
}
})
return targetMark
}

View File

@@ -0,0 +1,43 @@
import { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import { Dropdown } from 'react-bootstrap-5'
import MaterialIcon from '@/shared/components/material-icon'
export const SpellingSuggestionsLanguage = memo<{
language: { name: string }
handleClose: (focus: boolean) => void
}>(({ language, handleClose }) => {
const { t } = useTranslation()
const handleClick = useCallback(() => {
// open the left menu
window.dispatchEvent(
new CustomEvent('ui.toggle-left-menu', { detail: true })
)
// focus the spell check setting
window.dispatchEvent(
new CustomEvent('ui.focus-setting', { detail: 'spellCheckLanguage' })
)
handleClose(false)
}, [handleClose])
return (
<OLTooltip
id="spell-check-client-tooltip"
description={t('change_language')}
overlayProps={{ placement: 'right', delay: 100 }}
>
<span>
<Dropdown.Item
className="d-flex gap-2 align-items-center"
onClick={handleClick}
>
<MaterialIcon type="settings" />
<span>{language.name}</span>
</Dropdown.Item>
</span>
</OLTooltip>
)
})
SpellingSuggestionsLanguage.displayName = 'SpellingSuggestionsLanguage'

View File

@@ -0,0 +1,179 @@
import {
FC,
MouseEventHandler,
useCallback,
useEffect,
useMemo,
useState,
} from 'react'
import { SpellChecker, Word } from './spellchecker'
import { useTranslation } from 'react-i18next'
import getMeta from '@/utils/meta'
import classnames from 'classnames'
import { sendMB } from '@/infrastructure/event-tracking'
import { SpellingSuggestionsLanguage } from './spelling-suggestions-language'
import { captureException } from '@/infrastructure/error-reporter'
import { debugConsole } from '@/utils/debugging'
import { SpellCheckLanguage } from '../../../../../../types/project-settings'
import { Dropdown } from 'react-bootstrap-5'
const ITEMS_TO_SHOW = 8
type SpellingSuggestionsProps = {
word: Word
spellCheckLanguage?: string
spellChecker?: SpellChecker | null
handleClose: () => void
handleLearnWord: () => void
handleCorrectWord: (text: string) => void
}
export const SpellingSuggestions: FC<SpellingSuggestionsProps> = ({
word,
spellCheckLanguage,
spellChecker,
handleClose,
handleLearnWord,
handleCorrectWord,
}) => {
const [suggestions, setSuggestions] = useState<string[]>([])
const [waiting, setWaiting] = useState(true)
useEffect(() => {
spellChecker
?.suggest(word.text)
.then(result => {
setSuggestions(result.suggestions.slice(0, ITEMS_TO_SHOW))
setWaiting(false)
sendMB('spelling-suggestion-shown', {
language: spellCheckLanguage,
count: result.suggestions.length,
// word: transaction.state.sliceDoc(mark.from, mark.to),
})
})
.catch(error => {
captureException(error, {
tags: { ol_spell_check_language: spellCheckLanguage },
})
debugConsole.error(error)
})
}, [word, spellChecker, spellCheckLanguage])
const language = useMemo(() => {
if (spellCheckLanguage) {
return (getMeta('ol-languages') ?? []).find(
item => item.code === spellCheckLanguage
)
}
}, [spellCheckLanguage])
if (!language) {
return null
}
const innerProps = {
suggestions,
waiting,
handleClose,
handleCorrectWord,
handleLearnWord,
language,
}
return <B5SpellingSuggestions {...innerProps} />
}
type SpellingSuggestionsInnerProps = {
suggestions: string[]
waiting: boolean
handleClose: () => void
handleCorrectWord: (text: string) => void
handleLearnWord: () => void
language: SpellCheckLanguage
}
const B5SpellingSuggestions: FC<SpellingSuggestionsInnerProps> = ({
suggestions,
waiting,
language,
handleClose,
handleCorrectWord,
handleLearnWord,
}) => {
const { t } = useTranslation()
return (
<Dropdown>
<Dropdown.Menu
className={classnames('dropdown-menu', 'dropdown-menu-unpositioned', {
hidden: waiting,
})}
show={!waiting}
tabIndex={0}
role="menu"
onKeyDown={event => {
switch (event.code) {
case 'Escape':
case 'Tab':
event.preventDefault()
handleClose()
break
}
}}
>
{Array.isArray(suggestions) &&
suggestions.map((suggestion, index) => (
<BS5ListItem
key={suggestion}
content={suggestion}
handleClick={event => {
event.preventDefault()
handleCorrectWord(suggestion)
}}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={index === 0}
/>
))}
{suggestions?.length > 0 && <Dropdown.Divider />}
<BS5ListItem
content={t('add_to_dictionary')}
handleClick={event => {
event.preventDefault()
handleLearnWord()
}}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={suggestions?.length === 0}
/>
<Dropdown.Divider />
<SpellingSuggestionsLanguage
language={language}
handleClose={handleClose}
/>
</Dropdown.Menu>
</Dropdown>
)
}
const BS5ListItem: FC<{
content: string
handleClick: MouseEventHandler<HTMLButtonElement>
autoFocus?: boolean
}> = ({ content, handleClick, autoFocus }) => {
const handleListItem = useCallback(
(node: HTMLElement | null) => {
if (node && autoFocus) node.focus()
},
[autoFocus]
)
return (
<Dropdown.Item
role="menuitem"
className="btn-link text-left dropdown-menu-button"
onClick={handleClick}
ref={handleListItem}
>
{content}
</Dropdown.Item>
)
}

View File

@@ -0,0 +1,42 @@
import { ViewPlugin } from '@codemirror/view'
import { EditorSelection } from '@codemirror/state'
/**
* A custom extension that listens for an `editor:insert-symbol` event and inserts the given content into the document.
*/
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)
},
}
})
}

View File

@@ -0,0 +1,294 @@
import { EditorView } from '@codemirror/view'
import { Annotation, Compartment, TransactionSpec } from '@codemirror/state'
import { syntaxHighlighting } from '@codemirror/language'
import { classHighlighter } from './class-highlighter'
import classNames from 'classnames'
import {
FontFamily,
LineHeight,
OverallTheme,
userStyles,
} from '@/shared/utils/styles'
const optionsThemeConf = new Compartment()
const selectedThemeConf = new Compartment()
export const themeOptionsChange = Annotation.define<boolean>()
type Options = {
fontSize: number
fontFamily: FontFamily
lineHeight: LineHeight
overallTheme: OverallTheme
}
export const theme = (options: Options) => [
baseTheme,
staticTheme,
/**
* Syntax highlighting, using a highlighter which maps tags to class names.
*/
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>`
)}')`
const createThemeFromOptions = ({
fontSize = 12,
fontFamily = 'monaco',
lineHeight = 'normal',
overallTheme = '',
}: Options) => {
// Theme styles that depend on settings.
const styles = userStyles({ fontSize, fontFamily, lineHeight })
return [
EditorView.editorAttributes.of({
class: classNames(
overallTheme === '' ? 'overall-theme-dark' : 'overall-theme-light'
),
style: Object.entries({
'--font-size': styles.fontSize,
'--source-font-family': styles.fontFamily,
'--line-height': styles.lineHeight,
})
.map(([key, value]) => `${key}: ${value}`)
.join(';'),
}),
// set variables for tooltips, which are outside the editor
// TODO: set these on document.body, or a new container element for the tooltips, without using a style mod
EditorView.theme({
'.cm-tooltip': {
'--font-size': styles.fontSize,
'--source-font-family': styles.fontFamily,
'--line-height': styles.lineHeight,
},
}),
]
}
/**
* Base styles that can have &dark and &light variants
*/
const baseTheme = EditorView.baseTheme({
'.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': {
// 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)',
},
// double the specificity to override the underline squiggle
'.cm-lintRange.cm-lintRange': {
backgroundImage: 'none',
},
// 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-specialChar': {
color: 'red',
backgroundColor: 'rgba(255, 0, 0, 0.1)',
},
'.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)',
},
'.cm-diagnosticSource': {
display: 'none',
},
'.ol-cm-diagnostic-actions': {
marginTop: '4px',
},
'.cm-diagnostic:last-of-type .ol-cm-diagnostic-actions': {
marginBottom: '4px',
},
'.cm-vim-panel input': {
color: 'inherit',
},
})
/**
* 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',
fontVariantNumeric: 'slashed-zero',
},
// 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-scroller > .cm-cursorLayer .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',
},
// 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 themeCache = new Map<string, any>()
const loadSelectedTheme = async (editorTheme: string) => {
if (!editorTheme) {
editorTheme = 'textmate' // use the default theme if unset
}
if (!themeCache.has(editorTheme)) {
const { theme, highlightStyle, dark } = await import(
/* webpackChunkName: "cm6-theme" */ `../themes/cm6/${editorTheme}.json`
)
const extension = [
EditorView.theme(theme, { dark }),
EditorView.theme(highlightStyle, { dark }),
]
themeCache.set(editorTheme, extension)
}
return themeCache.get(editorTheme)
}

View File

@@ -0,0 +1,73 @@
import { Compartment, type Extension } from '@codemirror/state'
import CodeMirror, { CodeMirrorVim } from './bundle'
import { ViewPlugin } from '@codemirror/view'
const thirdPartyExtensionsConf = new Compartment()
const dispatchEvent = (extensions: Extension[]) => {
window.dispatchEvent(
new CustomEvent('UNSTABLE_editor:extensions', {
detail: { CodeMirror, CodeMirrorVim, extensions },
})
)
}
/**
* A custom extension that allows additional CodeMirror extensions to be provided by external code,
* e.g. browser extensions.
*/
export const thirdPartyExtensions = (): Extension => {
const extensions: Extension[] = []
dispatchEvent(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 [thirdPartyExtensionsConf.of(extensions), extensionLoaded]
}
const extensionLoaded = ViewPlugin.define(view => {
const listener = () => {
const extensions: Extension[] = []
dispatchEvent(extensions)
view.dispatch({
effects: thirdPartyExtensionsConf.reconfigure(extensions),
})
}
window.addEventListener('editor:extension-loaded', listener)
return {
destroy() {
window.removeEventListener('editor:extension-loaded', listener)
},
}
})

View File

@@ -0,0 +1,168 @@
import { EditorSelection, EditorState, SelectionRange } from '@codemirror/state'
import { Command, EditorView } from '@codemirror/view'
import {
closeSearchPanel,
openSearchPanel,
searchPanelOpen,
} from '@codemirror/search'
import { toggleRanges, wrapRanges } from '../../commands/ranges'
import {
ancestorListType,
toggleListForRanges,
unwrapBulletList,
unwrapDescriptionList,
unwrapNumberedList,
wrapInBulletList,
wrapInDescriptionList,
wrapInNumberedList,
} from './lists'
import { snippet } from '@codemirror/autocomplete'
import { snippets } from './snippets'
import { minimumListDepthForSelection } from '../../utils/tree-operations/ancestors'
import { isVisual } from '../visual/visual'
import { sendSearchEvent } from '@/features/event-tracking/search-events'
export const toggleBold = toggleRanges('\\textbf')
export const toggleItalic = toggleRanges('\\textit')
// TODO: apply as a snippet?
// TODO: read URL from clipboard?
export const wrapInHref = wrapRanges('\\href{}{', '}', false, (range, view) =>
isVisual(view) ? 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')
export const ensureEmptyLine = (
state: EditorState,
range: SelectionRange,
direction: 'above' | 'below' = 'below'
) => {
let pos = range.anchor
let suffix = ''
let prefix = ''
const line = state.doc.lineAt(pos)
if (line.text.trim().length) {
if (direction === 'below') {
pos = Math.min(line.to + 1, state.doc.length)
} else {
pos = Math.max(line.from - 1, 0)
}
const neighbouringLine = state.doc.lineAt(pos)
if (neighbouringLine.length && direction === 'below') {
suffix = '\n'
} else if (neighbouringLine.length && direction === 'above') {
prefix = '\n'
}
}
return { pos, suffix, prefix }
}
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 = (view: EditorView, sizeX: number, sizeY: number) => {
const { state, dispatch } = view
const visual = isVisual(view)
const placeholder = visual ? '' : '#{}'
const placeholderAtStart = visual ? '#{}' : ''
const { pos, suffix } = ensureEmptyLine(state, state.selection.main)
const template = `${placeholderAtStart}\n\\begin{table}
\t\\centering
\t\\begin{tabular}{${'c'.repeat(sizeX)}}
${(
'\t\t' +
`${placeholder} & ${placeholder}`.repeat(sizeX - 1) +
'\\\\\n'
).repeat(sizeY)}\t\\end{tabular}
\t\\caption{Caption}
\t\\label{tab:my_label}
\\end{table}${suffix}`
snippet(template)({ state, dispatch }, { label: 'Table' }, pos, pos)
return true
}
export const insertCite: Command = view => {
const { state, dispatch } = view
const pos = state.selection.main.anchor
const template = snippets.cite
snippet(template)({ state, dispatch }, { label: 'Cite' }, pos, pos)
return true
}
export const insertRef: Command = view => {
const { state, dispatch } = view
const pos = state.selection.main.anchor
const template = snippets.ref
snippet(template)({ state, dispatch }, { label: 'Ref' }, 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)
case 'description':
return unwrapDescriptionList(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)
case 'description':
return wrapInDescriptionList(view)
default:
return false
}
}
export const toggleSearch: Command = view => {
if (searchPanelOpen(view.state)) {
closeSearchPanel(view)
} else {
sendSearchEvent('search-open', {
searchType: 'document',
method: 'button',
location: 'toolbar',
mode: isVisual(view) ? 'visual' : 'source',
})
openSearchPanel(view)
}
return true
}
export const addComment = () => {
window.dispatchEvent(new Event('add-new-review-comment'))
}

View File

@@ -0,0 +1,349 @@
import { EditorView } from '@codemirror/view'
import {
ChangeSpec,
EditorSelection,
EditorState,
SelectionRange,
} from '@codemirror/state'
import {
getIndentUnit,
IndentContext,
indentString,
syntaxTree,
} from '@codemirror/language'
import {
ancestorNodeOfType,
ancestorOfNodeWithType,
ancestorWithType,
descendantsOfNodeWithType,
wrappedNodeOfType,
} from '../../utils/tree-operations/ancestors'
import { getEnvironmentName } from '../../utils/tree-operations/environments'
import { ListEnvironment } from '../../lezer-latex/latex.terms.mjs'
import { SyntaxNode } from '@lezer/common'
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 = cx.lineIndent(range.from)
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, -1).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)
),
{ scrollIntoView: true }
)
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 = cx.lineIndent(range.from)
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)
),
{ scrollIntoView: true }
)
return true
}
const toggleListForRange = (
view: EditorView,
range: SelectionRange,
environment: string
) => {
const ancestorNode =
ancestorNodeOfType(view.state, range.head, ListEnvironment) ??
wrappedNodeOfType(view.state, range, 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 : beginEnvNode.from,
to: emptyBeginLine
? Math.min(beginLine.to + 1, view.state.doc.length)
: beginEnvNode.to,
insert: '',
},
{
from: emptyEndLine
? Math.max(endLine.from - 1, 0)
: endEnvNode.from,
to: emptyEndLine ? endLine.to : endEnvNode.to,
insert: '',
},
]
// items that aren't within nested list environments
const itemNodes = descendantsOfNodeWithType(
ancestorNode,
'Item',
ListEnvironment
)
if (itemNodes.length > 0) {
const indentUnit = getIndentUnit(view.state)
for (const itemNode of itemNodes) {
const change: ChangeSpec = {
from: itemNode.from,
to: itemNode.to,
insert: '',
}
const line = view.state.doc.lineAt(itemNode.from)
const lineBeforeCommand = view.state.sliceDoc(
line.from,
itemNode.from
)
// if the line before the command is empty, remove one unit of indentation
if (lineBeforeCommand.trim().length === 0) {
const cx = new IndentContext(view.state)
const indentation = cx.lineIndent(itemNode.from)
change.from -= Math.min(indentation ?? 0, indentUnit)
}
changeSpec.push(change)
}
}
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 getListItems = (node: SyntaxNode): SyntaxNode[] => {
const items: SyntaxNode[] = []
node.cursor().iterate(nodeRef => {
if (nodeRef.type.is('Item')) {
items.push(nodeRef.node)
}
if (nodeRef.type.is('ListEnvironment') && nodeRef.node !== node) {
return false
}
})
return items
}
export const toggleListForRanges =
(environment: string) => (view: EditorView) => {
view.dispatch(
view.state.changeByRange(range =>
toggleListForRange(view, range, environment)
),
{ scrollIntoView: true }
)
}
export const wrapInBulletList = wrapRangesInList('itemize')
export const wrapInNumberedList = wrapRangesInList('enumerate')
export const wrapInDescriptionList = wrapRangesInList('description')
export const unwrapBulletList = unwrapRangesFromList('itemize')
export const unwrapNumberedList = unwrapRangesFromList('enumerate')
export const unwrapDescriptionList = unwrapRangesFromList('description')

View File

@@ -0,0 +1,165 @@
import { EditorSelection, EditorState, SelectionRange } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import { syntaxTree } from '@codemirror/language'
import { ancestorOfNodeWithType } 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 fromNode = tree.resolveInner(range.from, 1)
const fromAncestor = ancestorOfNodeWithType(fromNode, 'SectioningCommand')
const toNode = tree.resolveInner(range.to, -1)
const toAncestor = ancestorOfNodeWithType(toNode, 'SectioningCommand')
const command = fromAncestor ?? toAncestor
// from and to are both outside section heading
if (!command) {
return { range, level: 'text' }
}
if (fromAncestor && toAncestor) {
// from and to are inside different section headings
if (fromAncestor !== toAncestor) {
return { range, level: 'text' }
}
} else {
// the range isn't empty and only one end is inside a section heading
if (!range.empty) {
return { range, level: 'text' }
}
}
const ctrlSeq = command.firstChild
if (!ctrlSeq) {
return { range, level: 'text' }
}
const level = state.sliceDoc(ctrlSeq.from + 1, ctrlSeq.to).trim()
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,
}
}
}),
{ scrollIntoView: true }
)
}

View File

@@ -0,0 +1,9 @@
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),
cite: prepareSnippetTemplate('\\cite{${}}'),
ref: prepareSnippetTemplate('\\ref{${}}'),
}

View File

@@ -0,0 +1,326 @@
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) => {
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')
dom.id = 'ol-cm-toolbar-portal'
return { dom, top: true }
}
const toolbarTheme = EditorView.theme({
'.ol-cm-toolbar-wrapper': {
backgroundColor: 'var(--editor-toolbar-bg)',
color: 'var(--toolbar-btn-color)',
},
'.ol-cm-toolbar': {
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, & .popover-body': {
padding: 0,
},
'& .popover-body': {
color: 'inherit',
},
'& .arrow, & .popover-arrow': {
borderBottomColor: 'rgba(125, 125, 125, 0.2)',
'&:after': {
borderBottomColor: 'var(--editor-toolbar-bg)',
},
},
},
'.ol-cm-toolbar-header': {
color: 'var(--toolbar-btn-color)',
},
'.ol-cm-toolbar-dropdown-divider': {
borderBottom: '1px solid',
borderColor: 'var(--toolbar-dropdown-divider-color)',
},
// here render both the icons, and hide one depending on if its dark or light mode with &.overall-theme-dark
'.ol-cm-toolbar-ai-sparkle-gradient': {
display: 'block',
},
'.ol-cm-toolbar-ai-sparkle-white': {
display: 'none',
},
'&.overall-theme-dark .ol-cm-toolbar-ai-sparkle-gradient': {
display: 'none',
},
'&.overall-theme-dark .ol-cm-toolbar-ai-sparkle-white': {
display: 'block',
},
'.ol-cm-toolbar-button-menu-popover': {
backgroundColor: 'initial',
'& > .popover-content, & > .popover-body': {
padding: 0,
color: 'initial',
},
'& .arrow, & .popover-arrow': {
display: 'none',
},
'& .list-group': {
marginBottom: 0,
backgroundColor: 'var(--editor-toolbar-bg)',
borderRadius: '4px',
},
'& .list-group-item': {
width: '100%',
textAlign: 'start',
display: 'flex',
alignItems: 'center',
gap: '5px',
color: 'var(--toolbar-btn-color)',
borderColor: 'var(--editor-toolbar-bg)',
background: 'none',
'&:hover, &:focus': {
backgroundColor: 'rgba(125, 125, 125, 0.2)',
},
},
},
'.ol-cm-toolbar-button-group': {
display: 'flex',
alignItems: 'center',
whiteSpace: 'nowrap',
flexWrap: 'nowrap',
padding: '0 4px',
margin: '4px 0',
lineHeight: '1',
borderLeft: '1px solid rgba(125, 125, 125, 0.3)',
'&.ol-cm-toolbar-end': {
borderLeft: 'none',
},
'&.ol-cm-toolbar-stretch': {
flex: 1,
'.editor-toggle-switch + &': {
borderLeft: 'none', // avoid a left border when no toolbar buttons are shown
},
},
'&.overflow-hidden': {
borderLeft: 'none',
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: 'var(--border-radius-base)',
lineHeight: '1',
width: '24px',
height: '24px',
overflow: 'hidden',
color: 'inherit',
'&:hover, &:focus, &:active, &.active': {
backgroundColor: 'rgba(125, 125, 125, 0.1)',
color: 'inherit',
boxShadow: 'none',
'&[aria-disabled="true"]': {
opacity: '0.2',
},
},
'&.active, &:active': {
backgroundColor: 'rgba(125, 125, 125, 0.2)',
},
'&[aria-disabled="true"]': {
opacity: '0.2',
cursor: 'not-allowed',
},
'.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)',
},
'&[aria-disabled="true"]': {
opacity: 0.2,
},
},
'.ol-cm-toolbar-end': {
justifyContent: 'flex-end',
'& .badge': {
marginRight: '5px',
},
},
'.ol-cm-toolbar-overflow-toggle': {
display: 'none',
'&.ol-cm-toolbar-overflow-toggle-visible': {
display: 'flex',
},
},
'.ol-cm-toolbar-menu-toggle': {
background: 'transparent',
border: 'none',
color: 'inherit',
borderRadius: 'var(--border-radius-base)',
opacity: 0.8,
width: '120px',
fontSize: '13px',
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, & .popover-arrow': {
display: 'none',
},
'& .popover-content, & > .popover-body': {
padding: '0',
color: 'inherit',
},
'& .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',
color: 'inherit',
'&.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',
},
},
},
'&.overall-theme-dark .ol-cm-toolbar-table-grid': {
'& td.active': {
outlineColor: 'white',
background: 'rgb(125, 125, 125)',
},
},
'.ol-cm-toolbar-table-grid': {
borderCollapse: 'separate',
tableLayout: 'fixed',
fontSize: '6px',
cursor: 'pointer',
width: '160px',
'& td': {
outline: '1px solid #E7E9EE',
outlineOffset: '-2px',
width: '16px',
height: '16px',
'&.active': {
outlineColor: '#3265B2',
background: '#F1F4F9',
},
},
},
'.ol-cm-toolbar-table-size-label': {
maxWidth: '160px',
fontFamily: 'Lato, sans-serif',
fontSize: '12px',
},
'.ol-cm-toolbar-table-grid-popover': {
maxWidth: 'unset',
padding: '8px',
boxShadow: '0 5px 10px rgba(0, 0, 0, 0.2)',
borderRadius: '4px',
backgroundColor: 'var(--editor-toolbar-bg)',
pointerEvents: 'all',
color: 'var(--toolbar-btn-color)',
},
'.ol-cm-toolbar-button-menu-popover-unstyled': {
maxWidth: 'unset',
background: 'transparent',
border: 0,
padding: '0 8px 8px 160px',
boxShadow: 'none',
pointerEvents: 'none',
},
})
/**
* A panel which contains the editor toolbar, provided by a state field which allows the toolbar to be toggled,
* and styles for the toolbar.
*/
export const toolbarPanel = () => [toolbarState, toolbarTheme]

View File

@@ -0,0 +1,25 @@
import { EditorView } from '@codemirror/view'
import { sendMB } from '../../../../../infrastructure/event-tracking'
import { isVisual } from '../../visual/visual'
export function emitCommandEvent(
view: EditorView,
key: string,
command: string,
segmentation?: Record<string, string | number | boolean>
) {
const mode = isVisual(view) ? 'visual' : 'source'
sendMB(key, { command, mode, ...segmentation })
}
export function emitToolbarEvent(view: EditorView, command: string) {
emitCommandEvent(view, 'codemirror-toolbar-event', command)
}
export function emitShortcutEvent(
view: EditorView,
command: string,
segmentation?: Record<string, string | number | boolean>
) {
emitCommandEvent(view, 'codemirror-shortcut-event', command, segmentation)
}

View File

@@ -0,0 +1,98 @@
import {
EditorState,
RangeSet,
StateEffect,
StateField,
Transaction,
} from '@codemirror/state'
import {
findCommentsInCut,
findDetachedCommentsInChanges,
restoreCommentsOnPaste,
restoreDetachedComments,
StoredComment,
} from './changes/comments'
import { invertedEffects } from '@codemirror/commands'
import { DocumentContainer } from '@/features/ide-react/editor/document-container'
const restoreDetachedCommentsEffect = StateEffect.define<RangeSet<any>>({
map: (value, mapping) => {
return value
.update({
filter: (from, to) => {
return from <= mapping.length && to <= mapping.length
},
})
.map(mapping)
},
})
/**
* A custom extension that detects detached comments when a comment is cut and pasted,
* or when a deleted comment is undone
*/
export const trackDetachedComments = ({
currentDoc,
}: {
currentDoc: DocumentContainer
}) => {
// 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 null
}),
cutCommentsState,
]
}

View File

@@ -0,0 +1,251 @@
import {
Extension,
Facet,
StateEffect,
StateField,
TransactionSpec,
} from '@codemirror/state'
import {
Decoration,
EditorView,
ViewPlugin,
ViewUpdate,
WidgetType,
} from '@codemirror/view'
/**
* A custom extension which stores values for padding needed
* a) at the top and bottom of the editor, to match the height of the review panel, and
* b) at the bottom of the editor content, so the last line of the document can be scrolled to the top of the editor.
*/
export function verticalOverflow(): Extension {
return [
overflowPaddingState,
minimumBottomPaddingState,
bottomPadding,
topPadding,
contentAttributes,
topPaddingDecoration,
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)
}
},
}
})
const topPaddingFacet = Facet.define<number, number>({
combine(values) {
return Math.max(0, ...values)
},
})
const topPadding = topPaddingFacet.from(overflowPaddingState, state => {
return state.top
})
const bottomPaddingFacet = Facet.define<number, number>({
combine(values) {
return Math.max(0, ...values)
},
})
const bottomPadding = bottomPaddingFacet.computeN(
[overflowPaddingState, minimumBottomPaddingState],
state => {
return [
state.field(minimumBottomPaddingState),
state.field(overflowPaddingState).bottom,
]
}
)
// Set a style attribute on the contentDOM containing the calculated bottom padding.
// This value will be concatenated with style values from any other extensions.
const contentAttributes = EditorView.contentAttributes.compute(
[bottomPaddingFacet],
state => {
const bottom = state.facet(bottomPaddingFacet)
const style = `padding-bottom: ${bottom}px;`
return { style }
}
)
class TopPaddingWidget extends WidgetType {
constructor(private readonly height: number) {
super()
this.height = height
}
toDOM(view: EditorView): HTMLElement {
const element = document.createElement('div')
element.style.height = this.height + 'px'
return element
}
get estimatedHeight() {
return this.height
}
eq(widget: TopPaddingWidget) {
return this.height === widget.height
}
updateDOM(element: HTMLElement, view: EditorView): boolean {
element.style.height = this.height + 'px'
view.requestMeasure()
return true
}
}
const topPaddingDecoration = EditorView.decorations.compute(
[topPaddingFacet],
state => {
const top = state.facet(topPaddingFacet)
return Decoration.set([
Decoration.widget({
widget: new TopPaddingWidget(top),
block: true,
}).range(0),
])
}
)
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 updateChangesTopPadding(update: ViewUpdate): boolean {
return (
update.state.field(overflowPaddingState).top !==
update.startState.field(overflowPaddingState).top
)
}
export function editorVerticalTopPadding(view: EditorView): number {
return view.state.field(overflowPaddingState, false)?.top ?? 0
}
export function editorOverflowPadding(view: EditorView) {
return view.state.field(overflowPaddingState, false)
}

View File

@@ -0,0 +1,160 @@
import {
SelectionRange,
EditorSelection,
EditorState,
Transaction,
} from '@codemirror/state'
import { Command, EditorView } from '@codemirror/view'
const getNextLineBoundary = (
selection: SelectionRange,
forward: boolean,
view: EditorView,
includeWrappingCharacter = false
) => {
const newSelection = view.moveToLineBoundary(
EditorSelection.cursor(
selection.head,
1,
selection.bidiLevel || undefined,
selection.goalColumn
),
forward
)
// Adjust to be "before" the simulated line break
let offset = 0
if (
forward &&
!includeWrappingCharacter &&
view.lineBlockAt(selection.head).to !== newSelection.head
) {
offset = 1
}
return EditorSelection.cursor(
newSelection.head - offset,
selection.assoc,
selection.bidiLevel || undefined,
newSelection.goalColumn
)
}
const changeSelection = (
view: EditorView,
how: (selection: SelectionRange) => SelectionRange,
extend = false
) => {
view.dispatch({
selection: EditorSelection.create(
view.state.selection.ranges.map(start => {
const newSelection = how(start)
const anchor = extend ? start.anchor : newSelection.head
return EditorSelection.range(
anchor,
newSelection.head,
newSelection.goalColumn,
newSelection.bidiLevel || undefined
)
}),
view.state.selection.mainIndex
),
scrollIntoView: true,
userEvent: 'select',
})
}
export const cursorToEndOfVisualLine = (view: EditorView) =>
changeSelection(view, range => getNextLineBoundary(range, true, view), false)
export const selectToEndOfVisualLine = (view: EditorView) =>
changeSelection(view, range => getNextLineBoundary(range, true, view), true)
export const selectRestOfVisualLine = (view: EditorView) =>
changeSelection(
view,
range => getNextLineBoundary(range, true, view, true),
true
)
export const cursorToBeginningOfVisualLine = (view: EditorView) =>
changeSelection(view, range => getNextLineBoundary(range, false, view), false)
export const selectToBeginningOfVisualLine = (view: EditorView) =>
changeSelection(view, range => getNextLineBoundary(range, false, view), true)
export const deleteToVisualLineEnd: Command = view =>
deleteBy(view, pos => {
const lineEnd = getNextLineBoundary(
EditorSelection.cursor(pos),
true,
view,
true
).to
return pos < lineEnd ? lineEnd : Math.min(view.state.doc.length, pos + 1)
})
export const deleteToVisualLineStart: Command = view =>
deleteBy(view, pos => {
const lineStart = getNextLineBoundary(
EditorSelection.cursor(pos),
false,
view
).to
return pos > lineStart ? lineStart : Math.max(0, pos - 1)
})
/* eslint-disable */
/**
* The following definitions are from CodeMirror 6, licensed under the MIT license:
* https://github.com/codemirror/commands/blob/main/src/commands.ts
*/
type CommandTarget = { state: EditorState; dispatch: (tr: Transaction) => void }
function deleteBy(target: CommandTarget, by: (start: number) => number) {
if (target.state.readOnly) return false
let event = 'delete.selection',
{ state } = target
let changes = state.changeByRange(range => {
let { from, to } = range
if (from == to) {
let towards = by(from)
if (towards < from) {
event = 'delete.backward'
towards = skipAtomic(target, towards, false)
} else if (towards > from) {
event = 'delete.forward'
towards = skipAtomic(target, towards, true)
}
from = Math.min(from, towards)
to = Math.max(to, towards)
} else {
from = skipAtomic(target, from, false)
to = skipAtomic(target, to, true)
}
return from == to
? { range }
: { changes: { from, to }, range: EditorSelection.cursor(from) }
})
if (changes.changes.empty) return false
target.dispatch(
state.update(changes, {
scrollIntoView: true,
userEvent: event,
effects:
event == 'delete.selection'
? EditorView.announce.of(state.phrase('selection_deleted'))
: undefined,
})
)
return true
}
function skipAtomic(target: CommandTarget, pos: number, forward: boolean) {
if (target instanceof EditorView)
for (let ranges of target.state
.facet(EditorView.atomicRanges)
.map(f => f(target)))
ranges.between(pos, pos, (from, to) => {
if (from < pos && to > pos) pos = forward ? to : from
})
return pos
}

View File

@@ -0,0 +1,107 @@
// elements which should contain only block elements
const blockContainingElements = new Set([
'DL',
'FIELDSET',
'FIGURE',
'HEAD',
'OL',
'TABLE',
'TBODY',
'TFOOT',
'THEAD',
'TR',
'UL',
])
export const isBlockContainingElement = (node: Node): node is HTMLElement =>
blockContainingElements.has(node.nodeName)
// elements which are block elements (as opposed to inline elements)
const blockElements = new Set([
'ADDRESS',
'ARTICLE',
'ASIDE',
'BLOCKQUOTE',
'BODY',
'CANVAS',
'DD',
'DIV',
'DL',
'DT',
'FIELDSET',
'FIGCAPTION',
'FIGURE',
'FOOTER',
'FORM',
'H1',
'H2',
'H3',
'H4',
'H5',
'H6',
'HEADER',
'HGROUP',
'HR',
'LI',
'MAIN',
'NAV',
'NOSCRIPT',
'OL',
'P',
'PRE',
'SECTION',
'TABLE',
'TBODY',
'TD',
'TFOOT',
'TH',
'THEAD',
'TR',
'UL',
'VIDEO',
])
export const isBlockElement = (node: Node): node is HTMLElement =>
blockElements.has(node.nodeName)
const inlineElements = new Set([
'A',
'ABBR',
'ACRONYM',
'B',
'BIG',
'CITE',
'DEL',
'EM',
'I',
'INS',
'SMALL',
'SPAN',
'STRONG',
'SUB',
'SUP',
'TEXTAREA', // TODO
'TIME',
'TT',
])
export const isInlineElement = (node: Node): node is HTMLElement =>
inlineElements.has(node.nodeName)
const codeElements = new Set(['CODE', 'PRE'])
export const isCodeElement = (node: Node): node is HTMLElement =>
codeElements.has(node.nodeName)
const keepEmptyBlockElements = new Set(['TD', 'TH', 'CANVAS', 'DT', 'DD', 'HR'])
export const shouldRemoveEmptyBlockElement = (
node: Node
): node is HTMLElement =>
!keepEmptyBlockElements.has(node.nodeName) && !node.hasChildNodes()
export const isTextNode = (node: Node): node is Text =>
node.nodeType === Node.TEXT_NODE
export const isElementNode = (node: Node): node is HTMLElement =>
node.nodeType === Node.ELEMENT_NODE

View File

@@ -0,0 +1,79 @@
import {
EditorSelection,
EditorState,
SelectionRange,
Transaction,
} from '@codemirror/state'
import { syntaxTree } from '@codemirror/language'
import { SyntaxNode } from '@lezer/common'
/**
* A transaction filter which modifies a transaction if it places the cursor in front of a list item marker,
* to ensure that the cursor is positioned after the 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, index)
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,
index: number
) => {
let targetNode
if (node.type.is('Item')) {
targetNode = node
} else if (node.type.is('ItemCtrlSeq')) {
targetNode = node.parent
} else if (
node.type.is('Whitespace') &&
node.nextSibling?.type.is('Command')
) {
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
}
const previousHead = tr.startState.selection.ranges[index]?.head
// keyboard navigation
if (range.head < previousHead) {
// 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
}
}

View File

@@ -0,0 +1,307 @@
import {
Decoration,
DecorationSet,
ViewPlugin,
ViewUpdate,
} from '@codemirror/view'
import { EditorState, Range } from '@codemirror/state'
import { syntaxTree } from '@codemirror/language'
import { getUnstarredEnvironmentName } from '../../utils/tree-operations/environments'
import { centeringNodeForEnvironment } from '../../utils/tree-operations/figure'
import { parseTheoremStyles } from '../../utils/tree-operations/theorems'
import { Tree } from '@lezer/common'
import { parseColorArguments } from '../../utils/tree-operations/colors'
/**
* A view plugin that decorates ranges of text with Mark decorations.
* 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>[] = []
const theoremStyles = parseTheoremStyles(state, tree)
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') || nodeRef.type.is('Label')) {
const type = nodeRef.type.is('Caption') ? 'caption' : 'label'
// decorate caption and label lines with a class, for styling
const argument = nodeRef.node.getChild('$Argument')
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-${type}-line`,
}).range(line.from)
)
}
}
} else if (nodeRef.type.is('TextColorCommand')) {
const result = parseColorArguments(state, nodeRef.node)
if (result) {
const { color, from, to } = result
// decorate the content
decorations.push(
Decoration.mark({
class: 'ol-cm-textcolor',
inclusive: true,
attributes: {
style: `color: ${color}`,
},
}).range(from, to)
)
}
} else if (nodeRef.type.is('ColorBoxCommand')) {
const result = parseColorArguments(state, nodeRef.node)
if (result) {
const { color, from, to } = result
// decorate the content
decorations.push(
Decoration.mark({
class: 'ol-cm-colorbox',
inclusive: true,
attributes: {
style: `background-color: ${color}`,
},
}).range(from, to)
)
}
} else if (nodeRef.type.is('$Environment')) {
const environmentName = getUnstarredEnvironmentName(
nodeRef.node,
state
)
if (environmentName) {
switch (environmentName) {
case 'abstract':
case 'figure':
case 'table':
case 'verbatim':
case 'lstlisting':
{
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
case 'quote':
case 'quotation':
case 'quoting':
case 'displayquote':
{
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-quote-block',
'ol-cm-environment-line',
]
decorations.push(
Decoration.line({
class: classNames.join(' '),
}).range(line.from)
)
}
}
break
default:
if (theoremStyles.has(environmentName)) {
const theoremStyle = theoremStyles.get(environmentName)
if (theoremStyle) {
const lines = {
start: state.doc.lineAt(nodeRef.from),
end: state.doc.lineAt(nodeRef.to),
}
decorations.push(
Decoration.line({
class: [
`ol-cm-environment-theorem-${theoremStyle}`,
'ol-cm-environment-first-line',
].join(' '),
}).range(lines.start.from)
)
for (
let lineNumber = lines.start.number + 1;
lineNumber <= lines.end.number - 1;
lineNumber++
) {
const line = state.doc.line(lineNumber)
decorations.push(
Decoration.line({
class: [
`ol-cm-environment-theorem-${theoremStyle}`,
'ol-cm-environment-line',
].join(' '),
}).range(line.from)
)
}
decorations.push(
Decoration.line({
class: [
`ol-cm-environment-theorem-${theoremStyle}`,
'ol-cm-environment-last-line',
].join(' '),
}).range(lines.start.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
},
}
)

View File

@@ -0,0 +1,920 @@
import { EditorView } from '@codemirror/view'
import { Prec } from '@codemirror/state'
import {
insertPastedContent,
pastedContent,
storePastedContent,
} from './pasted-content'
import { debugConsole } from '@/utils/debugging'
import {
isBlockContainingElement,
isBlockElement,
isElementNode,
isInlineElement,
isTextNode,
shouldRemoveEmptyBlockElement,
} from './html-elements'
export const pasteHtml = [
Prec.highest(
EditorView.domEventHandlers({
paste(event, view) {
const { clipboardData } = event
if (!clipboardData) {
return false
}
// only handle pasted HTML
if (!clipboardData.types.includes('text/html')) {
return false
}
// ignore text/html from VS Code
if (
clipboardData.types.includes('application/vnd.code.copymetadata') ||
clipboardData.types.includes('vscode-editor-data')
) {
return false
}
const html = clipboardData.getData('text/html').trim()
const text = clipboardData.getData('text/plain').trim()
if (html.length === 0) {
return false
}
// convert the HTML to LaTeX
try {
const parser = new DOMParser()
const { documentElement } = parser.parseFromString(html, 'text/html')
// fall back to creating a figure when there's an image on the clipoard,
// unless the HTML indicates that it came from an Office application
// (which also puts an image on the clipboard)
if (
clipboardData.files.length > 0 &&
!hasProgId(documentElement) &&
!isOnlyTable(documentElement)
) {
return false
}
const bodyElement = documentElement.querySelector('body')
// DOMParser should always create a body element, so this is mostly for TypeScript
if (!bodyElement) {
return false
}
// if the only content is in a code block, use the plain text version
if (onlyCode(bodyElement)) {
return false
}
const latex = htmlToLaTeX(bodyElement)
// if there's no formatting, use the plain text version
if (latex === text && clipboardData.files.length === 0) {
return false
}
view.dispatch(insertPastedContent(view, { latex, text }))
view.dispatch(storePastedContent({ latex, text }, true))
return true
} catch (error) {
debugConsole.error(error)
// fall back to the default paste handler
return false
}
},
})
),
pastedContent,
]
const removeUnwantedElements = (
documentElement: HTMLElement,
selector: string
) => {
for (const element of documentElement.querySelectorAll(selector)) {
element.remove()
}
}
const findCodeContainingElement = (documentElement: HTMLElement) => {
let result: HTMLElement | null
// a code element
result = documentElement.querySelector<HTMLElement>('code')
if (result) {
return result
}
// a pre element with "monospace" somewhere in the font family
result = documentElement.querySelector<HTMLPreElement>('pre')
if (result?.style.fontFamily.includes('monospace')) {
return result
}
return null
}
// return true if the text content of the first <code> element
// is the same as the text content of the whole document element
const onlyCode = (documentElement: HTMLElement) => {
const codeElement = findCodeContainingElement(documentElement)
return (
codeElement?.textContent?.trim() === documentElement.textContent?.trim()
)
}
const hasProgId = (documentElement: HTMLElement) => {
const meta = documentElement.querySelector<HTMLMetaElement>(
'meta[name="ProgId"]'
)
return meta && meta.content.trim().length > 0
}
// detect a table (probably pasted from desktop Excel)
const isOnlyTable = (documentElement: HTMLElement) => {
const body = documentElement.querySelector<HTMLBodyElement>('body')
return (
body &&
body.childElementCount === 1 &&
body.firstElementChild!.nodeName === 'TABLE'
)
}
const htmlToLaTeX = (bodyElement: HTMLElement) => {
// remove style elements
removeUnwantedElements(bodyElement, 'style')
let before: string | null = null
let after: string | null = null
// repeat until the content stabilises
do {
before = bodyElement.textContent
// normalise whitespace in text
normaliseWhitespace(bodyElement)
// replace unwanted whitespace in blocks
processWhitespaceInBlocks(bodyElement)
after = bodyElement.textContent
} while (before !== after)
// pre-process table elements
processTables(bodyElement)
// pre-process lists
processLists(bodyElement)
// protect special characters in non-LaTeX text nodes
protectSpecialCharacters(bodyElement)
processMatchedElements(bodyElement)
const text = bodyElement.textContent
if (!text) {
return ''
}
return (
text
// remove zero-width spaces (e.g. those added by Powerpoint)
.replaceAll('', '')
// normalise multiple newlines
.replaceAll(/\n{2,}/g, '\n\n')
// only allow a single newline at the start and end
.replaceAll(/(^\n+|\n+$)/g, '\n')
// replace tab with 4 spaces (hard-coded indent unit)
.replaceAll('\t', ' ')
)
}
const trimInlineElements = (
element: HTMLElement,
precedingSpace = true
): boolean => {
for (const node of element.childNodes) {
if (isTextNode(node)) {
let text = node.textContent!
if (precedingSpace) {
text = text.replace(/^\s+/, '')
}
if (text === '') {
node.remove()
} else {
node.textContent = text
precedingSpace = /\s$/.test(text)
}
} else if (isInlineElement(node)) {
precedingSpace = trimInlineElements(node, precedingSpace)
} else if (isBlockElement(node)) {
precedingSpace = true // TODO
} else {
precedingSpace = false // TODO
}
}
// TODO: trim whitespace at the end
return precedingSpace
}
const processWhitespaceInBlocks = (documentElement: HTMLElement) => {
trimInlineElements(documentElement)
const walker = document.createTreeWalker(
documentElement,
NodeFilter.SHOW_ELEMENT,
node =>
isElementNode(node) && isElementContainingCode(node)
? NodeFilter.FILTER_REJECT
: NodeFilter.FILTER_ACCEPT
)
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
// TODO: remove leading newline from pre, code and textarea?
if (isBlockContainingElement(node)) {
// remove all text nodes directly inside elements that should only contain blocks
for (const childNode of node.childNodes) {
if (isTextNode(childNode)) {
childNode.remove()
}
}
}
if (isBlockElement(node)) {
trimInlineElements(node)
if (shouldRemoveEmptyBlockElement(node)) {
node.remove()
// TODO: and parents?
}
}
}
}
const normaliseWhitespace = (documentElement: HTMLElement) => {
const walker = document.createTreeWalker(
documentElement,
NodeFilter.SHOW_TEXT,
node =>
isElementNode(node) && isElementContainingCode(node)
? NodeFilter.FILTER_REJECT
: NodeFilter.FILTER_ACCEPT
)
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
const text = node.textContent
if (text !== null) {
if (/^\s+$/.test(text)) {
// replace nodes containing only whitespace (including non-breaking space) with a single space
node.textContent = ' '
} else {
// collapse contiguous whitespace (except for non-breaking space) to a single space
node.textContent = text.replaceAll(/[\n\r\f\t \u2028\u2029]+/g, ' ')
}
}
}
}
// TODO: negative lookbehind once Safari supports it
const specialCharacterRegExp = /(^|[^\\])([#$%&~_^\\{}])/g
const specialCharacterReplacer = (
_match: string,
prefix: string,
char: string
) => {
if (char === '\\') {
// convert `\` to `\textbackslash{}`, preserving subsequent whitespace
char = 'textbackslash{}'
}
return `${prefix}\\${char}`
}
const isElementContainingCode = (element: HTMLElement) =>
element.nodeName === 'CODE' ||
(element.nodeName === 'PRE' && element.style.fontFamily.includes('monospace'))
const protectSpecialCharacters = (documentElement: HTMLElement) => {
const walker = document.createTreeWalker(
documentElement,
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
node =>
isElementNode(node) && isElementContainingCode(node)
? NodeFilter.FILTER_REJECT
: NodeFilter.FILTER_ACCEPT
)
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
if (isTextNode(node)) {
const text = node.textContent
if (text) {
// replace non-backslash-prefixed characters
node.textContent = text.replaceAll(
specialCharacterRegExp,
specialCharacterReplacer
)
}
}
}
}
const processMatchedElements = (documentElement: HTMLElement) => {
for (const item of selectors) {
for (const element of documentElement.querySelectorAll<any>(
item.selector
)) {
if (!item.match || item.match(element)) {
// start the markup
if (item.start) {
const start = document.createTextNode(item.start(element))
if (item.inside) {
element.prepend(start)
} else {
element.before(start)
}
}
// end the markup
if (item.end) {
const end = document.createTextNode(item.end(element))
if (item.inside) {
element.append(end)
} else {
element.after(end)
}
}
}
}
}
}
const matchingParents = (element: HTMLElement, selector: string) => {
const matches = []
for (
let ancestor = element.parentElement?.closest(selector);
ancestor;
ancestor = ancestor.parentElement?.closest(selector)
) {
matches.push(ancestor)
}
return matches
}
const urlCharacterReplacements = new Map<string, string>([
['\\', '\\\\'],
['#', '\\#'],
['%', '\\%'],
['{', '%7B'],
['}', '%7D'],
])
const protectUrlCharacters = (url: string) => {
// NOTE: add new characters to both this regex and urlCharacterReplacements
return url.replaceAll(/[\\#%{}]/g, match => {
const replacement = urlCharacterReplacements.get(match)
if (!replacement) {
throw new Error(`No replacement found for ${match}`)
}
return replacement
})
}
const processLists = (element: HTMLElement) => {
for (const list of element.querySelectorAll('ol,ul')) {
// if the list has only one item, replace the list with an element containing the contents of the item
if (list.childElementCount === 1) {
const div = document.createElement('div')
div.append(...list.firstElementChild!.childNodes)
list.before('\n', div, '\n')
list.remove()
}
}
}
const processTables = (element: HTMLElement) => {
for (const table of element.querySelectorAll('table')) {
// create a wrapper element for the table and the caption
const container = document.createElement('div')
container.className = 'ol-table-wrap'
table.after(container)
// move the caption (if it exists) into the container before the table
const caption = table.querySelector('caption')
if (caption) {
container.append(caption)
}
// move the table into the container
container.append(table)
// add empty cells to account for rowspan
for (const cell of table.querySelectorAll<HTMLTableCellElement>(
'th[rowspan],td[rowspan]'
)) {
const rowspan = Number(cell.getAttribute('rowspan') || '1')
const colspan = Number(cell.getAttribute('colspan') || '1')
let row: HTMLTableRowElement | null = cell.closest('tr')
if (row) {
let position = 0
for (const child of row.cells) {
if (child === cell) {
break
}
position += Number(child.getAttribute('colspan') || '1')
}
for (let i = 1; i < rowspan; i++) {
const nextElement: Element | null = row?.nextElementSibling
if (!isTableRow(nextElement)) {
break
}
row = nextElement
let targetCell: HTMLTableCellElement | undefined
let targetPosition = 0
for (const child of row.cells) {
if (targetPosition === position) {
targetCell = child
break
}
targetPosition += Number(child.getAttribute('colspan') || '1')
}
const fillerCells = Array.from({ length: colspan }, () =>
document.createElement('td')
)
if (targetCell) {
targetCell.before(...fillerCells)
} else {
row.append(...fillerCells)
}
}
}
}
}
}
const isTableRow = (element: Element | null): element is HTMLTableRowElement =>
element?.nodeName === 'TR'
const cellAlignment = new Map([
['left', 'l'],
['center', 'c'],
['right', 'r'],
])
const tabular = (element: HTMLTableElement) => {
const definitions: Array<{
alignment: string
borderLeft: boolean
borderRight: boolean
}> = []
const rows = element.querySelectorAll('tr')
for (const row of rows) {
const cells = [...row.childNodes].filter(
element => element.nodeName === 'TD' || element.nodeName === 'TH'
) as Array<HTMLTableCellElement>
let index = 0
for (const cell of cells) {
// NOTE: reading the alignment and borders from the first cell definition in each column
if (definitions[index] === undefined) {
const { textAlign, borderLeftStyle, borderRightStyle } = cell.style
definitions[index] = {
alignment: textAlign,
borderLeft: visibleBorderStyle(borderLeftStyle),
borderRight: visibleBorderStyle(borderRightStyle),
}
}
index += Number(cell.getAttribute('colspan') ?? 1)
}
}
for (let index = 0; index <= definitions.length; index++) {
// fill in missing definitions
const item = definitions[index] || {
alignment: 'left',
borderLeft: false,
borderRight: false,
}
// remove left border if previous column had a right border
if (item.borderLeft && index > 0 && definitions[index - 1]?.borderRight) {
item.borderLeft = false
}
}
return definitions
.flatMap(definition => [
definition.borderLeft ? '|' : '',
cellAlignment.get(definition.alignment) ?? 'l',
definition.borderRight ? '|' : '',
])
.filter(Boolean)
.join(' ')
}
const listDepth = (element: HTMLElement): number =>
Math.max(0, matchingParents(element, 'ul,ol').length)
const indentUnit = ' ' // TODO: replace hard-coded indent unit?
const listIndent = (element: HTMLElement | null): string =>
element ? indentUnit.repeat(listDepth(element)) : ''
type ElementSelector<T extends string, E extends HTMLElement = HTMLElement> = {
selector: T
match?: (element: E) => boolean
start?: (element: E) => string
end?: (element: E) => string
inside?: boolean
}
const createSelector = <
T extends string,
E extends HTMLElement = T extends keyof HTMLElementTagNameMap
? HTMLElementTagNameMap[T]
: HTMLElement,
>({
selector,
...elementSelector
}: ElementSelector<T, E>) => ({
selector,
...elementSelector,
})
const headings = ['H1', 'H2', 'H3', 'H4', 'H5', 'H6']
const isHeading = (element: HTMLElement | null) => {
return element && headings.includes(element.nodeName)
}
const hasContent = (element: HTMLElement): boolean => {
return Boolean(element.textContent && element.textContent.trim().length > 0)
}
type BorderStyle =
| 'borderTopStyle'
| 'borderRightStyle'
| 'borderBottomStyle'
| 'borderLeftStyle'
const visibleBorderStyle = (style: CSSStyleDeclaration[BorderStyle]): boolean =>
!!style && style !== 'none' && style !== 'hidden'
const rowHasBorderStyle = (
element: HTMLTableRowElement,
style: BorderStyle
): boolean => {
if (visibleBorderStyle(element.style[style])) {
return true
}
const cells = element.querySelectorAll<HTMLTableCellElement>('th,td')
return [...cells].every(cell => visibleBorderStyle(cell.style[style]))
}
const isTableRowElement = (
element: Element | null
): element is HTMLTableRowElement => element?.nodeName === 'TR'
const nextRowHasBorderStyle = (
element: HTMLTableRowElement,
style: BorderStyle
) => {
const { nextElementSibling } = element
return (
isTableRowElement(nextElementSibling) &&
rowHasBorderStyle(nextElementSibling, style)
)
}
const startMulticolumn = (element: HTMLTableCellElement): string => {
const colspan = Number(element.getAttribute('colspan') || 1)
const alignment = cellAlignment.get(element.style.textAlign) ?? 'l'
return `\\multicolumn{${colspan}}{${alignment}}{`
}
const startMultirow = (element: HTMLTableCellElement): string => {
const rowspan = Number(element.getAttribute('rowspan') || 1)
// NOTE: it would be useful to read cell width if specified, using `*` as a starting point
return `\\multirow{${rowspan}}{*}{`
}
const listPrefix = (element: HTMLOListElement | HTMLUListElement) => {
if (isListOrListItemElement(element.parentElement)) {
// within a list = newline
return '\n'
}
// outside a list = double newline
return '\n\n'
}
const listSuffix = (element: HTMLOListElement | HTMLUListElement) => {
if (listDepth(element) === 0) {
// a top-level list => newline
return '\n'
} else {
// a nested list => no extra newline
return ''
}
}
const isListElement = (
element: Element | null
): element is HTMLOListElement | HTMLUListElement =>
element !== null && listNodeNames.includes(element.nodeName)
const isListOrListItemElement = (
element: Element | null
): element is HTMLOListElement | HTMLUListElement =>
element !== null && (isListElement(element) || element.nodeName === 'LI')
const listNodeNames = ['OL', 'UL']
const selectors = [
createSelector({
selector: 'b',
match: element =>
!element.style.fontWeight &&
!isHeading(element.parentElement) &&
hasContent(element),
start: () => '\\textbf{',
end: () => '}',
}),
createSelector({
selector: '*',
match: element =>
(element.style.fontWeight === 'bold' ||
parseInt(element.style.fontWeight) >= 700) &&
hasContent(element),
start: () => '\\textbf{',
end: () => '}',
inside: true,
}),
createSelector({
selector: 'strong',
match: element => !element.style.fontWeight && hasContent(element),
start: () => '\\textbf{',
end: () => '}',
}),
createSelector({
selector: 'i',
match: element => !element.style.fontStyle && hasContent(element),
start: () => '\\textit{',
end: () => '}',
}),
createSelector({
selector: '*',
match: element =>
element.style.fontStyle === 'italic' && hasContent(element),
start: () => '\\textit{',
end: () => '}',
inside: true,
}),
createSelector({
selector: 'em',
match: element => !element.style.fontStyle && hasContent(element),
start: () => '\\textit{',
end: () => '}',
}),
createSelector({
selector: 'sup',
match: element => !element.style.verticalAlign && hasContent(element),
start: () => '\\textsuperscript{',
end: () => '}',
}),
createSelector({
selector: 'span',
match: element =>
element.style.verticalAlign === 'super' && hasContent(element),
start: () => '\\textsuperscript{',
end: () => '}',
}),
createSelector({
selector: 'sub',
match: element => !element.style.verticalAlign && hasContent(element),
start: () => '\\textsubscript{',
end: () => '}',
}),
createSelector({
selector: 'span',
match: element =>
element.style.verticalAlign === 'sub' && hasContent(element),
start: () => '\\textsubscript{',
end: () => '}',
}),
createSelector({
selector: 'a',
match: element => !!element.href && hasContent(element),
start: (element: HTMLAnchorElement) => {
const url = protectUrlCharacters(element.href)
return `\\href{${url}}{`
},
end: () => `}`,
}),
createSelector({
selector: 'h1',
match: element => !element.closest('table') && hasContent(element),
start: () => `\n\n\\section{`,
end: () => `}\n\n`,
}),
createSelector({
selector: 'h2',
match: element => !element.closest('table') && hasContent(element),
start: () => `\n\n\\subsection{`,
end: () => `}\n\n`,
}),
createSelector({
selector: 'h3',
match: element => !element.closest('table') && hasContent(element),
start: () => `\n\n\\subsubsection{`,
end: () => `}\n\n`,
}),
createSelector({
selector: 'h4',
match: element => !element.closest('table') && hasContent(element),
start: () => `\n\n\\paragraph{`,
end: () => `}\n\n`,
}),
createSelector({
selector: 'h5',
match: element => !element.closest('table') && hasContent(element),
start: () => `\n\n\\subparagraph{`,
end: () => `}\n\n`,
}),
// TODO: h6?
createSelector({
selector: 'br',
match: element => !element.closest('table'),
start: () => `\n\n`,
}),
createSelector({
selector: 'code',
match: element =>
element.parentElement?.nodeName !== 'PRE' && hasContent(element),
start: () => `\\verb|`,
end: () => `|`,
}),
createSelector({
selector: 'pre > code',
match: element => hasContent(element),
start: () => `\n\n\\begin{verbatim}\n`,
end: () => `\n\\end{verbatim}\n\n`,
}),
createSelector({
selector: 'pre',
match: element =>
element.style.fontFamily.includes('monospace') &&
element.firstElementChild?.nodeName !== 'CODE' &&
hasContent(element),
start: () => `\n\n\\begin{verbatim}\n`,
end: () => `\n\\end{verbatim}\n\n`,
}),
createSelector({
selector: '.ol-table-wrap',
start: () => `\n\n\\begin{table}\n\\centering\n`,
end: () => `\n\\end{table}\n\n`,
}),
createSelector({
selector: 'table',
start: element => `\n\\begin{tabular}{${tabular(element)}}`,
end: () => `\\end{tabular}\n`,
}),
createSelector({
selector: 'thead',
start: () => `\n`,
end: () => `\n`,
}),
createSelector({
selector: 'tfoot',
start: () => `\n`,
end: () => `\n`,
}),
createSelector({
selector: 'tbody',
start: () => `\n`,
end: () => `\n`,
}),
createSelector({
selector: 'tr',
start: element => {
const borderTop = rowHasBorderStyle(element, 'borderTopStyle')
return borderTop ? '\\hline\n' : ''
},
end: element => {
const borderBottom = rowHasBorderStyle(element, 'borderBottomStyle')
return borderBottom && !nextRowHasBorderStyle(element, 'borderTopStyle')
? '\n\\hline\n'
: '\n'
},
}),
createSelector({
selector: 'tr > td, tr > th',
start: (element: HTMLTableCellElement) => {
let output = ''
const colspan = element.getAttribute('colspan')
if (colspan && Number(colspan) > 1) {
output += startMulticolumn(element)
}
// NOTE: multirow is nested inside multicolumn
const rowspan = element.getAttribute('rowspan')
if (rowspan && Number(rowspan) > 1) {
output += startMultirow(element)
}
return output
},
end: element => {
let output = ''
// NOTE: multirow is nested inside multicolumn
const rowspan = element.getAttribute('rowspan')
if (rowspan && Number(rowspan) > 1) {
output += '}'
}
const colspan = element.getAttribute('colspan')
if (colspan && Number(colspan) > 1) {
output += '}'
}
const row = element.parentElement as HTMLTableRowElement
const isLastChild = row.cells.item(row.cells.length - 1) === element
return output + (isLastChild ? ' \\\\' : ' & ')
},
}),
createSelector({
selector: 'caption',
start: () => `\n\n\\caption{`,
end: () => `}\n\n`,
}),
createSelector({
// selector: 'ul:has(> li:nth-child(2))', // only select lists with at least 2 items (once Firefox supports :has())
selector: 'ul',
start: element => {
return `${listPrefix(element)}${listIndent(element)}\\begin{itemize}`
},
end: element => {
return `\n${listIndent(element)}\\end{itemize}${listSuffix(element)}`
},
}),
createSelector({
// selector: 'ol:has(> li:nth-child(2))', // only select lists with at least 2 items (once Firefox supports :has())
selector: 'ol',
start: element => {
return `${listPrefix(element)}${listIndent(element)}\\begin{enumerate}`
},
end: element => {
return `\n${listIndent(element)}\\end{enumerate}${listSuffix(element)}`
},
}),
createSelector({
selector: 'li',
start: element => {
return `\n${listIndent(element.parentElement)}${indentUnit}\\item `
},
}),
createSelector({
selector: 'p',
match: element => {
// must have content
if (!hasContent(element)) {
return false
}
// inside lists and tables, must precede another paragraph
if (element.closest('li') || element.closest('table')) {
return element.nextElementSibling?.nodeName === 'P'
}
return true
},
end: () => '\n\n',
}),
createSelector({
selector: 'blockquote',
start: () => `\n\n\\begin{quote}\n`,
end: () => `\n\\end{quote}\n\n`,
}),
]

View File

@@ -0,0 +1,210 @@
import {
EditorSelection,
Range,
StateEffect,
StateField,
} from '@codemirror/state'
import { Decoration, EditorView, WidgetType } from '@codemirror/view'
import { undo } from '@codemirror/commands'
import { ancestorNodeOfType } from '../../utils/tree-operations/ancestors'
import ReactDOM from 'react-dom'
import { PastedContentMenu } from '../../components/paste-html/pasted-content-menu'
import { SplitTestProvider } from '../../../../shared/context/split-test-context'
export type PastedContent = { latex: string; text: string }
const pastedContentEffect = StateEffect.define<{
content: PastedContent
formatted: boolean
}>()
export const insertPastedContent = (
view: EditorView,
{ latex, text }: PastedContent
) =>
view.state.changeByRange(range => {
// avoid pasting formatted content into a math container
if (ancestorNodeOfType(view.state, range.anchor, '$MathContainer')) {
return {
range: EditorSelection.cursor(range.from + text.length),
changes: { from: range.from, to: range.to, insert: text },
}
}
return {
range: EditorSelection.cursor(range.from + latex.length),
changes: { from: range.from, to: range.to, insert: latex },
}
})
export const storePastedContent = (
content: PastedContent,
formatted: boolean
) => ({
effects: pastedContentEffect.of({ content, formatted }),
})
const pastedContentTheme = EditorView.baseTheme({
'.ol-cm-pasted-content-menu-toggle': {
background: 'none',
borderRadius: '8px',
border: '1px solid rgb(125, 125, 125)',
color: 'inherit',
margin: '0 4px',
opacity: '0.7',
'&:hover': {
opacity: '1',
},
'& .material-symbols': {
verticalAlign: 'text-bottom',
},
},
'.ol-cm-pasted-content-menu-popover': {
backgroundColor: '#fff',
maxWidth: 'unset',
'& .popover-content': {
padding: 0,
},
'& .popover-body': {
color: 'inherit',
padding: 0,
},
'& .popover-arrow::after': {
borderBottomColor: '#fff',
},
},
'&dark .ol-cm-pasted-content-menu-popover': {
background: 'rgba(0, 0, 0)',
},
'.ol-cm-pasted-content-menu': {
display: 'flex',
flexDirection: 'column',
boxSizing: 'border-box',
fontSize: '14px',
fontFamily: 'var(--font-sans)',
},
'.ol-cm-pasted-content-menu-item': {
color: 'inherit',
border: 'none',
background: 'none',
padding: '8px 16px',
width: '100%',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
whiteSpace: 'nowrap',
gap: '12px',
'&[aria-disabled="true"]': {
color: 'rgba(125, 125, 125, 0.5)',
},
'&:hover': {
backgroundColor: 'rgba(125, 125, 125, 0.2)',
},
},
'.ol-cm-pasted-content-menu-item-label': {
flex: 1,
textAlign: 'left',
},
'.ol-cm-pasted-content-menu-item-shortcut': {
textAlign: 'right',
},
})
export const pastedContent = StateField.define<{
content: PastedContent
formatted: boolean
selection: EditorSelection
} | null>({
create() {
return null
},
update(value, tr) {
if (tr.docChanged) {
// TODO: exclude remote changes (if they don't intersect with changed ranges)?
value = null
} else {
for (const effect of tr.effects) {
if (effect.is(pastedContentEffect)) {
value = {
...effect.value,
selection: tr.state.selection,
}
}
}
}
return value
},
provide(field) {
return [
EditorView.decorations.compute([field], state => {
const value = state.field(field)
if (!value) {
return Decoration.none
}
const decorations: Range<Decoration>[] = []
const { content, selection, formatted } = value
decorations.push(
Decoration.widget({
widget: new PastedContentMenuWidget(content, formatted),
side: 1,
}).range(selection.main.to)
)
return Decoration.set(decorations, true)
}),
pastedContentTheme,
]
},
})
class PastedContentMenuWidget extends WidgetType {
constructor(
private pastedContent: PastedContent,
private formatted: boolean
) {
super()
}
toDOM(view: EditorView) {
const element = document.createElement('span')
ReactDOM.render(
<SplitTestProvider>
<PastedContentMenu
insertPastedContent={this.insertPastedContent}
view={view}
formatted={this.formatted}
pastedContent={this.pastedContent}
/>
</SplitTestProvider>,
element
)
return element
}
insertPastedContent(
view: EditorView,
pastedContent: PastedContent,
formatted: boolean
) {
undo(view)
view.dispatch(
insertPastedContent(view, {
latex: formatted ? pastedContent.latex : pastedContent.text,
text: pastedContent.text,
})
)
view.dispatch(storePastedContent(pastedContent, formatted))
view.focus()
}
eq(widget: PastedContentMenuWidget) {
return (
widget.pastedContent === this.pastedContent &&
widget.formatted === this.formatted
)
}
}

View File

@@ -0,0 +1,113 @@
import {
EditorSelection,
EditorState,
SelectionRange,
StateField,
} from '@codemirror/state'
import { syntaxTree } from '@codemirror/language'
import { Tree } from '@lezer/common'
import {
ancestorOfNodeWithType,
descendantsOfNodeWithType,
} from '../../utils/tree-operations/ancestors'
import { getMousedownSelection, selectionIntersects } from './selection'
import { DecorationSet } from '@codemirror/view'
/**
* A custom extension that updates the selection in a transaction if the mouse pointer was used
* to position a cursor at the start or end of an argument (the cursor is placed inside the brace),
* or to drag a range across the whole range of an argument (the selection is placed inside the braces),
* when the selection was not already inside the command.
*/
export const selectDecoratedArgument = (
field: StateField<{ decorations: DecorationSet }>
) =>
EditorState.transactionFilter.of(tr => {
if (tr.selection && tr.isUserEvent('select.pointer')) {
const tree = syntaxTree(tr.state)
let selection = tr.selection
const mousedownSelection = getMousedownSelection(tr.state)
let replaced = false
const rangeSet = tr.state.field(field, false)?.decorations
for (const [index, range] of selection.ranges.entries()) {
if (rangeSet) {
let isAtomicRange = false
rangeSet.between(range.anchor, range.anchor, (_from, to) => {
if (to > range.anchor) {
isAtomicRange = true
return false
}
})
if (isAtomicRange === false) {
// skip since decoration is not covering the selection
continue
}
}
const replacementRange =
selectArgument(tree, range, mousedownSelection, 1) ||
selectArgument(tree, range, mousedownSelection, -1)
if (replacementRange) {
selection = selection.replaceRange(replacementRange, index)
replaced = true
}
}
if (replaced) {
return [tr, { selection }]
}
}
return tr
})
const selectArgument = (
tree: Tree,
range: SelectionRange,
mousedownSelection: EditorSelection | undefined,
side: -1 | 1
): SelectionRange | undefined => {
const anchor = tree.resolveInner(range.anchor, side)
const ancestorCommand = ancestorOfNodeWithType(anchor, '$Command')
if (!ancestorCommand) {
return
}
const mousedownSelectionInside =
mousedownSelection !== undefined &&
selectionIntersects(mousedownSelection, ancestorCommand)
if (mousedownSelectionInside) {
return
}
const [inner] = descendantsOfNodeWithType(ancestorCommand, '$TextArgument')
if (!inner) {
return
}
if (side === 1) {
if (
range.anchor === inner.from + 1 ||
range.anchor === ancestorCommand.from
) {
if (range.empty) {
// selecting at the start
return EditorSelection.cursor(inner.from + 1)
} else if (Math.abs(range.head - inner.to) < 2) {
// selecting from the start to the end
return EditorSelection.range(inner.from + 1, inner.to - 1)
}
}
} else {
if (range.anchor === inner.to - 1 || range.anchor === ancestorCommand.to) {
if (range.empty) {
// selecting at the end
return EditorSelection.cursor(inner.to - 1)
} else if (Math.abs(range.head - ancestorCommand.from) < 2) {
// selecting from the end to the start
return EditorSelection.range(inner.to - 1, inner.from + 1)
}
}
}
}

View File

@@ -0,0 +1,143 @@
import {
EditorSelection,
StateEffect,
Line,
Text,
StateField,
EditorState,
} 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,
limit: number = Number.POSITIVE_INFINITY
) => {
let { number, from } = line
for (
let lineNumber = number - 1;
lineNumber > 0 && number - lineNumber <= limit;
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,
limit: number = Number.POSITIVE_INFINITY
) => {
let { number, to } = line
for (
let lineNumber = number + 1;
lineNumber <= doc.lines && lineNumber - number <= limit;
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)
/**
* A listener for mousedown and mouseup events, dispatching an event
* to record the current mousedown status, which is stored in a state field.
*/
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),
})
})
},
contextmenu: (event: MouseEvent, view) => {
// treat a `contextmenu` event as a `mouseup` event, which isn't fired
window.setTimeout(() => {
view.dispatch({
effects: mouseDownEffect.of(false),
})
})
},
drop: (event: MouseEvent, view) => {
// treat a `drop` event as a `mouseup` event, which isn't fired
window.setTimeout(() => {
view.dispatch({
effects: mouseDownEffect.of(false),
})
})
},
})
const mousedownSelectionState = StateField.define<EditorSelection | undefined>({
create() {
return undefined
},
update(value, tr) {
if (value && tr.docChanged) {
value = value.map(tr.changes)
}
for (const effect of tr.effects) {
// store the previous selection on mousedown
if (effect.is(mouseDownEffect)) {
value = effect.value ? tr.startState.selection : undefined
}
}
return value
},
})
export const getMousedownSelection = (state: EditorState) =>
state.field(mousedownSelectionState)
export const mousedown = [mouseDownListener, mousedownSelectionState]

View File

@@ -0,0 +1,124 @@
import { DecorationSet, EditorView, ViewPlugin } from '@codemirror/view'
import {
EditorSelection,
EditorState,
RangeSet,
StateField,
} from '@codemirror/state'
import { syntaxTree } from '@codemirror/language'
import { collapsePreambleEffect, Preamble } from './visual-widgets/preamble'
/**
* A view plugin that moves the cursor from the start of the preamble into the document body when the doc is opened.
*/
export const skipPreambleWithCursor = (
field: StateField<{ preamble: Preamble; decorations: DecorationSet }>
) =>
ViewPlugin.define((view: EditorView) => {
let checkedOnce = false
const escapeFromAtomicRanges = (
selection: EditorSelection,
force = false
) => {
const originalSelection = selection
const atomicRangeSets = view.state
.facet(EditorView.atomicRanges)
.map(item => item(view))
for (const [index, range] of selection.ranges.entries()) {
const anchor = skipAtomicRanges(
view.state,
atomicRangeSets,
range.anchor
)
const head = skipAtomicRanges(view.state, atomicRangeSets, range.head)
if (anchor !== range.anchor || head !== range.head) {
selection = selection.replaceRange(
EditorSelection.range(anchor, head),
index
)
}
}
if (force || selection !== originalSelection) {
// TODO: needs to happen after cursor position is restored?
window.setTimeout(() => {
view.dispatch({
selection,
scrollIntoView: true,
})
})
}
}
const escapeFromPreamble = () => {
const preamble = view.state.field(field, false)?.preamble
if (preamble) {
escapeFromAtomicRanges(
EditorSelection.create([EditorSelection.cursor(preamble.to + 1)]),
true
)
}
}
return {
update(update) {
if (checkedOnce) {
if (
update.transactions.some(tr =>
tr.effects.some(effect => effect.is(collapsePreambleEffect))
)
) {
escapeFromPreamble()
}
} else {
const { state } = update
if (syntaxTree(state).length === 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 (
state.selection.eq(
EditorSelection.create([EditorSelection.cursor(0)])
)
) {
escapeFromPreamble()
} else {
escapeFromAtomicRanges(state.selection)
}
}
}
},
}
})
const skipAtomicRanges = (
state: EditorState,
rangeSets: RangeSet<any>[],
pos: number
) => {
let oldPos
do {
oldPos = pos
for (const rangeSet of rangeSets) {
rangeSet.between(pos, pos, (_from, to) => {
if (to > pos) {
pos = to
}
})
}
// move from the end of a line to the start of the next line
if (pos !== oldPos && state.doc.lineAt(pos).to === pos) {
pos++
}
} while (pos !== oldPos)
return Math.min(pos, state.doc.length)
}

View File

@@ -0,0 +1,533 @@
import { EditorView } from '@codemirror/view'
export const tableGeneratorTheme = EditorView.baseTheme({
'&dark .table-generator': {
'--table-generator-active-border-color': '#ccc',
'--table-generator-coming-soon-background-color': '#41464f',
'--table-generator-coming-soon-color': '#fff',
'--table-generator-divider-color': 'rgba(125,125,125,0.3)',
'--table-generator-dropdown-divider-color': 'rgba(125,125,125,0.3)',
'--table-generator-focus-border-color': '#5d7498',
'--table-generator-inactive-border-color': '#888',
'--table-generator-selected-background-color': '#ffffff2a',
'--table-generator-selector-background-color': '#777',
'--table-generator-selector-hover-color': '#3265b2',
'--table-generator-toolbar-background': '#2c3645',
'--table-generator-toolbar-button-active-background':
'rgba(125, 125, 125, 0.4)',
'--table-generator-toolbar-button-color': '#fff',
'--table-generator-toolbar-button-hover-background':
'rgba(125, 125, 125, 0.2)',
'--table-generator-toolbar-dropdown-border-color': 'rgba(125,125,125,0.3)',
'--table-generator-toolbar-dropdown-disabled-background':
'rgba(125,125,125,0.3)',
'--table-generator-toolbar-dropdown-disabled-color': '#999',
'--table-generator-toolbar-dropdown-active-background': 'var(--green-10)',
'--table-generator-toolbar-dropdown-active-color': 'var(--green-70)',
'--table-generator-toolbar-dropdown-active-hover-background':
'var(--green-10)',
'--table-generator-toolbar-dropdown-active-active-background':
'var(--green-20)',
'--table-generator-toolbar-shadow-color': '#1e253029',
'--table-generator-error-background': '#2c3645',
'--table-generator-error-color': '#fff',
'--table-generator-error-border-color': '#677283',
'--table-generator-column-size-indicator-background': 'var(--neutral-80)',
'--table-generator-column-size-indicator-hover-background':
'var(--neutral-70)',
'--table-generator-column-size-indicator-color': 'white',
'--table-generator-column-size-indicator-hover-color': 'white',
},
'&light .table-generator': {
'--table-generator-active-border-color': '#666',
'--table-generator-coming-soon-background-color': 'var(--neutral-10)',
'--table-generator-coming-soon-color': 'var(--neutral-70)',
'--table-generator-divider-color': 'var(--neutral-20)',
'--table-generator-dropdown-divider-color': 'var(--neutral-20)',
'--table-generator-focus-border-color': '#97b6e5',
'--table-generator-inactive-border-color': '#dedede',
'--table-generator-selected-background-color': 'var(--blue-10)',
'--table-generator-selector-background-color': 'var(--neutral-30)',
'--table-generator-selector-hover-color': '#3265b2',
'--table-generator-toolbar-background': '#fff',
'--table-generator-toolbar-button-active-background':
'rgba(47, 58, 76, 0.16)',
'--table-generator-toolbar-button-color': 'var(--neutral-70)',
'--table-generator-toolbar-button-hover-background':
'rgba(47, 58, 76, 0.08)',
'--table-generator-toolbar-dropdown-border-color': 'var(--neutral-60)',
'--table-generator-toolbar-dropdown-disabled-background': '#f2f2f2',
'--table-generator-toolbar-dropdown-disabled-color': 'var(--neutral-40)',
'--table-generator-toolbar-dropdown-active-background': 'var(--green-10)',
'--table-generator-toolbar-dropdown-active-color': 'var(--green-70)',
'--table-generator-toolbar-dropdown-active-hover-background':
'var(--green-10)',
'--table-generator-toolbar-dropdown-active-active-background':
'var(--green-20)',
'--table-generator-toolbar-shadow-color': '#1e253029',
'--table-generator-error-background': '#F1F4F9',
'--table-generator-error-color': 'black',
'--table-generator-error-border-color': '#C3D0E3',
'--table-generator-column-size-indicator-background': '#E7E9EE',
'--table-generator-column-size-indicator-hover-background': '#D7DADF',
'--table-generator-column-size-indicator-color': 'black',
'--table-generator-column-size-indicator-hover-color': 'black',
},
'.table-generator': {
position: 'relative',
'--table-generator-inactive-border-width': '1px',
'--table-generator-active-border-width': '1px',
'--table-generator-selector-handle-buffer': '12px',
'--table-generator-focus-border-width': '2px',
'--table-generator-focus-negative-border-width': '-2px',
},
'.table-generator-cell.selected': {
'background-color': 'var(--table-generator-selected-background-color)',
},
'.table-generator-cell:focus-visible': {
outline: '2px dotted var(--table-generator-focus-border-color)',
},
'.table-generator-cell': {
border:
'var(--table-generator-inactive-border-width) dashed var(--table-generator-inactive-border-color)',
'min-width': '40px',
height: '30px',
'&.selection-edge-top': {
'--shadow-top':
'0 var(--table-generator-focus-negative-border-width) 0 var(--table-generator-focus-border-color)',
},
'&.selection-edge-bottom': {
'--shadow-bottom':
'0 var(--table-generator-focus-border-width) 0 var(--table-generator-focus-border-color)',
},
'&.selection-edge-left': {
'--shadow-left':
'var(--table-generator-focus-negative-border-width) 0 0 var(--table-generator-focus-border-color)',
},
'&.selection-edge-right': {
'--shadow-right':
'var(--table-generator-focus-border-width) 0 0 var(--table-generator-focus-border-color)',
},
'box-shadow':
'var(--shadow-top, 0 0 0 transparent), var(--shadow-bottom, 0 0 0 transparent), var(--shadow-left, 0 0 0 transparent), var(--shadow-right, 0 0 0 transparent)',
'&.table-generator-cell-border-left': {
'border-left-style': 'solid',
'border-left-color': 'var(--table-generator-active-border-color)',
'border-left-width': 'var(--table-generator-active-border-width)',
},
'&.table-generator-cell-border-right': {
'border-right-style': 'solid',
'border-right-color': 'var(--table-generator-active-border-color)',
'border-right-width': 'var(--table-generator-active-border-width)',
},
'&.table-generator-row-border-top': {
'border-top-style': 'solid',
'border-top-color': 'var(--table-generator-active-border-color)',
'border-top-width': 'var(--table-generator-active-border-width)',
},
'&.table-generator-row-border-bottom': {
'border-bottom-style': 'solid',
'border-bottom-color': 'var(--table-generator-active-border-color)',
'border-bottom-width': 'var(--table-generator-active-border-width)',
},
'& .table-generator-cell-render': {
'overflow-x': 'auto',
'overflow-y': 'hidden',
width: '100%',
},
},
'.table-generator-table': {
'table-layout': 'fixed',
width: '95%',
'max-width': '95%',
margin: '0 auto',
cursor: 'default',
'& td': {
'&:not(.editing)': {
padding: '0 0.25em',
},
'vertical-align': 'top',
'&.alignment-left': {
'text-align': 'left',
},
'&.alignment-right': {
'text-align': 'right',
},
'&.alignment-center': {
'text-align': 'center',
},
'&.alignment-paragraph': {
'text-align': 'justify',
},
},
'& .table-generator-selector-cell': {
padding: '0',
border: 'none !important',
position: 'relative',
cursor: 'pointer',
'&.row-selector': {
width: 'calc(var(--table-generator-selector-handle-buffer) + 8px)',
'&::after': {
width: '4px',
bottom: '4px',
height: 'calc(100% - 8px)',
},
},
'&.column-selector': {
height: 'calc(var(--table-generator-selector-handle-buffer) + 8px)',
'&::after': {
width: 'calc(100% - 8px)',
height: '4px',
right: '4px',
},
},
'&::after': {
content: '""',
display: 'block',
position: 'absolute',
bottom: '8px',
right: '8px',
width: 'calc(100% - 8px)',
height: 'calc(100% - 8px)',
'background-color': 'var(--table-generator-selector-background-color)',
'border-radius': '4px',
},
'&:hover::after': {
'background-color': 'var(--table-generator-selector-hover-color)',
},
'&.fully-selected::after': {
'background-color': 'var(--table-generator-selector-hover-color)',
},
},
},
'.table-generator-floating-toolbar': {
position: 'absolute',
transform: 'translateY(-100%)',
left: '0',
right: '0',
margin: '0 auto',
// z-index of cursor layer is 150
'z-index': '152',
'border-radius': '4px',
width: 'max-content',
'justify-content': 'start',
maxWidth: '100%',
'background-color': 'var(--table-generator-toolbar-background)',
'box-shadow': '0px 2px 4px 0px var(--table-generator-toolbar-shadow-color)',
padding: '4px',
display: 'flex',
flexWrap: 'wrap',
rowGap: '8px',
'&.table-generator-toolbar-floating-custom-sizes': {
top: '-8px',
},
},
'.table-generator-toolbar-button': {
display: 'inline-flex',
'align-items': 'center',
'justify-content': 'center',
margin: '0',
'background-color': 'transparent',
border: 'none',
'border-radius': '4px',
'line-height': '1',
overflow: 'hidden',
color: 'var(--table-generator-toolbar-button-color)',
'text-align': 'center',
padding: '4px',
'&:not(first-child)': {
'margin-left': '4px',
},
'&:not(:last-child)': {
'margin-right': '4px',
},
'& > span': {
'font-size': '24px',
},
'&:hover, &:focus': {
'background-color':
'var(--table-generator-toolbar-button-hover-background)',
},
'&:active, &.active': {
'background-color':
'var(--table-generator-toolbar-button-active-background)',
},
'&:hover, &:focus, &:active, &.active': {
'box-shadow': 'none',
},
'&[aria-disabled="true"]': {
'&:hover, &:focus, &:active, &.active': {
'background-color': 'transparent',
},
opacity: '0.2',
},
},
'.toolbar-beta-badge': {
padding: '0 4px 2px 12px',
},
'.table-generator-button-group': {
display: 'inline-flex',
'align-items': 'center',
'justify-content': 'center',
'line-height': '1',
overflow: 'hidden',
'&:not(:last-child)': {
'border-right': '1px solid var(--table-generator-divider-color)',
'padding-right': '8px',
'margin-right': '8px',
},
},
'.table-generator-button-menu-popover': {
'background-color': 'var(--table-generator-toolbar-background) !important',
'& .popover-content, & .popover-body': {
padding: '4px',
},
'& .list-group': {
margin: '0',
padding: '0',
},
'& > .arrow, & > .popover-arrow': {
display: 'none',
},
},
'.table-generator-cell-input': {
'background-color': 'transparent',
width: '100%',
'text-align': 'inherit',
height: '1.5em',
'min-height': '100%',
border: '1px solid var(--table-generator-toolbar-shadow-color)',
padding: '0 0.25em',
resize: 'none',
'box-sizing': 'border-box',
overflow: 'hidden',
'&:focus, &:focus-visible': {
outline: '2px solid var(--table-generator-focus-border-color)',
'outline-offset': '-2px',
},
},
'.table-generator-border-options-coming-soon': {
display: 'flex',
margin: '4px',
'font-size': '12px',
background: 'var(--table-generator-coming-soon-background-color)',
color: 'var(--table-generator-coming-soon-color)',
padding: '8px',
gap: '6px',
'align-items': 'flex-start',
'max-width': '240px',
'font-family': 'var(--bs-body-font-family)',
'& .info-icon': {
flex: ' 0 0 24px',
},
},
'.table-generator-toolbar-dropdown-toggle': {
border: '1px solid var(--table-generator-toolbar-dropdown-border-color)',
'box-shadow': 'none',
background: 'transparent',
'white-space': 'nowrap',
color: 'var(--table-generator-toolbar-button-color)',
'border-radius': '4px',
padding: '6px 8px',
gap: '8px',
'min-width': '120px',
'font-size': '14px',
display: 'flex',
'align-items': 'center',
'justify-content': 'space-between',
'font-family': 'var(--bs-body-font-family)',
height: '36px',
'&:not(:first-child)': {
'margin-left': '8px',
},
'&[aria-disabled="true"]': {
'background-color':
'var(--table-generator-toolbar-dropdown-disabled-background)',
color: 'var(--table-generator-toolbar-dropdown-disabled-color)',
},
},
'.table-generator-toolbar-dropdown-popover': {
'max-width': '300px',
background: 'var(--table-generator-toolbar-background) !important',
'& .popover-content, & .popover-body': {
padding: '0',
},
'& > .arrow, & > .popover-arrow': {
display: 'none',
},
},
'.table-generator-toolbar-dropdown-menu': {
display: 'flex',
'flex-direction': 'column',
'min-width': '200px',
padding: '4px',
'& > button': {
border: 'none',
'box-shadow': 'none',
background: 'transparent',
'white-space': 'nowrap',
color: 'var(--table-generator-toolbar-button-color)',
'border-radius': '4px',
'font-size': '14px',
display: 'flex',
'align-items': 'center',
'justify-content': 'flex-start',
'column-gap': '8px',
'align-self': 'stretch',
padding: '12px 8px',
'font-family': 'var(--bs-body-font-family)',
'& .table-generator-button-label': {
'align-self': 'stretch',
flex: '1 0 auto',
'text-align': 'left',
},
'&.ol-cm-toolbar-dropdown-option-active': {
'background-color':
'var(--table-generator-toolbar-dropdown-active-background)',
color: 'var(--table-generator-toolbar-dropdown-active-color)',
},
'&:hover, &:focus': {
'background-color':
'var(--table-generator-toolbar-button-hover-background)',
},
'&:active, &.active': {
'background-color':
'var(--table-generator-toolbar-button-active-background)',
},
'&.ol-cm-toolbar-dropdown-option-active:hover, &.ol-cm-toolbar-dropdown-option-active:focus':
{
'background-color':
'var(--table-generator-toolbar-dropdown-active-hover-background)',
},
'&.ol-cm-toolbar-dropdown-option-active:active, &.ol-cm-toolbar-dropdown-option-active.active':
{
'background-color':
'var(--table-generator-toolbar-dropdown-active-active-background)',
},
'&:hover, &:focus, &:active, &.active': {
'box-shadow': 'none',
},
'&[aria-disabled="true"]': {
'&:hover, &:focus, &:active, &.active': {
'background-color': 'transparent',
},
color: 'var(--table-generator-toolbar-dropdown-disabled-color)',
},
},
'& > hr': {
background: 'var(--table-generator-dropdown-divider-color)',
margin: '2px 8px',
display: 'block',
'box-sizing': 'content-box',
border: '0',
height: '1px',
},
'& .ol-cm-toolbar-dropdown-option-content': {
textAlign: 'left',
flexGrow: '1',
},
},
'.ol-cm-environment-table.table-generator-error-container, .ol-cm-environment-table.ol-cm-tabular':
{
background: 'rgba(125, 125, 125, 0.05)',
'font-family': 'var(--bs-body-font-family)',
},
'.table-generator-filler-row': {
border: 'none !important',
'& td': {
'min-width': '40px',
},
},
'.table-generator-column-indicator-button': {
verticalAlign: 'middle',
borderRadius: '4px',
padding: '2px 4px 2px 4px',
background: 'var(--table-generator-column-size-indicator-background)',
margin: 0,
border: 'none',
fontFamily: 'Lato, sans-serif',
fontSize: '12px',
lineHeight: '16px',
fontWeight: 400,
display: 'flex',
maxWidth: '100%',
color: 'var(--table-generator-column-size-indicator-color)',
'&:hover': {
background:
'var(--table-generator-column-size-indicator-hover-background)',
color: 'var(--table-generator-column-size-indicator-hover-color)',
},
'& .table-generator-column-indicator-icon': {
fontSize: '16px',
lineHeight: '16px',
},
'& .table-generator-column-indicator-label': {
textOverflow: 'ellipsis',
overflow: 'hidden',
whiteSpace: 'nowrap',
},
},
'.table-generator-column-widths-row': {
height: '20px',
'& td': {
lineHeight: '1',
},
},
})

View File

@@ -0,0 +1,9 @@
import { EditorState } from '@codemirror/state'
import { IndentContext, indentString } from '@codemirror/language'
export const createListItem = (state: EditorState, pos: number) => {
const cx = new IndentContext(state)
const columns = cx.lineIndent(pos)
const indent = indentString(state, columns)
return `${indent}\\item `
}

View File

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

View File

@@ -0,0 +1,180 @@
import { EditorState } from '@codemirror/state'
import { SyntaxNode } from '@lezer/common'
import { COMMAND_SUBSTITUTIONS } from '../visual-widgets/character'
type Markup = {
elementType: keyof HTMLElementTagNameMap
className?: string
}
const textFormattingMarkupMap = new Map<string, Markup>([
[
'TextBoldCommand', // \\textbf
{ elementType: 'b' },
],
[
'TextItalicCommand', // \\textit
{ elementType: 'i' },
],
[
'TextSmallCapsCommand', // \\textsc
{ elementType: 'span', className: 'ol-cm-command-textsc' },
],
[
'TextTeletypeCommand', // \\texttt
{ elementType: 'span', className: 'ol-cm-command-texttt' },
],
[
'TextSuperscriptCommand', // \\textsuperscript
{ elementType: 'sup' },
],
[
'TextSubscriptCommand', // \\textsubscript
{ elementType: 'sub' },
],
[
'EmphasisCommand', // \\emph
{ elementType: 'em' },
],
[
'UnderlineCommand', // \\underline
{ elementType: 'span', className: 'ol-cm-command-underline' },
],
])
const markupMap = new Map<string, Markup>([
['\\and', { elementType: 'span', className: 'ol-cm-command-and' }],
])
/**
* 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 getText The editor state where `node` is from or a custom function
*/
export function typesetNodeIntoElement(
node: SyntaxNode,
element: HTMLElement,
getText: EditorState | ((from: number, to: number) => string)
) {
if (getText instanceof EditorState) {
getText = getText.sliceDoc.bind(getText)
}
// 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 = (element: HTMLElement) => ancestorStack.push(element)
let from = node.from
const addMarkup = (markup: Markup, childNode: SyntaxNode) => {
const element = document.createElement(markup.elementType)
if (markup.className) {
element.classList.add(markup.className)
}
pushAncestor(element)
from = chooseFrom(childNode)
}
node.cursor().iterate(
function enter(childNodeRef) {
const childNode = childNodeRef.node
if (from < childNode.from) {
ancestor().append(
document.createTextNode(getText(from, childNode.from))
)
from = childNode.from
}
// commands defined in the grammar
const markup = textFormattingMarkupMap.get(childNode.type.name)
if (markup) {
addMarkup(markup, childNode)
return
}
// commands not defined in the grammar
const commandName = unknownCommandName(childNode, getText)
if (commandName) {
const markup = markupMap.get(commandName)
if (markup) {
addMarkup(markup, childNode)
return
}
if (['\\corref', '\\fnref', '\\thanks'].includes(commandName)) {
// ignoring these commands
from = childNode.to
return false
}
const symbol = COMMAND_SUBSTITUTIONS.get(commandName)
if (symbol) {
ancestor().append(document.createTextNode(symbol))
from = childNode.to
return false
}
} else if (childNode.type.is('LineBreak')) {
ancestor().append(document.createElement('br'))
from = childNode.to
}
},
function leave(childNodeRef) {
const childNode = childNodeRef.node
if (shouldHandleLeave(childNode, getText)) {
const typeSetElement = popAncestor()
ancestor().appendChild(typeSetElement)
const textArgument = childNode.getChild('TextArgument')
const endBrace = textArgument?.getChild('CloseBrace')
if (endBrace) {
from = endBrace.to
}
}
}
)
if (from < node.to) {
ancestor().append(document.createTextNode(getText(from, node.to)))
}
return element
}
const chooseFrom = (node: SyntaxNode) =>
node.getChild('TextArgument')?.getChild('LongArg')?.from ?? node.to
const shouldHandleLeave = (
node: SyntaxNode,
getText: (from: number, to: number) => string
) => {
if (textFormattingMarkupMap.has(node.type.name)) {
return true
}
const commandName = unknownCommandName(node, getText)
return commandName && markupMap.has(commandName)
}
const unknownCommandName = (
node: SyntaxNode,
getText: (from: number, to: number) => string
): string | undefined => {
if (node.type.is('UnknownCommand')) {
const commandNameNode = node.getChild('$CtrlSeq')
if (commandNameNode) {
return getText(commandNameNode.from, commandNameNode.to).trim()
}
}
}

View File

@@ -0,0 +1,174 @@
import { keymap } from '@codemirror/view'
import {
ChangeSpec,
EditorSelection,
Prec,
SelectionRange,
} from '@codemirror/state'
import { ancestorNodeOfType } from '../../utils/tree-query'
import {
cursorIsAtStartOfListItem,
indentDecrease,
indentIncrease,
} from '../toolbar/commands'
import { createListItem } from '@/features/source-editor/extensions/visual/utils/list-item'
import { getListType } from '../../utils/tree-operations/lists'
/**
* A keymap which provides behaviours for the visual editor,
* including lists and text formatting.
*/
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 (/^\\item(\[])?$/.test(line.text.trim())) {
// no content on this line
// outside the end of the current list
const pos = listNode.to + 1
// delete the current line
const deleteCurrentLine = {
from: line.from,
to: line.to + 1,
insert: '',
}
const changes: ChangeSpec[] = [deleteCurrentLine]
// the new cursor position
let range: SelectionRange
// if this is a nested list, insert a new empty list item after this list
if (
listNode.parent?.parent?.parent?.parent?.type.is(
'ListEnvironment'
)
) {
const newListItem = createListItem(state, pos)
changes.push({
from: pos,
insert: newListItem + '\n',
})
// place the cursor at the end of the new list item
range = EditorSelection.cursor(pos + newListItem.length)
} else {
// place the cursor outside the end of the current list
range = EditorSelection.cursor(pos)
}
handled = true
return {
changes,
range: range.map(state.changes(deleteCurrentLine)),
}
}
}
// handle a list item that isn't at the end of a list
let insert = '\n' + createListItem(state, from)
const countWhitespaceAfterPosition = (pos: number) => {
const line = state.doc.lineAt(pos)
const followingText = state.sliceDoc(pos, line.to)
const matches = followingText.match(/^(\s+)/)
return matches ? matches[1].length : 0
}
let pos: number
if (getListType(state, listNode) === 'description') {
insert = insert.replace(/\\item $/, '\\item[] ')
// position the cursor inside the square brackets
pos = from + insert.length - 2
} else {
// move the cursor past any whitespace on the new line
pos = from + insert.length + countWhitespaceAfterPosition(from)
}
handled = true
return {
changes: { from, insert },
range: EditorSelection.cursor(pos, -1),
}
}
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,
},
])
)

View File

@@ -0,0 +1,492 @@
import { EditorView } from '@codemirror/view'
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
import { tags } from '@lezer/highlight'
import { Annotation, Compartment, Extension, Facet } from '@codemirror/state'
/**
* A syntax highlighter for content types that are only styled in the visual editor.
*/
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.strong, class: 'ol-cm-strong' },
{
tag: tags.monospace,
fontFamily: 'var(--source-font-family)',
lineHeight: 1,
overflowWrap: 'break-word',
},
])
)
const mainVisualTheme = 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((var(--content-width) - 95ch) / 2), calc(var(--content-width) * 0.08))', // max 95 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',
textUnderlineOffset: '2px',
},
'.ol-cm-monospace': {
fontFamily: 'var(--source-font-family)',
lineHeight: 1,
fontWeight: 'normal',
fontStyle: 'normal',
fontVariant: 'normal',
textDecoration: 'none',
},
'.ol-cm-strong': {
fontWeight: 700,
},
'.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)',
textWrap: 'balance',
},
'.ol-cm-authors': {
display: 'flex',
justifyContent: 'space-evenly',
gap: '0.5em',
flexWrap: 'wrap',
},
'.ol-cm-author': {
cursor: 'pointer',
display: 'inline-block',
minWidth: '150px',
},
'.ol-cm-icon-brace': {
filter: 'grayscale(1)',
marginRight: '2px',
},
'.ol-cm-indicator': {
color: 'rgba(125, 125, 125, 0.5)',
},
'.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)',
paddingBottom: '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-theorem-plain': {
fontStyle: 'italic',
},
'.ol-cm-begin-proof > .ol-cm-environment-name': {
fontStyle: 'italic',
},
'.ol-cm-environment-quote-block.ol-cm-environment-line': {
borderLeft: '4px solid rgba(125, 125, 125, 0.25)',
paddingLeft: '1em',
borderRadius: '0',
},
'.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-begin-abstract > .ol-cm-environment-name': {
fontFamily: 'var(--visual-font-family)',
fontSize: '1.2em',
fontWeight: 550,
textTransform: 'capitalize',
},
'.ol-cm-begin-theorem > .ol-cm-environment-name': {
fontFamily: 'var(--visual-font-family)',
fontWeight: 550,
padding: '0 6px',
textTransform: 'capitalize',
},
'.ol-cm-begin-theorem > .ol-cm-environment-padding:first-of-type': {
flex: 0,
},
'.ol-cm-item, .ol-cm-description-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-textmd, .ol-cm-command-textmd > .ol-cm-command-textbf': {
fontWeight: 'normal',
},
'.ol-cm-command-textsf': {
fontFamily: 'var(--source-font-family)',
},
'.ol-cm-command-textsuperscript': {
verticalAlign: 'super',
fontSize: 'smaller',
lineHeight: 'calc(var(--line-height) / 2)',
},
'.ol-cm-command-textsubscript': {
verticalAlign: 'sub',
fontSize: 'smaller',
lineHeight: 'calc(var(--line-height) / 2)',
},
'.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-space': {
display: 'inline-block',
},
'.ol-cm-environment-centered': {
'&.ol-cm-label-line, &.ol-cm-caption-line': {
textAlign: 'center',
},
'&.ol-cm-caption-line': {
padding: '0 10%',
},
},
'.ol-cm-caption-line .ol-cm-label': {
marginRight: '1ch',
},
'.ol-cm-environment-verbatim': {
fontFamily: 'var(--source-font-family)',
},
'.ol-cm-environment-lstlisting': {
fontFamily: 'var(--source-font-family)',
},
'.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-inline-edit-wrapper': {
display: 'inline-block',
position: 'relative',
verticalAlign: 'middle',
'& .ol-cm-graphics': {
paddingTop: 0,
paddingBottom: 0,
},
},
'.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: 'var(--font-sans)',
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',
},
'.ol-cm-environment-figure': {
position: 'relative',
},
'.ol-cm-graphics-edit-button': {
position: 'absolute',
top: '18px',
right: '18px',
},
'.ol-cm-footnote': {
display: 'inline-flex',
padding: '0 0.1em',
background: 'rgba(125, 125, 125, 0.25)',
borderRadius: '2px',
height: '1em',
cursor: 'pointer',
verticalAlign: 'text-top',
'&:not(.ol-cm-footnote-view):hover': {
background: 'rgba(125, 125, 125, 0.5)',
},
'&.ol-cm-footnote-view': {
height: 'auto',
verticalAlign: 'unset',
display: 'inline',
padding: '0 0.5em',
},
},
})
const contentWidthThemeConf = new Compartment()
const changeContentWidthAnnotation = Annotation.define<boolean>()
const currentWidth = Facet.define<string, string>({
combine: values => {
if (values.length === 0) {
return ''
}
return values[0]
},
})
function createContentWidthTheme(contentWidth: string) {
return [
currentWidth.of(contentWidth),
EditorView.editorAttributes.of({
style: `--content-width: ${contentWidth}`,
}),
]
}
const contentWidthSetter = EditorView.updateListener.of(update => {
if (update.geometryChanged && !update.docChanged) {
// Ignore any update triggered by this plugin
if (
update.transactions.some(tr =>
tr.annotation(changeContentWidthAnnotation)
)
) {
return
}
const viewWidth = `${update.view.contentDOM.offsetWidth}px`
const currentConfiguredWidth = update.state.facet(currentWidth)
if (currentConfiguredWidth === viewWidth) {
// We already have the correct width stored
return
}
update.view.dispatch({
effects: contentWidthThemeConf.reconfigure(
createContentWidthTheme(viewWidth)
),
// Set the selection explicitly to force the cursor to redraw if CM6
// fails to spot a change in geometry, which sometimes seems to happen
// (see #15145)
selection: update.view.state.selection,
annotations: changeContentWidthAnnotation.of(true),
})
}
})
export const visualTheme: Extension = [
contentWidthThemeConf.of(createContentWidthTheme('100%')),
mainVisualTheme,
contentWidthSetter,
]

View File

@@ -0,0 +1,60 @@
import { BeginWidget } from './begin'
import { EditorView } from '@codemirror/view'
import { SyntaxNode } from '@lezer/common'
import { typesetNodeIntoElement } from '../utils/typeset-content'
import { loadMathJax } from '../../../../mathjax/load-mathjax'
export class BeginTheoremWidget extends BeginWidget {
constructor(
public environment: string,
public name: string,
public argumentNode?: SyntaxNode | null
) {
super(environment)
}
toDOM(view: EditorView) {
const element = super.toDOM(view)
element.classList.add('ol-cm-begin-theorem')
return element
}
updateDOM(element: HTMLDivElement, view: EditorView) {
super.updateDOM(element, view)
element.classList.add('ol-cm-begin-theorem')
return true
}
eq(widget: BeginTheoremWidget) {
return (
super.eq(widget) &&
widget.name === this.name &&
widget.argumentNode === this.argumentNode
)
}
coordsAt(element: HTMLElement) {
return element.getBoundingClientRect()
}
buildName(nameElement: HTMLSpanElement, view: EditorView) {
nameElement.textContent = this.name
if (this.argumentNode) {
const suffixElement = document.createElement('span')
typesetNodeIntoElement(this.argumentNode, suffixElement, view.state)
nameElement.append(' (', suffixElement, ')')
loadMathJax()
.then(async MathJax => {
if (!this.destroyed) {
await MathJax.typesetPromise([nameElement])
view.requestMeasure()
MathJax.typesetClear([nameElement])
}
})
.catch(() => {
nameElement.classList.add('ol-cm-error')
})
}
}
}

View File

@@ -0,0 +1,74 @@
import { EditorView, WidgetType } from '@codemirror/view'
import { placeSelectionInsideBlock } from '../selection'
export class BeginWidget extends WidgetType {
destroyed = false
constructor(public environment: string) {
super()
}
toDOM(view: EditorView) {
this.destroyed = false
const element = document.createElement('div')
this.buildElement(element, view)
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, view: EditorView) {
this.destroyed = false
element.textContent = ''
element.className = ''
this.buildElement(element, view)
return true
}
destroy() {
this.destroyed = true
}
ignoreEvent(event: Event): boolean {
return event.type !== 'mouseup'
}
coordsAt(element: HTMLElement) {
return element.getBoundingClientRect()
}
buildName(name: HTMLSpanElement, view: EditorView) {
name.textContent = this.environment
}
buildElement(element: HTMLDivElement, view: EditorView) {
element.classList.add('ol-cm-begin', `ol-cm-begin-${this.environment}`)
const startPadding = document.createElement('span')
startPadding.classList.add(
'ol-cm-environment-padding',
'ol-cm-environment-start-padding'
)
element.appendChild(startPadding)
const name = document.createElement('span')
name.classList.add('ol-cm-environment-name')
this.buildName(name, view)
element.appendChild(name)
const endPadding = document.createElement('span')
endPadding.classList.add(
'ol-cm-environment-padding',
'ol-cm-environment-end-padding'
)
element.appendChild(endPadding)
}
}

View File

@@ -0,0 +1,28 @@
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
}
coordsAt(element: HTMLElement) {
return element.getBoundingClientRect()
}
}

View File

@@ -0,0 +1,166 @@
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'
}
coordsAt(element: HTMLElement) {
return element.getBoundingClientRect()
}
}
export const COMMAND_SUBSTITUTIONS = new Map([
['\\', ' '], // a trimmed \\ '
['\\%', '\u0025'],
['\\_', '\u005F'],
['\\}', '\u007D'],
['\\~', '\u007E'],
['\\&', '\u0026'],
['\\#', '\u0023'],
['\\{', '\u007B'],
['\\$', '\u0024'],
['\\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 = COMMAND_SUBSTITUTIONS.get(command)
if (substitution !== undefined) {
return new CharacterWidget(substitution)
}
}
export function hasCharacterSubstitution(command: string): boolean {
return COMMAND_SUBSTITUTIONS.has(command)
}

View File

@@ -0,0 +1,35 @@
import { WidgetType } from '@codemirror/view'
export class DescriptionItemWidget extends WidgetType {
constructor(public listDepth: number) {
super()
}
toDOM() {
const element = document.createElement('span')
element.classList.add('ol-cm-description-item')
this.setProperties(element)
return element
}
eq(widget: DescriptionItemWidget) {
return widget.listDepth === this.listDepth
}
updateDOM(element: HTMLElement) {
this.setProperties(element)
return true
}
ignoreEvent(event: Event): boolean {
return event.type !== 'mousedown' && event.type !== 'mouseup'
}
coordsAt(element: HTMLElement) {
return element.getBoundingClientRect()
}
setProperties(element: HTMLElement) {
element.style.setProperty('--list-depth', String(this.listDepth))
}
}

View File

@@ -0,0 +1,21 @@
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
}
coordsAt(element: HTMLElement) {
return element.getBoundingClientRect()
}
}

View File

@@ -0,0 +1,66 @@
import { EditorView } from '@codemirror/view'
import { GraphicsWidget } from './graphics'
import { editFigureDataEffect } from '../../figure-modal'
import { emitToolbarEvent } from '../../toolbar/utils/analytics'
export class EditableGraphicsWidget extends GraphicsWidget {
setEditDispatcher(button: HTMLButtonElement, view: EditorView) {
button.classList.toggle('hidden', !this.figureData)
if (this.figureData) {
button.onmousedown = event => {
event.preventDefault()
event.stopImmediatePropagation()
view.dispatch({ effects: editFigureDataEffect.of(this.figureData) })
window.dispatchEvent(new CustomEvent('figure-modal:open-modal'))
emitToolbarEvent(view, 'toolbar-figure-modal-edit')
return false
}
} else {
button.onmousedown = null
}
}
updateDOM(element: HTMLImageElement, view: EditorView): boolean {
this.destroyed = false
element.classList.toggle('ol-cm-environment-centered', this.centered)
if (
this.filePath === element.dataset.filepath &&
element.dataset.width === String(this.figureData?.width?.toString())
) {
// Figure remained the same, so just update the event listener on the button
const button = element.querySelector<HTMLButtonElement>(
'.ol-cm-graphics-edit-button'
)
if (button) {
this.setEditDispatcher(button, view)
}
return true
}
this.renderGraphic(element, view)
view.requestMeasure()
return true
}
coordsAt(element: HTMLElement) {
return element.getBoundingClientRect()
}
createEditButton(view: EditorView) {
const button = document.createElement('button')
button.setAttribute('aria-label', view.state.phrase('edit_figure'))
this.setEditDispatcher(button, view)
button.classList.add('btn', 'btn-secondary', 'ol-cm-graphics-edit-button')
const buttonLabel = document.createElement('span')
buttonLabel.classList.add('fa', 'fa-pencil')
button.append(buttonLabel)
return button
}
renderGraphic(element: HTMLElement, view: EditorView) {
super.renderGraphic(element, view)
if (this.figureData) {
const button = this.createEditButton(view)
element.prepend(button)
}
}
}

View File

@@ -0,0 +1,51 @@
import { EditorView } from '@codemirror/view'
import { EditableGraphicsWidget } from './editable-graphics'
export class EditableInlineGraphicsWidget extends EditableGraphicsWidget {
updateElementData(element: HTMLElement) {
element.dataset.filepath = this.filePath
element.dataset.width = this.figureData?.width?.toString()
if (this.figureData?.width) {
element.style.width = `min(100%, ${this.figureData.width * 100}%)`
} else {
element.style.width = ''
}
}
toDOM(view: EditorView) {
this.destroyed = false
const element = document.createElement('span')
element.classList.add('ol-cm-graphics-inline-edit-wrapper')
this.updateElementData(element)
const inlineElement = document.createElement('span')
inlineElement.classList.add('ol-cm-graphics-inline')
this.renderGraphic(inlineElement, view)
element.append(inlineElement)
return element
}
updateDOM(element: HTMLImageElement, view: EditorView): boolean {
const updated = super.updateDOM(element, view)
if (!updated) {
return false
}
// We need to make sure these are updated, as `renderGraphic` in the base
// class will update them on the inner element.
this.updateElementData(element)
view.requestMeasure()
return true
}
ignoreEvent(event: Event) {
return event.type !== 'mousedown' && event.type !== 'mouseup'
}
coordsAt(element: HTMLElement) {
return element.getBoundingClientRect()
}
// We set the actual figure width on the span rather than the img element
getFigureWidth(): string {
return '100%'
}
}

View File

@@ -0,0 +1,31 @@
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
}
coordsAt(element: HTMLElement) {
return element.getBoundingClientRect()
}
get estimatedHeight() {
return 30
}
}

View File

@@ -0,0 +1,17 @@
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
}
coordsAt(element: HTMLElement) {
return element.getBoundingClientRect()
}
}

View File

@@ -0,0 +1,46 @@
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'
}
coordsAt(element: HTMLElement) {
return element.getBoundingClientRect()
}
}

View File

@@ -0,0 +1,39 @@
import { WidgetType } from '@codemirror/view'
type NoteType = 'footnote' | 'endnote'
const symbols: Record<NoteType, string> = {
footnote: '*',
endnote: '†',
}
export class FootnoteWidget extends WidgetType {
constructor(private type: NoteType = 'footnote') {
super()
}
toDOM() {
const element = document.createElement('span')
element.classList.add('ol-cm-footnote')
element.setAttribute('role', 'button')
element.innerHTML = symbols[this.type]
return element
}
eq(widget: FootnoteWidget) {
return this.type === widget.type
}
updateDOM(element: HTMLElement): boolean {
element.innerHTML = symbols[this.type]
return true
}
ignoreEvent(event: Event) {
return event.type !== 'mousedown' && event.type !== 'mouseup'
}
coordsAt(element: HTMLElement) {
return element.getBoundingClientRect()
}
}

View File

@@ -0,0 +1,82 @@
import { EditorView, WidgetType } from '@codemirror/view'
import { SyntaxNode } from '@lezer/common'
import { loadMathJax } from '../../../../mathjax/load-mathjax'
import { selectNode } from '../utils/select-node'
import { typesetNodeIntoElement } from '../utils/typeset-content'
export type Frame = {
title: {
node: SyntaxNode
content: string
}
subtitle?: {
node: SyntaxNode
content: string
}
}
export class FrameWidget extends WidgetType {
destroyed = false
constructor(public frame: Frame) {
super()
}
toDOM(view: EditorView): HTMLElement {
this.destroyed = false
const element = document.createElement('div')
element.classList.add('ol-cm-frame-widget', 'ol-cm-divider')
const title = document.createElement('div')
title.classList.add('ol-cm-frame-title', 'ol-cm-heading')
title.addEventListener('mouseup', () =>
selectNode(view, this.frame.title.node)
)
typesetNodeIntoElement(this.frame.title.node, title, view.state)
element.appendChild(title)
if (this.frame.subtitle) {
const subtitle = document.createElement('div')
subtitle.classList.add('ol-cm-frame-subtitle', 'ol-cm-heading')
typesetNodeIntoElement(this.frame.subtitle.node, subtitle, view.state)
subtitle.addEventListener('mouseup', () =>
selectNode(view, this.frame.subtitle!.node)
)
element.appendChild(subtitle)
}
// render equations
loadMathJax()
.then(async MathJax => {
if (!this.destroyed) {
await MathJax.typesetPromise([element])
view.requestMeasure()
MathJax.typesetClear([element])
}
})
.catch(() => {
element.classList.add('ol-cm-error')
})
return element
}
destroy() {
this.destroyed = true
}
eq(other: FrameWidget): boolean {
return (
other.frame.title.content === this.frame.title.content &&
other.frame.subtitle?.content === this.frame.subtitle?.content
)
}
ignoreEvent(event: Event) {
return event.type !== 'mouseup'
}
coordsAt(element: HTMLElement) {
return element.getBoundingClientRect()
}
}

View File

@@ -0,0 +1,173 @@
import { EditorView, WidgetType } from '@codemirror/view'
import { placeSelectionInsideBlock } from '../selection'
import { isEqual } from 'lodash'
import { FigureData } from '../../figure-modal'
import { debugConsole } from '@/utils/debugging'
import { PreviewPath } from '../../../../../../../types/preview-path'
export class GraphicsWidget extends WidgetType {
destroyed = false
height = 300 // for estimatedHeight, updated when the image is loaded
constructor(
public filePath: string,
public previewByPath: (path: string) => PreviewPath | null,
public centered: boolean,
public figureData: FigureData | null
) {
super()
}
toDOM(view: EditorView): HTMLElement {
this.destroyed = false
// this is a block decoration, so it's outside the line decorations from the environment
const element = document.createElement('div')
element.classList.add('ol-cm-environment-figure')
element.classList.add('ol-cm-environment-line')
element.classList.toggle('ol-cm-environment-centered', this.centered)
this.renderGraphic(element, view)
element.addEventListener('mouseup', event => {
event.preventDefault()
view.dispatch(placeSelectionInsideBlock(view, event as MouseEvent))
})
return element
}
eq(widget: GraphicsWidget) {
return (
widget.filePath === this.filePath &&
widget.centered === this.centered &&
isEqual(this.figureData, widget.figureData)
)
}
updateDOM(element: HTMLImageElement, view: EditorView) {
this.destroyed = false
element.classList.toggle('ol-cm-environment-centered', this.centered)
if (
this.filePath === element.dataset.filepath &&
element.dataset.width === String(this.figureData?.width?.toString())
) {
return true
}
this.renderGraphic(element, view)
view.requestMeasure()
return true
}
ignoreEvent(event: Event) {
return (
event.type !== 'mouseup' &&
// Pass events through to the edit button
!(
event.target instanceof HTMLElement &&
event.target.closest('.ol-cm-graphics-edit-button')
)
)
}
destroy() {
this.destroyed = true
}
coordsAt(element: HTMLElement) {
return element.getBoundingClientRect()
}
get estimatedHeight(): number {
return this.height
}
renderGraphic(element: HTMLElement, view: EditorView) {
element.textContent = '' // ensure the element is empty
const preview = this.previewByPath(this.filePath)
element.dataset.filepath = this.filePath
element.dataset.width = this.figureData?.width?.toString()
if (!preview) {
const message = document.createElement('div')
message.classList.add('ol-cm-graphics-error')
message.classList.add('ol-cm-monospace')
message.textContent = this.filePath
element.append(message)
return
}
switch (preview.extension) {
case 'pdf':
{
const canvas = document.createElement('canvas')
canvas.classList.add('ol-cm-graphics')
this.renderPDF(view, canvas, preview.url).catch(debugConsole.error)
element.append(canvas)
}
break
default:
element.append(this.createImage(view, preview.url))
break
}
}
getFigureWidth() {
if (this.figureData?.width) {
return `min(100%, ${this.figureData.width * 100}%)`
}
return ''
}
createImage(view: EditorView, url: string) {
const image = document.createElement('img')
image.classList.add('ol-cm-graphics')
image.classList.add('ol-cm-graphics-loading')
const width = this.getFigureWidth()
image.style.width = width
image.style.maxWidth = width
image.src = url
image.addEventListener('load', () => {
image.classList.remove('ol-cm-graphics-loading')
this.height = image.height // for estimatedHeight
view.requestMeasure()
})
return image
}
async renderPDF(view: EditorView, canvas: HTMLCanvasElement, url: string) {
const { loadPdfDocumentFromUrl } = await import(
'@/features/pdf-preview/util/pdf-js'
)
// bail out if loading PDF.js took too long
if (this.destroyed) {
return
}
const pdf = await loadPdfDocumentFromUrl(url).promise
const page = await pdf.getPage(1)
// bail out if loading the PDF took too long
if (this.destroyed) {
return
}
const viewport = page.getViewport({ scale: 1 })
canvas.width = viewport.width
canvas.height = viewport.height
const width = this.getFigureWidth()
canvas.style.width = width
canvas.style.maxWidth = width
page.render({
canvasContext: canvas.getContext('2d')!,
viewport,
})
this.height = viewport.height
view.requestMeasure()
}
}

View File

@@ -0,0 +1,34 @@
import { WidgetType } from '@codemirror/view'
export class IconBraceWidget extends WidgetType {
constructor(private content?: string) {
super()
}
toDOM() {
const element = document.createElement('span')
element.classList.add('ol-cm-brace')
element.classList.add('ol-cm-icon-brace')
if (this.content !== undefined) {
element.textContent = this.content
}
return element
}
ignoreEvent(event: Event): boolean {
return event.type !== 'mousedown' && event.type !== 'mouseup'
}
eq(widget: IconBraceWidget) {
return widget.content === this.content
}
updateDOM(element: HTMLElement): boolean {
element.textContent = this.content ?? ''
return true
}
coordsAt(element: HTMLElement) {
return element.getBoundingClientRect()
}
}

View File

@@ -0,0 +1,31 @@
import { WidgetType } from '@codemirror/view'
export class IndicatorWidget extends WidgetType {
constructor(public content: string) {
super()
}
toDOM() {
const element = document.createElement('span')
element.classList.add('ol-cm-indicator')
element.textContent = this.content
return element
}
eq(widget: IndicatorWidget) {
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'
}
coordsAt(element: HTMLElement) {
return element.getBoundingClientRect()
}
}

View File

@@ -0,0 +1,23 @@
import { EditorView } from '@codemirror/view'
import { GraphicsWidget } from './graphics'
export class InlineGraphicsWidget extends GraphicsWidget {
toDOM(view: EditorView) {
this.destroyed = false
const element = document.createElement('span')
element.classList.add('ol-cm-graphics-inline')
this.renderGraphic(element, view)
return element
}
ignoreEvent(event: Event) {
return event.type !== 'mousedown' && event.type !== 'mouseup'
}
coordsAt(element: HTMLElement) {
return element.getBoundingClientRect()
}
}

View File

@@ -0,0 +1,64 @@
import { WidgetType } from '@codemirror/view'
import { ListEnvironmentName } from '../../../utils/tree-operations/ancestors'
export class ItemWidget extends WidgetType {
public listType: string
public suffix: string
bullets: string[] = ['disc', 'circle', 'square']
numbers: string[] = ['decimal', 'lower-alpha', 'lower-roman', 'upper-alpha']
constructor(
public currentEnvironment: ListEnvironmentName | 'document',
public ordinal: number,
public listDepth: number
) {
super()
if (currentEnvironment === 'itemize') {
// unordered list
this.listType = this.bullets[(listDepth - 1) % this.bullets.length]
this.suffix = "' '"
} else {
// ordered list
this.listType = this.numbers[(listDepth - 1) % this.numbers.length]
this.suffix = "'. '"
}
}
toDOM() {
const element = document.createElement('span')
element.classList.add('ol-cm-item')
element.textContent = ' ' // a space, so the line has width
this.setProperties(element)
return element
}
eq(widget: ItemWidget) {
return (
widget.currentEnvironment === this.currentEnvironment &&
widget.ordinal === this.ordinal &&
widget.listDepth === this.listDepth
)
}
updateDOM(element: HTMLElement) {
this.setProperties(element)
return true
}
ignoreEvent(event: Event): boolean {
return event.type !== 'mousedown' && event.type !== 'mouseup'
}
coordsAt(element: HTMLElement) {
return element.getBoundingClientRect()
}
setProperties(element: HTMLElement) {
element.style.setProperty('--list-depth', String(this.listDepth))
element.style.setProperty('--list-ordinal', String(this.ordinal))
element.style.setProperty('--list-type', this.listType)
element.style.setProperty('--list-suffix', this.suffix)
}
}

View File

@@ -0,0 +1,22 @@
import { WidgetType } from '@codemirror/view'
export class LaTeXWidget extends WidgetType {
toDOM() {
const element = document.createElement('span')
element.classList.add('ol-cm-tex')
element.innerHTML = 'L<sup>a</sup>T<sub>e</sub>X'
return element
}
eq() {
return true
}
ignoreEvent(event: Event) {
return event.type !== 'mousedown' && event.type !== 'mouseup'
}
coordsAt(element: HTMLElement) {
return element.getBoundingClientRect()
}
}

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