first commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
@@ -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)),
|
||||
]
|
||||
@@ -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 there’s 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',
|
||||
},
|
||||
})
|
||||
@@ -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)',
|
||||
},
|
||||
}),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// This is copied from CM6, which does not expose it publicly.
|
||||
// https://github.com/codemirror/view/blob/e7918b607753588a0b2a596e952068fa008bf84c/src/browser.ts
|
||||
const nav: any =
|
||||
typeof navigator !== 'undefined'
|
||||
? navigator
|
||||
: { userAgent: '', vendor: '', platform: '' }
|
||||
const doc: any =
|
||||
typeof document !== 'undefined'
|
||||
? document
|
||||
: { documentElement: { style: {} } }
|
||||
|
||||
const ieEdge = /Edge\/(\d+)/.exec(nav.userAgent)
|
||||
const ieUpTo10 = /MSIE \d/.test(nav.userAgent)
|
||||
const ie11Up = /Trident\/(?:[7-9]|\d{2,})\..*rv:(\d+)/.exec(nav.userAgent)
|
||||
const ie = !!(ieUpTo10 || ie11Up || ieEdge)
|
||||
const gecko = !ie && /gecko\/(\d+)/i.test(nav.userAgent)
|
||||
const chrome = !ie && /Chrome\/(\d+)/.exec(nav.userAgent)
|
||||
const webkit = 'webkitFontSmoothing' in doc.documentElement.style
|
||||
const safari = !ie && /Apple Computer/.test(nav.vendor)
|
||||
const ios =
|
||||
safari && (/Mobile\/\w+/.test(nav.userAgent) || nav.maxTouchPoints > 2)
|
||||
|
||||
export default {
|
||||
mac: ios || /Mac/.test(nav.platform),
|
||||
windows: /Win/.test(nav.platform),
|
||||
linux: /Linux|X11/.test(nav.platform),
|
||||
ie,
|
||||
ie_version: ieUpTo10
|
||||
? doc.documentMode || 6
|
||||
: ie11Up
|
||||
? +ie11Up[1]
|
||||
: ieEdge
|
||||
? +ieEdge[1]
|
||||
: 0,
|
||||
gecko,
|
||||
gecko_version: gecko
|
||||
? +(/Firefox\/(\d+)/.exec(nav.userAgent) || [0, 0])[1]
|
||||
: 0,
|
||||
chrome: !!chrome,
|
||||
chrome_version: chrome ? +chrome[1] : 0,
|
||||
ios,
|
||||
android: /Android\b/.test(nav.userAgent),
|
||||
webkit,
|
||||
safari,
|
||||
webkit_version: webkit
|
||||
? +(/\bAppleWebKit\/(\d+)/.exec(navigator.userAgent) || [0, 0])[1]
|
||||
: 0,
|
||||
tabSize:
|
||||
doc.documentElement.style.tabSize != null ? 'tab-size' : '-moz-tab-size',
|
||||
}
|
||||
@@ -0,0 +1,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,
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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')],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* This file is adapted from Lezer, licensed under the MIT license:
|
||||
* https://github.com/lezer-parser/highlight/blob/main/src/highlight.ts
|
||||
*/
|
||||
import { tagHighlighter, tags } from '@lezer/highlight'
|
||||
|
||||
export const classHighlighter = tagHighlighter([
|
||||
{ tag: tags.link, class: 'tok-link' },
|
||||
{ tag: tags.heading, class: 'tok-heading' },
|
||||
{ tag: tags.emphasis, class: 'tok-emphasis' },
|
||||
{ tag: tags.strong, class: 'tok-strong' },
|
||||
{ tag: tags.keyword, class: 'tok-keyword' },
|
||||
{ tag: tags.atom, class: 'tok-atom' },
|
||||
{ tag: tags.bool, class: 'tok-bool' },
|
||||
{ tag: tags.url, class: 'tok-url' },
|
||||
{ tag: tags.labelName, class: 'tok-labelName' },
|
||||
{ tag: tags.inserted, class: 'tok-inserted' },
|
||||
{ tag: tags.deleted, class: 'tok-deleted' },
|
||||
{ tag: tags.literal, class: 'tok-literal' },
|
||||
{ tag: tags.string, class: 'tok-string' },
|
||||
{ tag: tags.number, class: 'tok-number' },
|
||||
{
|
||||
tag: [tags.regexp, tags.escape, tags.special(tags.string)],
|
||||
class: 'tok-string2',
|
||||
},
|
||||
{ tag: tags.variableName, class: 'tok-variableName' },
|
||||
{ tag: tags.local(tags.variableName), class: 'tok-variableName tok-local' },
|
||||
{
|
||||
tag: tags.definition(tags.variableName),
|
||||
class: 'tok-variableName tok-definition',
|
||||
},
|
||||
{ tag: tags.special(tags.variableName), class: 'tok-variableName2' },
|
||||
{
|
||||
tag: tags.definition(tags.propertyName),
|
||||
class: 'tok-propertyName tok-definition',
|
||||
},
|
||||
{ tag: tags.typeName, class: 'tok-typeName' },
|
||||
{ tag: tags.namespace, class: 'tok-namespace' },
|
||||
{ tag: tags.className, class: 'tok-className' },
|
||||
{ tag: tags.macroName, class: 'tok-macroName' },
|
||||
{ tag: tags.propertyName, class: 'tok-propertyName' },
|
||||
{ tag: tags.operator, class: 'tok-operator' },
|
||||
{ tag: tags.comment, class: 'tok-comment' },
|
||||
{ tag: tags.meta, class: 'tok-meta' },
|
||||
{ tag: tags.invalid, class: 'tok-invalid' },
|
||||
{ tag: tags.punctuation, class: 'tok-punctuation' },
|
||||
// additional
|
||||
{ tag: tags.attributeValue, class: 'tok-attributeValue' },
|
||||
])
|
||||
@@ -0,0 +1,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,
|
||||
]
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
})
|
||||
@@ -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),
|
||||
]),
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -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 }),
|
||||
})
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -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'))
|
||||
}
|
||||
})
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
@@ -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'
|
||||
),
|
||||
})
|
||||
)
|
||||
@@ -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 }),
|
||||
])
|
||||
}
|
||||
@@ -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(),
|
||||
]
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
])
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}),
|
||||
]
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
@@ -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 {}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
])
|
||||
)
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const WORD_REGEX = /\\?['\p{L}]+/gu
|
||||
@@ -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),
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -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'))
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
@@ -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{${}}'),
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -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`,
|
||||
}),
|
||||
]
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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 `
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { EditorSelection } from '@codemirror/state'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { SyntaxNode } from '@lezer/common'
|
||||
|
||||
export const selectNode = (view: EditorView, node: SyntaxNode) => {
|
||||
view.dispatch({
|
||||
selection: EditorSelection.single(node.from + 1, node.to - 1),
|
||||
scrollIntoView: true,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
])
|
||||
)
|
||||
@@ -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,
|
||||
]
|
||||
@@ -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')
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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%'
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user