diff --git a/services/web/.eslintignore b/services/web/.eslintignore index af55608e46..3b4343dcca 100644 --- a/services/web/.eslintignore +++ b/services/web/.eslintignore @@ -4,5 +4,5 @@ modules/**/scripts frontend/js/vendor modules/**/frontend/js/vendor /public/ -modules/source-editor/frontend/js/lezer-latex/latex.mjs -modules/source-editor/frontend/js/lezer-latex/latex.terms.mjs +frontend/js/features/source-editor/lezer-latex/latex.mjs +frontend/js/features/source-editor/lezer-latex/latex.terms.mjs diff --git a/services/web/.gitignore b/services/web/.gitignore index 8783e87e63..ad9693e9a8 100644 --- a/services/web/.gitignore +++ b/services/web/.gitignore @@ -92,10 +92,10 @@ cypress/results/ !modules/history-migration/test/unit/src/data/track-changes-project.zip # Ace themes for conversion -modules/source-editor/frontend/js/themes/ace/ +frontend/js/features/source-editor/themes/ace/ # Compiled parser files -modules/source-editor/frontend/js/lezer-latex/latex.mjs -modules/source-editor/frontend/js/lezer-latex/latex.terms.mjs +frontend/js/features/source-editor/lezer-latex/latex.mjs +frontend/js/features/source-editor/lezer-latex/latex.terms.mjs !**/fixtures/**/*.log diff --git a/services/web/.prettierignore b/services/web/.prettierignore index f60bda48ce..bd3c471b9e 100644 --- a/services/web/.prettierignore +++ b/services/web/.prettierignore @@ -6,5 +6,5 @@ modules/**/frontend/js/vendor public/js public/minjs frontend/stylesheets/components/nvd3.less -modules/source-editor/frontend/js/lezer-latex/latex.mjs -modules/source-editor/frontend/js/lezer-latex/latex.terms.mjs +frontend/js/features/source-editor/lezer-latex/latex.mjs +frontend/js/features/source-editor/lezer-latex/latex.terms.mjs diff --git a/services/web/app/views/project/editor/editor-pane.pug b/services/web/app/views/project/editor/editor-pane.pug index a67e7eb377..e952a075ce 100644 --- a/services/web/app/views/project/editor/editor-pane.pug +++ b/services/web/app/views/project/editor/editor-pane.pug @@ -49,13 +49,10 @@ else .toolbar.toolbar-editor - if !moduleIncludesAvailable('editor:source-editor') + div(ng-if="editor.newSourceEditor") + include ../../source-editor/source-editor + div(ng-if="!editor.newSourceEditor") include ./source-editor - else - div(ng-if="editor.newSourceEditor") - != moduleIncludes('editor:source-editor', locals) - div(ng-if="!editor.newSourceEditor") - include ./source-editor if !isRestrictedTokenMember include ./review-panel diff --git a/services/web/app/views/project/editor/meta.pug b/services/web/app/views/project/editor/meta.pug index 50c219016f..a826cb9c23 100644 --- a/services/web/app/views/project/editor/meta.pug +++ b/services/web/app/views/project/editor/meta.pug @@ -23,7 +23,6 @@ meta(name="ol-wsRetryHandshake" data-type="json" content=settings.wsRetryHandsha meta(name="ol-pdfjsVariant" content=pdfjsVariant) meta(name="ol-debugPdfDetach" data-type="boolean" content=debugPdfDetach) meta(name="ol-showLegacySourceEditor", data-type="boolean" content=showLegacySourceEditor) -meta(name="ol-hasNewSourceEditor", data-type="boolean" content=moduleIncludesAvailable('editor:source-editor')) meta(name="ol-showSymbolPalette" data-type="boolean" content=showSymbolPalette) meta(name="ol-galileoEnabled" data-type="string" content=galileoEnabled) meta(name="ol-galileoPromptWords" data-type="string" content=galileoPromptWords) diff --git a/services/web/app/views/source-editor/source-editor.pug b/services/web/app/views/source-editor/source-editor.pug new file mode 100644 index 0000000000..e70469a1ce --- /dev/null +++ b/services/web/app/views/source-editor/source-editor.pug @@ -0,0 +1,4 @@ +source-editor#editor( + ng-if="!editor.showRichText" + ng-show="!!editor.sharejs_doc && !editor.opening && multiSelectedCount === 0 && !editor.error_state" +) diff --git a/services/web/cypress/support/webpack.cypress.ts b/services/web/cypress/support/webpack.cypress.ts index aaacd2e7d6..45b2ea596d 100644 --- a/services/web/cypress/support/webpack.cypress.ts +++ b/services/web/cypress/support/webpack.cypress.ts @@ -31,7 +31,7 @@ const buildConfig = () => { // add entrypoint under '/' for latex-linter worker addWorker( 'latex-linter-worker', - '../../modules/source-editor/frontend/js/languages/latex/linter/latex-linter.worker.js' + '../../frontend/js/features/source-editor/languages/latex/linter/latex-linter.worker.js' ) // add entrypoints under '/' for pdfjs workers diff --git a/services/web/frontend/js/features/editor-left-menu/components/settings/settings-font-family.tsx b/services/web/frontend/js/features/editor-left-menu/components/settings/settings-font-family.tsx index 7e22d33245..e239e509b7 100644 --- a/services/web/frontend/js/features/editor-left-menu/components/settings/settings-font-family.tsx +++ b/services/web/frontend/js/features/editor-left-menu/components/settings/settings-font-family.tsx @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next' -import { FontFamily } from '../../../../../../modules/source-editor/frontend/js/extensions/theme' +import { FontFamily } from '../../../source-editor/extensions/theme' import { useProjectSettingsContext } from '../../context/project-settings-context' import SettingsMenuSelect from './settings-menu-select' diff --git a/services/web/frontend/js/features/editor-left-menu/components/settings/settings-line-height.tsx b/services/web/frontend/js/features/editor-left-menu/components/settings/settings-line-height.tsx index 97ad38e8b4..6694306fe5 100644 --- a/services/web/frontend/js/features/editor-left-menu/components/settings/settings-line-height.tsx +++ b/services/web/frontend/js/features/editor-left-menu/components/settings/settings-line-height.tsx @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next' -import type { LineHeight } from '../../../../../../modules/source-editor/frontend/js/extensions/theme' +import type { LineHeight } from '../../../source-editor/extensions/theme' import { useProjectSettingsContext } from '../../context/project-settings-context' import SettingsMenuSelect from './settings-menu-select' diff --git a/services/web/frontend/js/features/editor-left-menu/components/settings/settings-overall-theme.tsx b/services/web/frontend/js/features/editor-left-menu/components/settings/settings-overall-theme.tsx index 7695e591b1..9cd8087c1d 100644 --- a/services/web/frontend/js/features/editor-left-menu/components/settings/settings-overall-theme.tsx +++ b/services/web/frontend/js/features/editor-left-menu/components/settings/settings-overall-theme.tsx @@ -5,7 +5,7 @@ import getMeta from '../../../../utils/meta' import SettingsMenuSelect, { Option } from './settings-menu-select' import { useProjectSettingsContext } from '../../context/project-settings-context' import type { OverallThemeMeta } from '../../../../../../types/project-settings' -import type { OverallTheme } from '../../../../../../modules/source-editor/frontend/js/extensions/theme' +import type { OverallTheme } from '../../../source-editor/extensions/theme' export default function SettingsOverallTheme() { const { t } = useTranslation() diff --git a/services/web/frontend/js/features/editor-left-menu/utils/api.ts b/services/web/frontend/js/features/editor-left-menu/utils/api.ts index 5cda4d9aa4..c573542b84 100644 --- a/services/web/frontend/js/features/editor-left-menu/utils/api.ts +++ b/services/web/frontend/js/features/editor-left-menu/utils/api.ts @@ -2,7 +2,7 @@ import type { FontFamily, LineHeight, OverallTheme, -} from '../../../../../modules/source-editor/frontend/js/extensions/theme' +} from '../../source-editor/extensions/theme' import type { Keybindings, PdfViewer, diff --git a/services/web/frontend/js/features/source-editor/commands/cursor.ts b/services/web/frontend/js/features/source-editor/commands/cursor.ts new file mode 100644 index 0000000000..0507823de3 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/commands/cursor.ts @@ -0,0 +1,27 @@ +import { EditorView } from '@codemirror/view' +import { EditorSelection } from '@codemirror/state' + +export const cloneSelectionVertically = + (forward: boolean, cumulative: boolean) => (view: EditorView) => { + const { main, ranges, mainIndex } = view.state.selection + const { anchor, head, goalColumn } = main + const start = EditorSelection.range(anchor, head, goalColumn) + const nextRange = view.moveVertically(start, forward) + let filteredRanges = [...ranges] + + if (!cumulative && filteredRanges.length > 1) { + // remove the current main range + filteredRanges.splice(mainIndex, 1) + } + + // prevent duplication when going in the opposite direction + filteredRanges = filteredRanges.filter( + item => item.from !== nextRange.from && item.to !== nextRange.to + ) + const selection = EditorSelection.create( + filteredRanges.concat(nextRange), + filteredRanges.length + ) + view.dispatch({ selection }) + return true + } diff --git a/services/web/frontend/js/features/source-editor/commands/indent.ts b/services/web/frontend/js/features/source-editor/commands/indent.ts new file mode 100644 index 0000000000..ff5ce705b7 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/commands/indent.ts @@ -0,0 +1,47 @@ +import { EditorSelection } from '@codemirror/state' +import { getIndentUnit, indentString, indentUnit } from '@codemirror/language' +import { EditorView } from '@codemirror/view' + +export const indentMore = (view: EditorView) => { + view.dispatch( + view.state.changeByRange(range => { + const doc = view.state.doc + + const changes = [] + + if (range.empty) { + // insert space(s) at the cursor + const line = doc.lineAt(range.from) + const unit = getIndentUnit(view.state) + const offset = range.from - line.from + const cols = unit - (offset % unit) + const insert = indentString(view.state, cols) + + changes.push({ from: range.from, insert }) + } else { + // indent selected lines + const insert = view.state.facet(indentUnit) + let previousLineNumber = -1 + for (let pos = range.from; pos <= range.to; pos++) { + const line = doc.lineAt(pos) + if (previousLineNumber === line.number) { + continue + } + changes.push({ from: line.from, insert }) + previousLineNumber = line.number + } + } + + const changeSet = view.state.changes(changes) + + return { + changes: changeSet, + range: EditorSelection.range( + changeSet.mapPos(range.anchor, 1), + changeSet.mapPos(range.head, 1) + ), + } + }) + ) + return true +} diff --git a/services/web/frontend/js/features/source-editor/commands/ranges.ts b/services/web/frontend/js/features/source-editor/commands/ranges.ts new file mode 100644 index 0000000000..bc2f354579 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/commands/ranges.ts @@ -0,0 +1,744 @@ +import { EditorView } from '@codemirror/view' +import { EditorSelection, EditorState, SelectionRange } from '@codemirror/state' +import { + ensureSyntaxTree, + foldedRanges, + foldEffect, + syntaxTree, +} from '@codemirror/language' +import { SyntaxNode } from '@lezer/common' +import { + ancestorOfNodeWithType, + isUnknownCommandWithName, +} from '../utils/tree-query' + +export const wrapRanges = + ( + prefix: string, + suffix: string, + wrapWholeLine = false, + selection?: (range: SelectionRange) => SelectionRange + ) => + (view: EditorView): boolean => { + if (view.state.readOnly) { + return false + } + view.dispatch( + view.state.changeByRange(range => { + const insert = { prefix, suffix } + + if (wrapWholeLine) { + const line = view.state.doc.lineAt(range.anchor) + + if (range.empty) { + // expand range to cover the whole line + range = EditorSelection.range(line.from, line.to) + } + + // add a newline at the start if needed + if (range.from !== line.from) { + insert.prefix = `\n${prefix}` + } + + // add a newline at the end if needed + if (range.to !== line.to) { + insert.suffix = `${suffix}\n` + } + } + + const content = view.state.sliceDoc(range.from, range.to) + + // map through the prefix only + const changedRange = range.map( + view.state.changes([ + { from: range.from, insert: `${insert.prefix}` }, + ]), + 1 + ) + + return { + range: selection ? selection(changedRange) : changedRange, + // create a single change, including the content + changes: [ + { + from: range.from, + to: range.to, + insert: `${insert.prefix}${content}${insert.suffix}`, + }, + ], + } + }) + ) + return true + } + +export const changeCase = + (upper = true) => + (view: EditorView) => { + if (view.state.readOnly) { + return false + } + view.dispatch( + view.state.changeByRange(range => { + // ignore empty ranges + if (range.empty) { + return { range } + } + + const text = view.state.doc.sliceString(range.from, range.to) + + return { + range, + changes: [ + { + from: range.from, + to: range.to, + insert: upper ? text.toUpperCase() : text.toLowerCase(), + }, + ], + } + }) + ) + return true + } + +export const duplicateSelection = (view: EditorView) => { + if (view.state.readOnly) { + return false + } + const foldedRangesInDocument = foldedRanges(view.state) + view.dispatch( + view.state.changeByRange(range => { + const folds: { offset: number; base: number; length: number }[] = [] + if (range.empty) { + const line = view.state.doc.lineAt(range.from) + let lineStart = line.from + let lineEnd = line.to + + // Calculate line start/end including folded ranges + // + // Note that at each iteration of the while loop, new folded ranges + // can be included. This happens when there are multiple folded ranges + // on a single editor line (but spanning multiple actual lines) + // + // For example, the following document: + // 1: \begin{document} + // 2: test + // 3: \end{document}\begin{document} + // 4: test + // 5: \end{document} + // + // Can be folded to: + // \begin{document}<...>\end{document}\begin{document}<...>\end{document} + // + // In this case, the first iterations of the while loop below will only + // include lines 3-5, since the overlapping folded range for line 5 + // is only the fold on lines 3-5. Hence in the while loop, we expand the + // selection until we include all the ranges. + let changed + do { + changed = false + foldedRangesInDocument.between(lineStart, lineEnd, (from, to) => { + const newLineStart = Math.min( + view.state.doc.lineAt(from).from, + lineStart + ) + const newLineEnd = Math.max(view.state.doc.lineAt(to).to, lineEnd) + if (newLineStart !== lineStart || newLineEnd !== lineEnd) { + changed = true + lineStart = newLineStart + lineEnd = newLineEnd + } + }) + } while (changed) + + // Collect information needed to fold duplicated lines + foldedRangesInDocument.between(lineStart, lineEnd, (from, to) => { + folds.push({ + offset: from - lineStart, + base: lineEnd + view.state.lineBreak.length, + length: to - from, + }) + }) + + // Duplicate the selected lines downwards + return { + range, + changes: [ + { + from: lineEnd, + insert: + view.state.lineBreak + view.state.doc.slice(lineStart, lineEnd), + }, + ], + // Add a fold effect for each fold in the original line + effects: folds.map(fold => + foldEffect.of({ + from: fold.base + fold.offset, + to: fold.base + fold.offset + fold.length, + }) + ), + } + } else { + // Duplicate selected text at head of selection + let newSelectionRange = range + if (range.head > range.anchor) { + // Duplicating to the right, so we need to update the selected range + newSelectionRange = EditorSelection.range( + range.head, + range.head + (range.to - range.from) + ) + } + return { + // The new range is the duplicated section, placed at the head of the + // original selection + range: newSelectionRange, + changes: [ + { + from: range.head, + insert: view.state.doc.slice(range.from, range.to), + }, + ], + } + } + }) + ) + return true +} + +function getParentNode( + position: number | SyntaxNode, + state: EditorState, + assoc: 0 | 1 | -1 = 1 +): SyntaxNode | undefined { + const tree = ensureSyntaxTree(state, 1000) + let node: SyntaxNode | undefined | null = + typeof position === 'number' + ? tree?.resolveInner(position, assoc) + : position + node = node?.parent + while ( + ['LongArg', 'TextArgument', 'OpenBrace', 'CloseBrace'].includes( + node?.type.name || '' + ) + ) { + node = node!.parent + } + return node || undefined +} + +function wrapRangeInCommand( + state: EditorState, + range: SelectionRange, + command: string +) { + const content = state.sliceDoc(range.from, range.to) + const changes = state.changes([ + { + from: range.from, + to: range.to, + insert: `${command}{${content}}`, + }, + ]) + return { + changes, + range: moveRange( + range, + range.from + command.length + 1, + range.from + command.length + content.length + 1 + ), + } +} + +function moveRange(range: SelectionRange, newFrom: number, newTo: number) { + const forwards = range.from === range.anchor + return forwards + ? EditorSelection.range(newFrom, newTo) + : EditorSelection.range(newTo, newFrom) +} + +function validateReplacement(expected: string, actual: string) { + if (expected !== actual) { + throw new Error( + `Replacement in toggleRange failed validation. Expected ${expected} got ${actual}` + ) + } +} + +function getWrappingAncestor( + node: SyntaxNode, + command: string, + state: EditorState +): SyntaxNode | null { + for ( + let ancestor: SyntaxNode | null = node; + ancestor; + ancestor = ancestor.parent + ) { + if (isUnknownCommandWithName(ancestor, command, state)) { + return ancestor + } + if (ancestor.type.is('UnknownCommand')) { + // We could multiple levels deep in bold/non-bold. So bail out in this case + return null + } + } + return null +} + +function adjustRangeIfNeeded( + command: string, + range: SelectionRange, + state: EditorState +) { + // Try to adjust the selection, if it is either + // 1. \textbf<{test>} + // 2. \textbf{ + // 3. \textbf<{test}> + // 4. <\textbf{test}> + // 4. \textbf<>{test} + const tree = syntaxTree(state) + if (tree.length < range.to) { + return range + } + + const nodeLeft = tree.resolveInner(range.from, 1) + const nodeRight = tree.resolveInner(range.to, -1) + const parentLeft = getWrappingAncestor(nodeLeft, command, state) + const parentRight = getWrappingAncestor(nodeRight, command, state) + + const parent = getParentNode(nodeLeft, state) + if (parent?.type.is('UnknownCommand') && spansWholeArgument(parent, range)) { + return bubbleUpRange( + command, + ancestorOfNodeWithType(nodeLeft, 'UnknownCommand'), + range, + state + ) + } + + if (!parentLeft) { + // We're not trying to unbold, so don't bother adjusting range + return bubbleUpRange( + command, + ancestorOfNodeWithType(nodeLeft, 'UnknownCommand'), + range, + state + ) + } + if (nodeLeft.type.is('CtrlSeq') && range.from === range.to) { + const command = nodeLeft.parent?.parent + if (!command) { + return range + } + return EditorSelection.cursor(command.from) + } + + let { from, to } = range + if (nodeLeft.type.is('CtrlSeq')) { + from = nodeLeft.to + 1 + } + if (nodeLeft.type.is('OpenBrace')) { + from = nodeLeft.to + } + // We know that parentLeft is the UnknownCommand, so now we check if we're + // to the right of the closing brace. (parent is TextArgument, grandparent is + // UnknownCommand) + if (parentLeft === parentRight && nodeRight.type.is('CloseBrace')) { + to = nodeRight.from + } + return bubbleUpRange(command, parentLeft, moveRange(range, from, to), state) +} + +function spansWholeArgument( + commandNode: SyntaxNode | null, + range: SelectionRange +): boolean { + const argument = commandNode?.getChild('TextArgument')?.getChild('LongArg') + const res = Boolean( + argument && argument.from === range.from && argument.to === range.to + ) + return res +} + +function bubbleUpRange( + command: string, + node: SyntaxNode | null, + range: SelectionRange, + state: EditorState +) { + let currentRange = range + for ( + let ancestorCommand: SyntaxNode | null = ancestorOfNodeWithType( + node, + 'UnknownCommand' + ); + spansWholeArgument(ancestorCommand, currentRange); + ancestorCommand = ancestorOfNodeWithType( + ancestorCommand.parent, + 'UnknownCommand' + ) + ) { + if (!ancestorCommand) { + break + } + currentRange = moveRange( + currentRange, + ancestorCommand.from, + ancestorCommand.to + ) + if (isUnknownCommandWithName(ancestorCommand, command, state)) { + const argumentNode = ancestorCommand + .getChild('TextArgument') + ?.getChild('LongArg') + if (!argumentNode) { + return range + } + return moveRange(range, argumentNode.from, argumentNode.to) + } + } + + return range +} + +export function toggleRanges(command: string) { + /* There are a number of situations we need to handle in this function. + * In the following examples, the selection range is marked within <> + + * 1. If the parent node at start and end of selection is different -> do + * nothing & show error. Case 8 is an exception to this. + * -> For good and bad, this disallows \textbf{this do} + * 2. If selection contains a BlankLine (i.e. two newlines in a row) -> do + * nothing & show error + * -> \textbf doesn't allow paragraph breaks). + * 3. If the selection is not within a \textbf -> wrap it in a \textbf + * 4. If selection is at the beginning of a \textbf -> shrink the \textbf + * command + * -> e.g. \textbf{ a test} → \textbf{ a test} + * 5. Similarly for selection at end of \textbf command + * 6. If selection is in the middle of a \textbf command -> split the command + * into two + * -> e.g. \textbf{this test} → \textbf{this }\textbf{ test} + * 7. If the selection is a whole \textbf command → remove the wrapping + * command. + * 8. If the selection spans two \textbf's with the same parent then join the + * two + * -> e.g. \textbf{this st} → \textbf{this st} + * 9. If the selection spans the end of a \textbf, into the parent of the + * command, then extend the \textbf. + * -> e.g. \textbf{this → \textbf{this } + * 10. Similarly for selections spanning the beginning of the selection + */ + return (view: EditorView): boolean => { + if (view.state.readOnly) { + return false + } + view.dispatch( + view.state.changeByRange(initialRange => { + const range = adjustRangeIfNeeded(command, initialRange, view.state) + const content = view.state.sliceDoc(range.from, range.to) + + const ancestorAtStartOfRange = getParentNode( + range.from, + view.state, + range.from === 0 ? 1 : -1 + ) + const ancestorAtEndOfRange = range.empty + ? ancestorAtStartOfRange + : getParentNode( + range.to, + view.state, + range.to === view.state.doc.length ? -1 : 1 + ) + + if (ancestorAtStartOfRange !== ancestorAtEndOfRange) { + // But handle the exception of case 8 + const ancestorAtStartIsWrappingCommand = + ancestorAtStartOfRange && + isUnknownCommandWithName( + ancestorAtStartOfRange, + command, + view.state + ) + const ancestorAtEndIsWrappingCommand = + ancestorAtEndOfRange && + isUnknownCommandWithName(ancestorAtEndOfRange, command, view.state) + if ( + ancestorAtStartIsWrappingCommand && + ancestorAtEndIsWrappingCommand && + ancestorAtStartOfRange?.parent?.parent && + ancestorAtEndOfRange?.parent?.parent + ) { + // Test for case 8 + const nextAncestorAtStartOfRange = + ancestorAtStartOfRange.parent.parent + const nextAncestorAtEndOfRange = ancestorAtEndOfRange.parent.parent + + if (nextAncestorAtStartOfRange === nextAncestorAtEndOfRange) { + // Join the two ranges + const textBetweenRanges = view.state.sliceDoc( + ancestorAtStartOfRange.to, + ancestorAtEndOfRange.from + ) + const ancestorStartArgumentNode = + ancestorAtStartOfRange.lastChild?.getChild('LongArg') + const ancestorEndArgumentNode = + ancestorAtEndOfRange.lastChild?.getChild('LongArg') + if (!ancestorStartArgumentNode || !ancestorEndArgumentNode) { + throw new Error("Can't find argument node") + } + const actualContent = view.state.sliceDoc( + ancestorAtStartOfRange.from, + ancestorAtEndOfRange.to + ) + const firstCommandArgument = view.state.sliceDoc( + ancestorStartArgumentNode.from, + ancestorStartArgumentNode.to + ) + const secondCommandArgument = view.state.sliceDoc( + ancestorEndArgumentNode.from, + ancestorEndArgumentNode.to + ) + validateReplacement( + `${command}{${firstCommandArgument}}${textBetweenRanges}${command}{${secondCommandArgument}}`, + actualContent + ) + const changes = view.state.changes([ + { + from: ancestorAtStartOfRange.from, + to: ancestorAtEndOfRange.to, + insert: `${command}{${firstCommandArgument}${textBetweenRanges}${secondCommandArgument}}`, + }, + ]) + return { + changes, + range: moveRange( + range, + range.from, + range.to - command.length - 1 - 1 + ), + } + } + } + + if ( + ancestorAtEndIsWrappingCommand && + ancestorAtEndOfRange.parent?.parent === ancestorAtStartOfRange + ) { + // Extend to the left. Case 10 + const contentUpToCommand = view.state.sliceDoc( + range.from, + ancestorAtEndOfRange.from + ) + const ancestorEndArgumentNode = + ancestorAtEndOfRange.lastChild?.getChild('LongArg') + if (!ancestorEndArgumentNode) { + throw new Error("Can't find argument node") + } + const commandContent = view.state.sliceDoc( + ancestorEndArgumentNode.from, + ancestorEndArgumentNode.to + ) + const actualContent = view.state.sliceDoc( + range.from, + ancestorAtEndOfRange.to + ) + validateReplacement( + `${contentUpToCommand}${command}{${commandContent}}`, + actualContent + ) + const changes = view.state.changes([ + { + from: range.from, + to: ancestorAtEndOfRange.to, + insert: `${command}{${contentUpToCommand}${commandContent}}`, + }, + ]) + return { + changes, + range: moveRange( + range, + range.from + command.length + 1, + range.to + ), + } + } + + if ( + ancestorAtStartIsWrappingCommand && + ancestorAtStartOfRange.parent?.parent === ancestorAtEndOfRange + ) { + // Extend to the right. Case 9 + const contentAfterCommand = view.state.sliceDoc( + ancestorAtStartOfRange.to, + range.to + ) + const ancestorStartArgumentNode = + ancestorAtStartOfRange.lastChild?.getChild('LongArg') + if (!ancestorStartArgumentNode) { + throw new Error("Can't find argument node") + } + const commandContent = view.state.sliceDoc( + ancestorStartArgumentNode.from, + ancestorStartArgumentNode.to + ) + const actualContent = view.state.sliceDoc( + ancestorAtStartOfRange.from, + range.to + ) + validateReplacement( + `${command}{${commandContent}}${contentAfterCommand}`, + actualContent + ) + const changes = view.state.changes([ + { + from: ancestorAtStartOfRange.from, + to: range.to, + insert: `${command}{${commandContent}${contentAfterCommand}}`, + }, + ]) + return { + changes, + range: moveRange(range, range.from, range.to - 1), + } + } + // Bail out in case 1 + // TODO: signal error to the user + return { range } + } + + const ancestor = ancestorAtStartOfRange + + // Bail out in case 2 + if (content.includes('\n\n')) { + // TODO: signal error to the user + return { range } + } + + const isCursorBeforeAncestor = + range.empty && + ancestor && + range.from === ancestor.from && + isUnknownCommandWithName(ancestor, command, view.state) + + // If we can't find an ancestor node, or if the parent is not an exsting + // \textbf, then we just wrap it in a range. Case 3. + if ( + isCursorBeforeAncestor || + !ancestor || + !isUnknownCommandWithName(ancestor, command, view.state) + ) { + return wrapRangeInCommand(view.state, range, command) + } + + const argumentNode = ancestor.lastChild?.getChild('LongArg') + if (!argumentNode) { + throw new Error("Can't find argument node") + } + + // We should trim from the beginning. Case 4 + if (range.from === argumentNode.from && range.to !== argumentNode.to) { + const textAfterSelection = view.state.sliceDoc( + range.to, + argumentNode.to + ) + const wholeCommand = view.state.sliceDoc(ancestor.from, ancestor.to) + validateReplacement( + `${command}{${content}${textAfterSelection}}`, + wholeCommand + ) + const changes = view.state.changes([ + { + from: ancestor.from, + to: ancestor.to, + insert: `${content}${command}{${textAfterSelection}}`, + }, + ]) + return { + range: moveRange( + range, + range.from - command.length - 1, + range.to - command.length - 1 + ), + changes, + } + } + + // We should trim from the end. Case 5 + if (range.to === argumentNode.to && range.from !== argumentNode.from) { + const textBeforeSelection = view.state.sliceDoc( + ancestor.from, + range.from + ) + const wholeCommand = view.state.sliceDoc(ancestor.from, ancestor.to) + validateReplacement(`${textBeforeSelection}${content}}`, wholeCommand) + const changes = view.state.changes([ + { + from: ancestor.from, + to: ancestor.to, + insert: `${textBeforeSelection}}${content}`, + }, + ]) + // We should shift selection forward by the } we insert + return { + range: moveRange(range, range.from + 1, range.to + 1), + changes, + } + } + + // We should split the command in two. Case 6 + if (range.from !== argumentNode.from && range.to !== argumentNode.to) { + const textBeforeSelection = view.state.sliceDoc( + ancestor.from, + range.from + ) + const textAfterSelection = view.state.sliceDoc(range.to, ancestor.to) + const wholeCommand = view.state.sliceDoc(ancestor.from, ancestor.to) + validateReplacement( + `${textBeforeSelection}${content}${textAfterSelection}`, + wholeCommand + ) + const changes = view.state.changes([ + { + from: ancestor.from, + to: ancestor.to, + insert: `${textBeforeSelection}}${content}${command}{${textAfterSelection}`, + }, + ]) + + return { + range: moveRange(range, range.from + 1, range.to + 1), + changes, + } + } + + // Remove the wrapping command. Case 7 + if (spansWholeArgument(ancestor, range)) { + const argumentContent = view.state.sliceDoc( + argumentNode.from, + argumentNode.to + ) + const wholeCommand = view.state.sliceDoc(ancestor.from, ancestor.to) + validateReplacement(`${command}{${content}}`, wholeCommand) + const changes = view.state.changes([ + { from: ancestor.from, to: ancestor.to, insert: argumentContent }, + ]) + + return { + range: moveRange( + range, + range.from - command.length - 1, + range.to - command.length - 1 + ), + changes, + } + } + + // Shouldn't happen, but default to just wrapping the content + return wrapRangeInCommand(view.state, range, command) + }) + ) + return true + } +} diff --git a/services/web/frontend/js/features/source-editor/commands/select.ts b/services/web/frontend/js/features/source-editor/commands/select.ts new file mode 100644 index 0000000000..aaa3bcbb7a --- /dev/null +++ b/services/web/frontend/js/features/source-editor/commands/select.ts @@ -0,0 +1,120 @@ +import { EditorView } from '@codemirror/view' +import { EditorSelection, Text } from '@codemirror/state' +import { selectNextOccurrence, SearchCursor } from '@codemirror/search' + +type Spec = { + caseSensitive?: boolean + unquoted: string +} + +const stringCursor = (spec: Spec, doc: Text, from: number, to: number) => { + return new SearchCursor( + doc, + spec.unquoted, + from, + to, + spec.caseSensitive ? undefined : x => x.toLowerCase() + ) +} + +class QueryType { + protected spec + + constructor(spec: Spec) { + this.spec = spec + } +} + +class StringQuery extends QueryType { + // Searching in reverse is, rather than implementing inverted search + // cursor, done by scanning chunk after chunk forward. + prevMatchInRange(doc: Text, from: number, to: number) { + for (let pos = to; ; ) { + const start = Math.max( + from, + pos - 10000 /* ChunkSize */ - this.spec.unquoted.length + ) + const cursor = stringCursor(this.spec, doc, start, pos) + let range = null + + while (!cursor.nextOverlapping().done) { + range = cursor.value + } + + if (range) { + return range + } + + if (start === from) { + return null + } + + pos -= 10000 /* ChunkSize */ + } + } + + prevMatch(doc: Text, curFrom: number, curTo: number) { + return ( + this.prevMatchInRange(doc, 0, curFrom) || + this.prevMatchInRange(doc, curTo, doc.length) + ) + } +} + +const selectWord = (view: EditorView) => { + const { selection } = view.state + const newSelection = EditorSelection.create( + selection.ranges.map( + range => + view.state.wordAt(range.head) || EditorSelection.cursor(range.head) + ), + selection.mainIndex + ) + + if (newSelection.eq(selection)) { + return false + } + + view.dispatch(view.state.update({ selection: newSelection })) + + return true +} + +const selectPrevOccurrence = (view: EditorView) => { + const { state } = view + const { ranges } = state.selection + + if (ranges.some(range => range.from === range.to)) { + return selectWord(view) + } + + const searchedText = state.sliceDoc(ranges[0].from, ranges[0].to) + + if ( + state.selection.ranges.some( + range => state.sliceDoc(range.from, range.to) !== searchedText + ) + ) { + return false + } + + const query = new StringQuery({ unquoted: searchedText }) + const { main } = state.selection + const range = query.prevMatch(state.doc, main.from, main.to) + + if (!range) { + return false + } + + view.dispatch({ + selection: state.selection.addRange( + EditorSelection.range(range.from, range.to) + ), + effects: EditorView.scrollIntoView(range.to), + }) + + return true +} + +export const selectOccurrence = (forward: boolean) => (view: EditorView) => + forward ? selectNextOccurrence(view) : selectPrevOccurrence(view) diff --git a/services/web/frontend/js/features/source-editor/components/codemirror-editor.tsx b/services/web/frontend/js/features/source-editor/components/codemirror-editor.tsx new file mode 100644 index 0000000000..0287e5e538 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/components/codemirror-editor.tsx @@ -0,0 +1,96 @@ +import { + createContext, + ElementType, + memo, + useContext, + useRef, + useState, +} from 'react' +import useIsMounted from '../../../shared/hooks/use-is-mounted' +import { EditorView } from '@codemirror/view' +import { EditorState } from '@codemirror/state' +import CodeMirrorView from './codemirror-view' +import CodeMirrorSearch from './codemirror-search' +import { CodeMirrorToolbar } from './codemirror-toolbar' +import { CodemirrorOutline } from './codemirror-outline' +import { dispatchTimer } from '../../../infrastructure/cm6-performance' + +import importOverleafModules from '../../../../macros/import-overleaf-module.macro' + +const sourceEditorComponents = importOverleafModules( + 'sourceEditorComponents' +) as { import: { default: ElementType }; path: string }[] + +function CodeMirrorEditor() { + // create the initial state + const [state, setState] = useState(() => { + return EditorState.create() + }) + + const isMounted = useIsMounted() + + // create the view using the initial state and intercept transactions + const viewRef = useRef(null) + if (viewRef.current === null) { + const timer = dispatchTimer() + + const view = new EditorView({ + state, + dispatch: tr => { + timer.start(tr) + view.update([tr]) + if (isMounted.current) { + setState(view.state) + } + timer.end(tr, view) + }, + }) + viewRef.current = view + } + + return ( + + + + + + + {sourceEditorComponents.map( + ({ import: { default: Component }, path }) => ( + + ) + )} + + + ) +} + +export default memo(CodeMirrorEditor) + +const CodeMirrorStateContext = createContext(undefined) + +export const useCodeMirrorStateContext = (): EditorState => { + const context = useContext(CodeMirrorStateContext) + + if (!context) { + throw new Error( + 'useCodeMirrorStateContext is only available inside CodeMirrorEditor' + ) + } + + return context +} + +const CodeMirrorViewContext = createContext(undefined) + +export const useCodeMirrorViewContext = (): EditorView => { + const context = useContext(CodeMirrorViewContext) + + if (!context) { + throw new Error( + 'useCodeMirrorViewContext is only available inside CodeMirrorEditor' + ) + } + + return context +} diff --git a/services/web/frontend/js/features/source-editor/components/codemirror-outline.tsx b/services/web/frontend/js/features/source-editor/components/codemirror-outline.tsx new file mode 100644 index 0000000000..2ee0ee50f5 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/components/codemirror-outline.tsx @@ -0,0 +1,156 @@ +import { createPortal } from 'react-dom' +import { useCodeMirrorStateContext } from './codemirror-editor' +import React, { useCallback, useMemo, useState } from 'react' +import OutlinePane from '../../outline/components/outline-pane' +import { documentOutline } from '../languages/latex/document-outline' +import isValidTeXFile from '../../../main/is-valid-tex-file' +import useScopeValue from '../../../shared/hooks/use-scope-value' +import useScopeEventEmitter from '../../../shared/hooks/use-scope-event-emitter' +import * as eventTracking from '../../../infrastructure/event-tracking' +import { nestOutline } from '../utils/tree-query' +import { ProjectionStatus } from '../utils/tree-operations/projection' +import useEventListener from '../../../shared/hooks/use-event-listener' +import useDeepCompareMemo from '../../../shared/hooks/use-deep-compare-memo' +import useDebounce from '../../../shared/hooks/use-debounce' + +const closestSectionLineNumber = ( + outline: { line: number }[] | undefined, + lineNumber: number +): number => { + if (!outline) { + return -1 + } + let highestLine = -1 + for (const section of outline) { + if (section.line > lineNumber) { + return highestLine + } + highestLine = section.line + } + return highestLine +} + +export const CodemirrorOutline = React.memo(function CodemirrorOutline() { + const state = useCodeMirrorStateContext() + const debouncedState = useDebounce(state, 100) + const [docName] = useScopeValue('editor.open_doc_name') + const goToLineEmitter = useScopeEventEmitter('editor:gotoLine', true) + const outlineToggledEmitter = useScopeEventEmitter('outline-toggled') + const [currentlyHighlightedLine, setCurrentlyHighlightedLine] = + useState(-1) + const isTexFile = useMemo(() => isValidTeXFile(docName), [docName]) + const [ignoreNextCursorUpdate, setIgnoreNextCursorUpdate] = + useState(false) + const [ignoreNextScroll, setIgnoreNextScroll] = useState(false) + const [binaryFileOpened, setBinaryFileOpened] = useState(false) + + useEventListener( + 'file-view:file-opened', + useCallback(_ => { + setBinaryFileOpened(true) + }, []) + ) + + useEventListener( + 'scroll:editor:update', + useCallback( + evt => { + if (ignoreNextScroll) { + setIgnoreNextScroll(false) + return + } + setCurrentlyHighlightedLine(evt.detail + 1) + }, + [ignoreNextScroll] + ) + ) + + useEventListener( + 'cursor:editor:update', + useCallback( + evt => { + if (ignoreNextCursorUpdate) { + setIgnoreNextCursorUpdate(false) + return + } + setCurrentlyHighlightedLine(evt.detail.row + 1) + }, + [ignoreNextCursorUpdate] + ) + ) + + useEventListener( + 'doc:after-opened', + useCallback(evt => { + if (evt.detail) { + setIgnoreNextCursorUpdate(true) + } + setBinaryFileOpened(false) + setIgnoreNextScroll(true) + }, []) + ) + + const outlineStatus = useMemo( + () => + debouncedState.field(documentOutline, false)?.status || + ProjectionStatus.Pending, + [debouncedState] + ) + + const flatOutline = useMemo(() => { + const outlineResult = debouncedState.field(documentOutline, false) + if (outlineResult?.status !== ProjectionStatus.Pending) { + // We have a (potentially partial) outline. + return outlineResult?.items!.map(element => { + // Remove {from, to} to not trip up deep comparison + const { level, title, line } = element + return { level, title, line } + }) + } + return undefined + }, [debouncedState]) + + const outline = useDeepCompareMemo(() => { + return flatOutline ? nestOutline(flatOutline) : [] + }, [flatOutline]) + + const jumpToLine = useCallback( + (lineNumber, syncToPdf) => { + setIgnoreNextScroll(true) + goToLineEmitter(lineNumber, 0, syncToPdf) + eventTracking.sendMB('outline-jump-to-line') + }, + [goToLineEmitter] + ) + + const onToggle = useCallback( + isOpen => { + outlineToggledEmitter(isOpen) + }, + [outlineToggledEmitter] + ) + + const highlightedLine = useMemo( + () => closestSectionLineNumber(flatOutline, currentlyHighlightedLine), + [flatOutline, currentlyHighlightedLine] + ) + + const outlineDomElement = document.querySelector('.outline-container') + if (!outlineDomElement) { + return null + } + + return createPortal( + , + outlineDomElement + ) +}) diff --git a/services/web/frontend/js/features/source-editor/components/codemirror-search-form.tsx b/services/web/frontend/js/features/source-editor/components/codemirror-search-form.tsx new file mode 100644 index 0000000000..3a7069bfa8 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/components/codemirror-search-form.tsx @@ -0,0 +1,422 @@ +import { + useCodeMirrorStateContext, + useCodeMirrorViewContext, +} from './codemirror-editor' +import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { runScopeHandlers } from '@codemirror/view' +import { + closeSearchPanel, + setSearchQuery, + SearchQuery, + findPrevious, + findNext, + replaceNext, + replaceAll, + getSearchQuery, + SearchCursor, +} from '@codemirror/search' +import { Button, ButtonGroup, FormControl, InputGroup } from 'react-bootstrap' +import { useTranslation } from 'react-i18next' +import Tooltip from '../../../shared/components/tooltip' +import Icon from '../../../shared/components/icon' +import classnames from 'classnames' +import useScopeValue from '../../../shared/hooks/use-scope-value' + +const MAX_MATCH_COUNT = 1000 + +type ActiveSearchOption = 'caseSensitive' | 'regexp' | 'wholeWord' | null + +const CodeMirrorSearchForm: FC = () => { + const view = useCodeMirrorViewContext() + const state = useCodeMirrorStateContext() + + const [keybindings] = useScopeValue('settings.mode') + const emacsKeybindingsActive = keybindings === 'emacs' + const [activeSearchOption, setActiveSearchOption] = + useState(null) + + // Generate random ID for option buttons. This is necessary because the label + // for each checkbox is separated from it in the DOM so that the buttons can + // be outside the natural tab order + const idSuffix = useMemo(() => Math.random().toString(16).slice(2), []) + const caseSensitiveId = 'caseSensitive' + idSuffix + const regexpId = 'regexp' + idSuffix + const wholeWordId = 'wholeWord' + idSuffix + + const { t } = useTranslation() + + const [position, setPosition] = useState<{ + current: number + total: number + } | null>(null) + + const formRef = useRef(null) + const inputRef = useRef(null) + const replaceRef = useRef(null) + + const handleInputRef = useCallback(node => { + inputRef.current = node + + // focus the search input when the panel opens + if (node) { + node.select() + node.focus() + } + }, []) + + const handleReplaceRef = useCallback(node => { + replaceRef.current = node + }, []) + + const handleSubmit = useCallback(event => { + event.preventDefault() + }, []) + + useEffect(() => { + const { from, to } = state.selection.main + + const query = getSearchQuery(state) + + if (query.valid) { + const cursor = query.getCursor(state.doc) as SearchCursor + + let total = 0 + let current = 0 + + while (!cursor.next().done) { + total++ + + if (total >= MAX_MATCH_COUNT) { + break + } + + const item = cursor.value + + if (current === 0 && item.from === from && item.to === to) { + current = total + } + } + + setPosition({ current, total }) + } else { + setPosition(null) + } + }, [state]) + + const handleChange = useCallback(() => { + if (formRef.current) { + const data = Object.fromEntries(new FormData(formRef.current)) + + const query = new SearchQuery({ + search: data.search as string, + replace: data.replace as string, + caseSensitive: data.caseSensitive === 'on', + regexp: data.regexp === 'on', + literal: true, + wholeWord: data.wholeWord === 'on', + }) + + view.dispatch({ effects: setSearchQuery.of(query) }) + } + }, [view]) + + const handleFormKeyDown = useCallback( + event => { + if (runScopeHandlers(view, event, 'search-panel')) { + event.preventDefault() + } + }, + [view] + ) + + // Returns true if the event was handled, false otherwise + const handleEmacsNavigation = useCallback( + event => { + const emacsCtrlSeq = + emacsKeybindingsActive && + event.ctrlKey && + !event.altKey && + !event.shiftKey + + if (!emacsCtrlSeq) { + return false + } + + switch (event.key) { + case 's': { + event.stopPropagation() + event.preventDefault() + findNext(view) + return true + } + case 'r': { + event.stopPropagation() + event.preventDefault() + findPrevious(view) + return true + } + case 'g': { + event.stopPropagation() + event.preventDefault() + closeSearchPanel(view) + document.dispatchEvent(new CustomEvent('cm:emacs-close-search-panel')) + return true + } + default: { + return false + } + } + }, + [view, emacsKeybindingsActive] + ) + + const handleSearchKeyDown = useCallback( + event => { + switch (event.key) { + case 'Enter': + event.preventDefault() + if (event.shiftKey) { + findPrevious(view) + } else { + findNext(view) + } + break + } + handleEmacsNavigation(event) + }, + [view, handleEmacsNavigation] + ) + + const handleReplaceKeyDown = useCallback( + event => { + switch (event.key) { + case 'Enter': + event.preventDefault() + replaceNext(view) + break + + case 'Tab': { + if (event.shiftKey) { + event.preventDefault() + inputRef.current?.focus() + } + } + } + handleEmacsNavigation(event) + }, + [view, handleEmacsNavigation] + ) + + const focusSearchBox = useCallback(() => { + inputRef.current?.focus() + }, []) + + const query = useMemo(() => { + return getSearchQuery(state) + }, [state]) + + return ( + // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ setActiveSearchOption('caseSensitive')} + onBlur={() => setActiveSearchOption(null)} + /> + + setActiveSearchOption('regexp')} + onBlur={() => setActiveSearchOption(null)} + /> + + setActiveSearchOption('wholeWord')} + onBlur={() => setActiveSearchOption(null)} + /> +
+ +
+ + + + + + + {position !== null && ( +
+ {position.total === MAX_MATCH_COUNT + ? `${position.current} ${t('of')} ${MAX_MATCH_COUNT}+` + : `${position.current} ${t('of')} ${position.total}`} +
+ )} +
+ +
+ + + +
+
+ +
+ +
+
+ ) +} + +export default CodeMirrorSearchForm diff --git a/services/web/frontend/js/features/source-editor/components/codemirror-search.tsx b/services/web/frontend/js/features/source-editor/components/codemirror-search.tsx new file mode 100644 index 0000000000..a533348518 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/components/codemirror-search.tsx @@ -0,0 +1,17 @@ +import { createPortal } from 'react-dom' +import CodeMirrorSearchForm from './codemirror-search-form' +import { useCodeMirrorViewContext } from './codemirror-editor' + +function CodeMirrorSearch() { + const view = useCodeMirrorViewContext() + + const dom = view.dom.querySelector('.ol-cm-search') + + if (!dom) { + return null + } + + return createPortal(, dom) +} + +export default CodeMirrorSearch diff --git a/services/web/frontend/js/features/source-editor/components/codemirror-toolbar.tsx b/services/web/frontend/js/features/source-editor/components/codemirror-toolbar.tsx new file mode 100644 index 0000000000..cd566a2007 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/components/codemirror-toolbar.tsx @@ -0,0 +1,121 @@ +import { memo, useCallback, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { + useCodeMirrorStateContext, + useCodeMirrorViewContext, +} from './codemirror-editor' +import { searchPanelOpen } from '@codemirror/search' +import { useResizeObserver } from '../../../shared/hooks/use-resize-observer' +import { ToolbarButton } from './toolbar/toolbar-button' +import { ToolbarItems } from './toolbar/toolbar-items' +import * as commands from '../extensions/toolbar/commands' +import { ToolbarOverflow } from './toolbar/overflow' +import useDropdown from '../../../shared/hooks/use-dropdown' +import { getPanel } from '@codemirror/view' +import { createToolbarPanel } from '../extensions/toolbar/toolbar-panel' + +export const CodeMirrorToolbar = () => { + const view = useCodeMirrorViewContext() + const panel = getPanel(view, createToolbarPanel) + + if (!panel) { + return null + } + + return createPortal(, panel.dom) +} + +const Toolbar = memo(function Toolbar() { + const state = useCodeMirrorStateContext() + + const [overflowed, setOverflowed] = useState(false) + const [collapsed, setCollapsed] = useState(false) + + const overflowBeforeRef = useRef(null) + const overflowedItemsRef = useRef>(new Set()) + + const { + open: overflowOpen, + onToggle: setOverflowOpen, + ref: overflowRef, + } = useDropdown() + + const buildOverflow = useCallback( + (element: Element) => { + setOverflowOpen(false) + setOverflowed(false) + + if (overflowBeforeRef.current) { + overflowedItemsRef.current = new Set() + + const buttonGroups = [ + ...element.querySelectorAll('[data-overflow]'), + ].reverse() + + // restore all the overflowed items + for (const buttonGroup of buttonGroups) { + buttonGroup.classList.remove('overflow-hidden') + } + + // find all the available items + for (const buttonGroup of buttonGroups) { + if (element.scrollWidth <= element.clientWidth) { + break + } + // add this item to the overflow + overflowedItemsRef.current.add(buttonGroup.dataset.overflow!) + buttonGroup.classList.add('overflow-hidden') + } + + setOverflowed(overflowedItemsRef.current.size > 0) + } + }, + [setOverflowOpen] + ) + + // build when the container resizes + const resizeRef = useResizeObserver(buildOverflow) + + const toggleToolbar = useCallback(() => { + setCollapsed(value => !value) + }, []) + + if (collapsed) { + return null + } + + return ( +
+ +
+ + + +
+
+ +
+
+
+
+ ) +}) diff --git a/services/web/frontend/js/features/source-editor/components/codemirror-view.tsx b/services/web/frontend/js/features/source-editor/components/codemirror-view.tsx new file mode 100644 index 0000000000..2e57a4e51a --- /dev/null +++ b/services/web/frontend/js/features/source-editor/components/codemirror-view.tsx @@ -0,0 +1,30 @@ +import { memo, useCallback, useEffect } from 'react' +import { useCodeMirrorViewContext } from './codemirror-editor' +import useCodeMirrorScope from '../hooks/use-codemirror-scope' + +function CodeMirrorView() { + const view = useCodeMirrorViewContext() + + // append the editor view dom to the container node when mounted + const containerRef = useCallback( + node => { + if (node) { + node.appendChild(view.dom) + } + }, + [view] + ) + + // destroy the editor when unmounted + useEffect(() => { + return () => { + view.destroy() + } + }, [view]) + + useCodeMirrorScope(view) + + return
+} + +export default memo(CodeMirrorView) diff --git a/services/web/frontend/js/features/source-editor/components/editor-switch.tsx b/services/web/frontend/js/features/source-editor/components/editor-switch.tsx index 9d65d766b9..1c626890fc 100644 --- a/services/web/frontend/js/features/source-editor/components/editor-switch.tsx +++ b/services/web/frontend/js/features/source-editor/components/editor-switch.tsx @@ -41,7 +41,6 @@ function Badge() { } const showLegacySourceEditor: boolean = getMeta('ol-showLegacySourceEditor') -const hasNewSourceEditor: boolean = getMeta('ol-hasNewSourceEditor') function EditorSwitch() { const [newSourceEditor, setNewSourceEditor] = useScopeValue( @@ -97,22 +96,18 @@ function EditorSwitch() {
Editor mode. - {hasNewSourceEditor && ( - <> - - - - )} + + {showLegacySourceEditor ? ( <> @@ -126,7 +121,7 @@ function EditorSwitch() { onChange={handleChange} /> ) : null} diff --git a/services/web/frontend/js/features/source-editor/components/source-editor.tsx b/services/web/frontend/js/features/source-editor/components/source-editor.tsx new file mode 100644 index 0000000000..18708091de --- /dev/null +++ b/services/web/frontend/js/features/source-editor/components/source-editor.tsx @@ -0,0 +1,25 @@ +import { lazy, memo, Suspense } from 'react' +import LoadingSpinner from '../../../shared/components/loading-spinner' +import withErrorBoundary from '../../../infrastructure/error-boundary' +import { ErrorBoundaryFallback } from '../../../shared/components/error-boundary-fallback' + +const CodeMirrorEditor = lazy( + () => + import(/* webpackChunkName: "codemirror-editor" */ './codemirror-editor') +) + +function SourceEditor() { + return ( + + +
+ } + > + + + ) +} + +export default withErrorBoundary(memo(SourceEditor), ErrorBoundaryFallback) diff --git a/services/web/frontend/js/features/source-editor/components/toolbar/overflow.tsx b/services/web/frontend/js/features/source-editor/components/toolbar/overflow.tsx new file mode 100644 index 0000000000..91f8deac9a --- /dev/null +++ b/services/web/frontend/js/features/source-editor/components/toolbar/overflow.tsx @@ -0,0 +1,62 @@ +import { FC, LegacyRef, memo } from 'react' +import { Button, Overlay, Popover } from 'react-bootstrap' +import classnames from 'classnames' +import Icon from '../../../../shared/components/icon' + +export const ToolbarOverflow: FC<{ + overflowed: boolean + target?: HTMLDivElement + overflowOpen: boolean + setOverflowOpen: (open: boolean) => void + overflowRef?: LegacyRef +}> = memo(function ToolbarOverflow({ + overflowed, + target, + overflowOpen, + setOverflowOpen, + overflowRef, + children, +}) { + const className = classnames( + 'ol-cm-toolbar-button', + 'ol-cm-toolbar-overflow-toggle', + { + 'ol-cm-toolbar-overflow-toggle-visible': overflowed, + } + ) + + return ( + <> + + + setOverflowOpen(false)} + > + +
{children}
+
+
+ + ) +}) diff --git a/services/web/frontend/js/features/source-editor/components/toolbar/section-heading-dropdown.tsx b/services/web/frontend/js/features/source-editor/components/toolbar/section-heading-dropdown.tsx new file mode 100644 index 0000000000..39a265bf5a --- /dev/null +++ b/services/web/frontend/js/features/source-editor/components/toolbar/section-heading-dropdown.tsx @@ -0,0 +1,115 @@ +import classnames from 'classnames' +import { + useCodeMirrorStateContext, + useCodeMirrorViewContext, +} from '../codemirror-editor' +import { + findCurrentSectionHeadingLevel, + setSectionHeadingLevel, +} from '../../extensions/toolbar/sections' +import { useCallback, useRef } from 'react' +import { Overlay, Popover } from 'react-bootstrap' +import useEventListener from '../../../../shared/hooks/use-event-listener' +import useDropdown from '../../../../shared/hooks/use-dropdown' +import { emitCommandEvent } from '../../extensions/toolbar/utils/analytics' +import Icon from '../../../../shared/components/icon' +import { useTranslation } from 'react-i18next' + +const levels = new Map([ + ['text', 'Normal text'], + ['section', 'Section'], + ['subsection', 'Subsection'], + ['subsubsection', 'Subsubsection'], + ['paragraph', 'Paragraph'], + ['subparagraph', 'Subparagraph'], +]) + +const levelsEntries = [...levels.entries()] + +export const SectionHeadingDropdown = () => { + const state = useCodeMirrorStateContext() + const view = useCodeMirrorViewContext() + const { t } = useTranslation() + + const { open: overflowOpen, onToggle: setOverflowOpen } = useDropdown() + + useEventListener( + 'resize', + useCallback(() => { + setOverflowOpen(false) + }, [setOverflowOpen]) + ) + + const toggleButtonRef = useRef(null) + + const currentLevel = findCurrentSectionHeadingLevel(state) + const currentLabel = currentLevel + ? levels.get(currentLevel.level) ?? currentLevel.level + : '---' + + return ( + <> + + + setOverflowOpen(false)} + animation={false} + container={document.querySelector('.cm-editor')} + containerPadding={0} + placement="bottom" + rootClose + target={toggleButtonRef.current ?? undefined} + > + + + + + + ) +} diff --git a/services/web/frontend/js/features/source-editor/components/toolbar/toolbar-button.tsx b/services/web/frontend/js/features/source-editor/components/toolbar/toolbar-button.tsx new file mode 100644 index 0000000000..62f575895a --- /dev/null +++ b/services/web/frontend/js/features/source-editor/components/toolbar/toolbar-button.tsx @@ -0,0 +1,86 @@ +import { memo, useCallback } from 'react' +import { EditorView } from '@codemirror/view' +import { useCodeMirrorViewContext } from '../codemirror-editor' +import { Button } from 'react-bootstrap' +import classnames from 'classnames' +import Tooltip from '../../../../shared/components/tooltip' +import { emitCommandEvent } from '../../extensions/toolbar/utils/analytics' +import Icon from '../../../../shared/components/icon' + +export const ToolbarButton = memo<{ + id: string + className?: string + label: string + command?: (view: EditorView) => void + active?: boolean + disabled?: boolean + icon: string + textIcon?: boolean + hidden?: boolean + shortcut?: string +}>(function ToolbarButton({ + id, + className, + label, + command, + active = false, + disabled, + icon, + textIcon = false, + hidden = false, + shortcut, +}) { + const view = useCodeMirrorViewContext() + + const handleMouseDown = useCallback(event => { + event.preventDefault() + }, []) + + const handleClick = useCallback( + event => { + emitCommandEvent(view, id) + if (command) { + event.preventDefault() + command(view) + view.focus() + } + }, + [command, view, id] + ) + + const button = ( + + ) + + if (!label) { + return button + } + + const description = ( + <> +
{label}
+ {shortcut &&
{shortcut}
} + + ) + + return ( + + {button} + + ) +}) diff --git a/services/web/frontend/js/features/source-editor/components/toolbar/toolbar-items.tsx b/services/web/frontend/js/features/source-editor/components/toolbar/toolbar-items.tsx new file mode 100644 index 0000000000..2725013d91 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/components/toolbar/toolbar-items.tsx @@ -0,0 +1,189 @@ +import { FC, memo, useCallback } from 'react' +import { EditorSelection, EditorState } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import { useEditorContext } from '../../../../shared/context/editor-context' +import useScopeEventEmitter from '../../../../shared/hooks/use-scope-event-emitter' +import { useLayoutContext } from '../../../../shared/context/layout-context' +import { + minimumListDepthForSelection, + withinFormattingCommand, +} from '../../utils/tree-operations/ancestors' +import { ToolbarButton } from './toolbar-button' +import { redo, undo } from '@codemirror/commands' +import * as commands from '../../extensions/toolbar/commands' +import { SectionHeadingDropdown } from './section-heading-dropdown' +import { canAddComment } from '../../extensions/toolbar/comments' +import { useTranslation } from 'react-i18next' + +const isMac = /Mac/.test(window.navigator?.platform) + +export const ToolbarItems: FC<{ + state: EditorState + overflowed?: Set +}> = memo(function ToolbarItems({ state, overflowed }) { + const { t } = useTranslation() + const { toggleSymbolPalette, showSymbolPalette } = useEditorContext() + const isActive = withinFormattingCommand(state) + const listDepth = minimumListDepthForSelection(state) + const addCommentEmitter = useScopeEventEmitter('comment:start_adding') + const { setReviewPanelOpen } = useLayoutContext() + const addComment = useCallback( + (view: EditorView) => { + const range = view.state.selection.main + if (range.empty) { + const line = view.state.doc.lineAt(range.head) + view.dispatch({ + selection: EditorSelection.range(line.from, line.to), + }) + } + setReviewPanelOpen(true) + addCommentEmitter() + }, + [addCommentEmitter, setReviewPanelOpen] + ) + + const showGroup = (group: string) => !overflowed || overflowed.has(group) + + return ( + <> + {showGroup('group-history') && ( +
+ + +
+ )} + {showGroup('group-section') && ( +
+ +
+ )} + {showGroup('group-format') && ( +
+ + +
+ )} + {showGroup('group-math') && ( +
+ + + +
+ )} + {showGroup('group-misc') && ( +
+ +
+ )} + {showGroup('group-list') && ( +
+ + + + +
+ )} + + ) +}) diff --git a/services/web/frontend/js/features/source-editor/controllers/source-editor-controller.ts b/services/web/frontend/js/features/source-editor/controllers/source-editor-controller.ts new file mode 100644 index 0000000000..67e9a5debf --- /dev/null +++ b/services/web/frontend/js/features/source-editor/controllers/source-editor-controller.ts @@ -0,0 +1,6 @@ +import { react2angular } from 'react2angular' +import SourceEditor from '../components/source-editor' +import App from '../../../base' +import { rootContext } from '../../../shared/context/root-context' + +App.component('sourceEditor', react2angular(rootContext.use(SourceEditor), [])) diff --git a/services/web/frontend/js/features/source-editor/extensions/annotations.ts b/services/web/frontend/js/features/source-editor/extensions/annotations.ts new file mode 100644 index 0000000000..f28a1b66f8 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/annotations.ts @@ -0,0 +1,139 @@ +import { EditorView } from '@codemirror/view' +import { Diagnostic, linter, lintGutter } from '@codemirror/lint' +import { + Compartment, + RangeSet, + RangeValue, + StateEffect, + StateField, + Text, +} from '@codemirror/state' +import { Annotation } from '../../../../../types/annotation' + +const compileLintSourceConf = new Compartment() + +export const annotations = () => [ + compileDiagnosticsState, + compileLintSourceConf.of(compileLogLintSource()), + lintGutter({ + hoverTime: 0, + }), + // move the lint gutter outside the line numbers + EditorView.baseTheme({ + '.cm-gutter-lint': { + order: -1, + }, + }), +] + +export const lintSourceConfig = { + delay: 100, + // Show highlights only for errors + markerFilter(diagnostics: readonly Diagnostic[]) { + return diagnostics.filter(d => d.severity === 'error') + }, + // Do not show any tooltips for highlights within the editor content + tooltipFilter() { + return [] + }, +} + +const compileLogLintSource = () => + linter(view => { + const items: Diagnostic[] = [] + const cursor = view.state.field(compileDiagnosticsState).iter() + while (cursor.value !== null) { + items.push({ + ...cursor.value.diagnostic, + from: cursor.from, + to: cursor.to, + }) + cursor.next() + } + return items + }, lintSourceConfig) + +class DiagnosticRangeValue extends RangeValue { + constructor(public diagnostic: Diagnostic) { + super() + } +} + +const setCompileDiagnosticsEffect = StateEffect.define() + +export const compileDiagnosticsState = StateField.define< + RangeSet +>({ + create() { + return RangeSet.empty + }, + update(value, transaction) { + for (const effect of transaction.effects) { + if (effect.is(setCompileDiagnosticsEffect)) { + return RangeSet.of( + effect.value.map(diagnostic => + new DiagnosticRangeValue(diagnostic).range( + diagnostic.from, + diagnostic.to + ) + ), + true + ) + } + } + + if (transaction.docChanged) { + value = value.map(transaction.changes) + } + + return value + }, +}) + +export const setAnnotations = (doc: Text, annotations: Annotation[]) => { + const diagnostics: Diagnostic[] = [] + + for (const annotation of annotations) { + // ignore "whole document" (row: -1) annotations + if (annotation.row !== -1) { + try { + diagnostics.push(convertAnnotationToDiagnostic(doc, annotation)) + } catch (error) { + // ignore invalid annotations + console.debug('invalid annotation position', error) + } + } + } + + return { + effects: setCompileDiagnosticsEffect.of(diagnostics), + } +} + +export const showCompileLogDiagnostics = (show: boolean) => { + return { + effects: [ + // reconfigure the compile log lint source + compileLintSourceConf.reconfigure(show ? compileLogLintSource() : []), + ], + } +} + +const convertAnnotationToDiagnostic = ( + doc: Text, + annotation: Annotation +): Diagnostic => { + if (annotation.row < 0) { + throw new Error(`Invalid annotation row ${annotation.row}`) + } + + const line = doc.line(annotation.row + 1) + + return { + from: line.from, + to: line.to, // NOTE: highlight whole line as synctex doesn't output column number + severity: annotation.type, + message: annotation.text, + // source: annotation.source, // NOTE: the source is displayed in the tooltip + } +} diff --git a/services/web/frontend/js/features/source-editor/extensions/auto-complete.ts b/services/web/frontend/js/features/source-editor/extensions/auto-complete.ts new file mode 100644 index 0000000000..ad24cba3eb --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/auto-complete.ts @@ -0,0 +1,151 @@ +import { + acceptCompletion, + autocompletion, + closeCompletion, + moveCompletionSelection, + startCompletion, + Completion, +} from '@codemirror/autocomplete' +import { EditorView, keymap } from '@codemirror/view' +import { Compartment, Prec, TransactionSpec } from '@codemirror/state' + +const autoCompleteConf = new Compartment() + +export const autoComplete = ({ autoComplete }: { autoComplete: boolean }) => + autoCompleteConf.of(createAutoComplete(autoComplete)) + +export const setAutoComplete = (autoComplete: boolean): TransactionSpec => { + return { + effects: autoCompleteConf.reconfigure(createAutoComplete(autoComplete)), + } +} + +const createAutoComplete = (enabled: boolean) => { + if (!enabled) { + return [] + } + + return [ + [ + autocompleteTheme, + autocompletion({ + closeOnBlur: false, + icons: false, + defaultKeymap: false, + addToOptions: [ + // display the completion "type" at the end of the suggestion + { + render: completion => { + const span = document.createElement('span') + span.classList.add('ol-cm-completionType') + if (completion.type) { + span.textContent = completion.type + } + return span + }, + position: 400, + }, + ], + optionClass: (completion: Completion) => { + return `ol-cm-completion-${completion.type}` + }, + interactionDelay: 0, + }), + Prec.highest( + keymap.of([ + { key: 'Escape', run: closeCompletion }, + { key: 'ArrowDown', run: moveCompletionSelection(true) }, + { key: 'ArrowUp', run: moveCompletionSelection(false) }, + { key: 'PageDown', run: moveCompletionSelection(true, 'page') }, + { key: 'PageUp', run: moveCompletionSelection(false, 'page') }, + { key: 'Enter', run: acceptCompletion }, + { key: 'Tab', run: acceptCompletion }, + ]) + ), + // NOTE: must be lower precedence than Ctrl-Space and Alt-Space in references-search + Prec.high( + keymap.of([ + { key: 'Ctrl-Space', run: startCompletion }, + { key: 'Alt-Space', run: startCompletion }, + ]) + ), + ], + ] +} + +const autocompleteTheme = EditorView.baseTheme({ + '.cm-tooltip.cm-tooltip-autocomplete': { + // shift the tooltip, so the completion aligns with the text + marginLeft: '-4px', + }, + '&light .cm-tooltip.cm-tooltip-autocomplete': { + border: '1px lightgray solid', + background: '#fefefe', + color: '#111', + boxShadow: '2px 3px 5px rgb(0 0 0 / 20%)', + }, + '&dark .cm-tooltip.cm-tooltip-autocomplete': { + border: '1px #484747 solid', + boxShadow: '2px 3px 5px rgba(0, 0, 0, 0.51)', + background: '#25282c', + color: '#c1c1c1', + }, + + // match editor font family and font size, so the completion aligns with the text + '.cm-tooltip.cm-tooltip-autocomplete > ul': { + fontFamily: 'var(--source-font-family)', + fontSize: 'var(--font-size)', + }, + '.cm-tooltip.cm-tooltip-autocomplete li[role="option"]': { + display: 'flex', + justifyContent: 'space-between', + lineHeight: 1.4, // increase the line height from default 1.2, for a larger target area + outline: '1px solid transparent', + }, + '&light .cm-tooltip.cm-tooltip-autocomplete li[role="option"]:hover': { + outlineColor: '#abbffe', + backgroundColor: 'rgba(233, 233, 253, 0.4)', + }, + '&dark .cm-tooltip.cm-tooltip-autocomplete li[role="option"]:hover': { + outlineColor: 'rgba(109, 150, 13, 0.8)', + backgroundColor: 'rgba(58, 103, 78, 0.62)', + }, + '.cm-tooltip.cm-tooltip-autocomplete ul li[aria-selected]': { + color: 'inherit', + }, + '&light .cm-tooltip.cm-tooltip-autocomplete ul li[aria-selected]': { + background: '#cad6fa', + }, + '&dark .cm-tooltip.cm-tooltip-autocomplete ul li[aria-selected]': { + background: '#3a674e', + }, + '.cm-completionMatchedText': { + textDecoration: 'none', // remove default underline, + fontWeight: 'bold', + }, + '&light .cm-completionMatchedText': { + color: '#2d69c7', + }, + '&dark .cm-completionMatchedText': { + color: '#93ca12', + }, + '.ol-cm-completionType': { + paddingLeft: '1em', + paddingRight: 0, + width: 'auto', + fontSize: '90%', + fontFamily: 'var(--source-font-family)', + opacity: '0.5', + }, + '.cm-completionInfo .ol-cm-symbolCompletionInfo': { + margin: 0, + whiteSpace: 'normal', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + textAlign: 'center', + }, + '.cm-completionInfo .ol-cm-symbolCharacter': { + fontSize: '32px', + }, +}) diff --git a/services/web/frontend/js/features/source-editor/extensions/auto-pair.ts b/services/web/frontend/js/features/source-editor/extensions/auto-pair.ts new file mode 100644 index 0000000000..446f5e9df8 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/auto-pair.ts @@ -0,0 +1,31 @@ +import { keymap } from '@codemirror/view' +import { Compartment, Prec, TransactionSpec } from '@codemirror/state' +import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete' +import { closePrefixedBrackets } from './close-prefixed-brackets' + +const autoPairConf = new Compartment() + +export const autoPair = ({ + autoPairDelimiters, +}: { + autoPairDelimiters: boolean +}) => autoPairConf.of(createAutoPair(autoPairDelimiters)) + +export const setAutoPair = (autoPairDelimiters: boolean): TransactionSpec => { + return { + effects: autoPairConf.reconfigure(createAutoPair(autoPairDelimiters)), + } +} + +const createAutoPair = (enabled: boolean) => { + if (!enabled) { + return [] + } + + return [ + closePrefixedBrackets(), + closeBrackets(), + // NOTE: using Prec.highest as this needs to run before the default Backspace handler + Prec.highest(keymap.of(closeBracketsKeymap)), + ] +} diff --git a/services/web/frontend/js/features/source-editor/extensions/bracket-matching.ts b/services/web/frontend/js/features/source-editor/extensions/bracket-matching.ts new file mode 100644 index 0000000000..3ec0bd64ad --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/bracket-matching.ts @@ -0,0 +1,134 @@ +import { + bracketMatching as bracketMatchingExtension, + matchBrackets, + type MatchResult, +} from '@codemirror/language' +import { Decoration, EditorView } from '@codemirror/view' +import { + EditorSelection, + Extension, + SelectionRange, + type Range, +} from '@codemirror/state' + +const matchingMark = Decoration.mark({ class: 'cm-matchingBracket' }) +const nonmatchingMark = Decoration.mark({ class: 'cm-nonmatchingBracket' }) + +const FORWARDS = 1 +const BACKWARDS = -1 +type Direction = 1 | -1 + +export const bracketMatching = () => { + return bracketMatchingExtension({ + renderMatch: match => { + const decorations: Range[] = [] + + if (matchedAdjacent(match)) { + // combine an adjacent pair of matching markers into a single decoration + decorations.push( + matchingMark.range( + Math.min(match.start.from, match.end.from), + Math.max(match.start.to, match.end.to) + ) + ) + } else { + // default match rendering (defaultRenderMatch in @codemirror/matchbrackets) + const mark = match.matched ? matchingMark : nonmatchingMark + decorations.push(mark.range(match.start.from, match.start.to)) + if (match.end) { + decorations.push(mark.range(match.end.from, match.end.to)) + } + } + + return decorations + }, + }) +} + +interface AdjacentMatchResult extends MatchResult { + end: { + from: number + to: number + } +} + +const matchedAdjacent = (match: MatchResult): match is AdjacentMatchResult => + Boolean( + match.matched && + match.end && + (match.start.to === match.end.from || match.end.to === match.start.from) + ) + +export const bracketSelection = (): Extension[] => [ + EditorView.domEventHandlers({ + dblclick: (evt, view) => { + const pos = view.posAtCoords({ + x: evt.pageX, + y: evt.pageY, + }) + if (!pos) return false + + const search = (direction: Direction, position: number) => { + const match = matchBrackets(view.state, position, direction, { + // Only look at data in the syntax tree, don't scan the text + maxScanDistance: 0, + }) + if (match?.matched && match.end) { + const newRange = EditorSelection.range( + Math.min(match.start.from, match.end.from), + Math.max(match.end.to, match.start.to) + ) + return newRange + } + return false + } + + const dispatchSelection = (range: SelectionRange) => { + view.dispatch({ + selection: range, + }) + return true + } + // 1. Look forwards, from the character *behind* the cursor + const forwardsExcludingBrackets = search(FORWARDS, pos - 1) + if (forwardsExcludingBrackets) { + return dispatchSelection( + EditorSelection.range( + forwardsExcludingBrackets.from + 1, + forwardsExcludingBrackets.to - 1 + ) + ) + } + + // 2. Look forwards, from the character *in front of* the cursor + const forwardsIncludingBrackets = search(FORWARDS, pos) + if (forwardsIncludingBrackets) { + return dispatchSelection(forwardsIncludingBrackets) + } + + // 3. Look backwards, from the character *behind* the cursor + const backwardsIncludingBrackets = search(BACKWARDS, pos) + if (backwardsIncludingBrackets) { + return dispatchSelection(backwardsIncludingBrackets) + } + + // 4. Look backwards, from the character *in front of* the cursor + const backwardsExcludingBrackets = search(BACKWARDS, pos + 1) + if (backwardsExcludingBrackets) { + return dispatchSelection( + EditorSelection.range( + backwardsExcludingBrackets.from + 1, + backwardsExcludingBrackets.to - 1 + ) + ) + } + + return false + }, + }), + EditorView.baseTheme({ + '.cm-matchingBracket': { + pointerEvents: 'none', + }, + }), +] diff --git a/services/web/frontend/js/features/source-editor/extensions/browser.ts b/services/web/frontend/js/features/source-editor/extensions/browser.ts new file mode 100644 index 0000000000..52da810b49 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/browser.ts @@ -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', +} diff --git a/services/web/frontend/js/features/source-editor/extensions/bundle.ts b/services/web/frontend/js/features/source-editor/extensions/bundle.ts new file mode 100644 index 0000000000..f2b67bcc81 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/bundle.ts @@ -0,0 +1,30 @@ +import { syntaxTree } from '@codemirror/language' +import { EditorSelection, StateEffect, StateField } from '@codemirror/state' +import { + Decoration, + EditorView, + hoverTooltip, + keymap, + ViewPlugin, + WidgetType, +} from '@codemirror/view' +import { CodeMirror, Vim, getCM } from '@replit/codemirror-vim' + +export default { + Decoration, + EditorSelection, + EditorView, + StateEffect, + StateField, + ViewPlugin, + WidgetType, + hoverTooltip, + keymap, + syntaxTree, +} + +export const CodeMirrorVim = { + CodeMirror, + Vim, + getCM, +} diff --git a/services/web/frontend/js/features/source-editor/extensions/changes/change-manager.ts b/services/web/frontend/js/features/source-editor/extensions/changes/change-manager.ts new file mode 100644 index 0000000000..32f638e70f --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/changes/change-manager.ts @@ -0,0 +1,482 @@ +import { trackChangesAnnotation } from '../realtime' +import { clearChangeMarkers, buildChangeMarkers } from '../track-changes' +import { + setVerticalOverflow, + updateSetsVerticalOverflow, + editorVerticalTopPadding, +} from '../vertical-overflow' +import { EditorSelection, EditorState } from '@codemirror/state' +import { EditorView, ViewUpdate } from '@codemirror/view' +import { CurrentDoc } from '../../../../../../types/current-doc' +import { fullHeightCoordsAtPos } from '../../utils/layer' +import { debounce } from 'lodash' + +// With less than this number of entries, don't bother culling to avoid +// little UI jumps when scrolling. +const CULL_AFTER = 100 + +export const dispatchEditorEvent = (type: string, payload?: unknown) => { + window.setTimeout(() => { + window.dispatchEvent( + new CustomEvent('editor:event', { + detail: { type, payload }, + }) + ) + }, 0) +} + +export type ChangeManager = { + initialize: () => void + handleUpdate: (update: ViewUpdate) => void + destroy: () => void +} + +export const createChangeManager = ( + view: EditorView, + currentDoc: CurrentDoc +): ChangeManager => { + /** + * Calculate the screen coordinates of each entry (change or comment), + * for use in the review panel. + * + * Returns a boolean indicating whether the visibility of any entry has changed + */ + const recalculateScreenPositions = (entries?: Record) => { + const contentRect = view.contentDOM.getBoundingClientRect() + + const { doc } = view.state + + const items = Object.values(entries || {}) + + const allVisible = items.length <= CULL_AFTER + let visibilityChanged = false + + const docLength = doc.length + + const editorPaddingTop = editorVerticalTopPadding(view) + + for (const entry of items) { + // TODO: clamp to max row and column, account for folding? + const coords = fullHeightCoordsAtPos( + view, + Math.min(entry.offset, docLength) // avoid exception for comments at end of document when deleting text + ) + + if (coords) { + const y = coords.top - contentRect.top - editorPaddingTop + const height = coords.bottom - coords.top + + entry.screenPos = { y, height, editorPaddingTop } + } + + if (allVisible) { + if (!entry.visible) { + visibilityChanged = true + } + entry.visible = true + } + } + + if (!allVisible) { + const { from, to } = view.viewport + + for (const entry of items) { + const previouslyVisible = entry.visible + + entry.visible = entry.offset >= from && entry.offset <= to + + if (previouslyVisible !== entry.visible) { + visibilityChanged = true + } + } + } + + return visibilityChanged + } + + /** + * Add a comment (thread) to the ShareJS doc when it's created + */ + const addComment = (offset: number, length: number, threadId: string) => { + currentDoc.submitOp({ + c: view.state.doc.sliceString(offset, offset + length), + p: offset, + t: threadId, + }) + } + + /** + * Remove a comment (thread) from the range tracker when it's deleted + */ + const removeComment = (commentId: string) => { + currentDoc.ranges.removeCommentId(commentId) + } + + /** + * Remove tracked changes from the range tracker when they're accepted + */ + const acceptChanges = (changeIds: string[]) => { + currentDoc.ranges.removeChangeIds(changeIds) + } + + /** + * Remove tracked changes from the range tracker when they're rejected, + * and restore the original content + */ + const rejectChanges = (changeIds: string[]) => { + const changes: any[] = currentDoc.ranges.getChanges(changeIds) + + if (changes.length === 0) { + return {} + } + + // When doing bulk rejections, adjacent changes might interact with each other. + // Consider an insertion with an adjacent deletion (which is a common use-case, replacing words): + // + // "foo bar baz" -> "foo quux baz" + // + // The change above will be modeled with two ops, with the insertion going first: + // + // foo quux baz + // |--| -> insertion of "quux", op 1, at position 4 + // | -> deletion of "bar", op 2, pushed forward by "quux" to position 8 + // + // When rejecting these changes at once, if the insertion is rejected first, we get unexpected + // results. What happens is: + // + // 1) Rejecting the insertion deletes the added word "quux", i.e., it removes 4 chars + // starting from position 4; + // + // "foo quux baz" -> "foo baz" + // |--| -> 4 characters to be removed + // + // 2) Rejecting the deletion adds the deleted word "bar" at position 8 (i.e. it will act as if + // the word "quuux" was still present). + // + // "foo baz" -> "foo bazbar" + // | -> deletion of "bar" is reverted by reinserting "bar" at position 8 + // + // While the intended result would be "foo bar baz", what we get is: + // + // "foo bazbar" (note "bar" readded at position 8) + // + // The issue happens because of step 1. To revert the insertion of "quux", 4 characters are deleted + // from position 4. This includes the position where the deletion exists; when that position is + // cleared, the RangesTracker considers that the deletion is gone and stops tracking/updating it. + // As we still hold a reference to it, the code tries to revert it by readding the deleted text, but + // does so at the outdated position (position 8, which was valid when "quux" was present). + // + // To avoid this kind of problem, we need to make sure that reverting operations doesn't affect + // subsequent operations that come after. Reverse sorting the operations based on position will + // achieve it; in the case above, it makes sure that the the deletion is reverted first: + // + // 1) Rejecting the deletion adds the deleted word "bar" at position 8 + // + // "foo quux baz" -> "foo quuxbar baz" + // | -> deletion of "bar" is reverted by + // reinserting "bar" at position 8 + // + // 2) Rejecting the insertion deletes the added word "quux", i.e., it removes 4 chars + // starting from position 4 and achieves the expected result: + // + // "foo quuxbar baz" -> "foo bar baz" + // |--| -> 4 characters to be removed + + changes.sort((a, b) => b.op.p - a.op.p) + + const changesToDispatch = changes.map(change => { + const { op } = change + + const opType = 'i' in op ? 'i' : 'c' in op ? 'c' : 'd' + + switch (opType) { + case 'd': { + return { + from: op.p, + to: op.p, + insert: op.d, + } + } + + case 'i': { + const from = op.p + const content = op.i + const to = from + content.length + + const text = view.state.doc.sliceString(from, to) + + if (text !== content) { + throw new Error( + `Op to be removed (${JSON.stringify( + change.op + )}) does not match editor text '${text}'` + ) + } + + return { from, to, insert: '' } + } + + default: { + throw new Error(`unknown change: ${JSON.stringify(change)}`) + } + } + }) + + return { + changes: changesToDispatch, + annotations: [trackChangesAnnotation.of('reject')], + } + } + + /** + * If the current selection is empty, select the whole line. + * + * Used when adding a comment with no selected range, e.g. with a keyboard shortcut. + */ + const selectCurrentLine = () => { + if (view.state.selection.main.empty) { + const line = view.state.doc.lineAt(view.state.selection.main.from) + + view.dispatch({ + selection: { + anchor: line.from, + head: line.to === view.state.doc.length ? line.to : line.to + 1, + }, + }) + } + } + + /** + * Collapse the current selection to a single point (after inserting a comment) + */ + const collapseSelection = () => { + view.dispatch({ + selection: EditorSelection.cursor(view.state.selection.main.head), + }) + } + + /** + * Listen for events dispatched from the (Angular) review panel. + * + * These are combined into a single listener, avoiding the need to add and remove event listeners individually. + */ + const reviewPanelEventListener = (event: Event) => { + const { type, payload } = ( + event as CustomEvent<{ type: string; payload: any }> + ).detail + + switch (type) { + // receive review panel scroll events + case 'scroll': { + view.scrollDOM.scrollBy(0, payload) + break + } + + case 'overview-closed': { + window.setTimeout(() => { + dispatchScrollEvent() + }, 0) + break + } + + case 'recalculate-screen-positions': { + const changed = recalculateScreenPositions(payload) + if (changed) { + dispatchEditorEvent('track-changes:visibility_changed') + } + break + } + + case 'changes:accept': { + acceptChanges(payload) + view.dispatch(buildChangeMarkers()) + broadcastChange() + dispatchFocusChangedEvent(view.state) + break + } + + case 'changes:reject': { + view.dispatch(rejectChanges(payload)) + dispatchFocusChangedEvent(view.state) + break + } + + case 'comment:select_line': { + selectCurrentLine() + break + } + + case 'comment:add': { + addComment(payload.offset, payload.length, payload.threadId) + collapseSelection() + break + } + + case 'comment:remove': { + removeComment(payload) + view.dispatch(buildChangeMarkers()) + broadcastChange() + break + } + + case 'comment:resolve_threads': + case 'comment:unresolve_thread': { + view.dispatch(buildChangeMarkers()) + broadcastChange() + break + } + + case 'loaded_threads': { + view.dispatch(buildChangeMarkers()) + broadcastChange() + window.setTimeout(() => { + dispatchFocusChangedEvent(view.state) + }, 0) + break + } + + case 'sizes': { + const { overflowTop, height } = payload + const padding = view.documentPadding + const contentHeight = + view.contentDOM.clientHeight - padding.top - padding.bottom + const paddingNeeded = height - contentHeight + + if (overflowTop !== padding.top || paddingNeeded !== padding.bottom) { + view.dispatch( + setVerticalOverflow({ + top: overflowTop, + bottom: paddingNeeded, + }) + ) + } + + break + } + } + } + + /** + * + */ + const broadcastChange = () => { + dispatchEditorEvent('track-changes:changed') + } + + /** + * When the editor content, focus, size, viewport or selection changes, + * tell the review panel to update. + * + * @param state object + */ + const dispatchFocusChangedEvent = debounce((state: EditorState) => { + // TODO: multiple selections? + const { from, to, empty } = state.selection.main + + dispatchEditorEvent('focus:changed', { from, to, empty }) + }, 50) + + /** + * When the editor is scrolled, tell the review panel so it can scroll in sync. + */ + const dispatchScrollEvent = () => { + window.dispatchEvent( + new CustomEvent('editor:scroll', { + detail: { + height: view.scrollDOM.scrollHeight, + scrollTop: view.scrollDOM.scrollTop, + paddingTop: editorVerticalTopPadding(view), + }, + }) + ) + } + + /** + * Add event listeners to the ShareJS doc so that change markers are rebuilt when the tracked changes are updated. + * + * Also add event listeners to the editor scroll DOM and window. + */ + const addListeners = () => { + // NOTE: the namespace "cm6" is needed so the listeners can be removed individually + currentDoc.on('ranges:dirty.cm6', () => { + // TODO: use currentDoc.ranges.getDirtyState and only update those which have changed? + window.setTimeout(() => { + view.dispatch(buildChangeMarkers()) + broadcastChange() + }, 0) + }) + + // called on joinDoc + currentDoc.on('ranges:clear.cm6', () => { + window.setTimeout(() => { + view.dispatch(clearChangeMarkers()) + broadcastChange() + }, 0) + }) + + // called on joinDoc + currentDoc.on('ranges:redraw.cm6', () => { + window.setTimeout(() => { + view.dispatch(buildChangeMarkers()) + broadcastChange() + }, 0) + }) + + // sync review panel scroll with editor scroll + view.scrollDOM.addEventListener('scroll', dispatchScrollEvent) + + // listen for events from the review panel controller + window.addEventListener('review-panel:event', reviewPanelEventListener) + } + + /** + * Remove event listeners + */ + const removeListeners = () => { + currentDoc.off('ranges:clear.cm6') + currentDoc.off('ranges:dirty.cm6') + currentDoc.off('ranges:redraw.cm6') + + view.scrollDOM.removeEventListener('scroll', dispatchScrollEvent) + + window.removeEventListener('review-panel:event', reviewPanelEventListener) + } + + let lastUpdatedTopPaddingAt = 0 + const PADDING_GEOMETRY_CHANGE_INTERVAL = 50 + + return { + initialize() { + addListeners() + dispatchEditorEvent('track-changes:changed') + }, + handleUpdate(update: ViewUpdate) { + if (updateSetsVerticalOverflow(update)) { + lastUpdatedTopPaddingAt = Date.now() + } + if ( + update.docChanged || + update.focusChanged || + update.viewportChanged || + update.selectionSet || + update.geometryChanged + ) { + // Ignore a change to the editor geometry that occurs immediately after + // an update to the vertical padding because otherwise it triggers + // another update to the padding and so on ad infinitum. This is not an + // ideal way to handle this but I couldn't see another way. + if ( + update.geometryChanged && + Date.now() - lastUpdatedTopPaddingAt < + PADDING_GEOMETRY_CHANGE_INTERVAL + ) { + return + } + dispatchFocusChangedEvent(update.state) + } + }, + destroy() { + removeListeners() + }, + } +} diff --git a/services/web/frontend/js/features/source-editor/extensions/changes/comments.ts b/services/web/frontend/js/features/source-editor/extensions/changes/comments.ts new file mode 100644 index 0000000000..c43aec78f8 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/changes/comments.ts @@ -0,0 +1,226 @@ +import { Range, RangeSet, RangeValue, Transaction } from '@codemirror/state' +import { CurrentDoc } from '../../../../../../types/current-doc' +import { + AnyOperation, + Change, + ChangeOperation, + CommentOperation, +} from '../../../../../../types/change' + +export type StoredComment = { + text: string + comments: { + offset: number + text: string + comment: Change + }[] +} + +/** + * Find tracked comments within the range of the current transaction's changes + */ +export const findCommentsInCut = ( + currentDoc: CurrentDoc, + transaction: Transaction +) => { + const items: StoredComment[] = [] + + transaction.changes.iterChanges((fromA, toA) => { + const comments = currentDoc.ranges.comments + .filter( + comment => + fromA <= comment.op.p && comment.op.p + comment.op.c.length <= toA + ) + .map(comment => ({ + offset: comment.op.p - fromA, + text: comment.op.c, + comment, + })) + + if (comments.length) { + items.push({ + text: transaction.startState.sliceDoc(fromA, toA), + comments, + }) + } + }) + + return items +} + +/** + * Find stored comments matching the text of the current transaction's changes + */ +export const findCommentsInPaste = ( + storedComments: StoredComment[], + transaction: Transaction +) => { + const ops: ChangeOperation[] = [] + + transaction.changes.iterChanges((fromA, toA, fromB, toB, inserted) => { + const insertedText = inserted.toString() + + // note: only using the first match + const matchedComment = storedComments.find( + item => item.text === insertedText + ) + + if (matchedComment) { + for (const { offset, text, comment } of matchedComment.comments) { + // Resubmitting an existing comment op (by thread id) will move it + ops.push({ + c: text, + p: fromB + offset, + t: comment.id, + }) + } + } + }) + + return ops +} + +class CommentRangeValue extends RangeValue { + constructor( + public content: string, + public comment: Change + ) { + super() + } +} + +/** + * Find tracked comments with no content with the ranges of a transaction's changes + */ +export const findDetachedCommentsInChanges = ( + currentDoc: CurrentDoc, + transaction: Transaction +) => { + const items: Range[] = [] + + transaction.changes.iterChanges((fromA, toA) => { + for (const comment of currentDoc.ranges.comments) { + const content = comment.op.c + + // TODO: handle comments that were never attached + if (!content.length) { + continue + } + + const from = comment.op.p + const to = from + content.length + + if (fromA <= from && to <= toA) { + items.push(new CommentRangeValue(content, comment).range(from, to)) + } + } + }) + + return RangeSet.of(items, true) +} + +/** + * Submit operations to the ShareJS doc + * (used when restoring comments on paste) + */ +const submitOps = ( + currentDoc: CurrentDoc, + ops: AnyOperation[], + transaction: Transaction +) => { + for (const op of ops) { + currentDoc.submitOp(op) + } + + // Check that comments still match text. Will throw error if not. + currentDoc.ranges.validate(transaction.state.doc.toString()) +} + +/** + * Wait for the ShareJS doc to fire an event, then submit the operations. + */ +const submitOpsAfterEvent = ( + currentDoc: CurrentDoc, + eventName: string, + ops: AnyOperation[], + transaction: Transaction +) => { + // We have to wait until the change has been processed by the range + // tracker, since if we move the ops into place beforehand, they will be + // moved again when the changes are processed by the range tracker. This + // ranges:dirty event is fired after the doc has applied the changes to + // the range tracker. + // TODO: could put this in an update listener instead, if the ShareJS doc has been updated by then? + currentDoc.on(eventName, () => { + currentDoc.off(eventName) + submitOps(currentDoc, ops, transaction) + }) +} + +/** + * Look through the comments stored on cut, and restore those in text that matches the pasted text. + */ +export const restoreCommentsOnPaste = ( + currentDoc: CurrentDoc, + transaction: Transaction, + storedComments: StoredComment[] +) => { + if (storedComments.length) { + const ops = findCommentsInPaste(storedComments, transaction) + + if (ops.length) { + submitOpsAfterEvent( + currentDoc, + 'ranges:dirty.paste-cm6', + ops, + transaction + ) + } + } +} + +/** + * When undoing a change, find comments from the original content and restore them. + */ +export const restoreDetachedComments = ( + currentDoc: CurrentDoc, + transaction: Transaction, + storedComments: RangeSet +) => { + const ops: ChangeOperation[] = [] + + const cursor = storedComments.iter() + + while (cursor.value) { + const { id } = cursor.value.comment + + const comment = currentDoc.ranges.comments.find(item => item.id === id) + + // check that the comment still exists and is detached + if (comment && comment.op.c === '') { + const content = transaction.state.doc.sliceString( + cursor.from, + cursor.from + cursor.value.content.length + ) + + if (cursor.value.content === content) { + ops.push({ + c: cursor.value.content, + p: cursor.from, + t: id, + }) + } + } + + cursor.next() + } + + // FIXME: timing issue with rapid undos + if (ops.length) { + window.setTimeout(() => { + submitOps(currentDoc, ops, transaction) + }, 0) + } + + // submitOpsAfterEvent('ranges:dirty.undo-cm6', ops, transaction) +} diff --git a/services/web/frontend/js/features/source-editor/extensions/class-highlighter.ts b/services/web/frontend/js/features/source-editor/extensions/class-highlighter.ts new file mode 100644 index 0000000000..a018ab6392 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/class-highlighter.ts @@ -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' }, +]) diff --git a/services/web/frontend/js/features/source-editor/extensions/close-prefixed-brackets.ts b/services/web/frontend/js/features/source-editor/extensions/close-prefixed-brackets.ts new file mode 100644 index 0000000000..00199f286e --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/close-prefixed-brackets.ts @@ -0,0 +1,206 @@ +/** + * This file is adapted from CodeMirror 6, licensed under the MIT license: + * https://github.com/codemirror/autocomplete/blob/main/src/closebrackets.ts + */ +import { EditorView } from '@codemirror/view' +import { + codePointAt, + codePointSize, + EditorSelection, + Extension, + SelectionRange, + Text, + TransactionSpec, +} from '@codemirror/state' +import { nextChar, prevChar } from '../languages/latex/completions/apply' +import { completionStatus } from '@codemirror/autocomplete' +import { ancestorNodeOfType } from '../utils/tree-query' +import browser from './browser' + +const dispatchInput = (view: EditorView, spec: TransactionSpec) => { + // This is consistent with CM6's closebrackets extension and allows other + // extensions that check for user input to be triggered + view.dispatch(spec, { + scrollIntoView: true, + userEvent: 'input.type', + }) + + return true +} + +const insertInput = (view: EditorView, insert: string) => { + const spec = view.state.changeByRange(range => { + return { + changes: [[{ from: range.from, insert }]], + range: EditorSelection.range(range.from + 1, range.to + 1), + } + }) + + return dispatchInput(view, spec) +} + +const insertBracket = (view: EditorView, open: string, close: string) => { + const spec = view.state.changeByRange(range => { + if (range.empty) { + return { + changes: [{ from: range.head, insert: open + close }], + range: EditorSelection.cursor(range.head + open.length), + } + } else { + return { + changes: [ + { from: range.from, insert: open }, + { from: range.to, insert: close }, + ], + range: EditorSelection.range( + range.anchor + open.length, + range.head + open.length + ), + } + } + }) + + return dispatchInput(view, spec) +} + +export const closePrefixedBrackets = (): Extension => { + return EditorView.inputHandler.of((view, from, to, insert) => { + if ( + (browser.android ? view.composing : view.compositionStarted) || + view.state.readOnly + ) { + return false + } + + // avoid auto-closing curly braces when autocomplete is open + if (insert === '{' && completionStatus(view.state)) { + return insertInput(view, insert) + } + + const { doc, selection } = view.state + + const sel = selection.main + + if ( + from !== sel.from || + to !== sel.to || + insert.length > 2 || + (insert.length === 2 && codePointSize(codePointAt(insert, 0)) === 1) + ) { + return false + } + + const [config] = view.state.languageDataAt<{ + brackets?: Record + }>('closePrefixedBrackets', sel.head) + + // no config for this language, don't handle + if (!config?.brackets) { + return false + } + + const prevCharacter = prevChar(view.state.doc, sel.from) + const input = `${prevCharacter}${insert}` + const close = config.brackets[input] ?? config.brackets[insert] + + // not specified, don't handle + if (close === undefined) { + return false + } + + // prevent auto-close, just insert the character + if (close === false) { + return insertInput(view, insert) + } + + const nextCharacter = nextChar(doc, sel.from) + + if (insert === '$') { + // avoid duplicating a math-closing dollar sign + if (moveOverClosingMathDollar(view, sel)) { + return true + } + + // avoid creating an odd number of dollar signs + const count = countSurroundingCharacters(doc, sel.from, insert) + if (count % 2 !== 0) { + return insertInput(view, insert) + } + } + + // This is the default set of "before" characters from the closeBrackets extension, + // plus $ (so $$ works as expected) + if (!sel.empty || !nextCharacter || /[\s)\]}:;>$]/.test(nextCharacter)) { + // auto-close + return insertBracket(view, insert, close) + } + + return false + }) +} + +const moveOverClosingMathDollar = ( + view: EditorView, + sel: SelectionRange +): boolean => { + if (!sel.empty) { + return false + } + + // inside dollar math + const outerNode = ancestorNodeOfType(view.state, sel.from, 'DollarMath') + if (!outerNode) { + return false + } + + // not display math + const innerNode = outerNode.getChild('InlineMath') + if (!innerNode) { + return false + } + + // the cursor is at the end of the InlineMath node + if (sel.from !== innerNode.to) { + return false + } + + // there's already some math content + const content = view.state.doc.sliceString(innerNode.from, innerNode.to) + if (content.length === 0) { + return false + } + + // move the cursor outside the DollarMath node + view.dispatch({ + selection: EditorSelection.cursor(outerNode.to), + }) + return true +} + +const countSurroundingCharacters = (doc: Text, pos: number, insert: string) => { + let count = 0 + + // count backwards + let to = pos + do { + const char = doc.sliceString(to - 1, to) + if (char !== insert) { + break + } + count++ + to-- + } while (to > 1) + + // count forwards + let from = pos + do { + const char = doc.sliceString(from, from + 1) + if (char !== insert) { + break + } + count++ + from++ + } while (from < doc.length) + + return count +} diff --git a/services/web/frontend/js/features/source-editor/extensions/cursor-highlights.ts b/services/web/frontend/js/features/source-editor/extensions/cursor-highlights.ts new file mode 100644 index 0000000000..16fc40f934 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/cursor-highlights.ts @@ -0,0 +1,207 @@ +import { + RangeSet, + RangeValue, + StateEffect, + StateField, + TransactionSpec, +} from '@codemirror/state' +import { + EditorView, + hoverTooltip, + layer, + RectangleMarker, + Tooltip, +} from '@codemirror/view' +import { findValidPosition } from '../utils/position' +import { Highlight } from '../../../../../types/highlight' +import { fullHeightCoordsAtPos, getBase } from '../utils/layer' + +export const cursorHighlights = () => { + return [ + cursorHighlightsState, + cursorHighlightsLayer, + hoverTooltip(cursorTooltip, { + hoverTime: 1, + }), + EditorView.theme({ + '.ol-cm-cursorHighlightsLayer': { + zIndex: 100, + contain: 'size style', + pointerEvents: 'none', + }, + '.ol-cm-cursorHighlight': { + color: 'hsl(var(--hue), 70%, 50%)', + borderLeft: '2px solid hsl(var(--hue), 70%, 50%)', + display: 'inline-block', + height: '1.6em', + position: 'absolute', + pointerEvents: 'none', + }, + '.ol-cm-cursorHighlight:before': { + content: "''", + position: 'absolute', + left: '-2px', + top: '-5px', + height: '5px', + width: '5px', + borderWidth: '3px 3px 2px 2px', + borderStyle: 'solid', + borderColor: 'inherit', + }, + '.ol-cm-cursorHighlightLabel': { + lineHeight: 1, + backgroundColor: 'hsl(var(--hue), 70%, 50%)', + padding: '1em 1em', + fontSize: '0.8rem', + fontFamily: 'Lato, sans-serif', + color: 'white', + fontWeight: 700, + whiteSpace: 'nowrap', + pointerEvents: 'none', + }, + }), + ] +} + +class HighlightRangeValue extends RangeValue { + constructor(public highlight: Highlight) { + super() + } +} + +const cursorHighlightsState = StateField.define>({ + create() { + return RangeSet.empty + }, + update(value, tr) { + for (const effect of tr.effects) { + if (effect.is(setCursorHighlightsEffect)) { + const highlightRanges = [] + + for (const highlight of effect.value) { + // NOTE: other highlight types could be handled here + if ('cursor' in highlight) { + try { + const { row, column } = highlight.cursor + const pos = findValidPosition(tr.state.doc, row + 1, column) + highlightRanges.push( + new HighlightRangeValue(highlight).range(pos) + ) + } catch (error) { + // ignore invalid highlights + console.debug('invalid highlight position', error) + } + } + } + + return RangeSet.of(highlightRanges, true) + } + } + + if (tr.docChanged) { + value = value.map(tr.changes) + } + + return value + }, +}) + +const cursorTooltip = (view: EditorView, pos: number): Tooltip | null => { + const highlights: Highlight[] = [] + + view.state + .field(cursorHighlightsState) + .between(pos, pos, (from, to, value) => { + highlights.push(value.highlight) + }) + + if (highlights.length === 0) { + return null + } + + return { + pos, + end: pos, + above: true, + create: () => { + const dom = document.createElement('div') + dom.classList.add('ol-cm-cursorTooltip') + + for (const highlight of highlights) { + const label = document.createElement('div') + label.classList.add('ol-cm-cursorHighlightLabel') + label.style.setProperty('--hue', highlight.hue) + label.textContent = highlight.label + dom.appendChild(label) + } + + return { dom } + }, + } +} + +const setCursorHighlightsEffect = StateEffect.define() + +export const setCursorHighlights = ( + cursorHighlights: Highlight[] = [] +): TransactionSpec => { + return { + effects: setCursorHighlightsEffect.of(cursorHighlights), + } +} + +class CursorMarker extends RectangleMarker { + constructor( + private highlight: Highlight, + className: string, + left: number, + top: number, + width: number | null, + height: number + ) { + super(className, left, top, width, height) + } + + draw(): HTMLDivElement { + const element = super.draw() + element.style.setProperty('--hue', this.highlight.hue) + return element + } +} + +// draw the collaborator cursors in a separate layer, so they don't affect word wrapping +const cursorHighlightsLayer = layer({ + above: true, + class: 'ol-cm-cursorHighlightsLayer', + update: (update, layer) => { + return ( + update.docChanged || + update.selectionSet || + update.transactions.some(tr => + tr.effects.some(effect => effect.is(setCursorHighlightsEffect)) + ) + ) + }, + markers(view) { + const markers: CursorMarker[] = [] + const highlightRanges = view.state.field(cursorHighlightsState) + const base = getBase(view) + const { from, to } = view.viewport + highlightRanges.between(from, to, (from, to, { highlight }) => { + const pos = fullHeightCoordsAtPos(view, from) + if (pos) { + markers.push( + new CursorMarker( + highlight, + 'ol-cm-cursorHighlight', + pos.left - base.left, + pos.top - base.top, + null, + pos.bottom - pos.top + ) + ) + } + }) + return markers + }, +}) diff --git a/services/web/frontend/js/features/source-editor/extensions/cursor-position.ts b/services/web/frontend/js/features/source-editor/extensions/cursor-position.ts new file mode 100644 index 0000000000..38dff4beed --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/cursor-position.ts @@ -0,0 +1,161 @@ +import { + EditorSelection, + EditorState, + SelectionRange, + Text, + TransactionSpec, +} from '@codemirror/state' +import { EditorView, ViewPlugin } from '@codemirror/view' +import { findValidPosition } from '../utils/position' +import customLocalStorage from '../../../infrastructure/local-storage' + +const buildStorageKey = (docId: string) => `doc.position.${docId}` + +export const cursorPosition = ({ + currentDoc: { doc_id: docId }, +}: { + currentDoc: { doc_id: string } +}) => { + return [ + // store cursor position + ViewPlugin.define(view => { + const unloadListener = () => { + storeCursorPosition(view, docId) + } + + window.addEventListener('unload', unloadListener) + + return { + destroy: () => { + window.removeEventListener('unload', unloadListener) + unloadListener() + }, + } + }), + + // Asynchronously dispatch cursor position when the selection changes and + // provide a little debouncing. Using requestAnimationFrame postpones it + // until the next CM6 DOM update. + ViewPlugin.define(view => { + let animationFrameRequest: number | null = null + + return { + update(update) { + if (update.selectionSet || update.docChanged) { + if (animationFrameRequest) { + window.cancelAnimationFrame(animationFrameRequest) + } + animationFrameRequest = window.requestAnimationFrame(() => { + animationFrameRequest = null + dispatchCursorPosition(update.state) + }) + } + }, + } + }), + ] +} + +// convert the selection head to a row and column +const buildCursorPosition = (state: EditorState) => { + const pos = state.selection.main.head + const line = state.doc.lineAt(pos) + const row = line.number - 1 // 0-indexed + const column = pos - line.from + return { row, column } +} + +// dispatch the current cursor position for use with synctex +const dispatchCursorPosition = (state: EditorState) => { + const cursorPosition = buildCursorPosition(state) + + window.dispatchEvent( + new CustomEvent('cursor:editor:update', { detail: cursorPosition }) + ) +} + +// store the cursor position for restoring on load +const storeCursorPosition = (view: EditorView, docId: string) => { + const key = buildStorageKey(docId) + const data = customLocalStorage.getItem(key) + + const cursorPosition = buildCursorPosition(view.state) + + customLocalStorage.setItem(key, { ...data, cursorPosition }) +} + +// restore the stored cursor position on load +export const restoreCursorPosition = ( + doc: Text, + docId: string +): TransactionSpec => { + try { + const key = buildStorageKey(docId) + const data = customLocalStorage.getItem(key) + + const { row = 0, column = 0 } = data?.cursorPosition || {} + + // restore the cursor to its original position, or the end of the document if past the end + const { lines } = doc + const lineNumber = row < lines ? row + 1 : lines + const line = doc.line(lineNumber) + const offset = line.from + column + const pos = Math.min(offset || 0, doc.length) + + return { + selection: EditorSelection.cursor(pos), + } + } catch (error) { + // ignore invalid cursor position + console.debug('invalid cursor position', error) + return {} + } +} + +const dispatchSelectionAndScroll = ( + view: EditorView, + selection: SelectionRange +) => { + view.dispatch({ + selection, + effects: EditorView.scrollIntoView(selection, { y: 'center' }), + }) + + view.focus() +} + +export const setCursorLineAndScroll = ( + view: EditorView, + lineNumber: number, + columnNumber = 0 +) => { + // TODO: map the position through any changes since the previous compile? + + let selectionRange + try { + const pos = findValidPosition(view.state.doc, lineNumber, columnNumber) + selectionRange = EditorSelection.cursor(pos) + } catch (error) { + // ignore invalid cursor position + console.debug('invalid cursor position', error) + } + + if (selectionRange) { + dispatchSelectionAndScroll(view, selectionRange) + } +} + +export const setCursorPositionAndScroll = (view: EditorView, pos: number) => { + let selectionRange + try { + pos = Math.min(pos, view.state.doc.length) + selectionRange = EditorSelection.cursor(pos) + } catch (error) { + // ignore invalid cursor position + console.debug('invalid cursor position', error) + } + + if (selectionRange) { + dispatchSelectionAndScroll(view, selectionRange) + } +} diff --git a/services/web/frontend/js/features/source-editor/extensions/draw-selection.ts b/services/web/frontend/js/features/source-editor/extensions/draw-selection.ts new file mode 100644 index 0000000000..046441f1d7 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/draw-selection.ts @@ -0,0 +1,101 @@ +import { EditorSelection, Prec } from '@codemirror/state' +import { EditorView, layer } from '@codemirror/view' +import { rectangleMarkerForRange } from '../utils/layer' +import { updateHasMouseDownEffect } from './visual/selection' +import browser from './browser' + +// This is mostly a copy of CodeMirror's built-in drawSelection extension. We +// have our own copy so that we can use our own implementation of +// rectangleMarkerForRange +export const drawSelection = () => { + return [cursorLayer, selectionLayer, hideNativeSelection] +} + +const canHidePrimary = !browser.ios + +const hideNativeSelection = Prec.highest( + EditorView.theme({ + '.cm-line': { + 'caret-color': canHidePrimary ? 'transparent !important' : null, + '& ::selection': { + backgroundColor: 'transparent !important', + }, + '&::selection': { + backgroundColor: 'transparent !important', + }, + }, + }) +) + +const cursorLayer = layer({ + above: true, + markers(view) { + const { + selection: { ranges, main }, + } = view.state + + const cursors = [] + + for (const range of ranges) { + const primary = range === main + + if (!range.empty || !primary || canHidePrimary) { + const className = primary + ? 'cm-cursor cm-cursor-primary' + : 'cm-cursor cm-cursor-secondary' + + const cursor = range.empty + ? range + : EditorSelection.cursor( + range.head, + range.head > range.anchor ? -1 : 1 + ) + + for (const piece of rectangleMarkerForRange(view, className, cursor)) { + cursors.push(piece) + } + } + } + + return cursors + }, + update(update, dom) { + if (update.transactions.some(tr => tr.selection)) { + dom.style.animationName = + dom.style.animationName === 'cm-blink' ? 'cm-blink2' : 'cm-blink' + } + return ( + update.docChanged || + update.selectionSet || + updateHasMouseDownEffect(update) + ) + }, + mount(dom, view) { + dom.style.animationDuration = '1200ms' + }, + class: 'cm-cursorLayer', +}) + +const selectionLayer = layer({ + above: false, + markers(view) { + const markers = [] + for (const range of view.state.selection.ranges) { + if (!range.empty) { + markers.push( + ...rectangleMarkerForRange(view, 'cm-selectionBackground', range) + ) + } + } + return markers + }, + update(update, dom) { + return ( + update.docChanged || + update.selectionSet || + update.viewportChanged || + updateHasMouseDownEffect(update) + ) + }, + class: 'cm-selectionLayer', +}) diff --git a/services/web/frontend/js/features/source-editor/extensions/editable.ts b/services/web/frontend/js/features/source-editor/extensions/editable.ts new file mode 100644 index 0000000000..4371810ebb --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/editable.ts @@ -0,0 +1,13 @@ +import { Compartment, EditorState, TransactionSpec } from '@codemirror/state' + +const readOnlyConf = new Compartment() + +export const editable = () => { + return [readOnlyConf.of(EditorState.readOnly.of(true))] +} + +export const setEditable = (value = true): TransactionSpec => { + return { + effects: [readOnlyConf.reconfigure(EditorState.readOnly.of(!value))], + } +} diff --git a/services/web/frontend/js/features/source-editor/extensions/empty-line-filler.ts b/services/web/frontend/js/features/source-editor/extensions/empty-line-filler.ts new file mode 100644 index 0000000000..243a094596 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/empty-line-filler.ts @@ -0,0 +1,76 @@ +import { + Decoration, + DecorationSet, + EditorView, + ViewPlugin, + ViewUpdate, + WidgetType, +} from '@codemirror/view' +import browser from './browser' + +class EmptyLineWidget extends WidgetType { + toDOM(view: EditorView): HTMLElement { + const element = document.createElement('span') + element.className = 'ol-cm-filler' + return element + } + + eq(widget: EmptyLineWidget) { + return true + } +} + +export const emptyLineFiller = () => { + if (browser.ios) { + // disable on iOS as it breaks Backspace across empty lines + // https://github.com/overleaf/internal/issues/12192 + return [] + } + + return [ + ViewPlugin.fromClass( + class { + decorations: DecorationSet + + constructor(view: EditorView) { + this.decorations = this.buildDecorations(view) + } + + update(update: ViewUpdate) { + if (update.docChanged || update.viewportChanged) + this.decorations = this.buildDecorations(update.view) + } + + buildDecorations(view: EditorView) { + const decorations = [] + const { from, to } = view.viewport + const { doc } = view.state + let pos = from + while (pos <= to) { + const line = doc.lineAt(pos) + if (line.length === 0) { + const decoration = Decoration.widget({ + widget: new EmptyLineWidget(), + side: 1, + }) + decorations.push(decoration.range(pos)) + } + pos = line.to + 1 + } + return Decoration.set(decorations) + } + }, + { + decorations(value) { + return value.decorations + }, + } + ), + EditorView.baseTheme({ + '.ol-cm-filler': { + display: 'inline-block', + width: '4px', + }, + }), + ] +} diff --git a/services/web/frontend/js/features/source-editor/extensions/exception-logger.ts b/services/web/frontend/js/features/source-editor/extensions/exception-logger.ts new file mode 100644 index 0000000000..117e9915a3 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/exception-logger.ts @@ -0,0 +1,11 @@ +import { Extension } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import { captureException } from '../../../infrastructure/error-reporter' + +export const exceptionLogger = (): Extension => { + return EditorView.exceptionSink.of(exception => { + captureException(exception, { + tags: { handler: 'cm6-exception' }, + }) + }) +} diff --git a/services/web/frontend/js/features/source-editor/extensions/filter-characters.ts b/services/web/frontend/js/features/source-editor/extensions/filter-characters.ts new file mode 100644 index 0000000000..d6f0e35d1b --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/filter-characters.ts @@ -0,0 +1,35 @@ +import { ChangeSpec, EditorState, Transaction } from '@codemirror/state' + +const BAD_CHARS_REGEXP = /[\0\uD800-\uDFFF]/g +const BAD_CHARS_REPLACEMENT_CHAR = '\uFFFD' + +export const filterCharacters = () => { + return EditorState.transactionFilter.of(tr => { + if (tr.docChanged && !tr.annotation(Transaction.remote)) { + const changes: ChangeSpec[] = [] + + tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => { + const text = inserted.toString() + + const newText = text.replaceAll( + BAD_CHARS_REGEXP, + BAD_CHARS_REPLACEMENT_CHAR + ) + + if (newText !== text) { + changes.push({ + from: fromB, + to: toB, + insert: newText, + }) + } + }) + + if (changes.length) { + return [tr, { changes, sequential: true }] + } + } + + return tr + }) +} diff --git a/services/web/frontend/js/features/source-editor/extensions/folding-keymap.ts b/services/web/frontend/js/features/source-editor/extensions/folding-keymap.ts new file mode 100644 index 0000000000..76fad5aec0 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/folding-keymap.ts @@ -0,0 +1,19 @@ +import { keymap } from '@codemirror/view' +import { foldAll, toggleFold, unfoldAll } from '@codemirror/language' + +export function foldingKeymap() { + return keymap.of([ + { + key: 'F2', + run: toggleFold, + }, + { + key: 'Alt-Shift-1', + run: foldAll, + }, + { + key: 'Alt-Shift-0', + run: unfoldAll, + }, + ]) +} diff --git a/services/web/frontend/js/features/source-editor/extensions/font-load.ts b/services/web/frontend/js/features/source-editor/extensions/font-load.ts new file mode 100644 index 0000000000..d85269b9f3 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/font-load.ts @@ -0,0 +1,33 @@ +import { ViewPlugin } from '@codemirror/view' +import { StateEffect } from '@codemirror/state' +import { updateHasEffect } from '../utils/effects' + +const fontLoadEffect = StateEffect.define() +export const hasFontLoadedEffect = updateHasEffect(fontLoadEffect) + +const plugin = ViewPlugin.define(view => { + function listener(this: FontFaceSet, event: FontFaceSetLoadEvent) { + view.dispatch({ effects: fontLoadEffect.of(event.fontfaces) }) + } + + const fontLoadSupport = 'fonts' in document + if (fontLoadSupport) { + // TypeScript doesn't appear to know the correct type for the listener + document.fonts.addEventListener('loadingdone', listener as EventListener) + } + + return { + destroy() { + if (fontLoadSupport) { + document.fonts.removeEventListener( + 'loadingdone', + listener as EventListener + ) + } + }, + } +}) + +export function fontLoad() { + return plugin +} diff --git a/services/web/frontend/js/features/source-editor/extensions/go-to-line.ts b/services/web/frontend/js/features/source-editor/extensions/go-to-line.ts new file mode 100644 index 0000000000..5669a74798 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/go-to-line.ts @@ -0,0 +1,61 @@ +import { Prec } from '@codemirror/state' +import { EditorView, keymap } from '@codemirror/view' +import { gotoLine } from '@codemirror/search' + +export const goToLinePanel = () => { + return [ + Prec.high( + keymap.of([ + { + key: 'Mod-Shift-l', + preventDefault: true, + run: gotoLine, + }, + ]) + ), + EditorView.baseTheme({ + '.cm-panel.cm-gotoLine': { + padding: '10px', + fontSize: '14px', + '& label': { + margin: 0, + fontSize: '14px', + '& .cm-textfield': { + margin: '0 10px', + maxWidth: '100px', + height: '34px', + padding: '5px 16px', + fontSize: '14px', + fontWeight: 'normal', + lineHeight: 'var(--line-height-base)', + color: 'var(--input-color)', + backgroundColor: '#fff', + backgroundImage: 'none', + borderRadius: 'var(--input-border-radius)', + boxShadow: 'inset 0 1px 1px rgb(0 0 0 / 8%)', + transition: + 'border-color ease-in-out .15s, box-shadow ease-in-out .15s', + '&:focus-visible': { + outline: 'none', + }, + '&:focus': { + borderColor: 'var(--input-border-focus)', + }, + }, + }, + '& .cm-button': { + padding: '4px 16px 5px', + textTransform: 'capitalize', + fontSize: '14px', + lineHeight: 'var(--line-height-base)', + userSelect: 'none', + backgroundImage: 'none', + backgroundColor: 'var(--btn-default-bg)', + borderRadius: 'var(--btn-border-radius-base)', + border: '0 solid transparent', + color: '#fff', + }, + }, + }), + ] +} diff --git a/services/web/frontend/js/features/source-editor/extensions/highlight-active-line.ts b/services/web/frontend/js/features/source-editor/extensions/highlight-active-line.ts new file mode 100644 index 0000000000..2566d57c76 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/highlight-active-line.ts @@ -0,0 +1,116 @@ +import { + Decoration, + DecorationSet, + EditorView, + layer, + LayerMarker, + RectangleMarker, + ViewPlugin, + ViewUpdate, +} from '@codemirror/view' +import { sourceOnly } from './visual/visual' +import { fullHeightCoordsAtPos } from '../utils/layer' + +export const highlightActiveLine = (visual: boolean) => { + // this extension should only be active in the source editor + return sourceOnly(visual, [ + activeLineLayer, + singleLineHighlighter, + EditorView.baseTheme({ + '.ol-cm-activeLineLayer': { + pointerEvents: 'none', + }, + }), + ]) +} + +/** + * Line decoration approach used for non-wrapped lines, adapted from built-in + * CodeMirror 6 highlightActiveLine, licensed under the MIT license: + * https://github.com/codemirror/view/blob/main/src/active-line.ts + */ +const lineDeco = Decoration.line({ class: 'cm-activeLine' }) + +const singleLineHighlighter = ViewPlugin.fromClass( + class { + decorations: DecorationSet + + constructor(view: EditorView) { + this.decorations = this.getDeco(view) + } + + update(update: ViewUpdate) { + if (update.docChanged || update.selectionSet) { + this.decorations = this.getDeco(update.view) + } + } + + getDeco(view: EditorView) { + const deco = [] + + // NOTE: only highlighting the active line for the main selection + const { main } = view.state.selection + + // No active line highlight when text is selected + if (main.empty) { + const line = view.lineBlockAt(main.head) + if (line.height <= view.defaultLineHeight) { + deco.push(lineDeco.range(line.from)) + } + } + + return Decoration.set(deco) + } + }, + { + decorations: v => v.decorations, + } +) + +// Custom layer approach, used only for wrapped lines +const activeLineLayer = layer({ + above: false, + class: 'ol-cm-activeLineLayer', + markers(view: EditorView): readonly LayerMarker[] { + const markers: LayerMarker[] = [] + + // NOTE: only highlighting the active line for the main selection + const { main } = view.state.selection + + // no active line highlight when text is selected + if (!main.empty) { + return markers + } + + // Use line decoration when line doesn't wrap + if (view.lineBlockAt(main.head).height <= view.defaultLineHeight) { + return markers + } + + const coords = fullHeightCoordsAtPos( + view, + main.head, + main.assoc || undefined + ) + + if (coords) { + const scrollRect = view.scrollDOM.getBoundingClientRect() + const contentRect = view.contentDOM.getBoundingClientRect() + const scrollTop = view.scrollDOM.scrollTop + + const top = coords.top - scrollRect.top + scrollTop + const left = contentRect.left - scrollRect.left + const width = contentRect.right - contentRect.left + const height = coords.bottom - coords.top + + markers.push( + new RectangleMarker('cm-activeLine', left, top, width, height) + ) + } + + return markers + }, + update(update: ViewUpdate): boolean { + return update.geometryChanged || update.selectionSet + }, +}) diff --git a/services/web/frontend/js/features/source-editor/extensions/highlight-selection-matches.ts b/services/web/frontend/js/features/source-editor/extensions/highlight-selection-matches.ts new file mode 100644 index 0000000000..5272d15b1e --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/highlight-selection-matches.ts @@ -0,0 +1,128 @@ +/** + * This file is adapted from CodeMirror 6, licensed under the MIT license: + * https://github.com/codemirror/search/blob/main/src/selection-match.ts + */ +import { EditorView, layer, RectangleMarker } from '@codemirror/view' +import { + CharCategory, + EditorSelection, + EditorState, + Extension, +} from '@codemirror/state' +import { SearchCursor } from '@codemirror/search' +import { rectangleMarkerForRange } from '../utils/layer' + +/* +This extension highlights text that matches the selection. +It uses the `"cm-selectionMatch"` class for the highlighting. + */ +export const highlightSelectionMatches = (): Extension => [ + layer({ + above: false, + markers(view) { + return buildMarkers(view, view.state) + }, + update(update) { + return update.docChanged || update.selectionSet || update.viewportChanged + }, + class: 'ol-cm-selectionMatchesLayer', + }), + EditorView.baseTheme({ + '.ol-cm-selectionMatchesLayer': { + contain: 'size style', + pointerEvents: 'none', + }, + '.cm-selectionMatch': { + position: 'absolute', + }, + }), +] + +// Whether the characters directly outside the given positions are non-word characters +function insideWordBoundaries( + check: (char: string) => CharCategory, + state: EditorState, + from: number, + to: number +): boolean { + return ( + (from === 0 || + check(state.sliceDoc(from - 1, from)) !== CharCategory.Word) && + (to === state.doc.length || + check(state.sliceDoc(to, to + 1)) !== CharCategory.Word) + ) +} + +// Whether the characters directly at the given positions are word characters +function insideWord( + check: (char: string) => CharCategory, + state: EditorState, + from: number, + to: number +): boolean { + return ( + check(state.sliceDoc(from, from + 1)) === CharCategory.Word && + check(state.sliceDoc(to - 1, to)) === CharCategory.Word + ) +} + +const buildMarkers = ( + view: EditorView, + state: EditorState +): RectangleMarker[] => { + const sel = state.selection + if (sel.ranges.length > 1) { + return [] + } + + const range = sel.main + + if (range.empty) { + return [] + } + + const len = range.to - range.from + if (len < 3 || len > 200) { + return [] + } + + const query = state.sliceDoc(range.from, range.to) // TODO: allow and include leading/trailing space? + if (query === '') { + return [] + } + + const check = state.charCategorizer(range.head) + if ( + !( + insideWordBoundaries(check, state, range.from, range.to) && + insideWord(check, state, range.from, range.to) + ) + ) { + return [] + } + + const markers: RectangleMarker[] = [] + + for (const part of view.visibleRanges) { + const cursor = new SearchCursor(state.doc, query, part.from, part.to) + + while (!cursor.next().done) { + const { from, to } = cursor.value + + if (!check || insideWordBoundaries(check, state, from, to)) { + markers.push( + ...rectangleMarkerForRange( + view, + 'cm-selectionMatch', + EditorSelection.range(from, to) + ) + ) + + if (markers.length > 100) { + return [] + } + } + } + } + return markers +} diff --git a/services/web/frontend/js/features/source-editor/extensions/indentation-markers.ts b/services/web/frontend/js/features/source-editor/extensions/indentation-markers.ts new file mode 100644 index 0000000000..c0cc2fe9d0 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/indentation-markers.ts @@ -0,0 +1,8 @@ +import { Extension } from '@codemirror/state' +import { indentationMarkers as markers } from '@replit/codemirror-indentation-markers' +import { sourceOnly } from './visual/visual' + +export const indentationMarkers = (visual: boolean): Extension => + sourceOnly(visual, [ + markers({ hideFirstIndent: true, highlightActiveBlock: false }), + ]) diff --git a/services/web/frontend/js/features/source-editor/extensions/index.ts b/services/web/frontend/js/features/source-editor/extensions/index.ts new file mode 100644 index 0000000000..c5ba38132c --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/index.ts @@ -0,0 +1,138 @@ +import { + EditorView, + highlightSpecialChars, + keymap, + rectangularSelection, + tooltips, + crosshairCursor, + dropCursor, + highlightActiveLineGutter, +} from '@codemirror/view' +import { EditorState, Extension } from '@codemirror/state' +import { foldGutter, indentOnInput } from '@codemirror/language' +import { history, historyKeymap, defaultKeymap } from '@codemirror/commands' +import { lintKeymap } from '@codemirror/lint' +import { language } from './language' +import { lineWrappingIndentation } from './line-wrapping-indentation' +import { theme } from './theme' +import { realtime } from './realtime' +import { cursorPosition } from './cursor-position' +import { scrollPosition } from './scroll-position' +import { annotations } from './annotations' +import { cursorHighlights } from './cursor-highlights' +import { autoComplete } from './auto-complete' +import { editable } from './editable' +import { autoPair } from './auto-pair' +import { phrases } from './phrases' +import { spelling } from './spelling' +import { shortcuts } from './shortcuts' +import { symbolPalette } from './symbol-palette' +import { trackChanges } from './track-changes' +import { search } from './search' +import { filterCharacters } from './filter-characters' +import { keybindings } from './keybindings' +import { bracketMatching, bracketSelection } from './bracket-matching' +import { verticalOverflow } from './vertical-overflow' +import { exceptionLogger } from './exception-logger' +import { thirdPartyExtensions } from './third-party-extensions' +import { lineNumbers } from './line-numbers' +import { highlightActiveLine } from './highlight-active-line' +import importOverleafModules from '../../../../macros/import-overleaf-module.macro' +import { emptyLineFiller } from './empty-line-filler' +import { goToLinePanel } from './go-to-line' +import { parserWatcher } from './wait-for-parser' +import { drawSelection } from './draw-selection' +import { visual } from './visual/visual' +import { scrollOneLine } from './scroll-one-line' +import { foldingKeymap } from './folding-keymap' +import { inlineBackground } from './inline-background' +import { fontLoad } from './font-load' +import { indentationMarkers } from './indentation-markers' + +const ignoredDefaultKeybindings = new Set([ + // NOTE: disable "Mod-Enter" as it's used for "Compile" + 'Mod-Enter', + // Disable Alt+Arrow as we have special behaviour on Windows / Linux + 'Alt-ArrowLeft', + 'Alt-ArrowRight', + // This keybinding causes issues on some keyboard layouts where \ is entered + // using AltGr. Windows treats Ctrl-Alt as AltGr, so trying to insert a \ + // with Ctrl-Alt would trigger this keybinding, rather than inserting a \ + 'Mod-Alt-\\', +]) + +const moduleExtensions: Array<() => Extension> = importOverleafModules( + 'sourceEditorExtensions' +).map((item: { import: { extension: Extension } }) => item.import.extension) + +export const createExtensions = (options: Record): Extension[] => [ + lineNumbers(), + highlightSpecialChars(), + history({ newGroupDelay: 250 }), + foldGutter({ + openText: '▾', + closedText: '▸', + }), + drawSelection(), + EditorState.allowMultipleSelections.of(true), + EditorView.lineWrapping, + indentOnInput(), + lineWrappingIndentation(options.visual.visual), + indentationMarkers(options.visual.visual), + bracketMatching(), + bracketSelection(), + rectangularSelection(), + crosshairCursor(), + dropCursor(), + tooltips({ + parent: document.body, + }), + keymap.of([ + ...defaultKeymap.filter( + // We only filter on keys, so if the keybinding doesn't have a key, + // allow it + item => !item.key || !ignoredDefaultKeybindings.has(item.key) + ), + ...historyKeymap, + ...lintKeymap, + ]), + foldingKeymap(), + goToLinePanel(), + filterCharacters(), + + // `autoComplete` needs to be before `keybindings` so that arrow key handling + // in the autocomplete pop-up takes precedence over Vim/Emacs key bindings + autoComplete(options.settings), + + // `keybindings` needs to be before `language` so that Vim/Emacs bindings take + // precedence over language-specific keyboard shortcuts + keybindings(), + + annotations(), // NOTE: must be before `language` + language(options.currentDoc, options.metadata, options.settings), + theme(options.theme), + realtime(options.currentDoc, options.handleError), + cursorPosition(options.currentDoc), + scrollPosition(options.currentDoc), + cursorHighlights(), + autoPair(options.settings), + editable(), + search(), + phrases(options.phrases), + parserWatcher(), + spelling(options.spelling), + shortcuts(), + symbolPalette(), + emptyLineFiller(), // NOTE: must be before `trackChanges` + trackChanges(options.currentDoc, options.changeManager), + visual(options.currentDoc, options.visual), + verticalOverflow(), + highlightActiveLine(options.visual.visual), + highlightActiveLineGutter(), + scrollOneLine(), + fontLoad(), + inlineBackground(options.visual.visual), + exceptionLogger(), + moduleExtensions.map(extension => extension()), + thirdPartyExtensions(), +] diff --git a/services/web/frontend/js/features/source-editor/extensions/inline-background.ts b/services/web/frontend/js/features/source-editor/extensions/inline-background.ts new file mode 100644 index 0000000000..ed5f8c7cf7 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/inline-background.ts @@ -0,0 +1,96 @@ +import { Annotation, Compartment } from '@codemirror/state' +import { EditorView, ViewPlugin } from '@codemirror/view' +import { themeOptionsChange } from './theme' +import { sourceOnly } from './visual/visual' +import { round } from 'lodash' +import { hasLanguageLoadedEffect } from './language' +import { hasFontLoadedEffect } from './font-load' + +const themeConf = new Compartment() +const changeHalfLeadingAnnotation = Annotation.define() + +function firstVisibleNonSpacePos(view: EditorView) { + for (const range of view.visibleRanges) { + const match = /\S/.exec(view.state.sliceDoc(range.from, range.to)) + if (match) { + return range.from + match.index + } + } + return null +} + +function measureHalfLeading(view: EditorView) { + const pos = firstVisibleNonSpacePos(view) + if (pos === null) { + return 0 + } + + const coords = view.coordsAtPos(pos) + if (!coords) { + return 0 + } + const inlineBoxHeight = coords.bottom - coords.top + + // Rounding prevents gaps appearing in some situations + return round((view.defaultLineHeight - inlineBoxHeight) / 2, 2) +} + +function createTheme(halfLeading: number) { + return EditorView.theme({ + '.cm-content': { + '--half-leading': halfLeading + 'px', + }, + }) +} + +const plugin = ViewPlugin.define( + view => { + let halfLeading = 0 + + const measureRequest = { + read: () => { + return measureHalfLeading(view) + }, + + write: (newHalfLeading: number) => { + if (newHalfLeading !== halfLeading) { + halfLeading = newHalfLeading + window.setTimeout(() => + view.dispatch({ + effects: themeConf.reconfigure(createTheme(newHalfLeading)), + annotations: changeHalfLeadingAnnotation.of(true), + }) + ) + } + }, + } + + return { + update(update) { + // Ignore any update triggered by this plugin + if ( + update.transactions.some(tr => + tr.annotation(changeHalfLeadingAnnotation) + ) + ) { + return + } + if ( + hasFontLoadedEffect(update) || + (update.geometryChanged && !update.docChanged) || + update.transactions.some(tr => tr.annotation(themeOptionsChange)) || + hasLanguageLoadedEffect(update) + ) { + view.requestMeasure(measureRequest) + } + }, + } + }, + { + provide: () => [themeConf.of(createTheme(0))], + } +) + +export const inlineBackground = (visual: boolean) => { + return sourceOnly(visual, plugin) +} diff --git a/services/web/frontend/js/features/source-editor/extensions/keybindings.ts b/services/web/frontend/js/features/source-editor/extensions/keybindings.ts new file mode 100644 index 0000000000..30abad9e3d --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/keybindings.ts @@ -0,0 +1,186 @@ +import { openSearchPanel } from '@codemirror/search' +import { + Compartment, + EditorSelection, + Prec, + TransactionSpec, +} from '@codemirror/state' +import { EmacsHandler } from '@replit/codemirror-emacs' +import { CodeMirror } from '@replit/codemirror-vim' +import { foldCode, toggleFold, unfoldCode } from '@codemirror/language' + +const hasNonEmptySelection = (cm: CodeMirror): boolean => { + const selections = cm.getSelections() + return selections.some(selection => selection.length) +} + +let customisedVim = false +const customiseVimOnce = (Vim: any, CodeMirror: any) => { + if (customisedVim) { + return + } + // Allow copy via Ctrl-C in insert mode + Vim.unmap('', '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 + ) { + 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('', 'action', 'insertModeCtrlC', undefined, { + context: 'insert', + }) + + // Code folding commands + Vim.defineAction('toggleFold', function (cm: CodeMirror) { + toggleFold(cm.cm6) + }) + Vim.mapCommand('za', 'action', 'toggleFold') + + Vim.defineAction('foldCode', function (cm: CodeMirror) { + foldCode(cm.cm6) + }) + Vim.mapCommand('zc', 'action', 'foldCode') + + Vim.defineAction('unfoldCode', function (cm: CodeMirror) { + unfoldCode(cm.cm6) + }) + Vim.mapCommand('zo', 'action', 'unfoldCode') + + // Make the Vim 'write' command start a compile + CodeMirror.commands.save = () => { + window.dispatchEvent(new Event('pdf:recompile')) + } + customisedVim = true +} + +// Used to ensure that only one listener is active +let emacsSearchCloseListener: (() => void) | undefined + +let customisedEmacs = false +const customiseEmacsOnce = () => { + if (customisedEmacs) { + return + } + customisedEmacs = true + + const jumpToLastMark = (handler: EmacsHandler) => { + const mark = handler.popEmacsMark() + if (!mark || !mark.length) { + return + } + let selection = null + if (mark.length >= 2) { + selection = EditorSelection.range(mark[0], mark[1]) + } else { + selection = EditorSelection.cursor(mark[0]) + } + handler.view.dispatch({ selection, scrollIntoView: true }) + } + + EmacsHandler.addCommands({ + openSearch(handler: EmacsHandler) { + const mark = handler.view.state.selection.main + handler.pushEmacsMark([mark.anchor, mark.head]) + openSearchPanel(handler.view) + if (emacsSearchCloseListener) { + document.removeEventListener( + 'cm:emacs-close-search-panel', + emacsSearchCloseListener + ) + } + emacsSearchCloseListener = () => { + jumpToLastMark(handler) + } + document.addEventListener( + 'cm:emacs-close-search-panel', + emacsSearchCloseListener + ) + }, + }) + EmacsHandler.bindKey('C-s', 'openSearch') + EmacsHandler.bindKey('C-r', 'openSearch') +} + +const options = [ + { + name: 'default', + load: async () => { + // TODO: load default keybindings? + return [] + }, + }, + { + name: 'vim', + load: () => + import('@replit/codemirror-vim').then(m => { + customiseVimOnce(m.Vim, m.CodeMirror) + return m.vim() + }), + }, + { + name: 'emacs', + load: () => + import('@replit/codemirror-emacs').then(m => { + customiseEmacsOnce() + return m.emacs() + }), + }, +] + +const keybindingsConf = new Compartment() + +export const keybindings = () => { + return keybindingsConf.of(Prec.highest([])) +} + +export const setKeybindings = async ( + selectedKeybindings = 'default' +): Promise => { + 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)), + } +} diff --git a/services/web/frontend/js/features/source-editor/extensions/language.ts b/services/web/frontend/js/features/source-editor/extensions/language.ts new file mode 100644 index 0000000000..c6db122cac --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/language.ts @@ -0,0 +1,92 @@ +import { + Compartment, + StateEffect, + StateField, + TransactionSpec, +} from '@codemirror/state' +import { languages } from '../languages' +import { ViewPlugin } from '@codemirror/view' +import { indentUnit, LanguageDescription } from '@codemirror/language' +import { Metadata } from '../../../../../types/metadata' +import { CurrentDoc } from '../../../../../types/current-doc' +import { updateHasEffect } from '../utils/effects' + +export const languageLoadedEffect = StateEffect.define() +export const hasLanguageLoadedEffect = updateHasEffect(languageLoadedEffect) + +const languageConf = new Compartment() + +type Options = { + syntaxValidation: boolean +} + +export const metadataState = StateField.define({ + create: () => undefined, + update: (value, transaction) => { + for (const effect of transaction.effects) { + if (effect.is(setMetadataEffect)) { + return effect.value + } + } + return value + }, +}) + +export const language = ( + currentDoc: CurrentDoc, + metadata: Metadata, + { syntaxValidation }: Options +) => { + const languageDescription = LanguageDescription.matchFilename( + languages, + currentDoc.docName + ) + + if (!languageDescription) { + return [] + } + + return [ + // Default to four-space indentation, which prevents a shift in line + // indentation markers when LaTeX loads + languageConf.of(indentUnit.of(' ')), + metadataState, + ViewPlugin.define(view => { + // load the language asynchronously + languageDescription.load().then(support => { + view.dispatch({ + effects: [ + languageConf.reconfigure(support), + languageLoadedEffect.of(null), + ], + }) + // Wait until the previous effects have been processed + view.dispatch({ + effects: [ + setMetadataEffect.of(metadata), + setSyntaxValidationEffect.of(syntaxValidation), + ], + }) + }) + + return {} + }), + metadataState, + ] +} + +export const setMetadataEffect = StateEffect.define() + +export const setMetadata = (values: Metadata): TransactionSpec => { + return { + effects: setMetadataEffect.of(values), + } +} + +export const setSyntaxValidationEffect = StateEffect.define() + +export const setSyntaxValidation = (value: boolean): TransactionSpec => { + return { + effects: setSyntaxValidationEffect.of(value), + } +} diff --git a/services/web/frontend/js/features/source-editor/extensions/line-numbers.ts b/services/web/frontend/js/features/source-editor/extensions/line-numbers.ts new file mode 100644 index 0000000000..ec90f7ab79 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/line-numbers.ts @@ -0,0 +1,85 @@ +import { EditorSelection, Extension } from '@codemirror/state' +import { + BlockInfo, + EditorView, + lineNumbers as cmLineNumbers, +} from '@codemirror/view' +import { DebouncedFunc, throttle } from 'lodash' + +export function lineNumbers(): Extension { + let listener: DebouncedFunc<(event: MouseEvent) => boolean> | null + + function disableListener() { + if (listener) { + document.removeEventListener('mousemove', listener) + listener = null + } + } + + // Creates a selection range capped within the document bounds. The range is + // anchored at the beginning so that it is a full line that is selected + function selection(view: EditorView, start: BlockInfo, end: BlockInfo) { + const clamp = (num: number) => + Math.max(0, Math.min(view.state.doc.length, num)) + + let startPos = start.from + let endPos = end.to + 1 + if (start.from === end.from) { + // Selecting one line + startPos = end.to + 1 + endPos = start.from + } else if (end.from < start.from) { + // End is prior to start + endPos = end.from + startPos = start.to + 1 + } + return EditorSelection.range(clamp(startPos), clamp(endPos)) + } + + // Wrapper around the built-in codemirror lineNumbers() extension + return cmLineNumbers({ + domEventHandlers: { + mousedown: (view, line, event) => { + // Disable default focusing of line number + event.preventDefault() + + // If we already have a listener, disable it + disableListener() + view.dispatch({ + selection: selection(view, line, line), + }) + + // Focus the editor + view.contentDOM.focus() + + // Set up new listener to track the mouse position + listener = throttle((event: MouseEvent) => { + // Check if we've missed a mouseup event by validating that the + // primary mouse button is still being held + if (event.buttons !== 1) { + disableListener() + return false + } + + // Map the mouse cursor to the document, and select the lines matched + const documentPosition = view.posAtCoords({ + x: event.pageX, + y: event.pageY, + }) + if (documentPosition) { + const endLine = view.lineBlockAt(documentPosition) + view.dispatch({ + selection: selection(view, line, endLine), + }) + } + }, 50) + document.addEventListener('mousemove', listener) + return false + }, + mouseup: () => { + disableListener() + return false + }, + }, + }) +} diff --git a/services/web/frontend/js/features/source-editor/extensions/line-wrapping-indentation.ts b/services/web/frontend/js/features/source-editor/extensions/line-wrapping-indentation.ts new file mode 100644 index 0000000000..2c0605994b --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/line-wrapping-indentation.ts @@ -0,0 +1,144 @@ +import { + Decoration, + DecorationSet, + EditorView, + ViewPlugin, + ViewUpdate, +} from '@codemirror/view' +import { type Range, StateEffect, StateField } from '@codemirror/state' +import { sourceOnly } from './visual/visual' + +const MAX_INDENT_FRACTION = 0.9 + +const setMaxIndentEffect = StateEffect.define() + +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({ + create() { + return 0 + }, + update(value, tr) { + for (const effect of tr.effects) { + if (effect.is(setMaxIndentEffect)) { + value = effect.value + } + } + + return value + }, + provide(field) { + return [ + // calculate the max indent when the geometry changes + ViewPlugin.define(view => { + const measure = { + key: 'line-wrapping-indentation-max-indent', + read(view: EditorView) { + return ( + (view.contentDOM.clientWidth / view.defaultCharacterWidth) * + MAX_INDENT_FRACTION + ) + }, + write(value: number, view: EditorView) { + if (view.state.field(field) !== value) { + window.setTimeout(() => { + view.dispatch({ + effects: setMaxIndentEffect.of(value), + }) + }) + } + }, + } + + return { + update(update: ViewUpdate) { + if (update.geometryChanged) { + view.requestMeasure(measure) + } + }, + } + }), + + // rebuild the decorations when needed + ViewPlugin.define<{ decorations: DecorationSet }>( + view => { + let previousMaxIndent = 0 + + const value = { + decorations: buildDecorations(view, view.state.field(field)), + + update(update: ViewUpdate) { + const maxIndent = view.state.field(field) + + if ( + maxIndent !== previousMaxIndent || + update.geometryChanged || + update.viewportChanged + ) { + value.decorations = buildDecorations(view, maxIndent) + } + + previousMaxIndent = maxIndent + }, + } + + return value + }, + { + decorations: value => value.decorations, + } + ), + ] + }, + }), + ]) +} + +export const buildDecorations = (view: EditorView, maxIndent: number) => { + const { state } = view + const { doc, tabSize } = state + + const decorations: Range[] = [] + + let from = 0 + + for (const line of doc.iterLines()) { + // const indent = line.match(/^(\s*)/)[1].length + const indent = calculateIndent(line, tabSize, maxIndent) + + if (indent) { + decorations.push(lineIndentDecoration(indent).range(from)) + } + + from += line.length + 1 + } + + return Decoration.set(decorations) +} + +const lineIndentDecoration = (indent: number) => + Decoration.line({ + attributes: { + // style: `text-indent: ${indent}ch hanging`, // "hanging" would be ideal, when browsers support it + style: `text-indent: -${indent}ch; padding-left: calc(${indent}ch + 4px)`, // add 4px to account for existing padding-left + }, + }) + +// calculate the character width of whitespace at the start of a line +const calculateIndent = (line: string, tabSize: number, maxIndent: number) => { + let indent = 0 + + for (const char of line) { + if (char === ' ') { + indent++ + } else if (char === '\t') { + indent += tabSize - (indent % tabSize) + } else { + break + } + } + + return Math.min(indent, maxIndent) +} diff --git a/services/web/frontend/js/features/source-editor/extensions/phrases.ts b/services/web/frontend/js/features/source-editor/extensions/phrases.ts new file mode 100644 index 0000000000..ad7b11f47d --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/phrases.ts @@ -0,0 +1,13 @@ +import { Compartment, EditorState, TransactionSpec } from '@codemirror/state' + +const phrasesConf = new Compartment() + +export const phrases = (phrases: Record) => { + return phrasesConf.of(EditorState.phrases.of(phrases)) +} + +export const setPhrases = (value: Record): TransactionSpec => { + return { + effects: phrasesConf.reconfigure(EditorState.phrases.of(value)), + } +} diff --git a/services/web/frontend/js/features/source-editor/extensions/realtime.ts b/services/web/frontend/js/features/source-editor/extensions/realtime.ts new file mode 100644 index 0000000000..6fabd6d653 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/realtime.ts @@ -0,0 +1,222 @@ +import { Prec, Transaction, Annotation } from '@codemirror/state' +import { EditorView, ViewPlugin } from '@codemirror/view' +import { EventEmitter } from 'events' +import { CurrentDoc } from '../../../../../types/current-doc' +import { ShareDoc } from '../../../../../types/share-doc' + +/* + * Integrate CodeMirror 6 with the real-time system, via ShareJS. + * + * Changes from CodeMirror are passed to the shareDoc + * via `handleTransaction`, while changes arriving from + * real-time are passed to CodeMirror via the EditorFacade. + * + * We use an `EditorFacade` to integrate with the rest of + * the IDE, providing an interface the other systems can work with. + * + * Related files: + * - frontend/js/ide/editor/Document.js + * - frontend/js/ide/editor/ShareJsDoc.js + * - frontend/js/ide/connection/EditorWatchdogManager.js + */ + +export const realtime = ( + { currentDoc }: { currentDoc: CurrentDoc }, + handleError: (error: Error) => void +) => { + const realtimePlugin = ViewPlugin.define(view => { + const editor = new EditorFacade(view) + + currentDoc.attachToCM6(editor) + + return { + update(update) { + if (update.docChanged) { + editor.handleUpdateFromCM(update.transactions) + } + }, + destroy() { + // TODO: wrap in a timeout so processing can finish? + // window.setTimeout(() => { + currentDoc.detachFromCM6() + // }, 0) + }, + } + }) + + // NOTE: not a view plugin, so shouldn't get removed + const ensureRealtimePlugin = EditorView.updateListener.of(update => { + if (!update.view.plugin(realtimePlugin)) { + const message = 'The realtime extension has been destroyed!!' + console.log(message) + if (currentDoc.doc) { + // display the "out of sync" modal + currentDoc.doc.emit('error', message) + } else { + // display the error boundary + handleError(new Error(message)) + } + } + }) + + return Prec.highest([realtimePlugin, ensureRealtimePlugin]) +} + +export class EditorFacade extends EventEmitter { + public shareDoc: ShareDoc | null + public events: EventEmitter + private maxDocLength?: number + + constructor(public view: EditorView) { + super() + this.view = view + this.shareDoc = null + this.events = new EventEmitter() + } + + getValue() { + return this.view.state.doc.toString() + } + + // Dispatch changes to CodeMirror view + cmInsert(position: number, text: string, origin?: string) { + this.view.dispatch({ + changes: { from: position, insert: text }, + annotations: [ + Transaction.remote.of(origin === 'remote'), + Transaction.addToHistory.of(origin !== 'remote'), + ], + }) + } + + cmDelete(position: number, text: string, origin?: string) { + this.view.dispatch({ + changes: { from: position, to: position + text.length }, + annotations: [ + Transaction.remote.of(origin === 'remote'), + Transaction.addToHistory.of(origin !== 'remote'), + ], + }) + } + + // Connect to ShareJS, passing changes to the CodeMirror view + // as new transactions. + // This is a broad immitation of helper functions supplied in + // the sharejs library. (See vendor/libs/sharejs, in particular + // the 'attach_cm' and 'attach_ace' helpers) + attachShareJs(shareDoc: ShareDoc, maxDocLength?: number) { + this.shareDoc = shareDoc + this.maxDocLength = maxDocLength + + const check = () => { + // run in a timeout so it checks the editor content once this update has been applied + window.setTimeout(() => { + const editorText = this.getValue() + const otText = shareDoc.getText() + + if (editorText !== otText) { + shareDoc.emit('error', 'Text does not match in CodeMirror 6') + console.error('Text does not match!') + console.error('editor: ' + editorText) + return console.error('ot: ' + otText) + } + }, 0) + } + + const onInsert = (pos: number, text: string) => { + this.cmInsert(pos, text, 'remote') + check() + } + + const onDelete = (pos: number, text: string) => { + this.cmDelete(pos, text, 'remote') + check() + } + + check() + + shareDoc.on('insert', onInsert) + shareDoc.on('delete', onDelete) + + shareDoc.detach_cm6 = () => { + shareDoc.removeListener('insert', onInsert) + shareDoc.removeListener('delete', onDelete) + delete shareDoc.detach_cm6 + this.shareDoc = null + } + } + + // Process an update from CodeMirror, applying changes to the + // ShareJs doc if appropriate + handleUpdateFromCM(transactions: readonly Transaction[]) { + const shareDoc = this.shareDoc + + if (!shareDoc) { + throw new Error('Trying to process updates with no shareDoc') + } + + for (const transaction of transactions) { + if (transaction.docChanged) { + const origin = chooseOrigin(transaction) + + if (origin === 'remote') { + return + } + + if ( + this.maxDocLength && + transaction.changes.desc.newLength >= this.maxDocLength + ) { + shareDoc.emit( + 'error', + new Error('document length is greater than maxDocLength') + ) + return + } + + let positionShift = 0 + + transaction.changes.iterChanges( + (fromA, toA, fromB, toB, insertedText) => { + const fromUndo = origin === 'undo' || origin === 'reject' + + const insertedLength = insertedText.length + const removedLength = toA - fromA + + const inserted = insertedLength > 0 + const removed = removedLength > 0 + + const pos = fromA + positionShift + + if (removed) { + shareDoc.del(pos, removedLength, fromUndo) + } + + if (inserted) { + shareDoc.insert(pos, insertedText.toString(), fromUndo) + } + + // TODO: mapPos instead? + positionShift = positionShift - removedLength + insertedLength + + this.emit('change', this, { origin, inserted, removed }) + } + ) + } + } + } +} + +export const trackChangesAnnotation = Annotation.define() + +const chooseOrigin = (transaction: Transaction) => { + if (transaction.annotation(Transaction.remote)) { + return 'remote' + } + if (transaction.annotation(Transaction.userEvent) === 'undo') { + return 'undo' + } + if (transaction.annotation(trackChangesAnnotation) === 'reject') { + return 'reject' + } +} diff --git a/services/web/frontend/js/features/source-editor/extensions/scroll-one-line.ts b/services/web/frontend/js/features/source-editor/extensions/scroll-one-line.ts new file mode 100644 index 0000000000..0dd6fa061b --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/scroll-one-line.ts @@ -0,0 +1,35 @@ +import { Command, EditorView, keymap } from '@codemirror/view' + +function scrollByLine(view: EditorView, lineCount: number) { + view.scrollDOM.scrollTop += view.defaultLineHeight * lineCount +} + +const scrollUpOneLine: Command = (view: EditorView) => { + scrollByLine(view, -1) + // Always consume the keypress to prevent the cursor going up a line when the + // editor is scrolled to the top + return true +} + +const scrollDownOneLine: Command = (view: EditorView) => { + scrollByLine(view, 1) + // Always consume the keypress to prevent the cursor going down a line when + // the editor is scrolled to the bottom + return true +} + +export function scrollOneLine() { + // Applied to Windows and Linux only + return keymap.of([ + { + linux: 'Ctrl-ArrowUp', + win: 'Ctrl-ArrowUp', + run: scrollUpOneLine, + }, + { + linux: 'Ctrl-ArrowDown', + win: 'Ctrl-ArrowDown', + run: scrollDownOneLine, + }, + ]) +} diff --git a/services/web/frontend/js/features/source-editor/extensions/scroll-position.ts b/services/web/frontend/js/features/source-editor/extensions/scroll-position.ts new file mode 100644 index 0000000000..ac73af4d58 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/scroll-position.ts @@ -0,0 +1,157 @@ +import { BlockInfo, EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view' +import { throttle } from 'lodash' +import customLocalStorage from '../../../infrastructure/local-storage' +import { + EditorSelection, + StateEffect, + Text, + TransactionSpec, +} from '@codemirror/state' +import { toggleVisualEffect } from './visual/visual' + +const buildStorageKey = (docId: string) => `doc.position.${docId}` + +type LineInfo = { + first: BlockInfo + middle: BlockInfo +} + +export const scrollPosition = ({ + currentDoc: { doc_id: docId }, +}: { + currentDoc: { doc_id: string } +}) => { + // store lineInfo for use on unload, when the DOM has already been unmounted + let lineInfo: LineInfo + + const scrollHandler = throttle( + (event, view) => { + // exclude a scroll event with no target, which happens when switching docs + if (event.target === view.scrollDOM) { + lineInfo = calculateLineInfo(view) + dispatchScrollPosition(lineInfo, view) + } + }, + // long enough to capture intent, but short enough that the selected heading in the outline appears current + 120, + { trailing: true } + ) + + return [ + // store/dispatch scroll position + ViewPlugin.define( + view => { + const unloadListener = () => { + if (lineInfo) { + storeScrollPosition(lineInfo, view, docId) + } + } + + window.addEventListener('unload', unloadListener) + + return { + update: (update: ViewUpdate) => { + for (const tr of update.transactions) { + for (const effect of tr.effects) { + if (effect.is(toggleVisualEffect)) { + // store the scroll position when switching between source and rich text + if (lineInfo) { + storeScrollPosition(lineInfo, view, docId) + } + } else if (effect.is(restoreScrollPositionEffect)) { + // restore the scroll position + window.setTimeout(() => { + view.dispatch(scrollStoredLineToTop(tr.state.doc, docId)) + window.dispatchEvent( + new Event('editor:scroll-position-restored') + ) + }) + } + } + } + }, + destroy: () => { + scrollHandler.cancel() + window.removeEventListener('unload', unloadListener) + unloadListener() + }, + } + }, + { + eventHandlers: { + scroll: scrollHandler, + }, + } + ), + ] +} + +const restoreScrollPositionEffect = StateEffect.define() + +export const restoreScrollPosition = () => { + return { + effects: restoreScrollPositionEffect.of(null), + } +} + +const calculateLineInfo = (view: EditorView) => { + // the top of the scrollDOM element relative to the top of the document + const { top, height } = view.scrollDOM.getBoundingClientRect() + const distanceFromDocumentTop = top - view.documentTop + + return { + first: view.lineBlockAtHeight(distanceFromDocumentTop), + // top plus half the height of the scrollDOM element + middle: view.lineBlockAtHeight(distanceFromDocumentTop + height / 2), + } +} + +// dispatch the middle visible line number (for the outline) +const dispatchScrollPosition = (lineInfo: LineInfo, view: EditorView) => { + const middleVisibleLine = view.state.doc.lineAt(lineInfo.middle.from).number + + window.dispatchEvent( + new CustomEvent('scroll:editor:update', { + detail: middleVisibleLine, + }) + ) +} + +// store the scroll position (first visible line number, for restoring on load) +const storeScrollPosition = ( + lineInfo: LineInfo, + view: EditorView, + docId: string +) => { + const key = buildStorageKey(docId) + const data = customLocalStorage.getItem(key) + const firstVisibleLine = view.state.doc.lineAt(lineInfo.first.from).number + + customLocalStorage.setItem(key, { ...data, firstVisibleLine }) +} + +// restore the scroll position using the stored first visible line number +const scrollStoredLineToTop = (doc: Text, docId: string): TransactionSpec => { + try { + const key = buildStorageKey(docId) + const data = customLocalStorage.getItem(key) + + // restore the scroll position to its original position, or the last line of the document + const firstVisibleLine = Math.min(data?.firstVisibleLine ?? 1, doc.lines) + + const line = doc.line(firstVisibleLine) + + const selectionRange = EditorSelection.cursor(line.from) + + return { + effects: EditorView.scrollIntoView(selectionRange, { + y: 'start', + yMargin: 0, + }), + } + } catch (e) { + // ignore invalid line number + console.error(e) + return {} + } +} diff --git a/services/web/frontend/js/features/source-editor/extensions/search.ts b/services/web/frontend/js/features/source-editor/extensions/search.ts new file mode 100644 index 0000000000..f094abdd04 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/search.ts @@ -0,0 +1,255 @@ +import { + searchKeymap, + search as searchExtension, + setSearchQuery, + getSearchQuery, + openSearchPanel, + SearchQuery, + searchPanelOpen, +} from '@codemirror/search' +import { EditorView, keymap, ViewPlugin } from '@codemirror/view' +import { Annotation, EditorState, TransactionSpec } from '@codemirror/state' +import { highlightSelectionMatches } from './highlight-selection-matches' + +const restoreSearchQueryAnnotation = Annotation.define() + +const ignoredSearchKeybindings = new Set([ + // This keybinding causes issues with entering @ on certain keyboard layouts + // https://github.com/overleaf/internal/issues/12119 + 'Alt-g', +]) + +const selectNextMatch = (query: SearchQuery, state: EditorState) => { + if (!query.valid) { + return false + } + + let cursor = query.getCursor(state.doc, state.selection.main.from) + + let result = cursor.next() + + if (result.done) { + cursor = query.getCursor(state.doc) + result = cursor.next() + } + + return result.done ? null : result.value +} + +let searchQuery: SearchQuery | null + +export const search = () => { + let open = false + + return [ + // keymap for search + keymap.of( + searchKeymap.filter( + item => !item.key || !ignoredSearchKeybindings.has(item.key) + ) + ), + + // highlight text which matches the current selection + highlightSelectionMatches(), + + // a wrapper round `search`, which creates a custom panel element and passes it to React by dispatching an event + searchExtension({ + literal: true, + createPanel: () => { + const dom = document.createElement('div') + dom.className = 'ol-cm-search' + + return { + dom, + mount() { + open = true + + // focus the search input when the panel is already open + const searchInput = + dom.querySelector('[main-field]') + if (searchInput) { + searchInput.focus() + searchInput.select() + } + }, + destroy() { + window.setTimeout(() => { + open = false // in a timeout, so the view plugin below can run its destroy method first + }, 0) + }, + } + }, + }), + + // restore a stored search and re-open the search panel + ViewPlugin.define(view => { + if (searchQuery) { + const _searchQuery = searchQuery + window.setTimeout(() => { + openSearchPanel(view) + view.dispatch({ + effects: setSearchQuery.of(_searchQuery), + annotations: restoreSearchQueryAnnotation.of(true), + }) + }, 0) + } + + return { + destroy() { + // persist the current search query if the panel is open + searchQuery = open ? getSearchQuery(view.state) : null + }, + } + }), + + // select a match while searching + EditorView.updateListener.of(update => { + for (const tr of update.transactions) { + // avoid changing the selection and viewport when switching between files + if (tr.annotation(restoreSearchQueryAnnotation)) { + continue + } + + for (const effect of tr.effects) { + if (effect.is(setSearchQuery)) { + const query = effect.value + if (!query) return + + // The rest of this messes up searching in Vim, which is handled by + // the Vim extension, so bail out here in Vim mode. Happily, the + // Vim extension sticks an extra property on the query value that + // can be checked + if ('forVim' in query) return + + const next = selectNextMatch(query, tr.state) + + if (next) { + // select a match if possible + const spec: TransactionSpec = { + selection: { anchor: next.from, head: next.to }, + userEvent: 'select.search', + } + + // scroll into view if not opening the panel + if (searchPanelOpen(tr.startState)) { + spec.effects = EditorView.scrollIntoView(next.from, { + y: 'center', + }) + } + + update.view.dispatch(spec) + } else { + // clear the selection if the query became invalid + const prevQuery = getSearchQuery(tr.startState) + + if (prevQuery.valid) { + const { from } = tr.startState.selection.main + + update.view.dispatch({ + selection: { anchor: from }, + }) + } + } + } + } + } + }), + + // search form theme + EditorView.theme({ + '.ol-cm-search-form': { + padding: '10px', + display: 'flex', + gap: '10px', + background: 'var(--ol-blue-gray-1)', + '--ol-cm-search-form-focus-shadow': + 'inset 0 1px 1px rgb(0 0 0 / 8%), 0 0 8px rgb(102 175 233 / 60%)', + }, + '.ol-cm-search-controls': { + display: 'grid', + gridTemplateColumns: 'auto auto', + gridTemplateRows: 'auto auto', + gap: '10px', + }, + '.ol-cm-search-form-row': { + display: 'flex', + gap: '10px', + justifyContent: 'space-between', + }, + '.ol-cm-search-form-group': { + display: 'flex', + gap: '10px', + alignItems: 'center', + }, + '.ol-cm-search-input-group': { + border: '1px solid var(--input-border)', + borderRadius: '20px', + background: 'white', + width: '100%', + maxWidth: '25em', + '& input[type="text"]': { + background: 'none', + boxShadow: 'none', + }, + '& input[type="text"]:focus': { + outline: 'none', + boxShadow: 'none', + }, + '& .btn.btn': { + background: 'var(--ol-blue-gray-0)', + color: 'var(--ol-blue-gray-3)', + borderRadius: '50%', + height: '2em', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: '2em', + marginRight: '3px', + '&.checked': { + color: '#fff', + backgroundColor: 'var(--ol-blue)', + }, + '&:active': { + boxShadow: 'none', + }, + }, + '&:focus-within': { + borderColor: 'var(--input-border-focus)', + boxShadow: 'var(--ol-cm-search-form-focus-shadow)', + }, + }, + '.input-group .ol-cm-search-form-input': { + border: 'none', + }, + '.ol-cm-search-input-button': { + background: '#fff', + color: 'inherit', + border: 'none', + }, + '.ol-cm-search-input-button.focused': { + borderColor: 'var(--input-border-focus)', + boxShadow: 'var(--ol-cm-search-form-focus-shadow)', + }, + '.ol-cm-search-form-button-group': { + flexShrink: 0, + }, + '.ol-cm-search-form-position': { + flexShrink: 0, + color: 'var(--ol-blue-gray-4)', + }, + '.ol-cm-search-hidden-inputs': { + position: 'absolute', + left: '-10000px', + }, + '.ol-cm-search-form-close': { + flex: 1, + }, + '.ol-cm-search-replace-input': { + order: 3, + }, + '.ol-cm-search-replace-buttons': { + order: 4, + }, + }), + ] +} diff --git a/services/web/frontend/js/features/source-editor/extensions/shortcuts.ts b/services/web/frontend/js/features/source-editor/extensions/shortcuts.ts new file mode 100644 index 0000000000..8ab5d81a03 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/shortcuts.ts @@ -0,0 +1,174 @@ +import { type KeyBinding, keymap } from '@codemirror/view' +import { Prec } from '@codemirror/state' +import { indentMore } from '../commands/indent' +import { + indentLess, + redo, + deleteLine, + toggleLineComment, + cursorLineBoundaryBackward, + selectLineBoundaryBackward, + cursorLineBoundaryForward, + selectLineBoundaryForward, + cursorSyntaxLeft, + selectSyntaxLeft, + cursorSyntaxRight, + selectSyntaxRight, +} from '@codemirror/commands' +import { changeCase, duplicateSelection } from '../commands/ranges' +import { selectOccurrence } from '../commands/select' +import { cloneSelectionVertically } from '../commands/cursor' +import { dispatchEditorEvent } from './changes/change-manager' + +export const shortcuts = () => { + const toggleReviewPanel = () => { + dispatchEditorEvent('toggle-review-panel') + return true + } + + const addNewCommentFromKbdShortcut = () => { + dispatchEditorEvent('add-new-comment') + return true + } + + const toggleTrackChangesFromKbdShortcut = () => { + dispatchEditorEvent('toggle-track-changes') + return true + } + + const keyBindings: KeyBinding[] = [ + { + key: 'Tab', + preventDefault: true, + run: indentMore, + }, + { + key: 'Shift-Tab', + preventDefault: true, + run: indentLess, + }, + { + key: 'Ctrl-y', + mac: 'Mod-y', + preventDefault: true, + run: redo, + }, + { + key: 'Ctrl-Shift-z', + preventDefault: true, + run: redo, + }, + { + key: 'Ctrl-Shift-/', + mac: 'Mod-Shift-/', + preventDefault: true, + run: toggleLineComment, + }, + { + key: 'Ctrl-ß', + mac: 'Mod-ß', + preventDefault: true, + run: toggleLineComment, + }, + { + key: 'Ctrl-#', + preventDefault: true, + run: toggleLineComment, + }, + { + key: 'Ctrl-u', + preventDefault: true, + run: changeCase(true), // uppercase + }, + { + key: 'Ctrl-Shift-u', + preventDefault: true, + run: changeCase(false), // lowercase + }, + { + key: 'Ctrl-d', + mac: 'Mod-d', + preventDefault: true, + run: deleteLine, + }, + { + key: 'Ctrl-j', + mac: 'Mod-j', + preventDefault: true, + run: toggleReviewPanel, + }, + { + key: 'Ctrl-Shift-c', + mac: 'Mod-Shift-c', + preventDefault: true, + run: addNewCommentFromKbdShortcut, + }, + { + key: 'Ctrl-Shift-a', + mac: 'Mod-Shift-a', + preventDefault: true, + run: toggleTrackChangesFromKbdShortcut, + }, + { + key: 'Ctrl-Alt-ArrowUp', + preventDefault: true, + run: cloneSelectionVertically(false, true), + }, + { + key: 'Ctrl-Alt-ArrowDown', + preventDefault: true, + run: cloneSelectionVertically(true, true), + }, + { + key: 'Ctrl-Alt-Shift-ArrowUp', + preventDefault: true, + run: cloneSelectionVertically(false, false), + }, + { + key: 'Ctrl-Alt-Shift-ArrowDown', + preventDefault: true, + run: cloneSelectionVertically(true, false), + }, + { + key: 'Ctrl-Alt-ArrowLeft', + preventDefault: true, + run: selectOccurrence(false), + }, + { + key: 'Ctrl-Alt-ArrowRight', + preventDefault: true, + run: selectOccurrence(true), + }, + { + key: 'Ctrl-Shift-d', + mac: 'Mod-Shift-d', + run: duplicateSelection, + }, + { + win: 'Alt-ArrowLeft', + linux: 'Alt-ArrowLeft', + run: cursorLineBoundaryBackward, + shift: selectLineBoundaryBackward, + preventDefault: true, + }, + { + win: 'Alt-ArrowRight', + linux: 'Alt-ArrowRight', + run: cursorLineBoundaryForward, + shift: selectLineBoundaryForward, + preventDefault: true, + }, + { + mac: 'Ctrl-ArrowLeft', + run: cursorSyntaxLeft, + shift: selectSyntaxLeft, + }, + { + mac: 'Ctrl-ArrowRight', + run: cursorSyntaxRight, + shift: selectSyntaxRight, + }, + ] + + return Prec.high(keymap.of(keyBindings)) +} diff --git a/services/web/frontend/js/features/source-editor/extensions/spelling/backend.ts b/services/web/frontend/js/features/source-editor/extensions/spelling/backend.ts new file mode 100644 index 0000000000..6b7e04aa45 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/spelling/backend.ts @@ -0,0 +1,35 @@ +import { postJSON } from '../../../../infrastructure/fetch-json' +import { Word } from './spellchecker' + +const apiUrl = (path: string) => { + return `/spelling${path}` +} + +export async function learnWordRequest(word?: Word) { + if (!word || !word.text) { + throw new Error(`Invalid word supplied: ${word}`) + } + return await postJSON(apiUrl('/learn'), { + body: { + word: word.text, + }, + }) +} + +export function spellCheckRequest( + language: string, + words: Word[], + controller: AbortController +) { + const signal = controller.signal + const textWords = words.map(w => w.text) + return postJSON<{ + misspellings: { index: number; suggestions: string[] }[] + }>(apiUrl('/check'), { + body: { + language, + words: textWords, + }, + signal, + }) +} diff --git a/services/web/frontend/js/features/source-editor/extensions/spelling/cache.ts b/services/web/frontend/js/features/source-editor/extensions/spelling/cache.ts new file mode 100644 index 0000000000..c83505df92 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/spelling/cache.ts @@ -0,0 +1,105 @@ +import { StateField, StateEffect } from '@codemirror/state' +import LRU from 'lru-cache' +import { Word } from './spellchecker' +const CACHE_MAX = 15000 + +export const cacheKey = (lang: string, wordText: string) => { + return `${lang}:${wordText}` +} + +export type WordCacheValue = string[] | boolean | number + +export class WordCache { + private _cache: LRU + + constructor() { + this._cache = new LRU({ max: CACHE_MAX }) + } + + set(lang: string, wordText: string, value: WordCacheValue) { + const key = cacheKey(lang, wordText) + this._cache.set(key, value) + } + + get(lang: string, wordText: string) { + const key = cacheKey(lang, wordText) + return this._cache.get(key) + } + + remove(lang: string, wordText: string) { + const key = cacheKey(lang, wordText) + this._cache.delete(key) + } + + /* + * Given a language and a list of words, + * check the cache and sort the words into two categories: + * - words we know to be misspelled + * - words that are presently unknown to us + */ + checkWords( + lang: string, + wordsToCheck: Word[] + ): { + knownMisspelledWords: Word[] + unknownWords: Word[] + } { + const knownMisspelledWords = [] + const unknownWords = [] + const seen: Record = {} + for (const word of wordsToCheck) { + const wordText = word.text + if (seen[wordText] == null) { + seen[wordText] = this.get(lang, wordText) + } + const cached = seen[wordText] + if (cached == null) { + // Word is not known + unknownWords.push(word) + } else if (cached === true) { + // Word is known to be correct + } else { + // Word is known to be misspelled + word.suggestions = cached + knownMisspelledWords.push(word) + } + } + return { + knownMisspelledWords, + unknownWords, + } + } +} + +export const addWordToCache = StateEffect.define<{ + lang: string + wordText: string + value: string[] | boolean +}>() + +export const removeWordFromCache = StateEffect.define<{ + lang: string + wordText: string +}>() + +// Share a single instance of WordCache between all instances of the CM6 source editor. This means that cached words are +// retained when switching away from CM6 and then back to it. +const wordCache = new WordCache() + +export const cacheField = StateField.define({ + 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 + }, +}) diff --git a/services/web/frontend/js/features/source-editor/extensions/spelling/context-menu.ts b/services/web/frontend/js/features/source-editor/extensions/spelling/context-menu.ts new file mode 100644 index 0000000000..3a6ad49f31 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/spelling/context-menu.ts @@ -0,0 +1,241 @@ +import { + StateField, + StateEffect, + Range, + RangeValue, + EditorSelection, +} from '@codemirror/state' +import { EditorView, showTooltip, Tooltip } from '@codemirror/view' +import { misspelledWordsField } from './misspelled-words' +import { addIgnoredWord } from './ignored-words' +import { learnWordRequest } from './backend' +import { Word } from './spellchecker' + +const ITEMS_TO_SHOW = 8 + +type Mark = Range + +/* + * The time until which a click event will be ignored, so it doesn't immediately close the spelling menu. + * Safari emits an additional "click" event when event.preventDefault() is called in the "contextmenu" event listener. + */ +let openingUntil = 0 + +/* + * Hide the spelling menu on click + */ +const handleClickEvent = (event: MouseEvent, view: EditorView) => { + if (Date.now() < openingUntil) { + return + } + + if (view.state.field(spellingMenuField, false)) { + view.dispatch({ + effects: hideSpellingMenu.of(null), + }) + } +} + +/* + * Detect when the user right-clicks on a misspelled word, + * and show a menu of suggestions + */ +const handleContextMenuEvent = (event: MouseEvent, view: EditorView) => { + const position = view.posAtCoords( + { + x: event.pageX, + y: event.pageY, + }, + false + ) + + const marks = view.state.field(misspelledWordsField) + + let targetMark: Mark | null = null + marks.between(view.viewport.from, view.viewport.to, (from, to, value) => { + if (position >= from && position <= to) { + targetMark = { from, to, value } + return false + } + }) + if (!targetMark) { + return + } + + const { from, to, value } = targetMark as Mark + + const targetWord = value.spec.word + if (!targetWord) { + console.debug( + '>> spelling no word associated with decorated range, stopping' + ) + return + } + + event.preventDefault() + + openingUntil = Date.now() + 100 + + view.dispatch({ + selection: EditorSelection.range(from, to), + effects: showSpellingMenu.of({ + mark: targetMark, + word: targetWord, + }), + }) +} + +/* + * Spelling menu "tooltip" field. + * Manages the menu of suggestions shown on right-click + */ +export const spellingMenuField = StateField.define({ + create() { + return null + }, + update(menu, transaction) { + for (const effect of transaction.effects) { + if (effect.is(hideSpellingMenu)) { + return null + } else if (effect.is(showSpellingMenu)) { + const { mark, word } = effect.value + // Build a "Tooltip" showing the suggestions + return { + pos: mark.from, + above: false, + strictSide: false, + create: view => { + return createSpellingSuggestionList(mark, word, view) + }, + } + } + } + return menu + }, + provide: field => { + return [ + showTooltip.from(field), + EditorView.domEventHandlers({ + contextmenu: handleContextMenuEvent, + click: handleClickEvent, + }), + ] + }, +}) + +const showSpellingMenu = StateEffect.define<{ mark: Mark; word: Word }>() + +const hideSpellingMenu = StateEffect.define() + +/* + * Creates the suggestion menu dom, to be displayed in the + * spelling menu "tooltip" + * */ +const createSpellingSuggestionList = ( + mark: Mark, + word: Word, + view: EditorView +) => { + // Wrapper div. + // Note, CM6 doesn't like showing complex elements + // 'inside' its Tooltip element, so we style this + // wrapper div to be basically invisible, and allow + // the dropdown list to hang off of it, giving the illusion that + // the list _is_ the tooltip. + // See the theme in spelling/index for styling that makes this work. + const dom = document.createElement('div') + dom.classList.add('ol-cm-spelling-context-menu-tooltip') + + // List + const list = document.createElement('ul') + list.classList.add('dropdown-menu', 'dropdown-menu-unpositioned') + + // List items, with links inside + if (Array.isArray(word.suggestions)) { + for (const suggestion of word.suggestions.slice(0, ITEMS_TO_SHOW)) { + const li = makeLinkItem(suggestion, event => { + const text = (event.target as HTMLElement).innerText + handleCorrectWord(mark, word, text, view) + event.preventDefault() + }) + list.appendChild(li) + } + } + + // Divider + const divider = document.createElement('li') + divider.classList.add('divider') + list.append(divider) + + // Add to Dictionary + const addToDictionary = makeLinkItem( + 'Add to Dictionary', + async function (event: Event) { + await handleLearnWord(word, view) + event.preventDefault() + } + ) + list.append(addToDictionary) + + dom.appendChild(list) + return { dom } +} + +const makeLinkItem = (suggestion: string, handler: EventListener) => { + const li = document.createElement('li') + const button = document.createElement('button') + button.classList.add('btn-link', 'text-left', 'dropdown-menu-button') + button.onclick = handler + button.textContent = suggestion + li.appendChild(button) + return li +} + +/* + * Learn a word, adding it to the local cache + * and sending it to the spelling backend + */ +const handleLearnWord = async function (word: Word, view: EditorView) { + try { + await learnWordRequest(word) + view.dispatch({ + effects: [addIgnoredWord.of(word), hideSpellingMenu.of(null)], + }) + } catch (err) { + console.error(err) + } +} + +/* + * Correct a word, removing the marked range + * and replacing it with the chosen text + */ +const handleCorrectWord = ( + mark: Mark, + word: Word, + text: string, + view: EditorView +) => { + const existingText = view.state.doc.sliceString(mark.from, mark.to) + // Defend against erroneous replacement, if the word at this + // position is not actually what we think it is + if (existingText !== word.text) { + console.debug( + '>> spelling word-to-correct does not match, stopping', + mark, + existingText, + word + ) + return + } + view.dispatch({ + changes: [ + { + from: mark.from, + to: mark.to, + insert: text, + }, + ], + effects: [hideSpellingMenu.of(null)], + }) +} diff --git a/services/web/frontend/js/features/source-editor/extensions/spelling/helpers.ts b/services/web/frontend/js/features/source-editor/extensions/spelling/helpers.ts new file mode 100644 index 0000000000..b030f6d80d --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/spelling/helpers.ts @@ -0,0 +1 @@ +export const WORD_REGEX = /\\?['\p{L}]+/gu diff --git a/services/web/frontend/js/features/source-editor/extensions/spelling/ignored-words.ts b/services/web/frontend/js/features/source-editor/extensions/spelling/ignored-words.ts new file mode 100644 index 0000000000..69105e3c43 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/spelling/ignored-words.ts @@ -0,0 +1,25 @@ +import { StateField, StateEffect } from '@codemirror/state' +import ignoredWords, { IgnoredWords } from '../../../dictionary/ignored-words' + +export const ignoredWordsField = StateField.define({ + create() { + return ignoredWords + }, + update(ignoredWords, transaction) { + for (const effect of transaction.effects) { + if (effect.is(addIgnoredWord)) { + const newWord = effect.value + ignoredWords.add(newWord.text) + } + } + return ignoredWords + }, +}) + +export const addIgnoredWord = StateEffect.define<{ + text: string +}>() + +export const updateAfterAddingIgnoredWord = StateEffect.define() + +export const resetSpellChecker = StateEffect.define() diff --git a/services/web/frontend/js/features/source-editor/extensions/spelling/index.ts b/services/web/frontend/js/features/source-editor/extensions/spelling/index.ts new file mode 100644 index 0000000000..3b068ce4ca --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/spelling/index.ts @@ -0,0 +1,140 @@ +import { EditorView, ViewPlugin } from '@codemirror/view' +import { + Compartment, + Facet, + StateEffect, + StateField, + TransactionSpec, +} from '@codemirror/state' +import { misspelledWordsField, resetMisspelledWords } from './misspelled-words' +import { + ignoredWordsField, + resetSpellChecker, + updateAfterAddingIgnoredWord, +} from './ignored-words' +import { addWordToCache, cacheField, removeWordFromCache } from './cache' +import { spellingMenuField } from './context-menu' +import { SpellChecker } from './spellchecker' + +const spellCheckLanguageConf = new Compartment() +const spellCheckLanguageFacet = Facet.define() + +type Options = { spellCheckLanguage?: string } + +/* + * Create the spelling extensions array, based on options passed. + */ +export const spelling = ({ spellCheckLanguage }: Options) => { + return [ + EditorView.baseTheme({ + '.ol-cm-spelling-error': { + textDecorationColor: 'red', + textDecorationLine: 'underline', + textDecorationStyle: 'dotted', + textDecorationThickness: '2px', + textDecorationSkipInk: 'none', + textUnderlineOffset: '0.2em', + }, + '.cm-tooltip.ol-cm-spelling-context-menu-tooltip': { + borderWidth: '0', + }, + }), + spellCheckLanguageConf.of(spellCheckLanguageFacet.of(spellCheckLanguage)), + spellCheckField, + misspelledWordsField, + ignoredWordsField, + cacheField, + spellingMenuField, + ] +} + +const spellCheckField = StateField.define({ + create(state) { + const [spellCheckLanguage] = state.facet(spellCheckLanguageFacet) + return spellCheckLanguage ? new SpellChecker(spellCheckLanguage) : null + }, + update(value, tr) { + for (const effect of tr.effects) { + if (effect.is(setSpellCheckLanguageEffect)) { + value?.destroy() + return effect.value ? new SpellChecker(effect.value) : null + } + } + return value + }, + provide(field) { + return [ + ViewPlugin.define(view => { + return { + destroy: () => { + view.state.field(field)?.destroy() + }, + } + }), + EditorView.domEventHandlers({ + focus: (event, view) => { + if (view.state.facet(EditorView.editable)) { + view.state.field(field)?.spellCheckAsap(view) + } + }, + }), + EditorView.updateListener.of(update => { + if (update.state.facet(EditorView.editable)) { + update.state.field(field)?.handleUpdate(update) + } + }), + ] + }, +}) + +const setSpellCheckLanguageEffect = StateEffect.define() + +export const setSpelling = ({ + spellCheckLanguage, +}: Options): TransactionSpec => { + return { + effects: [ + resetMisspelledWords.of(null), + spellCheckLanguageConf.reconfigure( + spellCheckLanguageFacet.of(spellCheckLanguage) + ), + setSpellCheckLanguageEffect.of(spellCheckLanguage), + ], + } +} + +export const addLearnedWord = ( + spellCheckLanguage: string, + word: string +): TransactionSpec => { + return { + effects: [ + addWordToCache.of({ + lang: spellCheckLanguage, + wordText: word, + value: true, + }), + updateAfterAddingIgnoredWord.of(word), + ], + } +} + +export const removeLearnedWord = ( + spellCheckLanguage: string, + word: string +): TransactionSpec => { + return { + effects: [ + removeWordFromCache.of({ + lang: spellCheckLanguage, + wordText: word, + }), + ], + } +} + +export const resetLearnedWords = (): TransactionSpec => { + return { + effects: [resetSpellChecker.of(null)], + } +} diff --git a/services/web/frontend/js/features/source-editor/extensions/spelling/line-tracker.ts b/services/web/frontend/js/features/source-editor/extensions/spelling/line-tracker.ts new file mode 100644 index 0000000000..d1a72339d8 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/spelling/line-tracker.ts @@ -0,0 +1,127 @@ +import OError from '@overleaf/o-error' +import { Text } from '@codemirror/state' +import { Word } from './spellchecker' +import { ViewUpdate } from '@codemirror/view' + +export class LineTracker { + private _lines: boolean[] + constructor(doc: Text) { + /* + * Maintain an array of booleans, one for each line of the document. + * `true` means a line has changed + */ + this._lines = new Array(doc.lines).fill(true) + } + + dump() { + return [...this._lines] + } + + count() { + return this._lines.length + } + + lineHasChanged(lineNumber: number) { + return this._lines[lineNumber - 1] === true + } + + /* + * Given a list of words, clear the 'changed' mark + * on the lines the words are on + */ + clearChangedLinesForWords(words: Word[]) { + words.forEach(word => { + this.clearLine(word.lineNumber) + }) + } + + clearLine(lineNumber: number) { + this._lines[lineNumber - 1] = false + } + + clearAllLines() { + this._lines = this._lines.map(() => false) + } + + resetAllLines() { + this._lines = this._lines.map(() => true) + } + + markLineAsUpdated(lineNumber: number) { + this._lines[lineNumber - 1] = true + } + + /* + * On update, for all changes, mark the affected lines + * as changed + */ + applyUpdate(update: ViewUpdate) { + for (const transaction of update.transactions) { + if (transaction.docChanged) { + let positionShift = 0 + transaction.changes.iterChanges( + (fromA, toA, fromB, toB, insertedText) => { + const insertedLength = insertedText.length + const removedLength = toA - fromA + const hasInserted = insertedLength > 0 + const hasRemoved = removedLength > 0 + const doc = update.state.doc + const oldDoc = transaction.startState.doc + if (hasRemoved) { + const startLine = oldDoc.lineAt(fromA).number + const endLine = oldDoc.lineAt(toA).number + /* Mark start line as changed, and remove deleted lines + * Example: + * with this text: + * |1|aaaa| + * |2|bbbb| => [false, false, false, false] + * |3|cccc| + * |4|dddd| + * + * with a selection covering 'bbcccc' across lines 2 and 3, + * press backspace, + * resulting in: + * |1|aaaa| + * |2|bb| => [false, true, false] + * |3|dddd| + */ + this._lines.splice(startLine - 1, endLine - startLine + 1, true) + } + if (hasInserted) { + const startLine = doc.lineAt(fromB).number + /* Mark start line as changed, and insert new (changed) lines after. + * Example: + * with this text: + * |1|aaaa| + * |2|bbbb| => [false, false, false] + * |3|cccc| + * + * with the cursor at the end of line 2, + * insert the following text: + * |1|xx| + * |2|yy| + * + * results in: + * |1|aaaa| + * |2|bbbbxx| => [false, true, true, false] + * |3|yy| + * |4|cccc| + */ + const changes = new Array(insertedText.lines).fill(true) + this._lines.splice(startLine - 1, 1, ...changes) + } + positionShift = positionShift - removedLength + insertedLength + } + ) + if (update.state.doc.lines !== this._lines.length) { + throw new OError( + 'LineTracker length does not match document line count' + ).withInfo({ + documentLines: update.state.doc.lines, + trackerLines: this._lines.length, + }) + } + } + } + } +} diff --git a/services/web/frontend/js/features/source-editor/extensions/spelling/misspelled-words.ts b/services/web/frontend/js/features/source-editor/extensions/spelling/misspelled-words.ts new file mode 100644 index 0000000000..b6b89cb9dc --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/spelling/misspelled-words.ts @@ -0,0 +1,100 @@ +import { StateField, StateEffect, Transaction } from '@codemirror/state' +import { EditorView, Decoration, DecorationSet } from '@codemirror/view' +import { updateAfterAddingIgnoredWord } from './ignored-words' +import _ from 'lodash' +import { Word } from './spellchecker' + +export const addMisspelledWords = StateEffect.define() + +export const resetMisspelledWords = StateEffect.define() + +const createMark = (word: Word) => { + const mark = Decoration.mark({ + class: 'ol-cm-spelling-error', + word, + }) + return mark.range(word.from, word.to) +} + +/* + * State for misspelled words, the results of a + * spellcheck request. Misspelled words are marked + * with a red wavy underline. + */ +export const misspelledWordsField = StateField.define({ + create() { + return Decoration.none + }, + update(marks, transaction) { + marks = marks.map(transaction.changes) + marks = removeMarksUnderEdit(marks, transaction) + for (const effect of transaction.effects) { + if (effect.is(addMisspelledWords)) { + // We're setting a new list of misspelled words + const misspelledWords = effect.value + marks = mergeMarks(marks, misspelledWords) + } else if (effect.is(updateAfterAddingIgnoredWord)) { + // Remove a misspelled word, all instances that match text + const word = effect.value + marks = removeAllMarksMatchingWordText(marks, word) + } else if (effect.is(resetMisspelledWords)) { + marks = Decoration.none + } + } + return marks + }, + provide: field => { + return EditorView.decorations.from(field) + }, +}) + +/* + * Remove any marks whos text has just been edited + */ +const removeMarksUnderEdit = ( + marks: DecorationSet, + transaction: Transaction +) => { + transaction.changes.iterChanges((fromA, toA) => { + marks = marks.update({ + // Filter out marks that overlap the change span + filter: (from, to, mark) => { + const changeStartWithinMark = from <= fromA && to >= fromA + const changeEndWithinMark = from <= toA && to >= toA + const markHasBeenEdited = changeStartWithinMark || changeEndWithinMark + return !markHasBeenEdited + }, + }) + }) + return marks +} + +/* + * Given the set of marks, and a list of new misspelled-words, + * merge these together into a new set of marks + */ +const mergeMarks = (marks: DecorationSet, words: Word[]) => { + const affectedLines = new Set(words.map(w => w.lineNumber)) + marks = marks + .update({ + filter: (from, to, mark) => { + return !affectedLines.has(mark.spec.word.lineNumber) + }, + }) + .update({ + add: _.sortBy(words, ['from']).map(w => createMark(w)), + sort: true, + }) + return marks +} + +/* + * Remove existing marks matching the text of a supplied word + */ +const removeAllMarksMatchingWordText = (marks: DecorationSet, word: string) => { + return marks.update({ + filter: (from, to, mark) => { + return mark.spec.word.text !== word + }, + }) +} diff --git a/services/web/frontend/js/features/source-editor/extensions/spelling/spellchecker.ts b/services/web/frontend/js/features/source-editor/extensions/spelling/spellchecker.ts new file mode 100644 index 0000000000..0a334496b9 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/spelling/spellchecker.ts @@ -0,0 +1,387 @@ +import { addMisspelledWords } from './misspelled-words' +import { ignoredWordsField, resetSpellChecker } from './ignored-words' +import { LineTracker } from './line-tracker' +import { cacheField, addWordToCache, WordCacheValue } from './cache' +import { WORD_REGEX } from './helpers' +import OError from '@overleaf/o-error' +import { spellCheckRequest } from './backend' +import { EditorView, ViewUpdate } from '@codemirror/view' +import { Line } from '@codemirror/state' +import { IgnoredWords } from '../../../dictionary/ignored-words' +import { + getNormalTextSpansFromLine, + NormalTextSpan, +} from '../../utils/tree-query' +import { waitForParser } from '../wait-for-parser' + +const DEBUG = window ? window.sl_debugging : false + +const _log = (...args: any) => { + if (DEBUG) { + console.debug('[SpellChecker]: ', ...args) + } +} + +/* + * Spellchecker, handles updates, schedules spelling checks + */ +export class SpellChecker { + private abortController?: AbortController | null = null + private timeout: number | null = null + private firstCheck = true + private lineTracker: LineTracker | null = null + private waitingForParser = false + private firstCheckPending = false + + // eslint-disable-next-line no-useless-constructor + constructor(private readonly language: string) { + this.language = language + } + + destroy() { + _log('destroy') + this._clearPendingSpellCheck() + } + + _abortRequest() { + if (this.abortController) { + _log('abort request') + this.abortController.abort() + this.abortController = null + } + } + + handleUpdate(update: ViewUpdate) { + if (!this.lineTracker) { + this.lineTracker = new LineTracker(update.state.doc) + } + if (update.docChanged) { + this.lineTracker.applyUpdate(update) + this.scheduleSpellCheck(update.view) + } else if (update.viewportChanged) { + this.scheduleSpellCheck(update.view) + } else if ( + update.transactions.some(tr => { + return tr.effects.some(effect => effect.is(resetSpellChecker)) + }) + ) { + this.lineTracker.resetAllLines() + this.spellCheckAsap(update.view) + } + // At the point that the spellchecker is initialized, the editor may not + // yet be editable, and the parser may not be ready. Therefore, to do the + // initial spellcheck, watch for changes in the editability of the editor + // and kick off the process that performs a spellcheck once the parser is + // ready. CM6 dispatches a transaction after every chunk of parser work and + // when the editability changes, which means the spell checker is + // initialized as soon as possible. + else if ( + this.firstCheck && + !this.firstCheckPending && + update.state.facet(EditorView.editable) + ) { + this.firstCheckPending = true + _log('Scheduling initial spellcheck') + this.spellCheckAsap(update.view) + } + } + + _performSpellCheck(view: EditorView) { + _log('Begin ---------------->') + const wordsToCheck = this.getWordsToCheck(view) + if (wordsToCheck.length === 0) { + return + } + _log( + '- words to check', + wordsToCheck.map(w => w.text) + ) + const cache = view.state.field(cacheField) + const { knownMisspelledWords, unknownWords } = cache.checkWords( + this.language, + wordsToCheck + ) + const processResult = ( + misspellings: { index: number; suggestions: string[] }[] + ) => { + this.lineTracker?.clearChangedLinesForWords(wordsToCheck) + if (this.firstCheck) { + this.firstCheck = false + this.firstCheckPending = false + } + const result = buildSpellCheckResult( + knownMisspelledWords, + unknownWords, + misspellings + ) + _log('- result', result) + window.setTimeout(() => { + view.dispatch({ + effects: compileEffects(result), + }) + }, 0) + _log('<---------------- End') + } + _log('- before spellcheck request') + _log( + ' - unknownWords', + unknownWords.map(w => w.text) + ) + _log( + ' - knownMisspelledWords', + knownMisspelledWords.map(w => w.text) + ) + if (unknownWords.length === 0) { + _log('- skip request') + processResult([]) + } else { + this._abortRequest() + this.abortController = new AbortController() + spellCheckRequest(this.language, unknownWords, this.abortController) + .then(result => { + this.abortController = null + processResult(result.misspellings) + }) + .catch(error => { + this.abortController = null + _log('>> error in spellcheck request', error) + }) + } + } + + _spellCheckWhenParserReady(view: EditorView) { + if (this.waitingForParser) { + return + } + + this.waitingForParser = true + waitForParser( + view, + view => viewportRangeToCheck(this.firstCheck, view).to + ).then(() => { + this.waitingForParser = false + this._performSpellCheck(view) + }) + } + + _clearPendingSpellCheck() { + if (this.timeout) { + window.clearTimeout(this.timeout) + this.timeout = null + } + this._abortRequest() + } + + _asyncSpellCheck(view: EditorView, delay: number) { + this._clearPendingSpellCheck() + + this.timeout = window.setTimeout(() => { + this._spellCheckWhenParserReady(view) + this.timeout = null + }, delay) + } + + spellCheckAsap(view: EditorView) { + this._asyncSpellCheck(view, 0) + } + + scheduleSpellCheck(view: EditorView) { + this._asyncSpellCheck(view, 1000) + } + + getWordsToCheck(view: EditorView) { + const lang = this.language + const ignoredWords = view.state.field(ignoredWordsField) + _log('- ignored words', ignoredWords) + if (!this.lineTracker) { + this.lineTracker = new LineTracker(view.state.doc) + } + let wordsToCheck: Word[] = [] + for (const line of viewportLinesToCheck( + this.lineTracker, + this.firstCheck, + view + )) { + wordsToCheck = wordsToCheck.concat( + getWordsFromLine(view, line, ignoredWords, lang) + ) + } + return wordsToCheck + } +} + +export class Word { + public text: string + public from: number + public to: number + public lineNumber: number + public lang: string + public suggestions?: WordCacheValue + + constructor(options: { + text: string + from: number + to: number + lineNumber: number + lang: string + }) { + const { text, from, to, lineNumber, lang } = options + if ( + text == null || + from == null || + to == null || + lineNumber == null || + lang == null + ) { + throw new OError('Spellcheck: invalid word').withInfo({ options }) + } + this.text = text + this.from = from + this.to = to + this.lineNumber = lineNumber + this.lang = lang + } +} + +export const buildSpellCheckResult = ( + knownMisspelledWords: Word[], + unknownWords: Word[], + misspellings: { index: number; suggestions: string[] }[] +) => { + const cacheAdditions: [Word, string[] | boolean][] = [] + // Put known misspellings into cache + const misspelledWords = misspellings.map(item => { + const word = { ...unknownWords[item.index] } + word.suggestions = item.suggestions + if (word.suggestions) { + cacheAdditions.push([word, word.suggestions]) + } + return word + }) + // if word was not misspelled, put it in the cache + for (const word of unknownWords) { + if (!misspelledWords.find(mw => mw.text === word.text)) { + cacheAdditions.push([word, true]) + } + } + const finalMisspellings = misspelledWords.concat(knownMisspelledWords) + _log('- result') + _log( + ' - finalMisspellings', + finalMisspellings.map(w => w.text) + ) + _log( + ' - cacheAdditions', + cacheAdditions.map(([w, v]) => `'${w.text}'=>${v}`) + ) + return { + cacheAdditions, + misspelledWords: finalMisspellings, + } +} + +export const compileEffects = (results: { + cacheAdditions: [Word, string[] | boolean][] + misspelledWords: Word[] +}) => { + const { cacheAdditions, misspelledWords } = results + return [ + addMisspelledWords.of(misspelledWords), + ...cacheAdditions.map(([word, value]) => { + return addWordToCache.of({ + lang: word.lang, + wordText: word.text, + value, + }) + }), + ] +} + +const viewportRangeToCheck = (firstCheck: boolean, view: EditorView) => { + const doc = view.state.doc + let { from, to } = view.viewport + let firstLineNumber = doc.lineAt(from).number + let lastLineNumber = doc.lineAt(to).number + + /* + * On first check, we scan the viewport plus some padding on either side. + * Then on subsequent checks we just scan the viewport + */ + if (firstCheck) { + const visibleLineCount = lastLineNumber - firstLineNumber + 1 + const padding = Math.floor(visibleLineCount * 2) + firstLineNumber = Math.max(firstLineNumber - padding, 1) + lastLineNumber = Math.min(lastLineNumber + padding, doc.lines) + from = doc.line(firstLineNumber).from + to = doc.line(lastLineNumber).to + } + + return { from, to, firstLineNumber, lastLineNumber } +} + +export const viewportLinesToCheck = ( + lineTracker: LineTracker, + firstCheck: boolean, + view: EditorView +) => { + const { firstLineNumber, lastLineNumber } = viewportRangeToCheck( + firstCheck, + view + ) + _log('- viewport lines', firstLineNumber, lastLineNumber) + const lines = [] + for ( + let lineNumber = firstLineNumber; + lineNumber <= lastLineNumber; + lineNumber++ + ) { + if (!lineTracker.lineHasChanged(lineNumber)) { + continue + } + lines.push(view.state.doc.line(lineNumber)) + } + _log( + '- lines to check', + lines.map(l => l.number) + ) + return lines +} + +export const getWordsFromLine = ( + view: EditorView, + line: Line, + ignoredWords: IgnoredWords, + lang: string +): Word[] => { + const lineNumber = line.number + const normalTextSpans: Array = getNormalTextSpansFromLine( + view, + line + ) + const words: Word[] = [] + let regexResult + normalTextSpans.forEach(span => { + WORD_REGEX.lastIndex = 0 // reset global stateful regexp for this usage + while ((regexResult = WORD_REGEX.exec(span.text))) { + let word = regexResult[0] + if (word.startsWith("'")) { + word = word.slice(1) + } + if (word.endsWith("'")) { + word = word.slice(0, -1) + } + if (!ignoredWords.has(word)) { + words.push( + new Word({ + text: word, + from: span.from + regexResult.index, + to: span.from + regexResult.index + word.length, + lineNumber, + lang, + }) + ) + } + } + }) + return words +} diff --git a/services/web/frontend/js/features/source-editor/extensions/symbol-palette.ts b/services/web/frontend/js/features/source-editor/extensions/symbol-palette.ts new file mode 100644 index 0000000000..1eb7705938 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/symbol-palette.ts @@ -0,0 +1,39 @@ +import { ViewPlugin } from '@codemirror/view' +import { EditorSelection } from '@codemirror/state' + +export const symbolPalette = () => { + return ViewPlugin.define(view => { + const listener = (event: Event) => { + const symbol = (event as CustomEvent<{ command: string }>).detail + + view.focus() + + const spec = view.state.changeByRange(range => { + const changeSet = view.state.changes([ + { + from: range.from, + to: range.to, + insert: symbol.command, + }, + ]) + + return { + range: EditorSelection.cursor(changeSet.mapPos(range.to, 1)), + changes: changeSet, + } + }) + + view.dispatch(spec, { + scrollIntoView: true, + }) + } + + window.addEventListener('editor:insert-symbol', listener) + + return { + destroy() { + window.removeEventListener('editor:insert-symbol', listener) + }, + } + }) +} diff --git a/services/web/frontend/js/features/source-editor/extensions/theme.ts b/services/web/frontend/js/features/source-editor/extensions/theme.ts new file mode 100644 index 0000000000..39379f4777 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/theme.ts @@ -0,0 +1,270 @@ +import { EditorView } from '@codemirror/view' +import { Annotation, Compartment, TransactionSpec } from '@codemirror/state' +import { defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language' +import { classHighlighter } from './class-highlighter' + +const optionsThemeConf = new Compartment() +const selectedThemeConf = new Compartment() +export const themeOptionsChange = Annotation.define() + +export type FontFamily = 'monaco' | 'lucida' +export type LineHeight = 'compact' | 'normal' | 'wide' +export type OverallTheme = '' | 'light-' + +type Options = { + fontSize: number + fontFamily: FontFamily + lineHeight: LineHeight + overallTheme: OverallTheme +} + +export const theme = (options: Options) => [ + baseTheme, + staticTheme, + syntaxHighlighting(classHighlighter), + optionsThemeConf.of(createThemeFromOptions(options)), + selectedThemeConf.of([]), +] + +export const setOptionsTheme = (options: Options): TransactionSpec => { + return { + effects: optionsThemeConf.reconfigure(createThemeFromOptions(options)), + annotations: themeOptionsChange.of(true), + } +} + +export const setEditorTheme = async ( + editorTheme: string +): Promise => { + const theme = await loadSelectedTheme(editorTheme) + + return { + effects: selectedThemeConf.reconfigure(theme), + } +} + +const svgUrl = (content: string) => + `url('data:image/svg+xml,${encodeURIComponent( + `${content}` + )}')` + +export const lineHeights: Record = { + compact: 1.33, + normal: 1.6, + wide: 2, +} + +const fontFamilies: Record = { + monaco: ['Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'monospace'], + lucida: ['Lucida Console', 'Source Code Pro', 'monospace'], +} + +const createThemeFromOptions = ({ + fontSize = 12, + fontFamily = 'monaco', + lineHeight = 'normal', + overallTheme = '', +}: Options) => { + // theme styles that depend on settings + return [ + EditorView.editorAttributes.of({ + class: overallTheme === '' ? 'overall-theme-dark' : 'overall-theme-light', + }), + EditorView.theme({ + '&.cm-editor': { + // set variables + '--font-size': `${fontSize}px`, + '--source-font-family': fontFamilies[fontFamily]?.join(', '), + '--line-height': lineHeights[lineHeight], + }, + '.cm-content': { + fontSize: 'var(--font-size)', + fontFamily: 'var(--source-font-family)', + lineHeight: 'var(--line-height)', + }, + '.cm-cursor-primary': { + fontSize: 'var(--font-size)', + fontFamily: 'var(--source-font-family)', + lineHeight: 'var(--line-height)', + }, + '.cm-gutters': { + fontSize: 'var(--font-size)', + lineHeight: 'var(--line-height)', + }, + '.cm-tooltip': { + // set variables for tooltips, which are outside the editor + '--font-size': `${fontSize}px`, + '--source-font-family': fontFamilies[fontFamily]?.join(', '), + '--line-height': lineHeights[lineHeight], + // NOTE: fontFamily is not set here, as most tooltips use the UI font + fontSize: 'var(--font-size)', + }, + '.cm-panel': { + fontSize: 'var(--font-size)', + }, + '.cm-foldGutter .cm-gutterElement > span': { + height: 'calc(var(--font-size) * var(--line-height))', + }, + '.cm-lineNumbers': { + fontFamily: 'var(--source-font-family)', + }, + }), + ] +} + +// base styles that can have &dark and &light variants +const baseTheme = EditorView.baseTheme({ + // use a background color for lint error ranges + '.cm-lintRange-error': { + padding: 'var(--half-leading, 0) 0', + background: 'rgba(255, 0, 0, 0.2)', + // avoid highlighting nested error ranges + '& .cm-lintRange-error': { + background: 'none', + }, + }, + '.cm-widgetBuffer': { + height: '1.3em', + }, + '.cm-snippetFieldPosition': { + display: 'inline-block', + height: '1.3em', + }, + // style the gutter fold button on hover + '&dark .cm-foldGutter .cm-gutterElement > span:hover': { + boxShadow: '0 1px 1px rgba(255, 255, 255, 0.2)', + backgroundColor: 'rgba(255, 255, 255, 0.1)', + }, + '&light .cm-foldGutter .cm-gutterElement > span:hover': { + borderColor: 'rgba(0, 0, 0, 0.3)', + boxShadow: '0 1px 1px rgba(255, 255, 255, 0.7)', + backgroundColor: 'rgba(255, 255, 255, 0.2)', + }, +}) + +// theme styles that don't depend on settings +// TODO: move some/all of these into baseTheme? +const staticTheme = EditorView.theme({ + // make the editor fill the available height + '&': { + height: '100%', + textRendering: 'optimizeSpeed', + }, + // remove the outline from the focused editor + '&.cm-editor.cm-focused:not(:focus-visible)': { + outline: 'none', + }, + // override default styles for the search panel + '.cm-panel.cm-search label': { + display: 'inline-flex', + alignItems: 'center', + fontWeight: 'normal', + }, + '.cm-selectionLayer': { + zIndex: -10, + }, + // remove the right-hand border from the gutter + // ensure the gutter doesn't shrink + '.cm-gutters': { + borderRight: 'none', + flexShrink: 0, + }, + // style the gutter fold button + // TODO: add a class to this element for easier theming + '.cm-foldGutter .cm-gutterElement > span': { + border: '1px solid transparent', + borderRadius: '3px', + display: 'inline-flex', + flexDirection: 'column', + justifyContent: 'center', + color: 'rgba(109, 109, 109, 0.7)', + }, + // reduce the padding around line numbers + '.cm-lineNumbers .cm-gutterElement': { + padding: '0', + userSelect: 'none', + }, + // make cursor visible with reduced opacity when the editor is not focused + '&:not(.cm-focused) .cm-cursor': { + display: 'block', + opacity: 0.2, + }, + // make the cursor wider, and use the themed color + '.cm-cursor, .cm-dropCursor': { + borderWidth: '2px', + marginLeft: '-1px', // half the border width + borderLeftColor: 'inherit', + }, + // set the default "selection match" style + '.cm-selectionMatch, .cm-searchMatch': { + backgroundColor: 'transparent', + outlineOffset: '-1px', + }, + // make sure selectionMatch inside searchMatch doesn't have a background colour + '.cm-searchMatch .cm-selectionMatch': { + backgroundColor: 'transparent !important', + }, + // Match the height of search matches to selection matches + '.cm-searchMatch': { + paddingTop: 'var(--half-leading)', + paddingBottom: 'var(--half-leading)', + }, + // remove border from hover tooltips (e.g. cursor highlights) + '.cm-tooltip-hover': { + border: 'none', + }, + // use the same style as Ace for snippet fields + '.cm-snippetField': { + background: 'rgba(194, 193, 208, 0.09)', + border: '1px dotted rgba(211, 208, 235, 0.62)', + }, + // style the fold placeholder + '.cm-foldPlaceholder': { + boxSizing: 'border-box', + display: 'inline-block', + height: '11px', + width: '1.8em', + marginTop: '-2px', + verticalAlign: 'middle', + backgroundImage: + 'url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABEAAAAJCAYAAADU6McMAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAJpJREFUeNpi/P//PwOlgAXGYGRklAVSokD8GmjwY1wasKljQpYACtpCFeADcHVQfQyMQAwzwAZI3wJKvCLkfKBaMSClBlR7BOQikCFGQEErIH0VqkabiGCAqwUadAzZJRxQr/0gwiXIal8zQQPnNVTgJ1TdawL0T5gBIP1MUJNhBv2HKoQHHjqNrA4WO4zY0glyNKLT2KIfIMAAQsdgGiXvgnYAAAAASUVORK5CYII="),url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAA3CAYAAADNNiA5AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAACJJREFUeNpi+P//fxgTAwPDBxDxD078RSX+YeEyDFMCIMAAI3INmXiwf2YAAAAASUVORK5CYII=")', + backgroundRepeat: 'no-repeat, repeat-x', + backgroundPosition: 'center center, top left', + color: 'transparent', + border: '1px solid black', + borderRadius: '2px', + }, + // align the lint icons with the line numbers + '.cm-gutter-lint .cm-gutterElement': { + padding: '0.3em', + }, + // reset the default style for the lint gutter error marker, which uses :before + '.cm-lint-marker-error:before': { + content: 'normal', + }, + // set a new icon for the lint gutter error marker + '.cm-lint-marker-error': { + content: svgUrl( + `` + ), + }, + // set a new icon for the lint gutter warning marker + '.cm-lint-marker-warning': { + content: svgUrl( + `` + ), + }, +}) + +const loadSelectedTheme = async (editorTheme: string) => { + const { theme, highlightStyle, dark } = await import( + /* webpackChunkName: "cm6-theme" */ `../themes/cm6/${editorTheme}.json` + ) + + return [ + EditorView.theme(theme, { dark }), + highlightStyle + ? EditorView.theme(highlightStyle, { dark }) + : syntaxHighlighting(defaultHighlightStyle, { fallback: true }), // use the default highlight style if none is provided + ] +} diff --git a/services/web/frontend/js/features/source-editor/extensions/third-party-extensions.ts b/services/web/frontend/js/features/source-editor/extensions/third-party-extensions.ts new file mode 100644 index 0000000000..4bd74c8e6a --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/third-party-extensions.ts @@ -0,0 +1,42 @@ +import type { Extension } from '@codemirror/state' +import CodeMirror, { CodeMirrorVim } from './bundle' + +export const thirdPartyExtensions = (): Extension => { + const extensions: Extension[] = [] + + window.dispatchEvent( + new CustomEvent('UNSTABLE_editor:extensions', { + detail: { CodeMirror, CodeMirrorVim, extensions }, + }) + ) + + Object.defineProperty(window, 'UNSTABLE_editorHelp', { + writable: false, + enumerable: true, + value: ` +Listen for the UNSTABLE_editor:extensions event to add your CodeMirror 6 +extension(s) to the extensions array. Use the exported objects to avoid +instanceof comparison errors. + +Open an issue on http://github.com/overleaf/overleaf if you think more +should be exported. + +This API is **unsupported** and subject to change without warning. + +Example: + +window.addEventListener("UNSTABLE_editor:extensions", function(evt) { + const { CodeMirror, extensions } = evt.detail; + + // CodeMirror contains exported objects from the CodeMirror instance + const { EditorSelection, ViewPlugin } = CodeMirror; + + // ... + + // Any custom extensions should be pushed to the \`extensions\` array + extensions.push(myCustomExtension) +});`, + }) + + return extensions +} diff --git a/services/web/frontend/js/features/source-editor/extensions/toolbar/commands.ts b/services/web/frontend/js/features/source-editor/extensions/toolbar/commands.ts new file mode 100644 index 0000000000..807fcfc6ea --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/toolbar/commands.ts @@ -0,0 +1,106 @@ +import { EditorSelection, EditorState, SelectionRange } from '@codemirror/state' +import { Command } from '@codemirror/view' +import { + closeSearchPanel, + openSearchPanel, + searchPanelOpen, +} from '@codemirror/search' +import { toggleRanges, wrapRanges } from '../../commands/ranges' +import { + ancestorListType, + toggleListForRanges, + unwrapBulletList, + unwrapNumberedList, + wrapInBulletList, + wrapInNumberedList, +} from './lists' +import { snippet } from '@codemirror/autocomplete' +import { snippets } from './snippets' +import { minimumListDepthForSelection } from '../../utils/tree-operations/ancestors' + +export const toggleBold = toggleRanges('\\textbf') +export const toggleItalic = toggleRanges('\\textit') +export const wrapInHref = wrapRanges('\\href{}{', '}', false, range => + EditorSelection.cursor(range.from - 2) +) +export const toggleBulletList = toggleListForRanges('itemize') +export const toggleNumberedList = toggleListForRanges('enumerate') +export const wrapInInlineMath = wrapRanges('\\(', '\\)') +export const wrapInDisplayMath = wrapRanges('\n\\[', '\\]\n') + +const ensureEmptyLine = (state: EditorState, range: SelectionRange) => { + let pos = range.anchor + let suffix = '' + + const line = state.doc.lineAt(pos) + if (line.length) { + pos = Math.min(line.to + 1, state.doc.length) + const nextLine = state.doc.lineAt(pos) + + if (nextLine.length) { + suffix = '\n' + } + } + return { pos, suffix } +} + +export const insertFigure: Command = view => { + const { state, dispatch } = view + const { pos, suffix } = ensureEmptyLine(state, state.selection.main) + const template = `\n${snippets.figure}\n${suffix}` + snippet(template)({ state, dispatch }, { label: 'Figure' }, pos, pos) + return true +} + +export const insertTable: Command = view => { + const { state, dispatch } = view + const { pos, suffix } = ensureEmptyLine(state, state.selection.main) + const template = `\n${snippets.table}\n${suffix}` + snippet(template)({ state, dispatch }, { label: 'Table' }, pos, pos) + return true +} + +export const indentDecrease: Command = view => { + if (minimumListDepthForSelection(view.state) < 2) { + return false + } + switch (ancestorListType(view.state)) { + case 'itemize': + return unwrapBulletList(view) + case 'enumerate': + return unwrapNumberedList(view) + default: + return false + } +} + +export const cursorIsAtStartOfListItem = (state: EditorState) => { + return state.selection.ranges.every(range => { + const line = state.doc.lineAt(range.from) + const prefix = state.sliceDoc(line.from, range.from) + return /\\item\s*$/.test(prefix) + }) +} + +export const indentIncrease: Command = view => { + if (minimumListDepthForSelection(view.state) < 1) { + return false + } + switch (ancestorListType(view.state)) { + case 'itemize': + return wrapInBulletList(view) + case 'enumerate': + return wrapInNumberedList(view) + default: + return false + } +} + +export const toggleSearch: Command = view => { + if (searchPanelOpen(view.state)) { + closeSearchPanel(view) + } else { + openSearchPanel(view) + } + return true +} diff --git a/services/web/frontend/js/features/source-editor/extensions/toolbar/comments.ts b/services/web/frontend/js/features/source-editor/extensions/toolbar/comments.ts new file mode 100644 index 0000000000..ae1e42f7f3 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/toolbar/comments.ts @@ -0,0 +1,14 @@ +import { EditorState } from '@codemirror/state' + +export const canAddComment = (state: EditorState) => { + // TODO: permissions.comment + + // allow an empty selection if there's content on the line + const range = state.selection.main + if (range.empty) { + return state.doc.lineAt(range.head).text.length > 0 + } + + // always allow a non-empty selection + return true +} diff --git a/services/web/frontend/js/features/source-editor/extensions/toolbar/lists.ts b/services/web/frontend/js/features/source-editor/extensions/toolbar/lists.ts new file mode 100644 index 0000000000..7f848c3557 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/toolbar/lists.ts @@ -0,0 +1,316 @@ +import { EditorView } from '@codemirror/view' +import { + ChangeSpec, + EditorSelection, + EditorState, + SelectionRange, +} from '@codemirror/state' +import { + getIndentation, + getIndentUnit, + IndentContext, + indentString, + syntaxTree, +} from '@codemirror/language' +import { + ancestorNodeOfType, + ancestorOfNodeWithType, + ancestorWithType, + descendantsOfNodeWithType, +} from '../../utils/tree-operations/ancestors' +import { getEnvironmentName } from '../../utils/tree-operations/environments' +import { ListEnvironment } from '../../lezer-latex/latex.terms.mjs' + +export const ancestorListType = (state: EditorState): string | null => { + const ancestorNode = ancestorWithType(state, ListEnvironment) + if (!ancestorNode) { + return null + } + return getEnvironmentName(ancestorNode, state) +} + +const wrapRangeInList = ( + state: EditorState, + range: SelectionRange, + environment: string, + prefix = '' +) => { + const cx = new IndentContext(state) + const columns = getIndentation(cx, range.from) ?? 0 + const unit = getIndentUnit(state) + const indent = indentString(state, columns) + const itemIndent = indentString(state, columns + unit) + + const fromLine = state.doc.lineAt(range.from) + const toLine = state.doc.lineAt(range.to) + + // TODO: merge with existing list at the same level? + const lines: string[] = [`${indent}\\begin{${environment}}`] + for (const line of state.doc.iterLines(fromLine.number, toLine.number + 1)) { + let content = line.trim() + if (content.endsWith('\\item')) { + content += ' ' // ensure a space after \item + } + + lines.push(`${itemIndent}${prefix}${content}`) + } + if (lines.length === 1) { + lines.push(`${itemIndent}${prefix}`) + } + + const changes = [ + { + from: fromLine.from, + to: toLine.to, + insert: lines.join('\n'), + }, + ] + + // map through the prefix + range = EditorSelection.cursor(range.to).map(state.changes(changes), 1) + + changes.push({ + from: toLine.to, + to: toLine.to, + insert: `\n${indent}\\end{${environment}}`, + }) + + return { + range, + changes, + } +} + +const wrapRangesInList = + (environment: string) => + (view: EditorView): boolean => { + view.dispatch( + view.state.changeByRange(range => + wrapRangeInList(view.state, range, environment) + ) + ) + return true + } + +const unwrapRangeFromList = ( + state: EditorState, + range: SelectionRange, + environment: string +) => { + const node = syntaxTree(state).resolveInner(range.from) + const list = ancestorOfNodeWithType(node, ListEnvironment) + if (!list) { + return { range } + } + + const fromLine = state.doc.lineAt(range.from) + const toLine = state.doc.lineAt(range.to) + + const listFromLine = state.doc.lineAt(list.from) + const listToLine = state.doc.lineAt(list.to) + + const cx = new IndentContext(state) + const columns = getIndentation(cx, range.from) ?? 0 + const unit = getIndentUnit(state) + const indent = indentString(state, columns - unit) // decrease indent depth + + // TODO: only move lines that are list items + + const changes: ChangeSpec[] = [] + + if (listFromLine.number === fromLine.number - 1) { + // remove \begin if there are no items before this one + changes.push({ + from: listFromLine.from, + to: listFromLine.to + 1, + insert: '', + }) + } else { + // finish the previous list for the previous items + changes.push({ + from: fromLine.from, + insert: `${indent}\\end{${environment}}\n`, + }) + } + + const ensureSpace = (state: EditorState, from: number, to: number) => { + return /^\s*$/.test(state.doc.sliceString(from, to)) + } + + for ( + let lineNumber = fromLine.number; + lineNumber <= toLine.number; + lineNumber++ + ) { + const line = state.doc.line(lineNumber) + const to = line.from + unit + + if (to <= line.to && ensureSpace(state, line.from, to)) { + // remove indent + changes.push({ + from: line.from, + to, + insert: '', + }) + } + } + + if (listToLine.number === toLine.number + 1) { + // remove \end if there are no items after this one + changes.push({ + from: listToLine.from, + to: listToLine.to + 1, + insert: '', + }) + } else { + // start a new list for the remaining items + changes.push({ + from: toLine.to, + insert: `\n${indent}\\begin{${environment}}`, + }) + } + + // map the range through these changes + range = range.map(state.changes(changes), -1) + + return { range, changes } +} + +const unwrapRangesFromList = + (environment: string) => + (view: EditorView): boolean => { + view.dispatch( + view.state.changeByRange(range => + unwrapRangeFromList(view.state, range, environment) + ) + ) + return true + } + +const toggleListForRange = ( + view: EditorView, + range: SelectionRange, + environment: string +) => { + const ancestorNode = ancestorNodeOfType( + view.state, + range.head, + ListEnvironment + ) + + if (ancestorNode) { + const beginEnvNode = ancestorNode.getChild('BeginEnv') + const endEnvNode = ancestorNode.getChild('EndEnv') + + if (beginEnvNode && endEnvNode) { + const beginEnvNameNode = beginEnvNode + ?.getChild('EnvNameGroup') + ?.getChild('ListEnvName') + + const endEnvNameNode = endEnvNode + ?.getChild('EnvNameGroup') + ?.getChild('ListEnvName') + + if (beginEnvNameNode && endEnvNameNode) { + const envName = view.state + .sliceDoc(beginEnvNameNode.from, beginEnvNameNode.to) + .trim() + + if (envName === environment) { + const beginLine = view.state.doc.lineAt(beginEnvNode.from) + const endLine = view.state.doc.lineAt(endEnvNode.from) + + // whether the command is the only content on this line, apart from whitespace + const emptyBeginLine = /^\s*\\begin\{[^}]*}\s*$/.test(beginLine.text) + const emptyEndLine = /^\s*\\end\{[^}]*}\s*$/.test(endLine.text) + + // toggle list off + const changeSpec: ChangeSpec[] = [ + { + from: emptyBeginLine ? beginLine.from - 1 : beginEnvNode.from, + to: emptyBeginLine ? beginLine.to : beginEnvNode.to, + insert: '', + }, + { + from: emptyEndLine ? endLine.from : endEnvNode.from, + to: emptyEndLine ? endLine.to + 1 : endEnvNode.to, + insert: '', + }, + ] + + const commandNodes = descendantsOfNodeWithType( + ancestorNode, + 'Item' + ).filter( + commandNode => + view.state.sliceDoc(commandNode.from, commandNode.to) === '\\item' + ) + + if (commandNodes.length > 0) { + // whether the command is the only content on this line, apart from whitespace + const emptyLineBeforeItem = /^\s*\\item\{/.test(beginLine.text) + + const indentUnit = emptyLineBeforeItem + ? getIndentUnit(view.state) + : 0 + + for (const commandNode of commandNodes) { + changeSpec.push({ + from: commandNode.from - indentUnit, + to: commandNode.to + 1, + insert: '', + }) + } + } + + const changes = view.state.changes(changeSpec) + + return { + range: range.map(changes), + changes, + } + } else { + // change list type + const changeSpec: ChangeSpec[] = [ + { + from: beginEnvNameNode.from, + to: beginEnvNameNode.to, + insert: environment, + }, + { + from: endEnvNameNode.from, + to: endEnvNameNode.to, + insert: environment, + }, + ] + + const changes = view.state.changes(changeSpec) + + return { + range: range.map(changes), + changes, + } + } + } + } + } else { + // create a new list + return wrapRangeInList(view.state, range, environment, '\\item ') + } + + return { range } +} + +export const toggleListForRanges = + (environment: string) => (view: EditorView) => { + view.dispatch( + view.state.changeByRange(range => + toggleListForRange(view, range, environment) + ) + ) + } + +export const wrapInBulletList = wrapRangesInList('itemize') +export const wrapInNumberedList = wrapRangesInList('enumerate') +export const unwrapBulletList = unwrapRangesFromList('itemize') +export const unwrapNumberedList = unwrapRangesFromList('enumerate') diff --git a/services/web/frontend/js/features/source-editor/extensions/toolbar/sections.ts b/services/web/frontend/js/features/source-editor/extensions/toolbar/sections.ts new file mode 100644 index 0000000000..54817f3aa4 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/toolbar/sections.ts @@ -0,0 +1,139 @@ +import { EditorSelection, EditorState, SelectionRange } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import { syntaxTree } from '@codemirror/language' +import { ancestorsOfNodeWithType } from '../../utils/tree-operations/ancestors' +import { SyntaxNode } from '@lezer/common' + +export const findCurrentSectionHeadingLevel = (state: EditorState) => { + const selections = state.selection.ranges.map(range => + rangeInfo(state, range) + ) + const currentLevels = new Set(selections.map(item => item.level)) + + return currentLevels.size === 1 ? selections[0] : null +} + +type RangeInfo = { + range: SelectionRange + command?: SyntaxNode + ctrlSeq?: SyntaxNode + level: string +} + +export const rangeInfo = ( + state: EditorState, + range: SelectionRange +): RangeInfo => { + const tree = syntaxTree(state) + const node = tree.resolveInner(range.anchor) + const command = ancestorsOfNodeWithType(node, 'SectioningCommand').next() + .value + const ctrlSeq = command?.firstChild + const level = ctrlSeq + ? state.sliceDoc(ctrlSeq.from + 1, ctrlSeq.to).trim() + : 'text' + + return { command, ctrlSeq, level, range } +} + +export const setSectionHeadingLevel = (view: EditorView, level: string) => { + view.dispatch( + view.state.changeByRange(range => { + const info = rangeInfo(view.state, range) + + if (level === info.level) { + return { range } + } + + if (level === 'text' && info.command) { + // remove + const argument = info.command.getChild('SectioningArgument') + if (argument) { + const content = view.state.sliceDoc( + argument.from + 1, + argument.to - 1 + ) + // map through the prefix only + const changedRange = range.map( + view.state.changes([ + { from: info.command.from, to: argument.from + 1, insert: '' }, + ]), + 1 + ) + return { + range: changedRange, + changes: [ + { + from: info.command.from, + to: info.command.to, + insert: content, + }, + ], + } + } + return { range } + } else if (info.level === 'text') { + // add + const insert = { + prefix: `\\${level}{`, + suffix: '}', + } + + const originalRange = range + const line = view.state.doc.lineAt(range.anchor) + if (range.empty) { + // expand range to cover the whole line + range = EditorSelection.range(line.from, line.to) + } else { + if (range.from !== line.from) { + insert.prefix = '\n' + insert.prefix + } + + if (range.to !== line.to) { + insert.suffix += '\n' + } + } + + const content = view.state.sliceDoc(range.from, range.to) + + // map through the prefix only + const changedRange = originalRange.map( + view.state.changes([ + { from: range.from, insert: `${insert.prefix}` }, + ]), + 1 + ) + + return { + range: changedRange, + // create a single change, including the content + changes: [ + { + from: range.from, + to: range.to, + insert: `${insert.prefix}${content}${insert.suffix}`, + }, + ], + } + } else { + // change + if (!info.ctrlSeq) { + return { range } + } + + const changes = view.state.changes([ + { + from: info.ctrlSeq.from + 1, + to: info.ctrlSeq.to, + insert: level, + }, + ]) + + return { + range: range.map(changes), + changes, + } + } + }) + ) +} diff --git a/services/web/frontend/js/features/source-editor/extensions/toolbar/snippets.ts b/services/web/frontend/js/features/source-editor/extensions/toolbar/snippets.ts new file mode 100644 index 0000000000..8cc826436a --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/toolbar/snippets.ts @@ -0,0 +1,7 @@ +import { environments } from '../../languages/latex/completions/data/environments' +import { prepareSnippetTemplate } from '../../languages/latex/snippets' + +export const snippets = { + figure: prepareSnippetTemplate(environments.get('figure') as string), + table: prepareSnippetTemplate(environments.get('table') as string), +} diff --git a/services/web/frontend/js/features/source-editor/extensions/toolbar/toolbar-panel.ts b/services/web/frontend/js/features/source-editor/extensions/toolbar/toolbar-panel.ts new file mode 100644 index 0000000000..0cbce418e4 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/toolbar/toolbar-panel.ts @@ -0,0 +1,221 @@ +import { StateEffect, StateField } from '@codemirror/state' +import { EditorView, showPanel } from '@codemirror/view' + +const toggleToolbarEffect = StateEffect.define() +const toolbarState = StateField.define({ + create: () => true, + update: (value, tr) => { + if (tr.state.readOnly) { + return false + } + for (const effect of tr.effects) { + if (effect.is(toggleToolbarEffect)) { + value = effect.value + } + } + return value + }, + provide: f => showPanel.from(f, on => (on ? createToolbarPanel : null)), +}) + +export function createToolbarPanel() { + const dom = document.createElement('div') + dom.classList.add('ol-cm-toolbar-portal') + return { dom, top: true } +} + +export const toolbarPanel = () => [ + toolbarState, + EditorView.theme({ + '.ol-cm-toolbar': { + backgroundColor: 'var(--editor-toolbar-bg)', + color: 'var(--toolbar-btn-color)', + flex: 1, + display: 'flex', + overflowX: 'hidden', + }, + '&.overall-theme-dark .ol-cm-toolbar': { + '& img': { + filter: 'invert(1)', + }, + }, + '.ol-cm-toolbar-overflow': { + display: 'flex', + flexWrap: 'wrap', + }, + '#popover-toolbar-overflow': { + padding: 0, + borderColor: 'rgba(125, 125, 125, 0.2)', + backgroundColor: 'var(--editor-toolbar-bg)', + color: 'var(--toolbar-btn-color)', + '& .popover-content': { + padding: 0, + }, + '& .arrow': { + borderBottomColor: 'rgba(125, 125, 125, 0.2)', + '&:after': { + borderBottomColor: 'var(--editor-toolbar-bg)', + }, + }, + }, + '.ol-cm-toolbar-button-group': { + display: 'flex', + alignItems: 'center', + whiteSpace: 'nowrap', + flexWrap: 'nowrap', + padding: '0 4px', + margin: '4px 0', + lineHeight: '1', + '&:not(:first-of-type)': { + borderLeft: '1px solid rgba(125, 125, 125, 0.3)', + '&.ol-cm-toolbar-end': { + borderLeft: 'none', + }, + '&.overflow-hidden': { + borderLeft: 'none', + }, + }, + '&.overflow-hidden': { + width: 0, + padding: 0, + }, + }, + '.ol-cm-toolbar-button': { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + padding: '0', + margin: '0 1px', + backgroundColor: 'transparent', + border: 'none', + borderRadius: '1px', + lineHeight: '1', + width: '24px', + height: '24px', + overflow: 'hidden', + '&:hover, &:focus, &:active, &.active': { + backgroundColor: 'rgba(125, 125, 125, 0.1)', + color: 'inherit', + boxShadow: 'none', + '&[disabled]': { + opacity: '0.2', + }, + }, + '&.active, &:active': { + backgroundColor: 'rgba(125, 125, 125, 0.2)', + }, + '&[disabled]': { + opacity: '0.2', + }, + '.overflow-hidden &': { + display: 'none', + }, + '&.ol-cm-toolbar-button-math': { + fontFamily: '"Noto Serif", serif', + fontSize: '16px', + fontWeight: 700, + }, + }, + '&.overall-theme-dark .ol-cm-toolbar-button': { + opacity: 0.8, + '&:hover, &:focus, &:active, &.active': { + backgroundColor: 'rgba(125, 125, 125, 0.2)', + }, + '&.active, &:active': { + backgroundColor: 'rgba(125, 125, 125, 0.4)', + }, + '&[disabled]': { + opacity: 0.2, + }, + }, + '.ol-cm-toolbar-end': { + flex: 1, + justifyContent: 'flex-end', + }, + '.ol-cm-toolbar-overflow-toggle': { + display: 'none', + '&.ol-cm-toolbar-overflow-toggle-visible': { + display: 'flex', + }, + }, + '.ol-cm-toolbar-menu-toggle': { + background: 'transparent', + boxShadow: 'none !important', + border: 'none', + whiteSpace: 'nowrap', + color: 'inherit', + borderRadius: '0', + opacity: 0.8, + width: '120px', + fontSize: '13px', + fontFamily: 'Lato', + fontWeight: '700', + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '5px 6px', + '&:hover, &:focus, &.active': { + backgroundColor: 'rgba(125, 125, 125, 0.1)', + opacity: '1', + color: 'inherit', + }, + '& .caret': { + marginTop: '0', + }, + }, + '.ol-cm-toolbar-menu-popover': { + border: 'none', + borderRadius: '0', + borderBottomLeftRadius: '4px', + borderBottomRightRadius: '4px', + boxShadow: '0 2px 5px rgb(0 0 0 / 20%)', + backgroundColor: 'var(--editor-toolbar-bg)', + color: 'var(--toolbar-btn-color)', + padding: '0', + '&.bottom': { + marginTop: '1px', + }, + '&.top': { + marginBottom: '1px', + }, + '& .arrow': { + display: 'none', + }, + '& .popover-content': { + padding: '0', + }, + '& .ol-cm-toolbar-menu': { + width: '120px', + display: 'flex', + flexDirection: 'column', + boxSizing: 'border-box', + fontSize: '14px', + }, + '& .ol-cm-toolbar-menu-item': { + border: 'none', + background: 'none', + padding: '4px 12px', + height: '40px', + display: 'flex', + alignItems: 'center', + fontWeight: 'bold', + '&.ol-cm-toolbar-menu-item-active': { + backgroundColor: 'rgba(125, 125, 125, 0.1)', + }, + '&:hover': { + backgroundColor: 'rgba(125, 125, 125, 0.2)', + color: 'inherit', + }, + '&.section-level-section': { + fontSize: '1.44em', + }, + '&.section-level-subsection': { + fontSize: '1.2em', + }, + '&.section-level-body': { + fontWeight: 'normal', + }, + }, + }, + }), +] diff --git a/services/web/frontend/js/features/source-editor/extensions/toolbar/utils/analytics.ts b/services/web/frontend/js/features/source-editor/extensions/toolbar/utils/analytics.ts new file mode 100644 index 0000000000..38ad29d495 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/toolbar/utils/analytics.ts @@ -0,0 +1,8 @@ +import { EditorView } from '@codemirror/view' +import { sendMB } from '../../../../../infrastructure/event-tracking' +import { isVisual } from '../../visual/visual' + +export function emitCommandEvent(view: EditorView, command: string) { + const mode = isVisual(view) ? 'visual' : 'source' + sendMB('codemirror-toolbar-event', { command, mode }) +} diff --git a/services/web/frontend/js/features/source-editor/extensions/track-changes.ts b/services/web/frontend/js/features/source-editor/extensions/track-changes.ts new file mode 100644 index 0000000000..e8533a1a62 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/track-changes.ts @@ -0,0 +1,355 @@ +import { + EditorState, + RangeSet, + StateEffect, + StateField, + Transaction, +} from '@codemirror/state' +import { + Decoration, + type DecorationSet, + EditorView, + type PluginValue, + ViewPlugin, + WidgetType, +} from '@codemirror/view' +import { + findCommentsInCut, + findDetachedCommentsInChanges, + restoreCommentsOnPaste, + restoreDetachedComments, + StoredComment, +} from './changes/comments' +import { invertedEffects } from '@codemirror/commands' +import { CurrentDoc } from '../../../../../types/current-doc' +import { + Change, + ChangeOperation, + CommentOperation, + DeleteOperation, + Operation, +} from '../../../../../types/change' +import { ChangeManager } from './changes/change-manager' + +const clearChangesEffect = StateEffect.define() +const buildChangesEffect = StateEffect.define() +const restoreDetachedCommentsEffect = StateEffect.define>({ + map: (value, mapping) => { + return value + .update({ + filter: (from, to) => { + return from <= mapping.length && to <= mapping.length + }, + }) + .map(mapping) + }, +}) + +type Options = { + currentDoc: CurrentDoc + loadingThreads: boolean +} + +export const trackChanges = ( + { currentDoc, loadingThreads }: Options, + changeManager: ChangeManager +) => { + // A state field that stored any comments found within the ranges of a "cut" transaction, + // to be restored when pasting matching text. + const cutCommentsState = StateField.define({ + create: () => { + return [] + }, + update: (value, transaction) => { + if (transaction.annotation(Transaction.remote)) { + return value + } + + if (!transaction.docChanged) { + return value + } + + if (transaction.isUserEvent('delete.cut')) { + return findCommentsInCut(currentDoc, transaction) + } + + if (transaction.isUserEvent('input.paste')) { + restoreCommentsOnPaste(currentDoc, transaction, value) + return [] + } + + return value + }, + }) + + return [ + // attach any comments detached by the transaction as an inverted effect, to be applied on undo + invertedEffects.of(transaction => { + if ( + transaction.docChanged && + !transaction.annotation(Transaction.remote) + ) { + const detachedComments = findDetachedCommentsInChanges( + currentDoc, + transaction + ) + if (detachedComments.size) { + return [restoreDetachedCommentsEffect.of(detachedComments)] + } + } + return [] + }), + + // restore any detached comments on undo + EditorState.transactionExtender.of(transaction => { + for (const effect of transaction.effects) { + if (effect.is(restoreDetachedCommentsEffect)) { + // send the comments to the ShareJS doc + restoreDetachedComments(currentDoc, transaction, effect.value) + + // return a transaction spec to rebuild the change markers + return buildChangeMarkers() + } + } + return null + }), + + cutCommentsState, + + // initialize/destroy the change manager, and handle any updates + ViewPlugin.define(() => { + changeManager.initialize() + + return { + update: update => { + changeManager.handleUpdate(update) + }, + destroy: () => { + changeManager.destroy() + }, + } + }), + + // draw change decorations + ViewPlugin.define< + PluginValue & { + decorations: DecorationSet + } + >( + () => { + return { + decorations: loadingThreads + ? Decoration.none + : buildChangeDecorations(currentDoc), + update(update) { + for (const transaction of update.transactions) { + this.decorations = this.decorations.map(transaction.changes) + + for (const effect of transaction.effects) { + if (effect.is(clearChangesEffect)) { + this.decorations = Decoration.none + } else if (effect.is(buildChangesEffect)) { + this.decorations = buildChangeDecorations(currentDoc) + } + } + } + }, + } + }, + { + decorations: value => value.decorations, + } + ), + + // styles for change decorations + trackChangesTheme, + ] +} + +export const clearChangeMarkers = () => { + return { + effects: clearChangesEffect.of(null), + } +} + +export const buildChangeMarkers = () => { + return { + effects: buildChangesEffect.of(null), + } +} + +const buildChangeDecorations = (currentDoc: CurrentDoc) => { + const changes = [...currentDoc.ranges.changes, ...currentDoc.ranges.comments] + + const decorations = [] + + for (const change of changes) { + try { + decorations.push(...createChangeRange(change, currentDoc)) + } catch (error) { + // ignore invalid changes + console.debug('invalid change position', error) + } + } + + return Decoration.set(decorations, true) +} + +class ChangeDeletedWidget extends WidgetType { + constructor(public change: Change) { + super() + } + + toDOM() { + const widget = document.createElement('span') + widget.classList.add('ol-cm-change') + widget.classList.add('ol-cm-change-d') + + return widget + } + + eq() { + return true + } +} + +class ChangeCalloutWidget extends WidgetType { + constructor(public change: Change, public opType: string) { + super() + } + + toDOM() { + const widget = document.createElement('span') + widget.className = 'ol-cm-change-callout' + widget.classList.add(`ol-cm-change-callout-${this.opType}`) + + const inner = document.createElement('span') + inner.classList.add('ol-cm-change-callout-inner') + widget.appendChild(inner) + + return widget + } + + eq(widget: ChangeCalloutWidget) { + return widget.opType === this.opType + } + + updateDOM(element: HTMLElement) { + element.className = 'ol-cm-change-callout' + element.classList.add(`ol-cm-change-callout-${this.opType}`) + return true + } +} + +// const isInsertOperation = (op: Operation): op is InsertOperation => 'i' in op +const isChangeOperation = (op: Operation): op is ChangeOperation => + 'c' in op && 't' in op +const isCommentOperation = (op: Operation): op is CommentOperation => + 'c' in op && !('t' in op) +const isDeleteOperation = (op: Operation): op is DeleteOperation => 'd' in op + +const createChangeRange = (change: Change, currentDoc: CurrentDoc) => { + const { id, metadata, op } = change + + const from = op.p + // TODO: find valid positions? + + if (isDeleteOperation(op)) { + const opType = 'd' + + const changeWidget = Decoration.widget({ + widget: new ChangeDeletedWidget(change as Change), + opType, + id, + metadata, + }) + + const calloutWidget = Decoration.widget({ + widget: new ChangeCalloutWidget(change, opType), + opType, + id, + metadata, + }) + + return [calloutWidget.range(from, from), changeWidget.range(from, from)] + } + + if (isChangeOperation(op) && currentDoc.ranges.resolvedThreadIds[op.t]) { + return [] + } + + const isChangeOrCommentOperation = + isChangeOperation(op) || isCommentOperation(op) + const opType = isChangeOrCommentOperation ? 'c' : 'i' + const changedText = isChangeOrCommentOperation ? op.c : op.i + const to = from + changedText.length + + // Mark decorations must not be empty + if (from === to) { + return [] + } + + const changeMark = Decoration.mark({ + tagName: 'span', + class: `ol-cm-change ol-cm-change-${opType}`, + opType, + id, + metadata, + }) + + const calloutWidget = Decoration.widget({ + widget: new ChangeCalloutWidget(change, opType), + opType, + id, + metadata, + }) + + return [calloutWidget.range(from, from), changeMark.range(from, to)] +} + +const trackChangesTheme = EditorView.baseTheme({ + '.cm-line': { + overflowX: 'hidden', // needed so the callout elements don't overflow (requires line wrapping to be on) + }, + '&light .ol-cm-change-i': { + backgroundColor: '#2c8e304d', + }, + '&dark .ol-cm-change-i': { + backgroundColor: 'rgba(37, 107, 41, 0.15)', + }, + '&light .ol-cm-change-c': { + backgroundColor: '#f3b1114d', + }, + '&dark .ol-cm-change-c': { + backgroundColor: 'rgba(194, 93, 11, 0.15)', + }, + '.ol-cm-change': { + padding: 'var(--half-leading, 0) 0', + }, + '.ol-cm-change-d': { + borderLeft: '2px dotted #c5060b', + marginLeft: '-1px', + }, + '.ol-cm-change-callout': { + position: 'relative', + pointerEvents: 'none', + padding: 'var(--half-leading, 0) 0', + }, + '.ol-cm-change-callout-inner': { + display: 'inline-block', + position: 'absolute', + left: 0, + bottom: 0, + width: '10000px', + borderBottom: '1px dashed black', + }, + '.ol-cm-change-callout-i .ol-cm-change-callout-inner': { + borderColor: '#2c8e30', + }, + '.ol-cm-change-callout-c .ol-cm-change-callout-inner': { + borderColor: '#f3b111', + }, + '.ol-cm-change-callout-d .ol-cm-change-callout-inner': { + borderColor: '#c5060b', + }, +}) diff --git a/services/web/frontend/js/features/source-editor/extensions/vertical-overflow.ts b/services/web/frontend/js/features/source-editor/extensions/vertical-overflow.ts new file mode 100644 index 0000000000..62233eaa17 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/vertical-overflow.ts @@ -0,0 +1,165 @@ +import { + Extension, + StateEffect, + StateField, + TransactionSpec, +} from '@codemirror/state' +import { EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view' + +export function verticalOverflow(): Extension { + return [ + overflowPaddingState, + minimumBottomPaddingState, + contentAttributes, + bottomPaddingPlugin, + topPaddingPlugin, + ] +} + +type VerticalPadding = { top: number; bottom: number } + +const setOverflowPaddingEffect = StateEffect.define() + +// 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({ + 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() + +// 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({ + create() { + return 0 + }, + update(value, tr) { + for (const effect of tr.effects) { + if (effect.is(setMinimumBottomPaddingEffect)) { + value = effect.value + } + } + return value + }, +}) + +// Set scrollTop to counteract changes to the top padding. +// This view plugin is needed because the overflowPaddingState StateField doesn't have access to the view. +const topPaddingPlugin = ViewPlugin.define(view => { + let previousTop = 0 + + return { + update: update => { + const { top } = update.state.field(overflowPaddingState) + if (top !== previousTop) { + const diff = top - previousTop + + if (diff < 0) { + // padding is decreasing, scroll now + view.scrollDOM.scrollTop += diff + } else { + // padding is increasing, scroll after it has been applied + view.requestMeasure({ + key: 'vertical-overflow-scroll-top', + read() { + // do nothing + }, + write(measure, view) { + view.scrollDOM.scrollTop += diff + }, + }) + } + + previousTop = top + } + }, + } +}) + +/** + * When the editor geometry changes, recalculate the amount of padding needed at + * the end of the doc: (the scrollDOM height - 1 line height). + * Adapted from the CodeMirror 6 scrollPastEnd extension, licensed under the MIT + * license: + * https://github.com/codemirror/view/blob/main/src/scrollpastend.ts + */ +const bottomPaddingPlugin = ViewPlugin.define(view => { + let previousHeight = 0 + + const measure = { + key: 'vertical-overflow-bottom-padding', + read(view: EditorView) { + return view.scrollDOM.clientHeight - view.defaultLineHeight + }, + write(height: number, view: EditorView) { + if (height !== previousHeight) { + // dispatch must be wrapped in a timeout to avoid clashing with the current update + window.setTimeout(() => + view.dispatch({ + effects: setMinimumBottomPaddingEffect.of(height), + }) + ) + previousHeight = height + } + }, + } + + view.requestMeasure(measure) + + return { + update: update => { + if (update.geometryChanged) { + update.view.requestMeasure(measure) + } + }, + } +}) + +// Set a style attribute on the contentDOM containing the calculated top and bottom padding. +// This value will be concatenated with style values from any other extensions. +const contentAttributes = EditorView.contentAttributes.compute( + [overflowPaddingState, minimumBottomPaddingState], + state => { + const overflowPadding = state.field(overflowPaddingState) + const minimumBottomPadding = state.field(minimumBottomPaddingState) + + const bottomPadding = Math.max(minimumBottomPadding, overflowPadding.bottom) + + return { + style: `padding-top: ${overflowPadding.top}px; padding-bottom: ${bottomPadding}px;`, + } + } +) + +export function setVerticalOverflow(padding: VerticalPadding): TransactionSpec { + return { + effects: [setOverflowPaddingEffect.of(padding)], + } +} + +export function updateSetsVerticalOverflow(update: ViewUpdate): boolean { + return update.transactions.some(tr => { + return tr.effects.some(effect => effect.is(setOverflowPaddingEffect)) + }) +} + +export function editorVerticalTopPadding(view: EditorView): number { + return view.state.field(overflowPaddingState).top +} diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/atomic-decorations.ts b/services/web/frontend/js/features/source-editor/extensions/visual/atomic-decorations.ts new file mode 100644 index 0000000000..13d3f726d8 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/visual/atomic-decorations.ts @@ -0,0 +1,927 @@ +import { EditorState, Range, StateField } from '@codemirror/state' +import { + Decoration, + DecorationSet, + EditorView, + WidgetType, +} from '@codemirror/view' +import { SyntaxNode, Tree } from '@lezer/common' +import { syntaxTree } from '@codemirror/language' +import { + hasMouseDownEffect, + mouseDownEffect, + selectionIntersects, + extendBackwardsOverEmptyLines, + extendForwardsOverEmptyLines, +} from './selection' +import { ItemWidget } from './visual-widgets/item' +import { LaTeXWidget } from './visual-widgets/latex' +import { BraceWidget } from './visual-widgets/brace' +import { ancestorNodeOfType } from '../../utils/tree-query' +import { MakeTitleWidget } from './visual-widgets/maketitle' +import { BeginWidget } from './visual-widgets/begin' +import { EndWidget } from './visual-widgets/end' +import { + getEnvironmentArguments, + getEnvironmentName, +} from '../../utils/tree-operations/environments' +import { MathWidget } from './visual-widgets/math' +import { GraphicsWidget } from './visual-widgets/graphics' +import { IconBraceWidget } from './visual-widgets/icon-brace' +import { TeXWidget } from './visual-widgets/tex' +import { + createCharacterCommand, + hasCharacterSubstitution, +} from './visual-widgets/character' +import { centeringNodeForEnvironment } from '../../utils/tree-operations/figure' +import { Frame, FrameWidget } from './visual-widgets/frame' +import { DividerWidget } from './visual-widgets/divider' +import { PreambleWidget } from './visual-widgets/preamble' +import { EndDocumentWidget } from './visual-widgets/end-document' +import { EnvironmentLineWidget } from './visual-widgets/environment-line' +import { ListEnvironmentName } from '../../utils/tree-operations/ancestors' +import { InlineGraphicsWidget } from './visual-widgets/inline-graphics' + +type Options = { + fileTreeManager: { + getPreviewByPath: ( + path: string + ) => { url: string; extension: string } | null + } +} + +function shouldDecorate( + state: EditorState, + extents: { from: number; to: number } +) { + return state.readOnly || !selectionIntersects(state.selection, extents) +} + +function shouldDecorateFromLineEdges( + state: EditorState, + extents: { from: number; to: number } +) { + return shouldDecorate(state, { + from: state.doc.lineAt(extents.from).from, + to: state.doc.lineAt(extents.to).to, + }) +} + +function decorateArgumentBraces( + startWidget: WidgetType, + argumentNode: SyntaxNode | null | undefined, + start: number, + decorateEmptyArguments = false, + endWidget?: WidgetType +): Range[] { + if (!argumentNode) { + return [] + } + const openBrace = argumentNode.getChild('OpenBrace') + const closeBrace = argumentNode.getChild('CloseBrace') + + if (openBrace && closeBrace) { + if ( + // Make sure that decoration ranges are non-empty + openBrace.to > start && + (decorateEmptyArguments || argumentNode.to - argumentNode.from > 2) + ) { + return [ + Decoration.replace({ + widget: startWidget, + }).range(start, openBrace.to), + + Decoration.replace({ widget: endWidget }).range( + closeBrace.from, + closeBrace.to + ), + ] + } + } + return [] +} + +const hasClosingBrace = (node: SyntaxNode) => + node.getChild('EnvNameGroup')?.getChild('CloseBrace') + +/** + * Atomic decorations replace a range of content with an uneditable widget. + * Decorations that span multiple lines must be contained in a StateField, not a ViewPlugin. + */ +export const atomicDecorations = (options: Options) => { + const getPreviewByPath = (path: string) => + options.fileTreeManager.getPreviewByPath(path) + + const createDecorations = (state: EditorState, tree: Tree): DecorationSet => { + const decorations: Range[] = [] + + const listEnvironmentStack: ListEnvironmentName[] = [] + let currentListEnvironment: ListEnvironmentName | undefined + + const ordinalStack: number[] = [] + let currentOrdinal = 0 + + let listDepth = 0 + + const preamble: { + from: number + to: number + title?: { + node: SyntaxNode + content: string + } + author?: { + node: SyntaxNode + content: string + } + } = { from: 0, to: 0 } + + // find the positions of the title and author in the preamble + tree.iterate({ + enter(nodeRef) { + if (nodeRef.type.is('DocumentEnvironment')) { + // Attempt to include \begin{document} in the preamble + preamble.to = nodeRef.node.getChild('Content')?.from ?? nodeRef.from + return false + } else if (nodeRef.type.is('Title')) { + const node = nodeRef.node.getChild('TextArgument') + if (node) { + const content = state.sliceDoc(node.from, node.to) + preamble.title = { node, content } + } + } else if (nodeRef.type.is('Author')) { + const node = nodeRef.node.getChild('TextArgument') + if (node) { + const content = state.sliceDoc(node.from, node.to) + preamble.author = { node, content } + } + } + }, + }) + if (preamble.to > 0) { + // hide the preamble. We use selectionIntersects directly, so that it also + // expands in readOnly mode. + const endLine = state.doc.lineAt(preamble.to).number + for (let lineNumber = 1; lineNumber <= endLine; ++lineNumber) { + const line = state.doc.line(lineNumber) + const classes = ['ol-cm-preamble-line'] + if (lineNumber === 1) { + classes.push('ol-cm-environment-first-line') + } + if (lineNumber === endLine) { + classes.push('ol-cm-environment-last-line') + } + decorations.push( + Decoration.line({ + class: classes.join(' '), + }).range(line.from) + ) + } + + const isExpanded = selectionIntersects(state.selection, preamble) + if (!isExpanded) { + decorations.push( + Decoration.replace({ + widget: new PreambleWidget(preamble.to, isExpanded), + block: true, + }).range(0, preamble.to) + ) + } else { + decorations.push( + Decoration.widget({ + widget: new PreambleWidget(preamble.to, isExpanded), + block: true, + side: -1, + }).range(0) + ) + } + } + + const startListEnvironment = (envName: ListEnvironmentName) => { + if (currentListEnvironment) { + listEnvironmentStack.push(currentListEnvironment) + ordinalStack.push(currentOrdinal) + } + currentListEnvironment = envName + currentOrdinal = 0 + } + + const endListEnvironment = () => { + currentListEnvironment = listEnvironmentStack.pop() + currentOrdinal = ordinalStack.pop() ?? 0 + } + + tree.iterate({ + enter(nodeRef) { + if (nodeRef.type.is('$Environment')) { + if (shouldDecorate(state, nodeRef)) { + const envName = getEnvironmentName(nodeRef.node, state) + const hideInEnvironmentTypes = ['figure', 'table'] + if (envName && hideInEnvironmentTypes.includes(envName)) { + const beginNode = nodeRef.node.getChild('BeginEnv') + const endNode = nodeRef.node.getChild('EndEnv') + if ( + beginNode && + endNode && + hasClosingBrace(beginNode) && + hasClosingBrace(endNode) + ) { + const beginLine = state.doc.lineAt(beginNode.from) + const endLine = state.doc.lineAt(endNode.from) + + const begin = { + from: beginLine.from, + to: extendForwardsOverEmptyLines(state.doc, beginLine), + } + const end = { + from: extendBackwardsOverEmptyLines(state.doc, endLine), + to: endLine.to, + } + + if (shouldDecorate(state, { from: begin.from, to: end.to })) { + decorations.push( + Decoration.replace({ + widget: new EnvironmentLineWidget(envName, 'begin'), + block: true, + }).range(begin.from, begin.to), + Decoration.replace({ + widget: new EnvironmentLineWidget(envName, 'end'), + block: true, + }).range(end.from, end.to) + ) + + const centeringNode = centeringNodeForEnvironment(nodeRef) + + if (centeringNode) { + const line = state.doc.lineAt(centeringNode.from) + const from = extendBackwardsOverEmptyLines(state.doc, line) + const to = extendForwardsOverEmptyLines(state.doc, line) + + decorations.push( + Decoration.replace({ + block: true, + }).range(from, to) + ) + } + } + } + } else if (nodeRef.type.is('ListEnvironment')) { + const beginNode = nodeRef.node.getChild('BeginEnv') + const endNode = nodeRef.node.getChild('EndEnv') + + if ( + beginNode && + endNode && + hasClosingBrace(beginNode) && + hasClosingBrace(endNode) + ) { + const beginLine = state.doc.lineAt(beginNode.from) + const endLine = state.doc.lineAt(endNode.from) + + const begin = { + from: beginLine.from, + to: extendForwardsOverEmptyLines(state.doc, beginLine), + } + const end = { + from: extendBackwardsOverEmptyLines(state.doc, endLine), + to: endLine.to, + } + + if ( + !selectionIntersects(state.selection, begin) && + !selectionIntersects(state.selection, end) + ) { + decorations.push( + Decoration.replace({ + block: true, + }).range(begin.from, begin.to), + Decoration.replace({ + block: true, + }).range(end.from, end.to) + ) + } + } + } + } + } else if (nodeRef.type.is('BeginEnv')) { + // the beginning of an environment, with an environment name argument + const envName = getEnvironmentName(nodeRef.node, state) + + if (envName) { + switch (envName) { + case 'itemize': + case 'enumerate': + startListEnvironment(envName) + listDepth++ + break + + case 'abstract': + if (shouldDecorate(state, nodeRef)) { + decorations.push( + Decoration.replace({ + widget: new BeginWidget(envName), + block: true, + }).range(nodeRef.from, nodeRef.to) + ) + } + break + case 'frame': + if (shouldDecorate(state, nodeRef)) { + const parent = nodeRef.node.parent + if (parent?.type.is('Environment')) { + const args = getEnvironmentArguments(parent) + if (!args) { + break + } + + if (args.length > 0) { + const title = args[0] + if (!title) { + break + } + let to = title.to + const titleTextNode = title.getChild('LongArg') + if (!titleTextNode) { + break + } + const frame: Frame = { + title: { + node: title, + content: state.sliceDoc( + titleTextNode.from, + titleTextNode.to + ), + }, + } + if (args.length > 1) { + // We have a subtitle too + const subtitle = args[1] + if (subtitle) { + const subtitleTextNode = subtitle.getChild('LongArg') + if (subtitleTextNode) { + to = subtitle.to + frame.subtitle = { + node: subtitle, + content: state.sliceDoc( + subtitleTextNode.from, + subtitleTextNode.to + ), + } + } + } + } + decorations.push( + Decoration.replace({ + widget: new FrameWidget(frame), + block: true, + }).range(nodeRef.from, to) + ) + } + } + } + break + default: + // do nothing + break + } + } + } else if (nodeRef.type.is('EndEnv')) { + // the end of an environment, with an environment name argument + const envName = getEnvironmentName(nodeRef.node, state) + + if (envName) { + switch (envName) { + case 'itemize': + case 'enumerate': + if (currentListEnvironment === envName) { + endListEnvironment() + } + listDepth-- + break + + case 'abstract': + if (shouldDecorate(state, nodeRef)) { + decorations.push( + Decoration.replace({ + widget: new EndWidget(), + block: true, + }).range(nodeRef.from, nodeRef.to) + ) + } + break + case 'document': + if (shouldDecorate(state, nodeRef)) { + decorations.push( + Decoration.replace({ + widget: new EndDocumentWidget(), + block: true, + }).range(nodeRef.from, nodeRef.to) + ) + } + break + case 'frame': + if (shouldDecorate(state, nodeRef)) { + decorations.push( + Decoration.replace({ + widget: new DividerWidget(), + block: true, + }).range(nodeRef.from, nodeRef.to) + ) + } + break + default: + // do nothing + break + } + } + } else if (nodeRef.type.is('$SectioningCommand')) { + const ancestorNode = ancestorNodeOfType( + state, + nodeRef.to, + 'SectioningCommand' + ) + if (ancestorNode) { + const shouldShowBraces = !shouldDecorate(state, ancestorNode) + // a section (or subsection, etc) command + const argumentNode = ancestorNode.getChild('SectioningArgument') + if (argumentNode) { + const braces = argumentNode.getChildren('$Brace') + if (braces.length !== 2) { + return false + } + const titleNode = argumentNode.getChild('LongArg') + if (!titleNode) { + return false + } + const title = state.sliceDoc(titleNode.from, titleNode.to) + if (!title.trim()) { + return false + } + + decorations.push( + Decoration.replace({ + widget: new BraceWidget(shouldShowBraces ? '}' : ''), + }).range(braces[1].from, braces[1].to) + ) + + decorations.push( + Decoration.replace({ + widget: new BraceWidget(shouldShowBraces ? '{' : ''), + }).range(nodeRef.from, titleNode.from) + ) + return false + } + } + } else if (nodeRef.type.is('VerbCommand')) { + if (shouldDecorate(state, nodeRef)) { + // \verb content (text only) + const contentNode = nodeRef.node.getChild('VerbContent') + + if (contentNode) { + if (contentNode.to - contentNode.from > 2) { + decorations.push( + Decoration.replace({}).range( + nodeRef.from, + contentNode.from + 1 + ) + ) + + decorations.push( + Decoration.replace({}).range(nodeRef.to - 1, nodeRef.to) + ) + } + } + } + + return false // no markup in verbatim content + } else if (nodeRef.type.is('Cite')) { + // \cite command with a bibkey argument + if (shouldDecorate(state, nodeRef)) { + const argumentNode = nodeRef.node + .getChild('BibKeyArgument') + ?.getChild('ShortTextArgument') + + decorations.push( + ...decorateArgumentBraces( + new IconBraceWidget('📚'), + argumentNode, + nodeRef.from + ) + ) + } + + return false // no markup in cite content + } else if (nodeRef.type.is('Ref')) { + // \ref command with a ref label argument + if (shouldDecorate(state, nodeRef)) { + const argumentNode = nodeRef.node + .getChild('RefArgument') + ?.getChild('ShortTextArgument') + + decorations.push( + ...decorateArgumentBraces( + new IconBraceWidget('🏷'), + argumentNode, + nodeRef.from + ) + ) + } + + return false // no markup in ref content + } else if (nodeRef.type.is('Label')) { + // \label definition + if (shouldDecorate(state, nodeRef)) { + const argumentNode = nodeRef.node + .getChild('LabelArgument') + ?.getChild('ShortTextArgument') + + decorations.push( + ...decorateArgumentBraces( + new IconBraceWidget('🏷'), + argumentNode, + nodeRef.from + ) + ) + } + + return false // no markup in label content + } else if (nodeRef.type.is('Include')) { + // \include (a file path) + if (shouldDecorate(state, nodeRef)) { + const argumentNode = nodeRef.node + .getChild('IncludeArgument') + ?.getChild('FilePathArgument') + decorations.push( + ...decorateArgumentBraces( + new IconBraceWidget('🔗'), + argumentNode, + nodeRef.from + ) + ) + } + + return false // no markup in include content + } else if (nodeRef.type.is('Input')) { + // \input (a file path) + // TODO: Ensure this works with BareFilePathArgument + if (shouldDecorate(state, nodeRef)) { + const contentNode = nodeRef.node.getChild('InputArgument') + + if (contentNode) { + if (contentNode.to - contentNode.from > 2) { + decorations.push( + Decoration.replace({ + widget: new IconBraceWidget('🔗'), + }).range(nodeRef.from, contentNode.from + 1) + ) + + decorations.push( + Decoration.replace({ + widget: new BraceWidget(), + }).range(nodeRef.to - 1, nodeRef.to) + ) + } + } + } + + return false // no markup in input content + } else if (nodeRef.type.is('Math')) { + // math equations + + const ancestorNode = + ancestorNodeOfType(state, nodeRef.from, '$MathContainer') || + ancestorNodeOfType(state, nodeRef.from, 'EquationEnvironment') || + // NOTE: EquationArrayEnvironment can be nested inside EquationEnvironment + ancestorNodeOfType(state, nodeRef.from, 'EquationArrayEnvironment') + + if ( + ancestorNode && + (ancestorNode.type.is('$Environment') + ? shouldDecorateFromLineEdges(state, ancestorNode) + : shouldDecorate(state, ancestorNode)) + ) { + // the content of the Math element, without braces + const innerContent = state.doc + .sliceString(nodeRef.from, nodeRef.to) + .trim() + + // only replace when there's content inside the braces + if (innerContent.length) { + let content = innerContent + let displayMode = false + + if (ancestorNode.type.is('$Environment')) { + const environmentName = getEnvironmentName(ancestorNode, state) + if (environmentName) { + // use the outer content of environments that MathJax supports + // https://docs.mathjax.org/en/latest/input/tex/macros/index.html#environments + if ( + environmentName !== 'math' && + environmentName !== 'displaymath' + ) { + content = state.doc + .sliceString(ancestorNode.from, ancestorNode.to) + .trim() + } + + if (environmentName !== 'math') { + displayMode = true + } + } + } else { + if ( + ancestorNode.type.is('BracketMath') || + Boolean(ancestorNode.getChild('DisplayMath')) + ) { + displayMode = true + } + } + + decorations.push( + Decoration.replace({ + widget: new MathWidget(content, displayMode), + block: displayMode, + }).range(ancestorNode.from, ancestorNode.to) + ) + return false + } + } + } else if (nodeRef.type.is('HrefCommand')) { + // a hyperlink with URL and content arguments + if (shouldDecorate(state, nodeRef)) { + const urlArgument = nodeRef.node.getChild('UrlArgument') + const textArgument = nodeRef.node.getChild('ShortTextArgument') + + if (urlArgument) { + decorations.push( + ...decorateArgumentBraces( + new BraceWidget(), + textArgument, + nodeRef.from + ) + ) + } + } + } else if (nodeRef.type.is('Caption')) { + if (shouldDecorate(state, nodeRef)) { + // a caption + const argumentNode = nodeRef.node.getChild('TextArgument') + decorations.push( + ...decorateArgumentBraces( + new BraceWidget(), + argumentNode, + nodeRef.from + ) + ) + } + } else if (nodeRef.type.is('IncludeGraphics')) { + // \includegraphics with a file path argument + if (shouldDecorate(state, nodeRef)) { + const filePathArgument = nodeRef.node + .getChild('IncludeGraphicsArgument') + ?.getChild('FilePathArgument') + ?.getChild('LiteralArgContent') + + if (filePathArgument) { + const filePath = state.doc.sliceString( + filePathArgument.from, + filePathArgument.to + ) + + if (filePath) { + const environmentNode = ancestorNodeOfType( + state, + nodeRef.from, + 'FigureEnvironment' + ) + const centered = Boolean( + environmentNode && + centeringNodeForEnvironment(environmentNode) + ) + + const line = state.doc.lineAt(nodeRef.from) + + const lineContainsOnlyNode = + line.text.trim().length === nodeRef.to - nodeRef.from + + if (lineContainsOnlyNode) { + decorations.push( + Decoration.replace({ + widget: new GraphicsWidget( + filePath, + getPreviewByPath, + centered + ), + block: true, + }).range(line.from, line.to) + ) + } else { + decorations.push( + Decoration.replace({ + widget: new InlineGraphicsWidget( + filePath, + getPreviewByPath, + centered + ), + }).range(nodeRef.from, nodeRef.to) + ) + } + } + + return false + } + } + } else if (nodeRef.type.is('Item')) { + // only decorate \item inside a list + if (currentListEnvironment) { + currentOrdinal++ + const line = state.doc.lineAt(nodeRef.from) + const onlySpaceBeforeNode = /^\s*$/.test( + state.sliceDoc(line.from, nodeRef.from) + ) + const from = onlySpaceBeforeNode ? line.from : nodeRef.from + decorations.push( + Decoration.replace({ + widget: new ItemWidget( + currentListEnvironment || 'document', + currentOrdinal, + listDepth + ), + }).range(from, nodeRef.to) + ) + return false + } + } else if (nodeRef.type.is('UnknownCommand')) { + // a command that's not defined separately by the grammar + const commandNode = nodeRef.node + const commandNameNode = commandNode.getChild('$CtrlSeq') + const textArgumentNode = commandNode.getChild('TextArgument') + + if (commandNameNode) { + const commandName = state.doc + .sliceString(commandNameNode.from, commandNameNode.to) + .trim() + + if (commandName.length > 0) { + if ( + // markup that can be toggled using toolbar buttons/keyboard shortcuts + ['\\textbf', '\\textit', '\\underline'].includes(commandName) + ) { + const argumentText = textArgumentNode?.getChild('LongArg') + const shouldShowBraces = + !shouldDecorate(state, nodeRef) || + argumentText?.from === argumentText?.to + decorations.push( + ...decorateArgumentBraces( + new BraceWidget(shouldShowBraces ? '{' : ''), + textArgumentNode, + nodeRef.from, + true, + new BraceWidget(shouldShowBraces ? '}' : '') + ) + ) + } else if ( + // markup that can't be toggled using toolbar buttons/keyboard shortcuts + ['\\textsc', '\\texttt', '\\sout', '\\emph'].includes( + commandName + ) + ) { + if (shouldDecorate(state, nodeRef)) { + decorations.push( + ...decorateArgumentBraces( + new BraceWidget(), + textArgumentNode, + nodeRef.from + ) + ) + } + } else if (commandName === '\\url') { + if (shouldDecorate(state, nodeRef)) { + // command name and opening brace + decorations.push( + ...decorateArgumentBraces( + new BraceWidget(), + textArgumentNode, + nodeRef.from + ) + ) + return false + } + } else if (commandName === '\\LaTeX') { + if (shouldDecorate(state, nodeRef)) { + decorations.push( + Decoration.replace({ + widget: new LaTeXWidget(), + }).range(nodeRef.from, nodeRef.to) + ) + return false + } + } else if (commandName === '\\TeX') { + if (shouldDecorate(state, nodeRef)) { + decorations.push( + Decoration.replace({ + widget: new TeXWidget(), + }).range(nodeRef.from, nodeRef.to) + ) + return false + } + } else if (commandName === '\\maketitle') { + if (shouldDecorate(state, nodeRef)) { + const line = state.doc.lineAt(nodeRef.from) + const from = extendBackwardsOverEmptyLines(state.doc, line) + const to = extendForwardsOverEmptyLines(state.doc, line) + + if (shouldDecorate(state, { from, to })) { + decorations.push( + Decoration.replace({ + widget: new MakeTitleWidget(preamble), + block: true, + }).range(from, to) + ) + } + + return false + } + } else if (hasCharacterSubstitution(commandName)) { + if (shouldDecorate(state, nodeRef)) { + const replacement = createCharacterCommand(commandName) + if (replacement) { + decorations.push( + Decoration.replace({ + widget: replacement, + }).range(nodeRef.from, nodeRef.to) + ) + return false + } + } + } + } + } + } + }, + }) + + return Decoration.set(decorations, true) + } + + let previousTree: Tree + + return [ + StateField.define<{ + mousedown: boolean + decorations: DecorationSet + }>({ + create(state) { + previousTree = syntaxTree(state) + + return { + mousedown: false, + decorations: createDecorations(state, previousTree), + } + }, + update(value, tr) { + for (const effect of tr.effects) { + // store the "mousedown" value when it changes + if (effect.is(mouseDownEffect)) { + value = { + mousedown: effect.value, + decorations: value.decorations, // unchanged + } + } + } + + const tree = syntaxTree(tr.state) + if ( + tree.type === previousTree.type && + tree.length < tr.state.doc.length + ) { + // still parsing + value = { + mousedown: value.mousedown, // unchanged + decorations: value.decorations.map(tr.changes), + } + } else if ( + // only update the decorations when the mouse is not making a selection + !value.mousedown && + (tree !== previousTree || tr.selection || hasMouseDownEffect(tr)) + ) { + // tree changed + previousTree = tree + // TODO: update the existing decorations for the changed range(s)? + value = { + mousedown: value.mousedown, // unchanged + decorations: createDecorations(tr.state, tree), + } + } + + return value + }, + provide(field) { + return [ + EditorView.decorations.from(field, field => field.decorations), + EditorView.atomicRanges.from(field, value => () => value.decorations), + ] + }, + }), + ] +} diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/list-item-marker.ts b/services/web/frontend/js/features/source-editor/extensions/visual/list-item-marker.ts new file mode 100644 index 0000000000..d12dc7ea84 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/visual/list-item-marker.ts @@ -0,0 +1,70 @@ +import { + EditorSelection, + EditorState, + SelectionRange, + Transaction, +} from '@codemirror/state' +import { syntaxTree } from '@codemirror/language' +import { SyntaxNode } from '@lezer/common' + +// avoid placing the cursor in front of a list item marker +export const listItemMarker = EditorState.transactionFilter.of(tr => { + if (tr.selection) { + let selection = tr.selection + for (const [index, range] of tr.selection.ranges.entries()) { + if (range.empty) { + const node = syntaxTree(tr.state).resolveInner(range.anchor, 1) + const pos = chooseTargetPosition(node, tr, range) + if (pos !== null) { + selection = selection.replaceRange( + EditorSelection.cursor( + pos, + range.assoc, + range.bidiLevel ?? undefined, // workaround for inconsistent types + range.goalColumn + ), + index + ) + } + } + } + if (selection !== tr.selection) { + return [tr, { selection }] + } + } + return tr +}) + +const chooseTargetPosition = ( + node: SyntaxNode, + tr: Transaction, + range: SelectionRange +) => { + let targetNode + if (node.type.is('Item')) { + targetNode = node + } else if (node.type.is('ItemCtrlSeq')) { + targetNode = node.parent + } else if (node.type.is('Whitespace')) { + targetNode = node.nextSibling?.firstChild?.firstChild + } + + if (!targetNode?.type.is('Item')) { + return null + } + + // mouse click + if (tr.isUserEvent('select.pointer')) { + // jump to after the item + return targetNode.to + } + + // keyboard navigation + if (range.assoc === 1 && !range.goalColumn) { + // moving backwards: jump to end of the previous line + return Math.max(tr.state.doc.lineAt(range.anchor).from - 1, 1) + } else { + // moving forwards: jump to after the item + return targetNode.to + } +} diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/mark-decorations.ts b/services/web/frontend/js/features/source-editor/extensions/visual/mark-decorations.ts new file mode 100644 index 0000000000..878e4f06f1 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/visual/mark-decorations.ts @@ -0,0 +1,180 @@ +import { + Decoration, + DecorationSet, + ViewPlugin, + ViewUpdate, +} from '@codemirror/view' +import { EditorState, Range } from '@codemirror/state' +import { syntaxTree } from '@codemirror/language' +import { getEnvironmentName } from '../../utils/tree-operations/environments' +import { centeringNodeForEnvironment } from '../../utils/tree-operations/figure' +import { Tree } from '@lezer/common' + +/** + * Mark decorations add attributes to elements within a range. + */ +export const markDecorations = ViewPlugin.define( + view => { + const createDecorations = ( + state: EditorState, + tree: Tree + ): DecorationSet => { + const decorations: Range[] = [] + + for (const { from, to } of view.visibleRanges) { + tree?.iterate({ + from, + to, + enter(nodeRef) { + if ( + nodeRef.type.is('KnownCommand') || + nodeRef.type.is('UnknownCommand') + ) { + // decorate commands with a class, for optional styling + const ctrlSeq = + nodeRef.node.getChild('$CtrlSeq') ?? + nodeRef.node.firstChild?.getChild('$CtrlSeq') + + if (ctrlSeq) { + const text = state.doc.sliceString(ctrlSeq.from + 1, ctrlSeq.to) + + // a special case for "label" as the whole command needs a space afterwards + if (text === 'label') { + // decorate the whole command + const from = nodeRef.from + const to = nodeRef.to + if (to > from) { + decorations.push( + Decoration.mark({ + class: `ol-cm-${text}`, + inclusive: true, + }).range(from, to) + ) + } + } else { + // decorate the command content + const from = ctrlSeq.to + 1 + const to = nodeRef.to - 1 + if (to > from) { + decorations.push( + Decoration.mark({ + class: `ol-cm-command-${text}`, + inclusive: true, + }).range(from, to) + ) + } + } + } + } else if (nodeRef.type.is('SectioningCommand')) { + // decorate section headings with a class, for styling + const ctrlSeq = nodeRef.node.getChild('$CtrlSeq') + if (ctrlSeq) { + const text = state.doc.sliceString(ctrlSeq.from + 1, ctrlSeq.to) + + decorations.push( + Decoration.mark({ + class: `ol-cm-heading ol-cm-command-${text}`, + }).range(nodeRef.from, nodeRef.to) + ) + } + } else if (nodeRef.type.is('Caption')) { + // decorate caption lines with a class, for styling + const argument = nodeRef.node.getChild('TextArgument') + + if (argument) { + const lines = { + start: state.doc.lineAt(nodeRef.from), + end: state.doc.lineAt(nodeRef.to), + } + + for ( + let lineNumber = lines.start.number; + lineNumber <= lines.end.number; + lineNumber++ + ) { + const line = state.doc.line(lineNumber) + decorations.push( + Decoration.line({ + class: 'ol-cm-caption-line', + }).range(line.from) + ) + } + } + } else if (nodeRef.type.is('$Environment')) { + const environmentName = getEnvironmentName(nodeRef.node, state) + + switch (environmentName) { + case 'abstract': + case 'figure': + case 'table': + { + const centered = Boolean( + centeringNodeForEnvironment(nodeRef) + ) + + const lines = { + start: state.doc.lineAt(nodeRef.from), + end: state.doc.lineAt(nodeRef.to), + } + + for ( + let lineNumber = lines.start.number; + lineNumber <= lines.end.number; + lineNumber++ + ) { + const line = state.doc.line(lineNumber) + + const classNames = [ + `ol-cm-environment-${environmentName}`, + 'ol-cm-environment-line', + ] + + if (centered) { + classNames.push('ol-cm-environment-centered') + } + + decorations.push( + Decoration.line({ + class: classNames.join(' '), + }).range(line.from) + ) + } + } + break + } + } + }, + }) + } + + return Decoration.set(decorations, true) + } + + let previousTree = syntaxTree(view.state) + + return { + decorations: createDecorations(view.state, previousTree), + update(update: ViewUpdate) { + const tree = syntaxTree(update.state) + + // still parsing + if ( + tree.type === previousTree.type && + tree.length < update.view.viewport.to + ) { + this.decorations = this.decorations.map(update.changes) + } else if (tree !== previousTree || update.viewportChanged) { + // parsed or resized + previousTree = tree + // TODO: update the existing decorations for the changed range(s)? + this.decorations = createDecorations(update.state, tree) + } + }, + } + }, + { + decorations(value) { + return value.decorations + }, + } +) diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/selection.ts b/services/web/frontend/js/features/source-editor/extensions/visual/selection.ts new file mode 100644 index 0000000000..92a964b6b8 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/visual/selection.ts @@ -0,0 +1,75 @@ +import { EditorSelection, StateEffect, Line, Text } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import { hasEffect, updateHasEffect } from '../../utils/effects' + +export const selectionIntersects = ( + selection: EditorSelection, + extents: { from: number; to: number } +) => + selection.ranges.some( + range => + // Case 1: from is inside node + (extents.from <= range.from && extents.to >= range.from) || + // Case 2: to is inside node + (extents.from <= range.to && extents.to >= range.to) + ) + +export const placeSelectionInsideBlock = ( + view: EditorView, + event: MouseEvent +) => { + const line = view.lineBlockAtHeight(event.pageY - view.documentTop) + + const selectionRange = EditorSelection.cursor(line.to) + const selection = event.ctrlKey + ? view.state.selection.addRange(selectionRange) + : selectionRange + + return { selection, effects: EditorView.scrollIntoView(line.to) } +} + +export const extendBackwardsOverEmptyLines = (doc: Text, line: Line) => { + let { number, from } = line + for (let lineNumber = number - 1; lineNumber > 0; lineNumber--) { + const line = doc.line(lineNumber) + if (line.text.trim().length > 0) { + break + } + from = line.from + } + return from +} + +export const extendForwardsOverEmptyLines = (doc: Text, line: Line) => { + let { number, to } = line + for (let lineNumber = number + 1; lineNumber <= doc.lines; lineNumber++) { + const line = doc.line(lineNumber) + if (line.text.trim().length > 0) { + break + } + to = line.to + } + + return to +} + +export const mouseDownEffect = StateEffect.define() +export const hasMouseDownEffect = hasEffect(mouseDownEffect) +export const updateHasMouseDownEffect = updateHasEffect(mouseDownEffect) + +export const mouseDownListener = EditorView.domEventHandlers({ + mousedown: (event, view) => { + // not wrapped in a timeout, so update listeners know that the mouse is down before they process the selection + view.dispatch({ + effects: mouseDownEffect.of(true), + }) + }, + mouseup: (event, view) => { + // wrap in a timeout, so update listeners receive this effect after the new selection has finished being handled + window.setTimeout(() => { + view.dispatch({ + effects: mouseDownEffect.of(false), + }) + }) + }, +}) diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/skip-preamble-cursor.ts b/services/web/frontend/js/features/source-editor/extensions/visual/skip-preamble-cursor.ts new file mode 100644 index 0000000000..fbd71cda8e --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/visual/skip-preamble-cursor.ts @@ -0,0 +1,35 @@ +import { EditorView, ViewPlugin } from '@codemirror/view' +import { EditorSelection } from '@codemirror/state' +import { findDocumentEnvironment } from '../../utils/tree-operations/environments' +import { syntaxTree } from '@codemirror/language' +export const skipPreambleWithCursor = ViewPlugin.define((view: EditorView) => { + let checkedOnce = false + return { + update(update) { + if ( + !checkedOnce && + syntaxTree(update.state).length === update.state.doc.length + ) { + checkedOnce = true + + // Only move the cursor if we're at the default position (0). Otherwise + // switching back and forth between source/RT while editing the preamble + // would be annoying. + if ( + update.view.state.selection.eq( + EditorSelection.create([EditorSelection.cursor(0)]) + ) + ) { + setTimeout(() => { + const position = findDocumentEnvironment(view.state) + view.dispatch({ + selection: EditorSelection.cursor( + Math.min(position ? position + 1 : 0, update.state.doc.length) + ), + }) + }, 0) + } + } + }, + } +}) diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/utils/select-node.ts b/services/web/frontend/js/features/source-editor/extensions/visual/utils/select-node.ts new file mode 100644 index 0000000000..057b221b20 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/visual/utils/select-node.ts @@ -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, + }) +} diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/utils/typeset-content.ts b/services/web/frontend/js/features/source-editor/extensions/visual/utils/typeset-content.ts new file mode 100644 index 0000000000..246fc5f49a --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/visual/utils/typeset-content.ts @@ -0,0 +1,80 @@ +import { EditorState } from '@codemirror/state' +import { SyntaxNode, SyntaxNodeRef } from '@lezer/common' +import { isUnknownCommandWithName } from '../../../utils/tree-query' + +function isNewline(node: SyntaxNodeRef, state: EditorState) { + if (!node.type.is('CtrlSym')) { + return false + } + const command = state.sliceDoc(node.from, node.to) + return command === '\\\\' +} + +/** + * Does a small amount of typesetting of LaTeX content into a DOM element. + * Does **not** typeset math, you **must manually** invoke MathJax after this + * function if you wish to typeset math content. + * @param node The syntax node containing the text to be typeset + * @param element The DOM element to typeset into + * @param state The editor state where `node` is from + */ +export function typesetNodeIntoElement( + node: SyntaxNode, + element: HTMLElement, + state: EditorState +) { + // If we're a TextArgument node, we should skip the braces + const argument = node.getChild('LongArg') + if (argument) { + node = argument + } + const ancestorStack = [element] + + const ancestor = () => ancestorStack[ancestorStack.length - 1] + const popAncestor = () => ancestorStack.pop()! + const pushAncestor = (x: HTMLElement) => ancestorStack.push(x) + + // NOTE: Quite hack-ish way to omit closing braces from the output + const ignoredRanges: { from: number; to: number }[] = [] + let from = node.from + + node.cursor().iterate( + function enter(childNodeRef) { + const childNode = childNodeRef.node + if (from < childNode.from) { + ancestor().append( + document.createTextNode(state.sliceDoc(from, childNode.from)) + ) + from = ignoredRanges.some( + range => range.from <= childNode.from && range.to >= childNode.from + ) + ? childNode.to + : childNode.from + } + if (isUnknownCommandWithName(childNode, '\\textit', state)) { + pushAncestor(document.createElement('i')) + const argument = childNode.getChild('TextArgument') + from = argument?.getChild('LongArg')?.from ?? childNode.to + const endBrace = argument?.getChild('CloseBrace') + if (endBrace) { + ignoredRanges.push(endBrace) + } + } else if (isNewline(childNode, state)) { + ancestor().appendChild(document.createElement('br')) + from = childNode.to + } + }, + function leave(childNodeRef) { + const childNode = childNodeRef.node + if (isUnknownCommandWithName(childNode, '\\textit', state)) { + const typeSetElement = popAncestor() + ancestor().appendChild(typeSetElement) + } + } + ) + if (from < node.to) { + ancestor().append(document.createTextNode(state.sliceDoc(from, node.to))) + } + + return element +} diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual-keymap.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual-keymap.ts new file mode 100644 index 0000000000..b1f4384e35 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/visual/visual-keymap.ts @@ -0,0 +1,141 @@ +import { keymap } from '@codemirror/view' +import { EditorSelection, Prec } from '@codemirror/state' +import { ancestorNodeOfType } from '../../utils/tree-query' +import { toggleRanges } from '../../commands/ranges' +import { + getIndentation, + IndentContext, + indentString, +} from '@codemirror/language' +import { + cursorIsAtStartOfListItem, + indentDecrease, + indentIncrease, +} from '../toolbar/commands' + +export const visualKeymap = Prec.highest( + keymap.of([ + // create a new list item with the same indentation + { + key: 'Enter', + run: view => { + const { state } = view + + let handled = false + + const changes = state.changeByRange(range => { + if (range.empty) { + const { from } = range + const listNode = ancestorNodeOfType(state, from, 'ListEnvironment') + if (listNode) { + const line = state.doc.lineAt(range.from) + const endLine = state.doc.lineAt(listNode.to) + + if (line.number === endLine.number - 1) { + // last item line + if (line.text.trim() === '\\item') { + // no content on this line + + // delete this line + const changes = state.changes({ + from: line.from, + to: line.to + 1, + insert: '', + }) + + // the start of the line after the list environment + const range = EditorSelection.cursor(endLine.to + 1).map( + changes + ) + + handled = true + + return { changes, range } + } + } + + // create a new list item + const cx = new IndentContext(state) + const columns = getIndentation(cx, from) ?? 0 + const indent = indentString(state, columns) + const insert = `\n${indent}\\item ` + + handled = true + + return { + changes: { from, insert }, + range: EditorSelection.cursor(from + insert.length), + } + } + + const sectioningNode = ancestorNodeOfType( + state, + from, + 'SectioningCommand' + ) + if (sectioningNode) { + // jump out of a section heading to the start of the next line + const nextLineNumber = state.doc.lineAt(from).number + 1 + if (nextLineNumber <= state.doc.lines) { + const line = state.doc.line(nextLineNumber) + handled = true + return { + range: EditorSelection.cursor(line.from), + } + } + } + } + + return { range } + }) + + if (handled) { + view.dispatch(changes, { + scrollIntoView: true, + userEvent: 'input', + }) + } + + return handled + }, + }, + // Increase list indent + { + key: 'Mod-]', + preventDefault: true, + run: indentIncrease, + }, + // Decrease list indent + { + key: 'Mod-[', + preventDefault: true, + run: indentDecrease, + }, + // Increase list indent + { + key: 'Tab', + preventDefault: true, + run: view => + cursorIsAtStartOfListItem(view.state) && indentIncrease(view), + }, + // Decrease list indent + { + key: 'Shift-Tab', + preventDefault: true, + run: indentDecrease, + }, + // Override bolding in RT mode + { + key: 'Ctrl-b', + mac: 'Mod-b', + preventDefault: true, + run: toggleRanges('\\textbf'), + }, + { + key: 'Ctrl-i', + mac: 'Mod-i', + preventDefault: true, + run: toggleRanges('\\textit'), + }, + ]) +) diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual-theme.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual-theme.ts new file mode 100644 index 0000000000..8a6d472d06 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/visual/visual-theme.ts @@ -0,0 +1,339 @@ +import { EditorView } from '@codemirror/view' +import { HighlightStyle, syntaxHighlighting } from '@codemirror/language' +import { tags } from '@lezer/highlight' + +export const visualHighlightStyle = syntaxHighlighting( + HighlightStyle.define([ + { tag: tags.link, class: 'ol-cm-link-text' }, + { tag: tags.url, class: 'ol-cm-url' }, + { tag: tags.typeName, class: 'ol-cm-monospace' }, + { tag: tags.attributeValue, class: 'ol-cm-monospace' }, + { tag: tags.keyword, class: 'ol-cm-monospace' }, + { tag: tags.string, class: 'ol-cm-monospace' }, + { tag: tags.punctuation, class: 'ol-cm-punctuation' }, + { tag: tags.literal, class: 'ol-cm-monospace' }, + { + tag: tags.monospace, + fontFamily: 'var(--source-font-family)', + lineHeight: 1, + overflowWrap: 'break-word', + }, + ]) +) + +export const visualTheme = EditorView.theme({ + '&.cm-editor': { + '--visual-font-family': + "'Noto Serif', 'Palatino Linotype', 'Book Antiqua', Palatino, serif !important", + '--visual-font-size': 'calc(var(--font-size) * 1.15)', + '& .cm-content': { + opacity: 0, + }, + '&.ol-cm-parsed .cm-content': { + opacity: 1, + transition: 'opacity 0.1s ease-out', + }, + }, + '.cm-content.cm-content': { + overflowX: 'hidden', // needed so the callout elements don't overflow (requires line wrapping to be on) + padding: '0 max(calc((100% - 100ch) / 2), 8%)', // max 100 characters per line + fontFamily: 'var(--visual-font-family)', + fontSize: 'var(--visual-font-size)', + }, + '.cm-cursor-primary.cm-cursor-primary': { + fontFamily: 'var(--visual-font-family)', + fontSize: 'var(--visual-font-size)', + }, + '.cm-line': { + overflowX: 'visible', // needed so the callout elements can overflow when the content has padding + }, + '.cm-gutter': { + opacity: '0.5', + }, + '.cm-tooltip': { + fontSize: 'calc(var(--font-size) * 1.15) !important', + }, + '.ol-cm-link-text': { + textDecoration: 'underline', + fontFamily: 'inherit', + }, + '.ol-cm-monospace': { + fontFamily: 'var(--source-font-family)', + lineHeight: 1, + fontWeight: 'normal', + fontStyle: 'normal', + fontVariant: 'normal', + textDecoration: 'none', + }, + '.ol-cm-punctuation': { + fontFamily: 'var(--source-font-family)', + lineHeight: 1, + }, + '.ol-cm-brace': { + opacity: '0.5', + }, + '.ol-cm-math': { + overflow: 'hidden', // stop the margin from the inner math element affecting the block height + }, + '.ol-cm-maketitle': { + textAlign: 'center', + paddingBottom: '2em', + }, + '.ol-cm-title': { + fontSize: '1.7em', + cursor: 'pointer', + padding: '0.5em', + lineHeight: 'calc(var(--line-height) * 5/6)', + }, + '.ol-cm-author': { + display: 'inline-block', + maxWidth: '45%', + minWidth: '200px', + verticalAlign: 'top', + cursor: 'pointer', + }, + '.ol-cm-author:not(:first-child)': { + display: 'inline-block', + marginLeft: '5%', + maxWidth: '45%', + }, + '.ol-cm-icon-brace': { + filter: 'grayscale(1)', + marginRight: '2px', + }, + '.ol-cm-begin': { + fontFamily: 'var(--source-font-family)', + minHeight: '1em', + textAlign: 'center', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + '.ol-cm-end': { + fontFamily: 'var(--source-font-family)', + padding: '0.5em 0 1.5em', + minHeight: '1em', + textAlign: 'center', + justifyContent: 'center', + background: `linear-gradient(180deg, rgba(0,0,0,0) calc(50% - 1px), rgba(192,192,192,1) calc(50%), rgba(0,0,0,0) calc(50% + 1px))`, + }, + '.ol-cm-environment-top': { + paddingTop: '1em', + }, + '.ol-cm-environment-bottom': { + paddingBottom: '1em', + }, + '.ol-cm-environment-first-line': { + paddingTop: '0.5em !important', + borderTopLeftRadius: '8px', + borderTopRightRadius: '8px', + }, + '.ol-cm-environment-last-line': { + paddingBottom: '1em !important', + borderBottomLeftRadius: '8px', + borderBottomRightRadius: '8px', + }, + '.ol-cm-environment-figure.ol-cm-environment-line, .ol-cm-environment-table.ol-cm-environment-line': + { + backgroundColor: 'rgba(125, 125, 125, 0.05)', + padding: '0 12px', + }, + '.ol-cm-environment-figure.ol-cm-environment-last-line, .ol-cm-environment-table.ol-cm-environment-last-line, .ol-cm-preamble-line.ol-cm-environment-last-line': + { + boxShadow: '0 2px 5px -3px rgb(125, 125, 125, 0.5)', + }, + '.ol-cm-environment-padding': { + flex: 1, + height: '1px', + background: `linear-gradient(180deg, rgba(0,0,0,0) calc(50% - 1px), rgba(192,192,192,1) calc(50%), rgba(0,0,0,0) calc(50% + 1px))`, + }, + '.ol-cm-environment-name': { + padding: '0 1em', + }, + '.ol-cm-environment-name-abstract': { + fontFamily: 'var(--visual-font-family)', + fontSize: '1.2em', + fontWeight: 550, + }, + '.ol-cm-environment-name-abstract:first-letter': { + textTransform: 'uppercase', + }, + '.ol-cm-item': { + paddingInlineStart: 'calc(var(--list-depth) * 2ch)', + }, + '.ol-cm-item::before': { + counterReset: 'list-item var(--list-ordinal)', + content: 'counter(list-item, var(--list-type)) var(--list-suffix)', + }, + '.ol-cm-heading': { + fontWeight: 550, + lineHeight: '1.35', + color: 'inherit !important', + background: 'inherit !important', + }, + '.ol-cm-command-part': { + fontSize: '2em', + }, + '.ol-cm-command-chapter': { + fontSize: '1.6em', + }, + '.ol-cm-command-section': { + fontSize: '1.44em', + }, + '.ol-cm-command-subsection': { + fontSize: '1.2em', + }, + '.ol-cm-command-subsubsection': { + fontSize: '1em', + }, + '.ol-cm-command-paragraph': { + fontSize: '1em', + }, + '.ol-cm-command-subparagraph': { + fontSize: '1em', + }, + '.ol-cm-frame-title': { + fontSize: '1.44em', + }, + '.ol-cm-frame-subtitle': { + fontSize: '1em', + }, + '.ol-cm-divider': { + borderBottom: '1px solid rgba(125, 125, 125, 0.1)', + padding: '0.5em 6px', + '&.ol-cm-frame-widget': { + borderBottom: 'none', + borderTop: '1px solid rgba(125, 125, 125, 0.1)', + }, + }, + '.ol-cm-command-textbf': { + fontWeight: 700, + }, + '.ol-cm-command-textit': { + fontStyle: 'italic', + }, + '.ol-cm-command-textsc': { + fontVariant: 'small-caps', + }, + '.ol-cm-command-texttt': { + fontFamily: 'monospace', + }, + '.ol-cm-command-underline': { + textDecoration: 'underline', + }, + '.ol-cm-command-sout': { + textDecoration: 'line-through', + }, + '.ol-cm-command-emph': { + fontStyle: 'italic', + '& .ol-cm-command-textit': { + fontStyle: 'normal', + }, + '.ol-cm-command-textit &': { + fontStyle: 'normal', + }, + }, + '.ol-cm-command-url': { + textDecoration: 'underline', + // copied from tags.monospace + fontFamily: 'var(--source-font-family)', + lineHeight: 1, + overflowWrap: 'break-word', + hyphens: 'auto', + }, + '.ol-cm-environment-centered.ol-cm-caption-line': { + padding: '0 10%', + textAlign: 'center', + }, + '.ol-cm-caption-line .ol-cm-label': { + marginRight: '1ch', + }, + '.ol-cm-tex': { + textTransform: 'uppercase', + '& sup': { + position: 'inherit', + fontSize: '0.85em', + verticalAlign: '0.15em', + marginLeft: '-0.36em', + marginRight: '-0.15em', + }, + '& sub': { + position: 'inherit', + fontSize: '1em', + verticalAlign: '-0.5ex', + marginLeft: '-0.1667em', + marginRight: '-0.125em', + }, + }, + '.ol-cm-graphics': { + display: 'block', + maxWidth: 'min(300px, 100%)', + paddingTop: '1em', + paddingBottom: '1em', + cursor: 'pointer', + '.ol-cm-graphics-inline &': { + display: 'inline', + }, + }, + '.ol-cm-graphics-loading': { + height: '300px', // guess that the height is the same as the max width + }, + '.ol-cm-graphics-error': { + border: '1px solid red', + padding: '8px', + }, + '.ol-cm-environment-centered .ol-cm-graphics': { + margin: '0 auto', + }, + '.ol-cm-command-verb .ol-cm-monospace': { + color: 'inherit', // remove syntax highlighting colour from verbatim content + }, + '.ol-cm-preamble-wrapper': { + padding: '0.5em 0', + '&.ol-cm-preamble-expanded': { + paddingBottom: '0', + }, + }, + '.ol-cm-preamble-widget, .ol-cm-end-document-widget': { + padding: '0.25em 1em', + borderRadius: '8px', + fontFamily: '"Lato", sans-serif', + fontSize: '14px', + '.ol-cm-preamble-expanded &': { + borderBottomLeftRadius: '0', + borderBottomRightRadius: '0', + borderBottom: '1px solid rgba(125, 125, 125, 0.2)', + }, + }, + '.ol-cm-preamble-widget': { + cursor: 'pointer', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + }, + '.ol-cm-preamble-expand-icon': { + width: '32px', + lineHeight: '32px', + textAlign: 'center', + transition: '0.2s ease-out', + opacity: '0.5', + '.ol-cm-preamble-widget:hover &': { + opacity: '1', + }, + '.ol-cm-preamble-expanded &': { + transform: 'rotate(180deg)', + }, + }, + '.ol-cm-preamble-line, .ol-cm-end-document-widget, .ol-cm-preamble-widget': { + backgroundColor: 'rgba(125, 125, 125, 0.05)', + }, + '.ol-cm-preamble-line': { + padding: '0 12px', + '&.ol-cm-environment-first-line': { + borderRadius: '0', + }, + }, + '.ol-cm-end-document-widget': { + textAlign: 'center', + }, +}) diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/begin.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/begin.ts new file mode 100644 index 0000000000..3f76c8e3db --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/begin.ts @@ -0,0 +1,49 @@ +import { EditorView, WidgetType } from '@codemirror/view' +import { placeSelectionInsideBlock } from '../selection' + +export class BeginWidget extends WidgetType { + constructor(public environment: string) { + super() + } + + toDOM(view: EditorView) { + const element = document.createElement('div') + element.classList.add('ol-cm-begin') + element.classList.add(`ol-cm-begin-${this.environment}`) + + const leftPadding = document.createElement('span') + leftPadding.classList.add('ol-cm-environment-padding') + element.appendChild(leftPadding) + + const name = document.createElement('span') + name.textContent = this.environment + name.classList.add('ol-cm-environment-name') + name.classList.add(`ol-cm-environment-name-${this.environment}`) + element.appendChild(name) + + const rightPadding = document.createElement('span') + rightPadding.classList.add('ol-cm-environment-padding') + element.appendChild(rightPadding) + + element.addEventListener('mouseup', event => { + event.preventDefault() + view.dispatch(placeSelectionInsideBlock(view, event as MouseEvent)) + }) + + return element + } + + eq(widget: BeginWidget) { + return widget.environment === this.environment + } + + updateDOM(element: HTMLDivElement) { + element.querySelector('.ol-cm-environment-name')!.textContent = + this.environment + return true + } + + ignoreEvent(event: Event): boolean { + return event.type !== 'mouseup' + } +} diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/brace.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/brace.ts new file mode 100644 index 0000000000..c5d288017f --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/brace.ts @@ -0,0 +1,24 @@ +import { WidgetType } from '@codemirror/view' + +export class BraceWidget extends WidgetType { + constructor(private content?: string) { + super() + } + + toDOM() { + const element = document.createElement('span') + element.classList.add('ol-cm-brace') + if (this.content !== undefined) { + element.textContent = this.content + } + return element + } + + ignoreEvent(event: Event) { + return event.type !== 'mousedown' && event.type !== 'mouseup' + } + + eq(widget: BraceWidget) { + return widget.content === this.content + } +} diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/character.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/character.ts new file mode 100644 index 0000000000..aabf57c968 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/character.ts @@ -0,0 +1,160 @@ +import { WidgetType } from '@codemirror/view' + +export class CharacterWidget extends WidgetType { + constructor(public content: string) { + super() + } + + toDOM() { + const element = document.createElement('span') + element.classList.add('ol-cm-character') + element.textContent = this.content + return element + } + + eq(widget: CharacterWidget) { + return widget.content === this.content + } + + updateDOM(element: HTMLElement): boolean { + element.textContent = this.content + return true + } + + ignoreEvent(event: Event) { + return event.type !== 'mousedown' && event.type !== 'mouseup' + } +} + +const SUBSTITUTIONS = new Map([ + ['\\', ' '], // a trimmed \\ ' + ['\\%', '\u0025'], + ['\\_', '\u005F'], + ['\\}', '\u007D'], + ['\\&', '\u0026'], + ['\\#', '\u0023'], + ['\\{', '\u007B'], + ['\\textasciicircum', '\u005E'], + ['\\textless', '\u003C'], + ['\\textasciitilde', '\u007E'], + ['\\textordfeminine', '\u00AA'], + ['\\textasteriskcentered', '\u204E'], + ['\\textordmasculine', '\u00BA'], + ['\\textbackslash', '\u005C'], + ['\\textparagraph', '\u00B6'], + ['\\textbar', '\u007C'], + ['\\textperiodcentered', '\u00B7'], + ['\\textbardbl', '\u2016'], + ['\\textpertenthousand', '\u2031'], + ['\\textperthousand', '\u2030'], + ['\\textbraceleft', '\u007B'], + ['\\textquestiondown', '\u00BF'], + ['\\textbraceright', '\u007D'], + ['\\textquotedblleft', '\u201C'], + ['\\textbullet', '\u2022'], + ['\\textquotedblright', '\u201D'], + ['\\textcopyright', '\u00A9'], + ['\\textquoteleft', '\u2018'], + ['\\textdagger', '\u2020'], + ['\\textquoteright', '\u2019'], + ['\\textdaggerdbl', '\u2021'], + ['\\textregistered', '\u00AE'], + ['\\textdollar', '\u0024'], + ['\\textsection', '\u00A7'], + ['\\textellipsis', '\u2026'], + ['\\textsterling', '\u00A3'], + ['\\textemdash', '\u2014'], + ['\\texttrademark', '\u2122'], + ['\\textendash', '\u2013'], + ['\\textunderscore', '\u005F'], + ['\\textexclamdown', '\u00A1'], + ['\\textvisiblespace', '\u2423'], + ['\\textgreater', '\u003E'], + ['\\ddag', '\u2021'], + ['\\pounds', '\u00A3'], + ['\\copyright', '\u00A9'], + ['\\dots', '\u2026'], + ['\\S', '\u00A7'], + ['\\dag', '\u2020'], + ['\\P', '\u00B6'], + ['\\aa', '\u00E5'], + ['\\DH', '\u00D0'], + ['\\L', '\u0141'], + ['\\o', '\u00F8'], + ['\\th', '\u00FE'], + ['\\AA', '\u00C5'], + ['\\DJ', '\u0110'], + ['\\l', '\u0142'], + ['\\oe', '\u0153'], + ['\\TH', '\u00DE'], + ['\\AE', '\u00C6'], + ['\\dj', '\u0111'], + ['\\NG', '\u014A'], + ['\\OE', '\u0152'], + ['\\ae', '\u00E6'], + ['\\IJ', '\u0132'], + ['\\ng', '\u014B'], + ['\\ss', '\u00DF'], + ['\\dh', '\u00F0'], + ['\\ij', '\u0133'], + ['\\O', '\u00D8'], + ['\\SS', '\u1E9E'], + ['\\guillemetleft', '\u00AB'], + ['\\guilsinglleft', '\u2039'], + ['\\quotedblbase', '\u201E'], + ['\\textquotedbl', '\u0022'], + ['\\guillemetright', '\u00BB'], + ['\\guilsinglright', '\u203A'], + ['\\quotesinglbase', '\u201A'], + ['\\textbaht', '\u0E3F'], + ['\\textdollar', '\u0024'], + ['\\textwon', '\u20A9'], + ['\\textcent', '\u00A2'], + ['\\textlira', '\u20A4'], + ['\\textyen', '\u00A5'], + ['\\textcentoldstyle', '\u00A2'], + ['\\textdong', '\u20AB'], + ['\\textnaira', '\u20A6'], + ['\\textcolonmonetary', '\u20A1'], + ['\\texteuro', '\u20AC'], + ['\\textpeso', '\u20B1'], + ['\\textcurrency', '\u00A4'], + ['\\textflorin', '\u0192'], + ['\\textsterling', '\u00A3'], + ['\\textcircledP', '\u2117'], + ['\\textcopyright', '\u00A9'], + ['\\textservicemark', '\u2120'], + ['\\textregistered', '\u00AE'], + ['\\texttrademark', '\u2122'], + ['\\textblank', '\u2422'], + ['\\textpilcrow', '\u00B6'], + ['\\textbrokenbar', '\u00A6'], + ['\\textquotesingle', '\u0027'], + ['\\textdblhyphen', '\u2E40'], + ['\\textdblhyphenchar', '\u2E40'], + ['\\textdiscount', '\u2052'], + ['\\textrecipe', '\u211E'], + ['\\textestimated', '\u212E'], + ['\\textreferencemark', '\u203B'], + ['\\textinterrobang', '\u203D'], + ['\\textthreequartersemdash', '\u2014'], + ['\\textinterrobangdown', '\u2E18'], + ['\\texttildelow', '\u02F7'], + ['\\textnumero', '\u2116'], + ['\\texttwelveudash', '\u2014'], + ['\\textopenbullet', '\u25E6'], + ['\\ldots', '\u2026'], +]) + +export function createCharacterCommand( + command: string +): CharacterWidget | undefined { + const substitution = SUBSTITUTIONS.get(command) + if (substitution !== undefined) { + return new CharacterWidget(substitution) + } +} + +export function hasCharacterSubstitution(command: string): boolean { + return SUBSTITUTIONS.has(command) +} diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/divider.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/divider.ts new file mode 100644 index 0000000000..fe22963a50 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/divider.ts @@ -0,0 +1,17 @@ +import { WidgetType } from '@codemirror/view' + +export class DividerWidget extends WidgetType { + toDOM() { + const element = document.createElement('div') + element.classList.add('ol-cm-divider') + return element + } + + eq() { + return true + } + + updateDOM(): boolean { + return true + } +} diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/end-document.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/end-document.ts new file mode 100644 index 0000000000..7617aa1004 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/end-document.ts @@ -0,0 +1,27 @@ +import { EditorView, WidgetType } from '@codemirror/view' +import { placeSelectionInsideBlock } from '../selection' + +export class EndDocumentWidget extends WidgetType { + toDOM(view: EditorView): HTMLElement { + const element = document.createElement('div') + element.classList.add('ol-cm-end-document-widget') + element.textContent = view.state.phrase('End of document') + element.addEventListener('mouseup', event => { + event.preventDefault() + view.dispatch(placeSelectionInsideBlock(view, event as MouseEvent)) + }) + return element + } + + ignoreEvent(event: Event): boolean { + return event.type !== 'mouseup' + } + + eq(): boolean { + return true + } + + updateDOM(): boolean { + return true + } +} diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/end.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/end.ts new file mode 100644 index 0000000000..f3c50ef4ec --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/end.ts @@ -0,0 +1,13 @@ +import { WidgetType } from '@codemirror/view' + +export class EndWidget extends WidgetType { + toDOM() { + const element = document.createElement('div') + element.classList.add('ol-cm-end') + return element + } + + eq(widget: EndWidget) { + return true + } +} diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/environment-line.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/environment-line.ts new file mode 100644 index 0000000000..1d3ef080f6 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/environment-line.ts @@ -0,0 +1,39 @@ +import { EditorView, WidgetType } from '@codemirror/view' + +export class EnvironmentLineWidget extends WidgetType { + constructor(public environment: string, public line?: 'begin' | 'end') { + super() + } + + toDOM(view: EditorView) { + const element = document.createElement('div') + element.classList.add(`ol-cm-environment-${this.environment}`) + element.classList.add('ol-cm-environment-edge') + + const line = document.createElement('div') + element.append(line) + + line.classList.add('ol-cm-environment-line') + line.classList.add(`ol-cm-environment-${this.environment}`) + switch (this.line) { + case 'begin': + element.classList.add('ol-cm-environment-top') + line.classList.add('ol-cm-environment-first-line') + break + case 'end': + element.classList.add('ol-cm-environment-bottom') + line.classList.add('ol-cm-environment-last-line') + break + } + + return element + } + + eq(widget: EnvironmentLineWidget) { + return widget.environment === this.environment && widget.line === this.line + } + + ignoreEvent(event: Event): boolean { + return event.type !== 'mousedown' && event.type !== 'mouseup' + } +} diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/frame.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/frame.ts new file mode 100644 index 0000000000..8ee2331011 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/frame.ts @@ -0,0 +1,77 @@ +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() + } + }) + .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' + } +} diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/graphics.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/graphics.ts new file mode 100644 index 0000000000..deb0313fa0 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/graphics.ts @@ -0,0 +1,142 @@ +import { EditorView, WidgetType } from '@codemirror/view' +import { placeSelectionInsideBlock } from '../selection' + +export class GraphicsWidget extends WidgetType { + destroyed = false + + height = 300 // for estimatedHeight, updated when the image is loaded + + constructor( + public filePath: string, + public getPreviewByPath: ( + filePath: string + ) => { url: string; extension: string } | null, + public centered: boolean + ) { + 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 + ) + } + + updateDOM(element: HTMLImageElement, view: EditorView) { + this.destroyed = false + element.classList.toggle('ol-cm-environment-centered', this.centered) + this.renderGraphic(element, view) + return true + } + + ignoreEvent(event: Event) { + return event.type !== 'mouseup' + } + + destroy() { + this.destroyed = true + } + + get estimatedHeight(): number { + return this.height + } + + renderGraphic(element: HTMLElement, view: EditorView) { + element.textContent = '' // ensure the element is empty + + const preview = this.getPreviewByPath(this.filePath) + + 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(error => { + console.error(error) + }) + element.append(canvas) + } + break + + default: + element.append(this.createImage(view, preview.url)) + break + } + } + + createImage(view: EditorView, url: string) { + const image = document.createElement('img') + image.classList.add('ol-cm-graphics') + image.classList.add('ol-cm-graphics-loading') + + 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 { PDFJS } = await this.importPDFJS() + + // bail out if loading PDF.js took too long + if (this.destroyed) { + return + } + + const pdf = await PDFJS.getDocument(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 + page.render({ + canvasContext: canvas.getContext('2d'), + viewport, + }) + this.height = viewport.height + view.requestMeasure() + } + + async importPDFJS(): Promise { + return import('../../../../pdf-preview/util/pdf-js-versions').then( + m => m.default + ) + } +} diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/icon-brace.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/icon-brace.ts new file mode 100644 index 0000000000..54d8112dfc --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/icon-brace.ts @@ -0,0 +1,30 @@ +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 + } +} diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/inline-graphics.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/inline-graphics.ts new file mode 100644 index 0000000000..543f39d5a0 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/inline-graphics.ts @@ -0,0 +1,19 @@ +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' + } +} diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/item.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/item.ts new file mode 100644 index 0000000000..36ed7aac64 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/item.ts @@ -0,0 +1,60 @@ +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' + } + + 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) + } +} diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/latex.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/latex.ts new file mode 100644 index 0000000000..93854fab90 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/latex.ts @@ -0,0 +1,18 @@ +import { WidgetType } from '@codemirror/view' + +export class LaTeXWidget extends WidgetType { + toDOM() { + const element = document.createElement('span') + element.classList.add('ol-cm-tex') + element.innerHTML = 'LaTeX' + return element + } + + eq() { + return true + } + + ignoreEvent(event: Event) { + return event.type !== 'mousedown' && event.type !== 'mouseup' + } +} diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/maketitle.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/maketitle.ts new file mode 100644 index 0000000000..c2c43dfcf7 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/maketitle.ts @@ -0,0 +1,138 @@ +import { EditorState } from '@codemirror/state' +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' + +type Preamble = { + title?: { + node: SyntaxNode + content: string + } + author?: { + node: SyntaxNode + content: string + } +} + +export class MakeTitleWidget extends WidgetType { + destroyed = false + + constructor(public preamble: Preamble) { + super() + } + + toDOM(view: EditorView) { + this.destroyed = false + const element = document.createElement('div') + element.classList.add('ol-cm-maketitle') + this.buildContent(view, element) + return element + } + + eq(widget: MakeTitleWidget) { + return isShallowEqualPreamble(widget.preamble, this.preamble, [ + 'title', + 'author', + ]) + } + + // TODO: needs view + // updateDOM(element: HTMLElement): boolean { + // this.destroyed = false + // this.buildContent(view, element) + // return true + // } + + ignoreEvent(event: Event) { + return event.type !== 'mouseup' + } + + destroy() { + this.destroyed = true + } + + buildContent(view: EditorView, element: HTMLElement) { + if (this.preamble.title) { + const titleElement = buildTitleElement( + view.state, + this.preamble.title.node + ) + titleElement.addEventListener('mouseup', () => { + if (this.preamble.title) { + selectNode(view, this.preamble.title.node) + } + }) + element.append(titleElement) + + // render equations + loadMathJax() + .then(async MathJax => { + if (!this.destroyed) { + await MathJax.typesetPromise([element]) + view.requestMeasure() + } + }) + .catch(() => { + element.classList.add('ol-cm-error') + }) + } + + if (this.preamble.author) { + const authorsElement = buildAuthorsElement( + view.state, + this.preamble.author.node + ) + authorsElement.addEventListener('mouseup', () => { + if (this.preamble.author) { + selectNode(view, this.preamble.author.node) + } + }) + element.append(authorsElement) + } + } +} + +const isShallowEqualPreamble = ( + a: Preamble, + b: Preamble, + fields: Array +) => fields.every(field => a[field]?.content === b[field]?.content) + +function buildTitleElement( + state: EditorState, + argumentNode: SyntaxNode +): HTMLDivElement { + const element = document.createElement('div') + element.classList.add('ol-cm-title') + typesetNodeIntoElement(argumentNode, element, state) + return element +} + +function buildAuthorsElement( + state: EditorState, + argumentNode: SyntaxNode +): HTMLDivElement { + const element = document.createElement('div') + element.classList.add('ol-cm-authors') + + const content = state.sliceDoc(argumentNode.from + 1, argumentNode.to - 1) + const authors = content.replaceAll(/\s+/g, ' ').split('\\and') + + for (const authorParts of authors) { + const authorElement = document.createElement('div') + authorElement.classList.add('ol-cm-author') + + for (const authorInfoItem of authorParts.split('\\\\')) { + const authorLineElement = document.createElement('div') + authorLineElement.classList.add('ol-cm-author-line') + authorLineElement.textContent = authorInfoItem.trim() + authorElement.appendChild(authorLineElement) + } + + element.append(authorElement) + } + + return element +} diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/math.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/math.ts new file mode 100644 index 0000000000..c151073ac6 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/math.ts @@ -0,0 +1,72 @@ +import { EditorView, WidgetType } from '@codemirror/view' +import { loadMathJax } from '../../../../mathjax/load-mathjax' +import { placeSelectionInsideBlock } from '../selection' + +export class MathWidget extends WidgetType { + destroyed = false + + constructor(public math: string, public displayMode: boolean) { + super() + } + + toDOM(view: EditorView) { + this.destroyed = false + const element = document.createElement(this.displayMode ? 'div' : 'span') + element.classList.add('ol-cm-math') + if (this.displayMode) { + element.addEventListener('mouseup', event => { + event.preventDefault() + view.dispatch(placeSelectionInsideBlock(view, event as MouseEvent)) + }) + } + this.renderMath(element).catch(() => { + element.classList.add('ol-cm-math-error') + }) + return element + } + + eq(widget: MathWidget) { + return widget.math === this.math && widget.displayMode === this.displayMode + } + + updateDOM(element: HTMLElement) { + this.destroyed = false + this.renderMath(element).catch(() => { + element.classList.add('ol-cm-math-error') + }) + return true + } + + ignoreEvent(event: Event) { + // always enable mouseup to release the decorations + if (event.type === 'mouseup') { + return false + } + + // inline math needs mousedown to set the selection + if (!this.displayMode && event.type === 'mousedown') { + return false + } + + // ignore other events + return true + } + + destroy() { + this.destroyed = true + } + + async renderMath(element: HTMLElement) { + const MathJax = await loadMathJax() + + if (!this.destroyed) { + MathJax.texReset([0]) // equation numbering is disabled, but this is still needed + const math = await MathJax.tex2svgPromise(this.math, { + ...MathJax.getMetricsFor(element), + display: this.displayMode, + }) + element.textContent = '' + element.append(math) + } + } +} diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/preamble.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/preamble.ts new file mode 100644 index 0000000000..9b1633e468 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/preamble.ts @@ -0,0 +1,79 @@ +import { EditorSelection } from '@codemirror/state' +import { EditorView, WidgetType } from '@codemirror/view' + +export class PreambleWidget extends WidgetType { + constructor(public length: number, public expanded: boolean) { + super() + } + + toDOM(view: EditorView): HTMLElement { + const wrapper = document.createElement('div') + wrapper.classList.add('ol-cm-preamble-wrapper') + wrapper.classList.toggle('ol-cm-preamble-expanded', this.expanded) + const element = document.createElement('div') + wrapper.appendChild(element) + element.classList.add('ol-cm-preamble-widget') + const expandIcon = document.createElement('i') + expandIcon.classList.add( + 'ol-cm-preamble-expand-icon', + 'fa', + 'fa-chevron-down' + ) + const helpText = document.createElement('div') + const helpLink = document.createElement('a') + helpLink.href = + '/learn/latex/Learn_LaTeX_in_30_minutes#The_preamble_of_a_document' + helpLink.target = '_blank' + const icon = document.createElement('i') + icon.classList.add('fa', 'fa-question-circle') + icon.title = view.state.phrase('Learn more') + helpLink.appendChild(icon) + const textNode = document.createElement('span') + textNode.classList.add('ol-cm-preamble-text') + textNode.textContent = this.getToggleText(view) + helpText.appendChild(textNode) + if (this.expanded) { + helpText.append(document.createTextNode(' '), helpLink) + } + element.append(helpText, expandIcon) + + element.addEventListener('mouseup', (event: MouseEvent) => { + if (event.button !== 0) { + return true + } + if (helpLink.contains(event.target as Node | null)) { + return true + } + event.preventDefault() + if (this.expanded) { + const target = Math.min(this.length + 1, view.state.doc.length) + view.dispatch({ + selection: EditorSelection.single(target), + scrollIntoView: true, + }) + } else { + view.dispatch({ + selection: EditorSelection.single(0), + scrollIntoView: true, + }) + } + }) + + return wrapper + } + + ignoreEvent(event: Event): boolean { + return event.type !== 'mouseup' + } + + eq(other: PreambleWidget): boolean { + return this.expanded === other.expanded + } + + getToggleText(view: EditorView) { + if (this.expanded) { + return view.state.phrase(`Hide document preamble`) + } + return view.state.phrase(`Show document preamble`) + } +} diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/tex.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/tex.ts new file mode 100644 index 0000000000..5b8393a466 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/tex.ts @@ -0,0 +1,18 @@ +import { WidgetType } from '@codemirror/view' + +export class TeXWidget extends WidgetType { + toDOM() { + const element = document.createElement('span') + element.classList.add('ol-cm-tex') + element.innerHTML = 'TeX' + return element + } + + eq() { + return true + } + + ignoreEvent(event: Event) { + return event.type !== 'mousedown' && event.type !== 'mouseup' + } +} diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual.ts new file mode 100644 index 0000000000..4d229d4850 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/visual/visual.ts @@ -0,0 +1,194 @@ +import { + Compartment, + EditorState, + Extension, + StateEffect, + StateField, + TransactionSpec, +} from '@codemirror/state' +import { visualHighlightStyle, visualTheme } from './visual-theme' +import { atomicDecorations } from './atomic-decorations' +import { markDecorations } from './mark-decorations' +import { EditorView, ViewPlugin } from '@codemirror/view' +import { visualKeymap } from './visual-keymap' +import { skipPreambleWithCursor } from './skip-preamble-cursor' +import { mouseDownEffect, mouseDownListener } from './selection' +import { findEffect } from '../../utils/effects' +import { forceParsing, syntaxTree } from '@codemirror/language' +import { hasLanguageLoadedEffect } from '../language' +import { restoreScrollPosition } from '../scroll-position' +import { toolbarPanel } from '../toolbar/toolbar-panel' +import { CurrentDoc } from '../../../../../../types/current-doc' +import isValidTeXFile from '../../../../main/is-valid-tex-file' +import { listItemMarker } from './list-item-marker' + +type Options = { + visual: boolean + fileTreeManager: { + getPreviewByPath: ( + path: string + ) => { url: string; extension: string } | null + } +} + +const visualConf = new Compartment() + +export const toggleVisualEffect = StateEffect.define() +export const findToggleVisualEffect = findEffect(toggleVisualEffect) + +const visualState = StateField.define({ + create() { + return false + }, + update(value, tr) { + return findToggleVisualEffect(tr)?.value ?? value + }, +}) + +const configureVisualExtensions = (options: Options) => + options.visual ? extension(options) : [] + +export const visual = (currentDoc: CurrentDoc, options: Options): Extension => { + if (!isValidTeXFile(currentDoc.docName)) { + return [] + } + + return [ + visualState.init(() => options.visual), + visualConf.of(configureVisualExtensions(options)), + ] +} + +export const isVisual = (view: EditorView) => { + return view.state.field(visualState, false) || false +} + +export const setVisual = (options: Options): TransactionSpec => { + return { + effects: [ + toggleVisualEffect.of(options.visual), + visualConf.reconfigure(configureVisualExtensions(options)), + ], + } +} + +export const sourceOnly = (visual: boolean, extension: Extension) => { + const conf = new Compartment() + const configure = (visual: boolean) => (visual ? [] : extension) + return [ + conf.of(configure(visual)), + + // Respond to switching editor modes + EditorState.transactionExtender.of(tr => { + const effect = findToggleVisualEffect(tr) + if (effect) { + return { + effects: conf.reconfigure(configure(effect.value)), + } + } + return null + }), + + // restore the scroll position when switching to source mode + 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 parsedAttributesConf = new Compartment() +const showContentWhenParsed = [ + parsedAttributesConf.of([EditorView.editable.of(false)]), + ViewPlugin.define(view => { + const showContent = () => { + view.dispatch( + { + effects: parsedAttributesConf.reconfigure([ + EditorView.editorAttributes.of({ + class: 'ol-cm-parsed', + }), + EditorView.editable.of(true), + ]), + }, + restoreScrollPosition() + ) + view.focus() + } + + // already parsed + if (syntaxTree(view.state).length === view.state.doc.length) { + window.setTimeout(showContent) + return {} + } + + // as a fallback, make sure the content is visible after 5s + const fallbackTimer = window.setTimeout(showContent, 5000) + + let languageLoaded = false + + return { + update(update) { + // wait for the language to load before telling the parser to run + if (!languageLoaded && hasLanguageLoadedEffect(update)) { + languageLoaded = true + // in a timeout, as this is already in a dispatch cycle + window.setTimeout(() => { + // run asynchronously + new Promise(() => { + // tell the parser to run until the end of the document + forceParsing(view, view.state.doc.length, Infinity) + // clear the fallback timeout + window.clearTimeout(fallbackTimer) + // show the content, in a timeout so the decorations can build first + window.setTimeout(showContent) + }).catch(error => { + console.error(error) + }) + }) + } + }, + } + }), +] + +const scrollJumpAdjuster = EditorState.transactionExtender.of(tr => { + // Attach a "scrollIntoView" effect on all mouse selections to adjust for + // any jumps that may occur when hiding/showing decorations. + if (!tr.scrollIntoView) { + for (const effect of tr.effects) { + if (effect.is(mouseDownEffect) && effect.value === false) { + return { + effects: EditorView.scrollIntoView(tr.newSelection.main.head), + } + } + } + } + + return {} +}) + +const extension = (options: Options) => [ + visualTheme, + visualHighlightStyle, + mouseDownListener, + listItemMarker, + markDecorations, + atomicDecorations(options), + skipPreambleWithCursor, + visualKeymap, + toolbarPanel(), + scrollJumpAdjuster, + showContentWhenParsed, +] diff --git a/services/web/frontend/js/features/source-editor/extensions/wait-for-parser.ts b/services/web/frontend/js/features/source-editor/extensions/wait-for-parser.ts new file mode 100644 index 0000000000..3fafe04471 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/wait-for-parser.ts @@ -0,0 +1,78 @@ +import { EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view' +import { syntaxTreeAvailable } from '@codemirror/language' +import { EditorState } from '@codemirror/state' + +// Either a number representing the document position the parser needs to have +// reached or a function that returns a document position. This covers the case +// when the requirements change while waiting for the parser, such as when +// scrolling. +type UpTo = number | ((view: EditorView) => number) + +type ParserWait = { + promise: Promise + upTo?: UpTo + resolve: () => void +} + +const plugin = ViewPlugin.fromClass( + class { + waits: ParserWait[] = [] + + // eslint-disable-next-line no-useless-constructor + constructor(readonly view: EditorView) {} + + parserReady(wait: ParserWait, state: EditorState) { + const upTo = + typeof wait.upTo === 'function' ? wait.upTo(this.view) : wait.upTo + return syntaxTreeAvailable(state, upTo) + } + + wait(upTo?: UpTo) { + const promise = new Promise(resolve => { + const wait = { + promise, + upTo, + resolve, + } + + // Resolve immediately if the parser is ready. Otherwise, watch for + // updates. + if (this.parserReady(wait, this.view.state)) { + wait.resolve() + } else { + this.waits.push(wait) + } + }) + + return promise + } + + update(update: ViewUpdate) { + const unresolvedWaits: ParserWait[] = [] + for (const wait of this.waits) { + if (this.parserReady(wait, update.state)) { + wait.resolve() + } else { + unresolvedWaits.push(wait) + } + } + this.waits = unresolvedWaits + } + } +) + +export function parserWatcher() { + return plugin +} + +// Returns a promise that is resolved as soon as CM6 reports that the parser is +// ready, up to a specified offset in the document or the end if none is +// specified. CM6 dispatches a transaction after every chunk of parser work +// and the view plugin checks after each, so there is minimal delay +export function waitForParser(view: EditorView, upTo?: UpTo) { + const pluginInstance = view.plugin(plugin) + if (!pluginInstance) { + throw new Error('No parser watcher view plugin found') + } + return pluginInstance.wait(upTo) +} diff --git a/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts b/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts new file mode 100644 index 0000000000..e9512055d9 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts @@ -0,0 +1,478 @@ +import { useCallback, useEffect, useRef } from 'react' +import { EditorState } from '@codemirror/state' +import useScopeValue from '../../../shared/hooks/use-scope-value' +import useScopeEventEmitter from '../../../shared/hooks/use-scope-event-emitter' +import useEventListener from '../../../shared/hooks/use-event-listener' +import useScopeEventListener from '../../../shared/hooks/use-scope-event-listener' +import { createExtensions } from '../extensions' +import { + FontFamily, + LineHeight, + lineHeights, + OverallTheme, + setEditorTheme, + setOptionsTheme, +} from '../extensions/theme' +import { + restoreCursorPosition, + setCursorLineAndScroll, + setCursorPositionAndScroll, +} from '../extensions/cursor-position' +import { + setAnnotations, + showCompileLogDiagnostics, +} from '../extensions/annotations' +import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context' +import { setCursorHighlights } from '../extensions/cursor-highlights' +import { setMetadata, setSyntaxValidation } from '../extensions/language' +import { useIdeContext } from '../../../shared/context/ide-context' +import { restoreScrollPosition } from '../extensions/scroll-position' +import { setEditable } from '../extensions/editable' +import { useFileTreeData } from '../../../shared/context/file-tree-data-context' +import { useEditorContext } from '../../../shared/context/editor-context' +import { setAutoPair } from '../extensions/auto-pair' +import { setAutoComplete } from '../extensions/auto-complete' +import { usePhrases } from './use-phrases' +import { setPhrases } from '../extensions/phrases' +import { + addLearnedWord, + removeLearnedWord, + resetLearnedWords, + setSpelling, +} from '../extensions/spelling' +import { createChangeManager } from '../extensions/changes/change-manager' +import { setKeybindings } from '../extensions/keybindings' +import { Highlight } from '../../../../../types/highlight' +import { EditorView } from '@codemirror/view' +import { CurrentDoc } from '../../../../../types/current-doc' +import { useErrorHandler } from 'react-error-boundary' +import { setVisual } from '../extensions/visual/visual' + +function useCodeMirrorScope(view: EditorView) { + const ide = useIdeContext() + + const { fileTreeData } = useFileTreeData() + const { permissionsLevel } = useEditorContext() + + // set up scope listeners + + const { logEntryAnnotations, uncompiled, compiling } = useCompileContext() + + const [loadingThreads] = useScopeValue('loadingThreads') + + const [currentDoc] = useScopeValue('editor.sharejs_doc') + const [docName] = useScopeValue('editor.open_doc_name') + const [trackChanges] = useScopeValue('editor.trackChanges') + + const [fontFamily] = useScopeValue('settings.fontFamily') + const [fontSize] = useScopeValue('settings.fontSize') + const [lineHeight] = useScopeValue('settings.lineHeight') + const [overallTheme] = useScopeValue('settings.overallTheme') + const [autoComplete] = useScopeValue('settings.autoComplete') + const [editorTheme] = useScopeValue('settings.editorTheme') + const [autoPairDelimiters] = useScopeValue( + 'settings.autoPairDelimiters' + ) + const [mode] = useScopeValue('settings.mode') + const [syntaxValidation] = useScopeValue('settings.syntaxValidation') + + const [cursorHighlights] = useScopeValue>( + 'onlineUserCursorHighlights' + ) + + const [spellCheckLanguage] = useScopeValue( + 'project.spellCheckLanguage' + ) + + const [visual] = useScopeValue('editor.showVisual') + + // build the translation phrases + const phrases = usePhrases() + + const phrasesRef = useRef(phrases) + + // initialise the local state + + const themeRef = useRef({ + fontFamily, + fontSize, + lineHeight, + overallTheme, + editorTheme, + }) + + useEffect(() => { + themeRef.current = { + fontFamily, + fontSize, + lineHeight, + overallTheme, + editorTheme, + } + + view.dispatch( + setOptionsTheme({ + fontFamily, + fontSize, + lineHeight, + overallTheme, + }) + ) + + setEditorTheme(editorTheme).then(spec => { + view.dispatch(spec) + }) + }, [view, fontFamily, fontSize, lineHeight, overallTheme, editorTheme]) + + const settingsRef = useRef({ + autoComplete, + autoPairDelimiters, + mode, + syntaxValidation, + }) + + const currentDocRef = useRef({ + currentDoc, + docName, + trackChanges, + loadingThreads, + }) + + useEffect(() => { + if (currentDoc) { + currentDocRef.current.currentDoc = currentDoc + } + }, [view, currentDoc]) + + useEffect(() => { + if (docName) { + currentDocRef.current.docName = docName + } + }, [view, docName]) + + useEffect(() => { + currentDocRef.current.loadingThreads = loadingThreads + }, [view, loadingThreads]) + + useEffect(() => { + currentDocRef.current.trackChanges = trackChanges + + if (currentDoc) { + if (trackChanges) { + currentDoc.track_changes_as = window.user.id || 'anonymous' + } else { + currentDoc.track_changes_as = null + } + } + }, [currentDoc, trackChanges]) + + useEffect(() => { + if (lineHeight && fontSize) { + window.dispatchEvent( + new CustomEvent('editor:event', { + detail: { + type: 'line-height', + payload: lineHeights[lineHeight] * fontSize, + }, + }) + ) + } + }, [lineHeight, fontSize]) + + const spellingRef = useRef({ + spellCheckLanguage, + }) + + useEffect(() => { + spellingRef.current = { + spellCheckLanguage, + } + view.dispatch(setSpelling(spellingRef.current)) + }, [view, spellCheckLanguage]) + + // listen to doc:after-opened, and focus the editor + useEffect(() => { + const listener = () => { + scheduleFocus(view) + } + window.addEventListener('doc:after-opened', listener) + return () => window.removeEventListener('doc:after-opened', listener) + }, [view]) + + // set the project metadata, mostly for use in autocomplete + // TODO: read this data from the scope? + const metadataRef = useRef({ + documents: ide.metadataManager.metadata.state.documents, + references: ide.$scope.$root._references.keys, + fileTreeData, + }) + + // listen to project metadata (docs + packages) updates + useEffect(() => { + const listener = (event: Event) => { + metadataRef.current.documents = ( + event as CustomEvent> + ).detail + view.dispatch(setMetadata(metadataRef.current)) + } + window.addEventListener('project:metadata', listener) + return () => window.removeEventListener('project:metadata', listener) + }, [view]) + + // listen to project reference keys updates + useEffect(() => { + const listener = (event: Event) => { + metadataRef.current.references = (event as CustomEvent).detail + view.dispatch(setMetadata(metadataRef.current)) + } + window.addEventListener('project:references', listener) + return () => window.removeEventListener('project:references', listener) + }, [view]) + + // listen to project root folder updates + useEffect(() => { + if (fileTreeData) { + metadataRef.current.fileTreeData = fileTreeData + view.dispatch(setMetadata(metadataRef.current)) + } + }, [view, fileTreeData]) + + const editableRef = useRef(permissionsLevel !== 'readOnly') + + const visualRef = useRef({ + fileTreeManager: ide.fileTreeManager, + visual, + }) + + const handleError = useErrorHandler() + + // create a new state when currentDoc changes + + useEffect(() => { + if (currentDoc) { + const state = EditorState.create({ + doc: currentDoc.getSnapshot(), + extensions: createExtensions({ + currentDoc: { + ...currentDocRef.current, + currentDoc, + }, + theme: themeRef.current, + metadata: metadataRef.current, + settings: settingsRef.current, + phrases: phrasesRef.current, + spelling: spellingRef.current, + visual: visualRef.current, + changeManager: createChangeManager(view, currentDoc), + handleError, + }), + }) + view.setState(state) + + // synchronous config + view.dispatch( + restoreCursorPosition(state.doc, currentDoc.doc_id), + setEditable(editableRef.current), + setOptionsTheme(themeRef.current) + ) + + // asynchronous config + setEditorTheme(themeRef.current.editorTheme).then(spec => { + view.dispatch(spec) + }) + + setKeybindings(settingsRef.current.mode).then(spec => { + view.dispatch(spec) + }) + + if (!visualRef.current.visual) { + window.setTimeout(() => { + view.dispatch(restoreScrollPosition()) + view.focus() + }) + } + } + // IMPORTANT: This effect must not depend on anything variable apart from currentDoc, + // as the editor state is recreated when the effect runs. + }, [view, currentDoc, handleError]) + + useEffect(() => { + visualRef.current.visual = visual + view.dispatch(setVisual(visualRef.current)) + view.dispatch({ + effects: EditorView.scrollIntoView(view.state.selection.main.head), + }) + // clear performance measures and marks when switching between Source and Rich Text + window.dispatchEvent(new Event('editor:visual-switch')) + }, [view, visual]) + + useEffect(() => { + editableRef.current = permissionsLevel !== 'readOnly' + view.dispatch(setEditable(editableRef.current)) // the editor needs to be locked when there's a problem saving data + }, [view, permissionsLevel]) + + useEffect(() => { + phrasesRef.current = phrases + view.dispatch(setPhrases(phrases)) + }, [view, phrases]) + + // listen to editor settings updates + useEffect(() => { + settingsRef.current.autoPairDelimiters = autoPairDelimiters + view.dispatch(setAutoPair(autoPairDelimiters)) + }, [view, autoPairDelimiters]) + + useEffect(() => { + settingsRef.current.autoComplete = autoComplete + view.dispatch(setAutoComplete(autoComplete)) + }, [view, autoComplete]) + + useEffect(() => { + settingsRef.current.mode = mode + setKeybindings(mode).then(spec => { + view.dispatch(spec) + }) + }, [view, mode]) + + useEffect(() => { + settingsRef.current.syntaxValidation = syntaxValidation + view.dispatch(setSyntaxValidation(syntaxValidation)) + }, [view, syntaxValidation]) + + const emitSyncToPdf = useScopeEventEmitter('cursor:editor:syncToPdf') + + const handleGoToLine = useCallback( + (event, lineNumber, columnNumber, syncToPdf) => { + setCursorLineAndScroll(view, lineNumber, columnNumber) + if (syncToPdf) { + emitSyncToPdf() + } + }, + [emitSyncToPdf, view] + ) + + // select and scroll to position on editor:gotoLine event (from synctex) + useScopeEventListener('editor:gotoLine', handleGoToLine) + + const handleGoToOffset = useCallback( + (event, offset) => { + setCursorPositionAndScroll(view, offset) + }, + [view] + ) + + // select and scroll to position on editor:gotoOffset event (from review panel) + useScopeEventListener('editor:gotoOffset', handleGoToOffset) + + // dispatch 'cursor:editor:update' to Angular scope (for synctex and realtime) + const dispatchCursorUpdate = useScopeEventEmitter('cursor:editor:update') + + const handleCursorUpdate = useCallback( + (event: CustomEvent) => { + dispatchCursorUpdate(event.detail) + }, + [dispatchCursorUpdate] + ) + + // listen for 'cursor:editor:update' events from CodeMirror, and dispatch them to Angular + useEventListener('cursor:editor:update', handleCursorUpdate) + + // dispatch 'cursor:editor:update' to Angular scope (for outline) + const dispatchScrollUpdate = useScopeEventEmitter('scroll:editor:update') + + const handleScrollUpdate = useCallback( + (event: CustomEvent) => { + dispatchScrollUpdate(event.detail) + }, + [dispatchScrollUpdate] + ) + + // listen for 'cursor:editor:update' events from CodeMirror, and dispatch them to Angular + useEventListener('scroll:editor:update', handleScrollUpdate) + + // enable the compile log linter a) when "Code Check" is off, b) when the project hasn't changed and isn't compiling. + // the project "changed at" date is reset at the start of the compile, i.e. "the project hasn't changed", + // but we don't want to display the compile log diagnostics from the previous compile. + const enableCompileLogLinter = + !syntaxValidation || (!uncompiled && !compiling) + + // store enableCompileLogLinter in a ref for use in useEffect + const enableCompileLogLinterRef = useRef(enableCompileLogLinter) + + useEffect(() => { + enableCompileLogLinterRef.current = enableCompileLogLinter + }, [enableCompileLogLinter]) + + // enable/disable the compile log linter as appropriate + useEffect(() => { + // dispatch in a timeout, so the dispatch isn't in the same cycle as the edit which caused it + window.setTimeout(() => { + view.dispatch(showCompileLogDiagnostics(enableCompileLogLinter)) + }, 0) + }, [view, enableCompileLogLinter]) + + // set the compile log annotations when they change + useEffect(() => { + if (currentDoc && logEntryAnnotations) { + const annotations = logEntryAnnotations[currentDoc.doc_id] + + // dispatch in a timeout, so the dispatch isn't in the same cycle as the edit which caused it + window.setTimeout(() => { + view.dispatch( + setAnnotations(view.state.doc, annotations || []), + // reconfigure the compile log lint source, so it runs once with the new data + showCompileLogDiagnostics(enableCompileLogLinterRef.current) + ) + }) + } + }, [view, currentDoc, logEntryAnnotations]) + + const highlightsRef = useRef<{ cursorHighlights: Highlight[] }>({ + cursorHighlights: [], + }) + + useEffect(() => { + if (cursorHighlights && currentDoc) { + const items = cursorHighlights[currentDoc.doc_id] + highlightsRef.current.cursorHighlights = items + view.dispatch(setCursorHighlights(items)) + } + }, [view, cursorHighlights, currentDoc]) + + const handleAddLearnedWords = useCallback( + (event: CustomEvent) => { + // If the word addition is from adding the word to the dictionary via the + // editor, there will be a transaction running now so wait for that to + // finish before starting a new one + window.setTimeout(() => { + view.dispatch(addLearnedWord(spellCheckLanguage, event.detail)) + }, 0) + }, + [spellCheckLanguage, view] + ) + + useEventListener('learnedWords:add', handleAddLearnedWords) + + const handleRemoveLearnedWords = useCallback( + (event: CustomEvent) => { + view.dispatch(removeLearnedWord(spellCheckLanguage, event.detail)) + }, + [spellCheckLanguage, view] + ) + + useEventListener('learnedWords:remove', handleRemoveLearnedWords) + + const handleResetLearnedWords = useCallback(() => { + view.dispatch(resetLearnedWords()) + }, [view]) + + useEventListener('learnedWords:reset', handleResetLearnedWords) +} + +export default useCodeMirrorScope + +const scheduleFocus = (view: EditorView) => { + window.setTimeout(() => { + view.focus() + }, 0) +} diff --git a/services/web/frontend/js/features/source-editor/hooks/use-phrases.ts b/services/web/frontend/js/features/source-editor/hooks/use-phrases.ts new file mode 100644 index 0000000000..4936746a76 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/hooks/use-phrases.ts @@ -0,0 +1,17 @@ +import { useTranslation } from 'react-i18next' +import { useMemo } from 'react' + +export const usePhrases = (): Record => { + const { t } = useTranslation() + + return useMemo(() => { + return { + 'Fold line': t('fold_line'), + 'Unfold line': t('unfold_line'), + 'Learn more': t('learn_more'), + 'Hide document preamble': t('hide_document_preamble'), + 'Show document preamble': t('show_document_preamble'), + 'End of document': t('end_of_document'), + } + }, [t]) +} diff --git a/services/web/frontend/js/features/source-editor/ide/index.js b/services/web/frontend/js/features/source-editor/ide/index.js new file mode 100644 index 0000000000..4e62de4c8d --- /dev/null +++ b/services/web/frontend/js/features/source-editor/ide/index.js @@ -0,0 +1 @@ +import '../controllers/source-editor-controller' diff --git a/services/web/frontend/js/features/source-editor/languages/index.ts b/services/web/frontend/js/features/source-editor/languages/index.ts new file mode 100644 index 0000000000..99dfe80d98 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/languages/index.ts @@ -0,0 +1,61 @@ +import { LanguageDescription } from '@codemirror/language' + +export const languages = [ + LanguageDescription.of({ + name: 'latex', + extensions: [ + 'tex', + 'bib', + 'sty', + 'cls', + 'clo', + 'bst', + 'bbl', + 'pdf_tex', + 'pdf_t', + 'map', + 'fd', + 'enc', + 'def', + 'mf', + 'pgf', + 'tikz', + 'bbx', + 'cbx', + 'dbx', + 'lbx', + 'lco', + 'ldf', + 'xmpdata', + 'Rnw', + 'lyx', + 'inc', + 'dtx', + 'hak', + 'eps_tex', + 'brf', + 'ins', + 'hva', + 'Rtex', + 'rtex', + 'pstex', + 'pstex_t', + 'gin', + 'fontspec', + 'pygstyle', + 'pygtex', + 'ps_tex', + ], + load: () => { + return import('./latex').then(m => m.latex()) + }, + }), + LanguageDescription.of({ + name: 'markdown', + extensions: ['md', 'markdown'], + // @ts-ignore TODO: find out how to add support extensions + load: () => { + return import('./markdown').then(m => m.markdown()) + }, + }), +] diff --git a/services/web/frontend/js/features/source-editor/languages/latex/complete.ts b/services/web/frontend/js/features/source-editor/languages/latex/complete.ts new file mode 100644 index 0000000000..76f8f46ba0 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/languages/latex/complete.ts @@ -0,0 +1,381 @@ +import { + CompletionContext, + CompletionResult, + CompletionSource, + ifIn, +} from '@codemirror/autocomplete' +import { customEndCompletions } from './completions/environments' +import { customCommandCompletions } from './completions/doc-commands' +import { customEnvironmentCompletions } from './completions/doc-environments' +import { Completions } from './completions/types' +import { buildReferenceCompletions } from './completions/references' +import { buildPackageCompletions } from './completions/packages' +import { buildLabelCompletions } from './completions/labels' +import { buildIncludeCompletions } from './completions/include' +import { buildBibliographyStyleCompletions } from './completions/bibliography-styles' +import { buildClassCompletions } from './completions/classes' +import { buildAllCompletions } from './completions' +import { + ifInType, + cursorIsAtBeginEnvironment, + cursorIsAtEndEnvironment, +} from '../../utils/tree-query' + +function blankCompletions(): Completions { + return { + bibliographies: [], + bibliographyStyles: [], + classes: [], + commands: [], + graphics: [], + includes: [], + labels: [], + packages: [], + references: [], + } +} + +export function getCompletionMatches(context: CompletionContext) { + // NOTE: [^\\] is needed to match commands inside the parameters of other commands + const matchBefore = context.explicit + ? context.matchBefore(/(?:^|\\)[^\\]*(\[[^\]]*])?[^\\]*/) // don't require a backslash if opening on explicit "startCompletion" keypress + : context.matchBefore(/\\?\\[^\\]*(\[[^\]]*])?[^\\]*/) + + if (!matchBefore) { + return null + } + + // ignore some matches when not opening on explicit "startCompletion" keypress + if (!context.explicit) { + // ignore matches that end with two backslashes. \\ shouldn't show the autocomplete as it's used for line break. + if (/\\\\$/.test(matchBefore.text)) { + return null + } + + // ignore matches that end with whitespace, unless after a comma + // e.g. \item with a trailing space shouldn't show the autocomplete. + if (/[^,\s]\s+$/.test(matchBefore.text)) { + return null + } + } + + const multipleArgumentMatcher = + /^(?\\(?\w+)\*?(?(\[[^\]]*?]|\{[^}]*?})+)?{)(?([^}]+\s*,\s*)+)?(?[^}]+)?$/ + // If this is a command with multiple comma-separated arguments, show deduplicated available completions + const match = matchBefore.text.match(multipleArgumentMatcher) + + return { match, matchBefore } +} + +export function getCompletionDetails( + match: RegExpMatchArray, + matchBefore: { + from: number + to: number + text: string + } +) { + let { before, command, existing } = match.groups as { + before?: string + command: string + existing?: string + } + + command = command.toLowerCase() + + const existingKeys = existing + ? existing + .split(',') + .map(key => key.trim()) + .filter(Boolean) + : [] + + const from = + matchBefore.from + (before?.length || 0) + (existing?.length || 0) + const validFor = /[^}\s]*/ + + return { command, existingKeys, from, validFor } +} + +export type CompletionBuilderOptions = { + context: CompletionContext + completions: Completions + match: RegExpMatchArray + matchBefore: { from: number; to: number; text: string } + existingKeys: string[] + from: number + validFor: RegExp + before: string +} + +export const makeArgumentCompletionSource = ( + ifInSpec: string[], + builder: (builderOptions: CompletionBuilderOptions) => CompletionResult | null +): CompletionSource => { + const completionSource: CompletionSource = (context: CompletionContext) => { + const completionMatches = getCompletionMatches(context) + + if (!completionMatches) { + return null + } + + const completions: Completions = blankCompletions() + + const { match, matchBefore } = completionMatches + + if (!match) { + return null + } + + const { before } = match.groups as { + before: string + } + + const { existingKeys, from, validFor } = getCompletionDetails( + match, + matchBefore + ) + + return builder({ + completions, + context, + match, + matchBefore, + before, + existingKeys, + from, + validFor, + }) + } + return ifIn(ifInSpec, completionSource) +} + +export const bibKeyArgumentCompletionSource: CompletionSource = + makeArgumentCompletionSource( + ['BibKeyArgument'], + ({ completions, context, from, validFor, existingKeys }) => { + buildReferenceCompletions(completions, context) + + return { + from, + validFor, + options: completions.references.filter( + item => !existingKeys.includes(item.label) + ), + } + } + ) + +export const refArgumentCompletionSource: CompletionSource = + makeArgumentCompletionSource( + ['RefArgument'], + ({ completions, context, from, validFor, existingKeys }) => { + buildLabelCompletions(completions, context) + + return { + from, + validFor, + options: completions.labels.filter( + item => !existingKeys.includes(item.label) + ), + } + } + ) + +export const packageArgumentCompletionSource: CompletionSource = + makeArgumentCompletionSource( + ['PackageArgument'], + ({ completions, context, from, validFor, existingKeys }) => { + buildPackageCompletions(completions, context) + + return { + from, + validFor, + options: completions.packages.filter( + item => !existingKeys.includes(item.label) + ), + } + } + ) + +export const inputArgumentCompletionSource: CompletionSource = + makeArgumentCompletionSource( + ['InputArgument'], + ({ completions, context, from }) => { + buildIncludeCompletions(completions, context) + + return { + from, + validFor: /^[^}]*/, + options: completions.includes, + } + } + ) +export const includeArgumentCompletionSource: CompletionSource = + makeArgumentCompletionSource( + ['IncludeArgument'], + ({ completions, context, from }) => { + buildIncludeCompletions(completions, context) + + return { + from, + validFor: /^[^}]*/, + options: completions.includes, + } + } + ) + +export const includeGraphicsArgumentCompletionSource: CompletionSource = + makeArgumentCompletionSource( + ['IncludeGraphicsArgument'], + ({ completions, context, from }) => { + buildIncludeCompletions(completions, context) + + return { + from, + validFor: /^[^}]*/, + options: completions.graphics, + } + } + ) + +export const environmentNameCompletionSource: CompletionSource = + makeArgumentCompletionSource( + ['EnvNameGroup'], + ({ completions, context, matchBefore, before }) => { + if (cursorIsAtBeginEnvironment(context.state, context.pos)) { + buildAllCompletions(completions, context) + + return { + from: matchBefore.from, + validFor: /^\\begin{\S*/, + options: [ + ...completions.commands, + ...customEnvironmentCompletions(context), + ], + } + } else if (cursorIsAtEndEnvironment(context.state, context.pos)) { + return { + from: matchBefore.from + before.length, + validFor: /^[^}]*/, + options: customEndCompletions(context), + } + } else { + return null + } + } + ) + +export const documentClassArgumentCompletionSource: CompletionSource = + makeArgumentCompletionSource( + ['DocumentClassArgument'], + ({ completions, from }) => { + buildClassCompletions(completions) + + return { + from, + validFor: /^[^}]*/, + options: completions.classes, + } + } + ) + +export const bibliographyArgumentCompletionSource: CompletionSource = + makeArgumentCompletionSource( + ['BibliographyArgument'], + ({ completions, context, from }) => { + buildIncludeCompletions(completions, context) + + return { + from, + validFor: /^[^}]*/, + options: completions.bibliographies, + } + } + ) + +export const bibliographyStyleArgumentCompletionSource: CompletionSource = + makeArgumentCompletionSource( + ['BibliographyStyleArgument'], + ({ completions, from }) => { + buildBibliographyStyleCompletions(completions) + + return { + from, + validFor: /^[^}]*/, + options: completions.bibliographyStyles, + } + } + ) + +export const argumentCompletionSources: CompletionSource[] = [ + bibKeyArgumentCompletionSource, + refArgumentCompletionSource, + packageArgumentCompletionSource, + inputArgumentCompletionSource, + includeArgumentCompletionSource, + includeGraphicsArgumentCompletionSource, + environmentNameCompletionSource, + documentClassArgumentCompletionSource, + bibliographyArgumentCompletionSource, + bibliographyStyleArgumentCompletionSource, +] + +const commandCompletionSource = (context: CompletionContext) => { + const completionMatches = getCompletionMatches(context) + + if (!completionMatches) { + return null + } + + const { match, matchBefore } = completionMatches + if (match) { + // We're already in a command argument, bail out + return null + } + + const completions: Completions = blankCompletions() + + buildAllCompletions(completions, context) + + // ensure that there's only one completion for each label + const uniqueCommandCompletions = Array.from( + new Map( + [ + ...completions.commands, + ...customCommandCompletions(context, completions.commands), + ].map(completion => [completion.label, completion]) + ).values() + ) + + // Unknown commands + const prefixMatcher = /^\\[^{\s]*$/ + const prefixMatch = matchBefore.text.match(prefixMatcher) + if (prefixMatch) { + return { + from: matchBefore.from, + validFor: prefixMatcher, + options: [ + ...uniqueCommandCompletions, + ...customEnvironmentCompletions(context), + ], + } + } + + // anything else (no validFor) + return { + from: matchBefore.to, + options: uniqueCommandCompletions, + } +} + +export const inCommandCompletionSource: CompletionSource = ifInType( + '$CtrlSeq', + context => { + return context.explicit ? null : commandCompletionSource(context) + } +) + +export const explicitCommandCompletionSource: CompletionSource = context => { + return context.explicit ? commandCompletionSource(context) : null +} diff --git a/services/web/frontend/js/features/source-editor/languages/latex/completions/apply.ts b/services/web/frontend/js/features/source-editor/languages/latex/completions/apply.ts new file mode 100644 index 0000000000..a6a9ab80e9 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/languages/latex/completions/apply.ts @@ -0,0 +1,120 @@ +import { codePointAt, codePointSize, Text } from '@codemirror/state' +import { + clearSnippet, + Completion, + insertCompletionText, + snippet, +} from '@codemirror/autocomplete' +import { EditorView } from '@codemirror/view' +import { prepareSnippetTemplate } from '../snippets' + +// from https://github.com/codemirror/autocomplete/blob/main/src/closebrackets.ts +export const nextChar = (doc: Text, pos: number) => { + const next = doc.sliceString(pos, pos + 2) + return next.slice(0, codePointSize(codePointAt(next, 0))) +} + +export const prevChar = (doc: Text, pos: number) => { + const prev = doc.sliceString(pos - 2, pos) + return codePointSize(codePointAt(prev, 0)) === prev.length + ? prev + : prev.slice(1) +} + +// Apply a completed command, removing any subsequent closing brace, optionally +// providing a function that generates the completion text. If missing, the +// completion label is used. +export const createCommandApplier = + (text: string) => + (view: EditorView, completion: Completion, from: number, to: number) => { + const { doc } = view.state + + // extend forwards to cover an unpaired closing brace + if (nextChar(doc, to) === '}') { + if (countUnclosedBraces(doc, from, to) < 0) { + to++ + } + } + // TODO: extend `to` to cover more subsequent characters? + view.dispatch(insertCompletionText(view.state, text, from, to)) + } + +// apply a completed required parameter, adding a closing brace and extending the range if needed +export const createRequiredParameterApplier = + (text: string) => + (view: EditorView, completion: Completion, from: number, to: number) => { + const { doc } = view.state + + // add a closing brace if needed + if (nextChar(doc, to) !== '}') { + if (countUnclosedBraces(doc, from, to) > 0) { + text += '}' + } + + // extend over subsequent text that isn't a brace, space, or comma + const match = doc.sliceString(to, doc.lineAt(from).to).match(/^[^}\s,]+/) + if (match) { + to += match[0].length + } + } + + view.dispatch(insertCompletionText(view.state, text, from, to)) + } + +/* + * Handle trailing '}' characters and tabbing back from final placeholder when + * inserting snippets + */ +const customSnippetApply = (template: string, clear = false) => { + return ( + view: EditorView, + completion: Completion, + from: number, + to: number + ) => { + // extend forwards to cover an unpaired closing brace + if (nextChar(view.state.doc, to) === '}') { + if (countUnclosedBraces(view.state.doc, from, to) < 0) { + to++ + } + } + + const snippetApply = snippet(prepareSnippetTemplate(template)) + snippetApply(view, completion, from, to) + if (clear) { + clearSnippet(view) + } + } +} + +const countUnclosedBraces = (doc: Text, from: number, to: number): number => { + const line = doc.lineAt(from) + + const textBefore = doc.sliceString(line.from, from) + const textAfter = doc.sliceString(to, line.to) + + const textAfterMatch = textAfter.match(/^[^\\]*/) + + const openBraces = + (textBefore.match(/\{/g) || []).length - + (textBefore.match(/}/g) || []).length + + const closedBraces = textAfterMatch + ? (textAfterMatch[0].match(/}/g) || []).length - + (textAfterMatch[0].match(/\{/g) || []).length + : 0 + + return openBraces - closedBraces +} + +// Convert from Ace `$1` to CodeMirror numbered placeholder format `${1}` or `#{1}` in snippets. +// Note: metadata from the server still uses the old format, so it's not enough to convert all +// the bundled data to the new format. +export const customSnippetCompletion = ( + template: string, + completion: Completion, + clear = false +) => { + completion.apply = customSnippetApply(template, clear) + return completion +} diff --git a/services/web/frontend/js/features/source-editor/languages/latex/completions/bibliography-styles.ts b/services/web/frontend/js/features/source-editor/languages/latex/completions/bibliography-styles.ts new file mode 100644 index 0000000000..cf0c200e6d --- /dev/null +++ b/services/web/frontend/js/features/source-editor/languages/latex/completions/bibliography-styles.ts @@ -0,0 +1,17 @@ +import { Completions } from './types' +import { createRequiredParameterApplier } from './apply' +import { bibliographyStyles } from './data/bibliography-styles' + +const values = Object.values(bibliographyStyles).flat() + +export function buildBibliographyStyleCompletions(completions: Completions) { + // TODO: find bibliography package from context and use only relevant styles + + for (const item of values) { + completions.bibliographyStyles.push({ + type: 'bib', + label: item, + apply: createRequiredParameterApplier(item), + }) + } +} diff --git a/services/web/frontend/js/features/source-editor/languages/latex/completions/classes.ts b/services/web/frontend/js/features/source-editor/languages/latex/completions/classes.ts new file mode 100644 index 0000000000..a57c476fd4 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/languages/latex/completions/classes.ts @@ -0,0 +1,13 @@ +import { createRequiredParameterApplier } from './apply' +import { classNames } from './data/class-names' +import { Completions } from './types' + +export function buildClassCompletions(completions: Completions) { + for (const item of classNames) { + completions.classes.push({ + type: 'pkg', + label: item, + apply: createRequiredParameterApplier(item), + }) + } +} diff --git a/services/web/frontend/js/features/source-editor/languages/latex/completions/data/bibliography-styles.ts b/services/web/frontend/js/features/source-editor/languages/latex/completions/data/bibliography-styles.ts new file mode 100644 index 0000000000..5d020f5b4f --- /dev/null +++ b/services/web/frontend/js/features/source-editor/languages/latex/completions/data/bibliography-styles.ts @@ -0,0 +1,45 @@ +export const bibliographyStyles: Record = { + // https://www.overleaf.com/learn/latex/Bibtex_bibliography_styles + bibtex: [ + 'abbrv', + 'acm', + 'alpha', + 'apalike', + 'ieeetr', + 'plain', + 'siam', + 'unsrt', + ], + // https://www.overleaf.com/learn/latex/Natbib_bibliography_styles + natbib: ['dinat', 'plainnat', 'abbrvnat', 'unsrtnat', 'rusnat', 'ksfh_nat'], + // https://www.overleaf.com/learn/latex/Biblatex_bibliography_styles + biblatex: [ + 'numeric', + 'alphabetic', + 'authoryear', + 'authortitle', + 'verbose', + 'reading', + 'draft', + 'authoryear-icomp', + 'apa', + 'bwl-FU', + 'chem-acs', + 'chem-angew', + 'chem-biochem', + 'chem-rsc', + 'ieee', + 'mla', + 'musuos', + 'nature', + 'nejm', + 'phys', + 'science', + 'geschichtsfrkl', + 'oscola', + ], + // https://ctan.org/tex-archive/macros/latex/contrib/biblatex-contrib + 'biblatex-contrib': [ + // TODO + ], +} diff --git a/services/web/frontend/js/features/source-editor/languages/latex/completions/data/class-names.ts b/services/web/frontend/js/features/source-editor/languages/latex/completions/data/class-names.ts new file mode 100644 index 0000000000..47304de189 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/languages/latex/completions/data/class-names.ts @@ -0,0 +1,11 @@ +// https://www.overleaf.com/learn/latex/Creating_a_document_in_LaTeX#Reference_guide + +// TODO: more class names +export const classNames = [ + 'article', + 'report', + 'book', + 'letter', + // 'slides', + 'beamer', +] diff --git a/services/web/frontend/js/features/source-editor/languages/latex/completions/data/environments.ts b/services/web/frontend/js/features/source-editor/languages/latex/completions/data/environments.ts new file mode 100644 index 0000000000..f35a701bcc --- /dev/null +++ b/services/web/frontend/js/features/source-editor/languages/latex/completions/data/environments.ts @@ -0,0 +1,77 @@ +export const snippet = (name: string) => `\\begin{${name}} +\t$1 +\\end{${name}}` + +export const snippetNoIndent = (name: string) => `\\begin{${name}} +$1 +\\end{${name}}` + +export const environments = new Map([ + ['abstract', snippet('abstract')], + ['align', snippet('align')], + ['align*', snippet('align*')], + [ + 'array', + `\\begin{array}{\${1:cc}} +\t$2 & $3 \\\\ +\t$4 & $5 +\\end{array}`, + ], + ['center', snippet('center')], + ['document', snippetNoIndent('document')], + ['equation', snippet('equation')], + ['equation*', snippet('equation*')], + [ + 'enumerate', + `\\begin{enumerate} +\t\\item $1 +\\end{enumerate}`, + ], + [ + 'figure', + `\\begin{figure} +\t\\centering +\t\\includegraphics{$1} +\t\\caption{\${2:Caption}} +\t\\label{\${3:fig:my_label}} +\\end{figure}`, + ], + [ + 'frame', + `\\begin{frame}{\${1:Frame Title}} +\t$2 +\\end{frame}`, + ], + ['gather', snippet('gather')], + ['gather*', snippet('gather*')], + [ + 'itemize', + `\\begin{itemize} +\t\\item $1 +\\end{itemize}`, + ], + ['multline', snippet('multline')], + ['multline*', snippet('multline*')], + ['quote', snippet('quote')], + ['split', snippet('split')], + [ + 'table', + `\\begin{table}[$1] +\t\\centering +\t\\begin{tabular}{\${2:c|c}} +\t\t$3 & $4 \\\\ +\t\t$5 & $6 +\t\\end{tabular} +\t\\caption{\${7:Caption}} +\t\\label{\${8:tab:my_label}} +\\end{table}`, + ], + [ + 'tabular', + `\\begin{tabular}{\${1:c|c}} +\t$2 & $3 \\\\ +\t$4 & $5 +\\end{tabular}`, + ], + ['verbatim', snippet('verbatim')], +]) diff --git a/services/web/frontend/js/features/source-editor/languages/latex/completions/data/package-names.ts b/services/web/frontend/js/features/source-editor/languages/latex/completions/data/package-names.ts new file mode 100644 index 0000000000..7b0f687f14 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/languages/latex/completions/data/package-names.ts @@ -0,0 +1,101 @@ +export const packageNames: string[] = [ + 'inputenc', + 'graphicx', + 'amsmath', + 'geometry', + 'amssymb', + 'hyperref', + 'babel', + 'color', + 'xcolor', + 'url', + 'natbib', + 'fontenc', + 'fancyhdr', + 'amsfonts', + 'booktabs', + 'amsthm', + 'float', + 'tikz', + 'caption', + 'setspace', + 'multirow', + 'array', + 'multicol', + 'titlesec', + 'enumitem', + 'ifthen', + 'listings', + 'blindtext', + 'subcaption', + 'times', + 'bm', + 'subfigure', + 'algorithm', + 'fontspec', + 'biblatex', + 'tabularx', + 'microtype', + 'etoolbox', + 'parskip', + 'calc', + 'verbatim', + 'mathtools', + 'epsfig', + 'wrapfig', + 'lipsum', + 'cite', + 'textcomp', + 'longtable', + 'textpos', + 'algpseudocode', + 'enumerate', + 'subfig', + 'pdfpages', + 'epstopdf', + 'latexsym', + 'lmodern', + 'pifont', + 'ragged2e', + 'rotating', + 'dcolumn', + 'xltxtra', + 'marvosym', + 'indentfirst', + 'xspace', + 'csquotes', + 'xparse', + 'changepage', + 'soul', + 'xunicode', + 'comment', + 'mathrsfs', + 'tocbibind', + 'lastpage', + 'algorithm2e', + 'pgfplots', + 'lineno', + 'algorithmic', + 'fullpage', + 'mathptmx', + 'todonotes', + 'ulem', + 'tweaklist', + 'moderncvstyleclassic', + 'collection', + 'moderncvcompatibility', + 'gensymb', + 'helvet', + 'siunitx', + 'adjustbox', + 'placeins', + 'colortbl', + 'appendix', + 'makeidx', + 'supertabular', + 'ifpdf', + 'framed', + 'aliascnt', + 'layaureo', + 'authblk', +] diff --git a/services/web/frontend/js/features/source-editor/languages/latex/completions/data/snippets.ts b/services/web/frontend/js/features/source-editor/languages/latex/completions/data/snippets.ts new file mode 100644 index 0000000000..0194a61e66 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/languages/latex/completions/data/snippets.ts @@ -0,0 +1,7 @@ +export default [ + { + type: 'cmd', + label: '\\verb||', + snippet: '\\verb|#{}|', + }, +] diff --git a/services/web/frontend/js/features/source-editor/languages/latex/completions/data/top-hundred-snippets.ts b/services/web/frontend/js/features/source-editor/languages/latex/completions/data/top-hundred-snippets.ts new file mode 100644 index 0000000000..f9d9000e3a --- /dev/null +++ b/services/web/frontend/js/features/source-editor/languages/latex/completions/data/top-hundred-snippets.ts @@ -0,0 +1,692 @@ +export default [ + { + caption: '\\begin{}', + snippet: '\\begin{$1}', + meta: 'env', + score: 7.849662248028187, + }, + { + caption: '\\end{}', + snippet: '\\end{$1}', + meta: 'env', + score: 7.847906405228455, + }, + { + caption: '\\usepackage[]{}', + snippet: '\\usepackage[$1]{$2}', + meta: 'pkg', + score: 5.427890758130527, + }, + { + caption: '\\item', + snippet: '\\item', + meta: 'cmd', + score: 3.800886892251021, + }, + { + caption: '\\item[]', + snippet: '\\item[$1]', + meta: 'cmd', + score: 3.800886892251021, + }, + { + caption: '\\section{}', + snippet: '\\section{$1}', + meta: 'cmd', + score: 3.0952612541683835, + }, + { + caption: '\\textbf{}', + snippet: '\\textbf{$1}', + meta: 'cmd', + score: 2.627755982816738, + }, + { + caption: '\\cite{}', + snippet: '\\cite{$1}', + meta: 'cmd', + score: 2.341195220791228, + }, + { + caption: '\\label{}', + snippet: '\\label{$1}', + meta: 'cmd', + score: 1.897791904799601, + }, + { + caption: '\\textit{}', + snippet: '\\textit{$1}', + meta: 'cmd', + score: 1.6842996195493385, + }, + { + caption: '\\includegraphics[]{}', + snippet: '\\includegraphics[$1]{$2}', + meta: 'cmd', + score: 1.4595731795525781, + }, + { + caption: '\\documentclass[]{}', + snippet: '\\documentclass[$1]{$2}', + meta: 'cmd', + score: 1.4425339817971206, + }, + { + caption: '\\documentclass{}', + snippet: '\\documentclass{$1}', + meta: 'cmd', + score: 1.4425339817971206, + }, + { + caption: '\\ref{}', + snippet: '\\ref{$1}', + meta: 'cross-reference', + score: 0.014379554883991673, + }, + { + caption: '\\frac{}{}', + snippet: '\\frac{$1}{$2}', + meta: 'cmd', + score: 1.4341091141105058, + }, + { + caption: '\\subsection{}', + snippet: '\\subsection{$1}', + meta: 'cmd', + score: 1.3890912739512353, + }, + { + caption: '\\hline', + snippet: '\\hline', + meta: 'cmd', + score: 1.3209538327406387, + }, + { + caption: '\\caption{}', + snippet: '\\caption{$1}', + meta: 'cmd', + score: 1.2569477427490174, + }, + { + caption: '\\centering', + snippet: '\\centering', + meta: 'cmd', + score: 1.1642881814937829, + }, + { + caption: '\\vspace{}', + snippet: '\\vspace{$1}', + meta: 'cmd', + score: 0.9533807826673939, + }, + { + caption: '\\title{}', + snippet: '\\title{$1}', + meta: 'cmd', + score: 0.9202908262245683, + }, + { + caption: '\\author{}', + snippet: '\\author{$1}', + meta: 'cmd', + score: 0.8973590434087177, + }, + { + caption: '\\author[]{}', + snippet: '\\author[$1]{$2}', + meta: 'cmd', + score: 0.8973590434087177, + }, + { + caption: '\\maketitle', + snippet: '\\maketitle', + meta: 'cmd', + score: 0.7504160124360846, + }, + { + caption: '\\textwidth', + snippet: '\\textwidth', + meta: 'cmd', + score: 0.7355328080889112, + }, + { + caption: '\\newcommand{}{}', + snippet: '\\newcommand{$1}{$2}', + meta: 'cmd', + score: 0.7264891987129375, + }, + { + caption: '\\newcommand{}[]{}', + snippet: '\\newcommand{$1}[$2]{$3}', + meta: 'cmd', + score: 0.7264891987129375, + }, + { + caption: '\\date{}', + snippet: '\\date{$1}', + meta: 'cmd', + score: 0.7225518453076786, + }, + { + caption: '\\emph{}', + snippet: '\\emph{$1}', + meta: 'cmd', + score: 0.7060308784832261, + }, + { + caption: '\\textsc{}', + snippet: '\\textsc{$1}', + meta: 'cmd', + score: 0.6926466355384758, + }, + { + caption: '\\multicolumn{}{}{}', + snippet: '\\multicolumn{$1}{$2}{$3}', + meta: 'cmd', + score: 0.5473606021405326, + }, + { + caption: '\\input{}', + snippet: '\\input{$1}', + meta: 'cmd', + score: 0.4966021927742672, + }, + { + caption: '\\alpha', + snippet: '\\alpha', + meta: 'cmd', + score: 0.49520006391384913, + }, + { + caption: '\\in', + snippet: '\\in', + meta: 'cmd', + score: 0.4716039670146658, + }, + { + caption: '\\mathbf{}', + snippet: '\\mathbf{$1}', + meta: 'cmd', + score: 0.4682018419466319, + }, + { + caption: '\\right', + snippet: '\\right', + meta: 'cmd', + score: 0.4299239459457309, + }, + { + caption: '\\left', + snippet: '\\left', + meta: 'cmd', + score: 0.42937815279867964, + }, + { + caption: '\\sum', + snippet: '\\sum', + meta: 'cmd', + score: 0.42607994509619934, + }, + { + caption: '\\chapter{}', + snippet: '\\chapter{$1}', + meta: 'cmd', + score: 0.422097569591803, + }, + { + caption: '\\par', + snippet: '\\par', + meta: 'cmd', + score: 0.413853376001159, + }, + { + caption: '\\lambda', + snippet: '\\lambda', + meta: 'cmd', + score: 0.39389600578684125, + }, + { + caption: '\\subsubsection{}', + snippet: '\\subsubsection{$1}', + meta: 'cmd', + score: 0.3727781330132016, + }, + { + caption: '\\bibitem{}', + snippet: '\\bibitem{$1}', + meta: 'cmd', + score: 0.3689547570562042, + }, + { + caption: '\\bibitem[]{}', + snippet: '\\bibitem[$1]{$2}', + meta: 'cmd', + score: 0.3689547570562042, + }, + { + caption: '\\text{}', + snippet: '\\text{$1}', + meta: 'cmd', + score: 0.3608680734736821, + }, + { + caption: '\\setlength{}{}', + snippet: '\\setlength{$1}{$2}', + meta: 'cmd', + score: 0.354445763583904, + }, + { + caption: '\\mathcal{}', + snippet: '\\mathcal{$1}', + meta: 'cmd', + score: 0.35084018920966636, + }, + { + caption: '\\newpage', + snippet: '\\newpage', + meta: 'cmd', + score: 0.3277033727934986, + }, + { + caption: '\\renewcommand{}{}', + snippet: '\\renewcommand{$1}{$2}', + meta: 'cmd', + score: 0.3267437011085663, + }, + { + caption: '\\theta', + snippet: '\\theta', + meta: 'cmd', + score: 0.3210417159232142, + }, + { + caption: '\\hspace{}', + snippet: '\\hspace{$1}', + meta: 'cmd', + score: 0.3147206476372336, + }, + { + caption: '\\beta', + snippet: '\\beta', + meta: 'cmd', + score: 0.3061799530337638, + }, + { + caption: '\\texttt{}', + snippet: '\\texttt{$1}', + meta: 'cmd', + score: 0.3019066753744355, + }, + { + caption: '\\times', + snippet: '\\times', + meta: 'cmd', + score: 0.2957960629411553, + }, + { + caption: '\\color{}', + snippet: '\\color{$1}', + meta: 'cmd', + score: 0.2864294797053033, + }, + { + caption: '\\mu', + snippet: '\\mu', + meta: 'cmd', + score: 0.27635652476799255, + }, + { + caption: '\\bibliography{}', + snippet: '\\bibliography{$1}', + meta: 'cmd', + score: 0.2659628337907604, + }, + { + caption: '\\linewidth', + snippet: '\\linewidth', + meta: 'cmd', + score: 0.2639498312518439, + }, + { + caption: '\\delta', + snippet: '\\delta', + meta: 'cmd', + score: 0.2620578600722735, + }, + { + caption: '\\sigma', + snippet: '\\sigma', + meta: 'cmd', + score: 0.25940147926344487, + }, + { + caption: '\\pi', + snippet: '\\pi', + meta: 'cmd', + score: 0.25920934567729714, + }, + { + caption: '\\hat{}', + snippet: '\\hat{$1}', + meta: 'cmd', + score: 0.25264309033778715, + }, + { + caption: '\\bibliographystyle{}', + snippet: '\\bibliographystyle{$1}', + meta: 'cmd', + score: 0.25122317941387773, + }, + { + caption: '\\small', + snippet: '\\small', + meta: 'cmd', + score: 0.2447632045426295, + }, + { + caption: '\\LaTeX', + snippet: '\\LaTeX', + meta: 'cmd', + score: 0.2334089308452787, + }, + { + caption: '\\cdot', + snippet: '\\cdot', + meta: 'cmd', + score: 0.23029085545522762, + }, + { + caption: '\\footnote{}', + snippet: '\\footnote{$1}', + meta: 'cmd', + score: 0.2253056071787701, + }, + { + caption: '\\newtheorem{}{}', + snippet: '\\newtheorem{$1}{$2}', + meta: 'cmd', + score: 0.215689795055434, + }, + { + caption: '\\Delta', + snippet: '\\Delta', + meta: 'cmd', + score: 0.21386475063892618, + }, + { + caption: '\\tau', + snippet: '\\tau', + meta: 'cmd', + score: 0.21236188205859796, + }, + { + caption: '\\hfill', + snippet: '\\hfill', + meta: 'cmd', + score: 0.2058248088519886, + }, + { + caption: '\\leq', + snippet: '\\leq', + meta: 'cmd', + score: 0.20498894440637172, + }, + { + caption: '\\footnotesize', + snippet: '\\footnotesize', + meta: 'cmd', + score: 0.2038592081252624, + }, + { + caption: '\\large', + snippet: '\\large', + meta: 'cmd', + score: 0.20377416734108866, + }, + { + caption: '\\sqrt{}', + snippet: '\\sqrt{$1}', + meta: 'cmd', + score: 0.20240160977404634, + }, + { + caption: '\\epsilon', + snippet: '\\epsilon', + meta: 'cmd', + score: 0.2005136761359043, + }, + { + caption: '\\Large', + snippet: '\\Large', + meta: 'cmd', + score: 0.1987771081149759, + }, + { + caption: '\\rho', + snippet: '\\rho', + meta: 'cmd', + score: 0.1959287380541684, + }, + { + caption: '\\omega', + snippet: '\\omega', + meta: 'cmd', + score: 0.19326783415115262, + }, + { + caption: '\\mathrm{}', + snippet: '\\mathrm{$1}', + meta: 'cmd', + score: 0.19117752976172653, + }, + { + caption: '\\boldsymbol{}', + snippet: '\\boldsymbol{$1}', + meta: 'cmd', + score: 0.18137737738638837, + }, + { + caption: '\\gamma', + snippet: '\\gamma', + meta: 'cmd', + score: 0.17940276535431304, + }, + { + caption: '\\clearpage', + snippet: '\\clearpage', + meta: 'cmd', + score: 0.1789117552185788, + }, + { + caption: '\\infty', + snippet: '\\infty', + meta: 'cmd', + score: 0.17837290019711305, + }, + { + caption: '\\phi', + snippet: '\\phi', + meta: 'cmd', + score: 0.17405809173097808, + }, + { + caption: '\\partial', + snippet: '\\partial', + meta: 'cmd', + score: 0.17168102367966637, + }, + { + caption: '\\include{}', + snippet: '\\include{$1}', + meta: 'cmd', + score: 0.1547080054979312, + }, + { + caption: '\\address{}', + snippet: '\\address{$1}', + meta: 'cmd', + score: 0.1525055392611109, + }, + { + caption: '\\quad', + snippet: '\\quad', + meta: 'cmd', + score: 0.15242755832392743, + }, + { + caption: '\\paragraph{}', + snippet: '\\paragraph{$1}', + meta: 'cmd', + score: 0.152074250347974, + }, + { + caption: '\\varepsilon', + snippet: '\\varepsilon', + meta: 'cmd', + score: 0.05411564201390573, + }, + { + caption: '\\zeta', + snippet: '\\zeta', + meta: 'cmd', + score: 0.023330249803752954, + }, + { + caption: '\\eta', + snippet: '\\eta', + meta: 'cmd', + score: 0.11088718379889091, + }, + { + caption: '\\vartheta', + snippet: '\\vartheta', + meta: 'cmd', + score: 0.0025822992078068712, + }, + { + caption: '\\iota', + snippet: '\\iota', + meta: 'cmd', + score: 0.0024774003791525486, + }, + { + caption: '\\kappa', + snippet: '\\kappa', + meta: 'cmd', + score: 0.04887876299369008, + }, + { + caption: '\\nu', + snippet: '\\nu', + meta: 'cmd', + score: 0.09206962821059342, + }, + { + caption: '\\xi', + snippet: '\\xi', + meta: 'cmd', + score: 0.06496042899265699, + }, + { + caption: '\\varpi', + snippet: '\\varpi', + meta: 'cmd', + score: 0.0007039358167790341, + }, + { + caption: '\\varrho', + snippet: '\\varrho', + meta: 'cmd', + score: 0.0011279491613898612, + }, + { + caption: '\\varsigma', + snippet: '\\varsigma', + meta: 'cmd', + score: 0.0010424880711234978, + }, + { + caption: '\\upsilon', + snippet: '\\upsilon', + meta: 'cmd', + score: 0.00420715572598688, + }, + { + caption: '\\varphi', + snippet: '\\varphi', + meta: 'cmd', + score: 0.03351251516668212, + }, + { + caption: '\\chi', + snippet: '\\chi', + meta: 'cmd', + score: 0.043373492287805675, + }, + { + caption: '\\psi', + snippet: '\\psi', + meta: 'cmd', + score: 0.09994508706163642, + }, + { + caption: '\\Gamma', + snippet: '\\Gamma', + meta: 'cmd', + score: 0.04801549269801977, + }, + { + caption: '\\Theta', + snippet: '\\Theta', + meta: 'cmd', + score: 0.038090902146599444, + }, + { + caption: '\\Lambda', + snippet: '\\Lambda', + meta: 'cmd', + score: 0.032206594305977686, + }, + { + caption: '\\Xi', + snippet: '\\Xi', + meta: 'cmd', + score: 0.01060997225400494, + }, + { + caption: '\\Pi', + snippet: '\\Pi', + meta: 'cmd', + score: 0.021264671817473237, + }, + { + caption: '\\Sigma', + snippet: '\\Sigma', + meta: 'cmd', + score: 0.05769642802079917, + }, + { + caption: '\\Upsilon', + snippet: '\\Upsilon', + meta: 'cmd', + score: 0.00032875192955749566, + }, + { + caption: '\\Phi', + snippet: '\\Phi', + meta: 'cmd', + score: 0.0538724950042562, + }, + { + caption: '\\Psi', + snippet: '\\Psi', + meta: 'cmd', + score: 0.03056589143021648, + }, + { + caption: '\\Omega', + snippet: '\\Omega', + meta: 'cmd', + score: 0.09490387997853639, + }, +] diff --git a/services/web/frontend/js/features/source-editor/languages/latex/completions/doc-commands.ts b/services/web/frontend/js/features/source-editor/languages/latex/completions/doc-commands.ts new file mode 100644 index 0000000000..d92f2ad555 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/languages/latex/completions/doc-commands.ts @@ -0,0 +1,82 @@ +import { customSnippetCompletion } from './apply' +import { Completion, CompletionContext } from '@codemirror/autocomplete' +import { documentCommands } from '../document-commands' +import { Command } from '../../../utils/tree-operations/commands' + +const commandNameFromLabel = (label: string): string | undefined => + label.match(/^\\\w+/)?.[0] + +export function customCommandCompletions( + context: CompletionContext, + commandCompletions: Completion[] +) { + const existingCommands = new Set( + commandCompletions + .map(item => commandNameFromLabel(item.label)) + .filter(Boolean) + ) + + const output = [] + + const items = countCommandUsage(context) + + for (const item of items.values()) { + if (!existingCommands.has(commandNameFromLabel(item.label))) { + output.push( + customSnippetCompletion(item.snippet, { + type: 'cmd', + label: item.label, + boost: item.count - 10, + }) + ) + } + } + + return commandCompletions.concat(output) +} + +const countCommandUsage = (context: CompletionContext) => { + const { doc } = context.state + + const excludeLineNumber = doc.lineAt(context.pos).number + + const result = new Map< + string, + { label: string; snippet: string; count: number } + >() + + const commandListProjection = context.state.field(documentCommands) + if (!commandListProjection.items) { + return result + } + + for (const command of commandListProjection.items) { + if (command.line === excludeLineNumber) { + continue + } + const label = buildLabel(command) + const snippet = buildSnippet(command) + + const item = result.get(label) || { label, snippet, count: 0 } + item.count++ + result.set(label, item) + } + + return result +} + +const buildLabel = (command: Command): string => { + return [ + `${command.title}`, + '[]'.repeat(command.optionalArgCount), + '{}'.repeat(command.requiredArgCount), + ].join('') +} + +const buildSnippet = (command: Command): string => { + return [ + `${command.title}`, + '[#{}]'.repeat(command.optionalArgCount), + '{#{}}'.repeat(command.requiredArgCount), + ].join('') +} diff --git a/services/web/frontend/js/features/source-editor/languages/latex/completions/doc-environments.ts b/services/web/frontend/js/features/source-editor/languages/latex/completions/doc-environments.ts new file mode 100644 index 0000000000..69631c78b0 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/languages/latex/completions/doc-environments.ts @@ -0,0 +1,42 @@ +import { customBeginCompletion } from './environments' +import { CompletionContext } from '@codemirror/autocomplete' +import { documentEnvironmentNames } from '../document-environment-names' +import { ProjectionResult } from '../../../utils/tree-operations/projection' +import { EnvironmentName } from '../../../utils/tree-operations/environments' + +/** + * Environments from the current doc + */ +export function customEnvironmentCompletions(context: CompletionContext) { + const items = findEnvironmentsInDoc(context) + + const completions = [] + + for (const env of items.values()) { + const completion = customBeginCompletion(env) + if (completion) { + completions.push(completion) + } + } + + return completions +} + +const findEnvironmentsInDoc = (context: CompletionContext) => { + const result = new Set() + + const environmentNamesProjection: ProjectionResult = + context.state.field(documentEnvironmentNames) + if (!environmentNamesProjection || !environmentNamesProjection.items) { + return result + } + + for (const environment of environmentNamesProjection.items) { + // include the environment name if it's outside the current context + if (environment.to < context.pos || environment.from > context.pos) { + result.add(environment.title) + } + } + + return result +} diff --git a/services/web/frontend/js/features/source-editor/languages/latex/completions/environments.ts b/services/web/frontend/js/features/source-editor/languages/latex/completions/environments.ts new file mode 100644 index 0000000000..61ace565f7 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/languages/latex/completions/environments.ts @@ -0,0 +1,69 @@ +import { environments, snippet } from './data/environments' +import { customSnippetCompletion, createCommandApplier } from './apply' +import { Completion, CompletionContext } from '@codemirror/autocomplete' +import { Completions } from './types' + +/** + * Environments from bundled data + */ +export function buildEnvironmentCompletions(completions: Completions) { + for (const [item, snippet] of environments) { + completions.commands.push( + customSnippetCompletion( + snippet, + { + type: 'env', + label: `\\begin{${item}} …`, + }, + // clear snippet for some environments after inserting + item === 'itemize' || item === 'enumerate' + ) + ) + } +} + +/** + * A `begin` environment completion with a snippet, for the current context + */ +export function customBeginCompletion(name: string) { + if (environments.has(name)) { + return null + } + + return customSnippetCompletion(snippet(name), { + label: `\\begin{${name}} …`, + }) +} + +/** + * `end` completions for open environments in the current doc, up to the current context + * @return {*[]} + */ +export function customEndCompletions(context: CompletionContext): Completion[] { + const openEnvironments = new Set() + + for (const line of context.state.doc.iterRange(0, context.pos)) { + for (const match of line.matchAll(/\\(?begin|end){(?[^}]+)}/g)) { + const { cmd, env } = match.groups as { cmd: string; env: string } + + if (cmd === 'begin') { + openEnvironments.add(env) + } else { + openEnvironments.delete(env) + } + } + } + + const completions: Completion[] = [] + + let boost = 10 + for (const env of openEnvironments) { + completions.push({ + label: env, + boost: boost++, // environments opened later rank higher + apply: createCommandApplier(env), + }) + } + + return completions +} diff --git a/services/web/frontend/js/features/source-editor/languages/latex/completions/include.ts b/services/web/frontend/js/features/source-editor/languages/latex/completions/include.ts new file mode 100644 index 0000000000..5b4bbddc34 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/languages/latex/completions/include.ts @@ -0,0 +1,101 @@ +import { CompletionContext } from '@codemirror/autocomplete' +import { createCommandApplier, createRequiredParameterApplier } from './apply' +import { Folder } from '../../../../../../../types/folder' +import { Completions } from './types' +import { metadataState } from '../../../extensions/language' + +// TODO: case-insensitive regex + +function removeBibExtension(path: string) { + return path.replace(/\.bib$/, '') +} + +function removeTexExtension(path: string) { + return path.replace(/\.tex$/, '') +} + +/** + * Completions based on files in the project + */ +export function buildIncludeCompletions( + completions: Completions, + context: CompletionContext +) { + const metadata = context.state.field(metadataState, false) + + if (!metadata?.fileTreeData) { + return + } + + // files in the project folder + const processFile = (path: string) => { + if (/\.(?:tex|txt)$/.test(path)) { + // path parameter for \include{path} or \input{path} + completions.includes.push({ + type: 'file', + label: path, + apply: createRequiredParameterApplier(removeTexExtension(path)), + }) + + // \include{path} + completions.commands.push({ + type: 'cmd', + label: `\\include{${path}}`, + apply: createCommandApplier(`\\include{${removeTexExtension(path)}}`), + }) + + // \input{path} + completions.commands.push({ + type: 'cmd', + label: `\\input{${path}}`, + apply: createCommandApplier(`\\input{${removeTexExtension(path)}}`), + }) + } + + // TODO: a better list of graphics extensions? + if (/\.(eps|jpe?g|gif|png|tiff?|pdf|svg)$/.test(path)) { + // path parameter for \includegraphics{path} + completions.graphics.push({ + type: 'file', + label: path, + apply: createRequiredParameterApplier(path), // TODO: remove extension? + }) + + const label = `\\includegraphics{${path}}` + + // \includegraphics{path} + completions.commands.push({ + type: 'cmd', + label, + apply: createCommandApplier(label), + }) + } + + if (/\.bib$/.test(path)) { + const label = removeBibExtension(path) + // path without extension for \bibliography{path} + completions.bibliographies.push({ + type: 'bib', + label, + apply: createRequiredParameterApplier(label), + }) + } + } + + // iterate through the files in a folder + const processFolder = ({ folders, docs, fileRefs }: Folder, path = '') => { + for (const doc of docs) { + processFile(`${path}${doc.name}`) + } + + for (const fileRef of fileRefs) { + processFile(`${path}${fileRef.name}`) + } + + for (const folder of folders) { + processFolder(folder, `${path}${folder.name}/`) + } + } + + processFolder(metadata.fileTreeData) +} diff --git a/services/web/frontend/js/features/source-editor/languages/latex/completions/index.ts b/services/web/frontend/js/features/source-editor/languages/latex/completions/index.ts new file mode 100644 index 0000000000..ead038b625 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/languages/latex/completions/index.ts @@ -0,0 +1,26 @@ +import { buildIncludeCompletions } from './include' +import { buildLabelCompletions } from './labels' +import { buildPackageCompletions } from './packages' +import { buildSnippetCompletions } from './snippets' +import { buildEnvironmentCompletions } from './environments' +import { buildReferenceCompletions } from './references' +import { buildClassCompletions } from './classes' +import { Completions } from './types' +import { buildBibliographyStyleCompletions } from './bibliography-styles' +import { CompletionContext } from '@codemirror/autocomplete' + +export const buildAllCompletions = ( + completions: Completions, + context: CompletionContext +) => { + buildSnippetCompletions(completions) + buildEnvironmentCompletions(completions) + buildClassCompletions(completions) + buildBibliographyStyleCompletions(completions) + buildIncludeCompletions(completions, context) + buildReferenceCompletions(completions, context) + buildLabelCompletions(completions, context) + buildPackageCompletions(completions, context) + + return completions +} diff --git a/services/web/frontend/js/features/source-editor/languages/latex/completions/labels.ts b/services/web/frontend/js/features/source-editor/languages/latex/completions/labels.ts new file mode 100644 index 0000000000..46a6a6145f --- /dev/null +++ b/services/web/frontend/js/features/source-editor/languages/latex/completions/labels.ts @@ -0,0 +1,32 @@ +import { createRequiredParameterApplier } from './apply' +import { Completions } from './types' +import { metadataState } from '../../../extensions/language' +import { CompletionContext } from '@codemirror/autocomplete' + +/** + * Labels parsed from docs in the project, for cross-referencing + */ +export function buildLabelCompletions( + completions: Completions, + context: CompletionContext +) { + const metadata = context.state.field(metadataState, false) + + if (!metadata) { + return + } + + const uniqueLabels = new Set( + Object.values(metadata.documents) + .map(doc => doc.labels) + .flat(1) + ) + + for (const label of uniqueLabels) { + completions.labels.push({ + type: 'label', + label, + apply: createRequiredParameterApplier(label), + }) + } +} diff --git a/services/web/frontend/js/features/source-editor/languages/latex/completions/packages.ts b/services/web/frontend/js/features/source-editor/languages/latex/completions/packages.ts new file mode 100644 index 0000000000..e17b7ab3a0 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/languages/latex/completions/packages.ts @@ -0,0 +1,96 @@ +import { + customSnippetCompletion, + createRequiredParameterApplier, + createCommandApplier, +} from './apply' +import { packageNames } from './data/package-names' +import { Completions } from './types' +import { CompletionContext } from '@codemirror/autocomplete' +import { metadataState } from '../../../extensions/language' + +/** + * Completions based on package names from bundled data and packages in the project + */ +export function buildPackageCompletions( + completions: Completions, + context: CompletionContext +) { + const metadata = context.state.field(metadataState, false) + + if (!metadata) { + return + } + + const uniquePackageNames = new Set(packageNames) + + // package names and commands from packages in the project + for (const doc of Object.values(metadata.documents)) { + for (const [packageName, commands] of Object.entries(doc.packages)) { + uniquePackageNames.add(packageName) + + for (const item of commands) { + completions.commands.push( + customSnippetCompletion(item.snippet, { + type: item.meta, + label: item.caption, + }) + ) + } + } + } + + const existingPackageNames = findExistingPackageNames(context) + + for (const item of uniquePackageNames) { + if (!existingPackageNames.has(item)) { + // package name parameter completion + completions.packages.push({ + type: 'pkg', + label: item, + apply: createRequiredParameterApplier(item), + }) + + const label = `\\usepackage{${item}}` + + // full command with parameter completion + completions.commands.push({ + type: 'pkg', + label, + apply: createCommandApplier(label), + }) + } + } + + // empty \\usepackage{…} snippet + completions.commands.push( + customSnippetCompletion('\\usepackage{#{}}', { + type: 'pkg', + label: '\\usepackage{}', + boost: 10, + }) + ) +} + +const findExistingPackageNames = (context: CompletionContext) => { + const { doc } = context.state + + const excludeLineNumber = doc.lineAt(context.pos).number + + const items = new Set() + + let currentLineNumber = 1 + for (const line of doc.iterLines()) { + if (currentLineNumber++ === excludeLineNumber) { + continue + } + + // TODO: exclude comments + + for (const match of line.matchAll(/\\usepackage(\[.+?])?{(?\w+)}/g)) { + const { name } = match.groups as { name: string } + items.add(name) + } + } + + return items +} diff --git a/services/web/frontend/js/features/source-editor/languages/latex/completions/references.ts b/services/web/frontend/js/features/source-editor/languages/latex/completions/references.ts new file mode 100644 index 0000000000..7360e39d2f --- /dev/null +++ b/services/web/frontend/js/features/source-editor/languages/latex/completions/references.ts @@ -0,0 +1,26 @@ +/** + * `cite` completions based on reference keys in the project + */ +import { CompletionContext } from '@codemirror/autocomplete' +import { Completions } from './types' +import { metadataState } from '../../../extensions/language' +import { createRequiredParameterApplier } from './apply' + +export function buildReferenceCompletions( + completions: Completions, + context: CompletionContext +) { + const metadata = context.state.field(metadataState, false) + + if (!metadata) { + return + } + + for (const reference of metadata.references) { + completions.references.push({ + type: 'reference', + label: reference, + apply: createRequiredParameterApplier(reference), + }) + } +} diff --git a/services/web/frontend/js/features/source-editor/languages/latex/completions/snippets.ts b/services/web/frontend/js/features/source-editor/languages/latex/completions/snippets.ts new file mode 100644 index 0000000000..4495f7591f --- /dev/null +++ b/services/web/frontend/js/features/source-editor/languages/latex/completions/snippets.ts @@ -0,0 +1,28 @@ +import topHundredSnippets from './data/top-hundred-snippets' +import snippets from './data/snippets' +import { customSnippetCompletion } from './apply' +import { Completions } from './types' + +/** + * Completions based on bundled snippets + */ +export function buildSnippetCompletions(completions: Completions) { + for (const item of topHundredSnippets) { + completions.commands.push( + customSnippetCompletion(item.snippet, { + type: item.meta, + label: item.caption, + boost: item.score, + }) + ) + } + + for (const item of snippets) { + completions.commands.push( + customSnippetCompletion(item.snippet, { + type: item.type, + label: item.label, + }) + ) + } +} diff --git a/services/web/frontend/js/features/source-editor/languages/latex/completions/types.ts b/services/web/frontend/js/features/source-editor/languages/latex/completions/types.ts new file mode 100644 index 0000000000..5b7779ca44 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/languages/latex/completions/types.ts @@ -0,0 +1,3 @@ +import { Completion } from '@codemirror/autocomplete' + +export type Completions = Record diff --git a/services/web/frontend/js/features/source-editor/languages/latex/debug-panel.ts b/services/web/frontend/js/features/source-editor/languages/latex/debug-panel.ts new file mode 100644 index 0000000000..2700e961ee --- /dev/null +++ b/services/web/frontend/js/features/source-editor/languages/latex/debug-panel.ts @@ -0,0 +1,178 @@ +import { Decoration, EditorView, Panel, showPanel } from '@codemirror/view' +import { languageLoadedEffect } from '../../extensions/language' +import { Compartment, EditorState } from '@codemirror/state' +import { getAncestorStack } from '../../utils/tree-query' +import { resolveNodeAtPos } from '../../utils/tree-operations/common' + +const decorationsConf = new Compartment() + +export const debugPanel = () => { + const enableDebugPanel = new URLSearchParams(window.location.search).has( + 'cm_debug_panel' + ) + + if (!enableDebugPanel) { + return [] + } + + return [ + showPanel.of(createInfoPanel), + + decorationsConf.of(EditorView.decorations.of(Decoration.none)), + + // clear the highlight when the selection changes + EditorView.updateListener.of(update => { + if (update.selectionSet) { + update.view.dispatch({ + effects: decorationsConf.reconfigure( + EditorView.decorations.of(Decoration.none) + ), + }) + } + }), + + EditorView.baseTheme({ + '.ol-cm-debug-panel': { + paddingBottom: '24px', + }, + '.ol-cm-debug-panel-type': { + backgroundColor: '#138a07', + color: '#fff', + padding: '0px 4px', + marginLeft: '4px', + borderRadius: '4px', + }, + '.ol-cm-debug-panel-item': { + border: 'none', + backgroundColor: '#fff', + color: '#000', + outline: '1px solid transparent', + marginBottom: '2px', + display: 'inline-flex', + alignItems: 'center', + '&:hover': { + outlineColor: '#000', + }, + }, + '.ol-cm-debug-panel-position': { + position: 'absolute', + bottom: '0', + right: '0', + padding: '5px', + }, + '.ol-cm-debug-panel-node-highlight': { + backgroundColor: '#ffff0077', + }, + }), + ] +} + +const placeholder = () => document.createElement('div') + +const createInfoPanel = (view: EditorView): Panel => { + const dom = document.createElement('div') + dom.className = 'ol-cm-debug-panel' + dom.append(buildPanelContent(view, view.state)) + + return { + dom, + update(update) { + if (update.selectionSet) { + // update when the selection changes + dom.firstChild!.replaceWith( + buildPanelContent(update.view, update.state) + ) + } else { + // update when the language is loaded + for (const tr of update.transactions) { + if (tr.effects.some(effect => effect.is(languageLoadedEffect))) { + dom.firstChild!.replaceWith( + buildPanelContent(update.view, update.state) + ) + } + } + } + }, + } +} + +const buildPanelContent = ( + view: EditorView, + state: EditorState +): HTMLDivElement => { + const pos = state.selection.main.anchor + const ancestors = getAncestorStack(state, pos) + + if (!ancestors) { + return placeholder() + } + + if (ancestors.length > 0) { + const node = ancestors[ancestors.length - 1] + const nodeBefore = resolveNodeAtPos(state, pos, -1) + const nodeAfter = resolveNodeAtPos(state, pos, 1) + + const parts = [] + if (nodeBefore) { + parts.push(`[${nodeBefore.name}]`) + } + parts.push(node.label) + if (nodeAfter) { + parts.push(`[${nodeAfter.name}]`) + } + node.label = parts.join(' ') + } + + const panelContent = document.createElement('div') + panelContent.style.padding = '5px 10px' + + const line = state.doc.lineAt(pos) + const column = pos - line.from + 1 + const positionContainer = document.createElement('div') + positionContainer.className = 'ol-cm-debug-panel-position' + positionContainer.textContent = `line ${line.number}, col ${column}, pos ${pos}` + panelContent.appendChild(positionContainer) + + const stackContainer = document.createElement('div') + for (const [index, item] of ancestors.entries()) { + if (index > 0) { + stackContainer.append(' > ') + } + const element = document.createElement('button') + element.className = 'ol-cm-debug-panel-item' + + const label = document.createElement('span') + label.className = 'ol-cm-debug-panel-label' + label.textContent = item.label + element.append(label) + + if (item.type) { + const type = document.createElement('span') + type.className = 'ol-cm-debug-panel-type' + type.textContent = item.type + element.append(type) + } + + element.addEventListener('click', () => { + view.dispatch({ + effects: [ + decorationsConf.reconfigure( + EditorView.decorations.of( + Decoration.set( + Decoration.mark({ + class: 'ol-cm-debug-panel-node-highlight', + }).range(item.from, item.to) + ) + ) + ), + EditorView.scrollIntoView(item.from, { y: 'center' }), + ], + }) + }) + + stackContainer.append(element) + } + panelContent.appendChild(stackContainer) + + return panelContent +} diff --git a/services/web/frontend/js/features/source-editor/languages/latex/debug-print.ts b/services/web/frontend/js/features/source-editor/languages/latex/debug-print.ts new file mode 100644 index 0000000000..bb4bf0b7f7 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/languages/latex/debug-print.ts @@ -0,0 +1,282 @@ +// from https://gist.github.com/msteen/e4828fbf25d6efef73576fc43ac479d2 +// https://discuss.codemirror.net/t/whats-the-best-to-test-and-debug-grammars/2542/5 + +// MIT License +// +// Copyright (c) 2021 Matthijs Steen +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { Text } from '@codemirror/state' +import { Input, NodeType, SyntaxNode, Tree, TreeCursor } from '@lezer/common' + +class StringInput implements Input { + private input: string + + constructor(input: string) { + this.input = input + } + + get length() { + return this.input.length + } + + chunk(from: number): string { + return this.input.slice(from) + } + + lineChunks = false + + read(from: number, to: number): string { + return this.input.slice(from, to) + } +} + +export function sliceType( + cursor: TreeCursor, + input: Input, + type: number +): string | null { + if (cursor.type.id === type) { + const s = input.read(cursor.from, cursor.to) + cursor.nextSibling() + return s + } + return null +} + +export function isType(cursor: TreeCursor, type: number): boolean { + const cond = cursor.type.id === type + if (cond) cursor.nextSibling() + return cond +} + +export type CursorNode = { + type: NodeType + from: number + to: number + isLeaf: boolean +} + +function cursorNode( + { type, from, to }: TreeCursor, + isLeaf = false +): CursorNode { + return { type, from, to, isLeaf } +} + +export type TreeTraversal = { + beforeEnter?: (cursor: TreeCursor) => void + onEnter: (node: CursorNode) => false | void + onLeave?: (node: CursorNode) => false | void +} + +type TreeTraversalOptions = { + from?: number + to?: number + includeParents?: boolean +} & TreeTraversal + +export function traverseTree( + cursor: TreeCursor | Tree | SyntaxNode, + { + from = -Infinity, + to = Infinity, + includeParents = false, + beforeEnter, + onEnter, + onLeave, + }: TreeTraversalOptions +): void { + if (!(cursor instanceof TreeCursor)) + cursor = cursor instanceof Tree ? cursor.cursor() : cursor.cursor() + for (;;) { + let node = cursorNode(cursor) + let leave = false + if (node.from <= to && node.to >= from) { + const enter = + !node.type.isAnonymous && + (includeParents || (node.from >= from && node.to <= to)) + if (enter && beforeEnter) beforeEnter(cursor) + node.isLeaf = !cursor.firstChild() + if (enter) { + leave = true + if (onEnter(node) === false) return + } + if (!node.isLeaf) continue + } + for (;;) { + node = cursorNode(cursor, node.isLeaf) + if (leave && onLeave) if (onLeave(node) === false) return + leave = cursor.type.isAnonymous + node.isLeaf = false + if (cursor.nextSibling()) break + if (!cursor.parent()) return + leave = true + } + } +} + +function isChildOf(child: CursorNode, parent: CursorNode): boolean { + return ( + child.from >= parent.from && + child.from <= parent.to && + child.to <= parent.to && + child.to >= parent.from + ) +} + +export function validatorTraversal( + input: Input | string, + { fullMatch = true }: { fullMatch?: boolean } = {} +) { + if (typeof input === 'string') input = new StringInput(input) + const state = { + valid: true, + parentNodes: [] as CursorNode[], + lastLeafTo: 0, + } + return { + state, + traversal: { + onEnter(node) { + state.valid = true + if (!node.isLeaf) state.parentNodes.unshift(node) + if (node.from > node.to || node.from < state.lastLeafTo) { + state.valid = false + } else if (node.isLeaf) { + if ( + state.parentNodes.length && + !isChildOf(node, state.parentNodes[0]) + ) + state.valid = false + state.lastLeafTo = node.to + } else { + if (state.parentNodes.length) { + if (!isChildOf(node, state.parentNodes[0])) state.valid = false + } else if ( + fullMatch && + (node.from !== 0 || node.to !== input.length) + ) { + state.valid = false + } + } + }, + onLeave(node) { + if (!node.isLeaf) state.parentNodes.shift() + }, + } as TreeTraversal, + } +} + +export function validateTree( + tree: TreeCursor | Tree | SyntaxNode, + input: Input | string, + options?: { fullMatch?: boolean } +): boolean { + const { state, traversal } = validatorTraversal(input, options) + traverseTree(tree, traversal) + return state.valid +} + +function colorize(value: any): string { + return '' + value +} + +type PrintTreeOptions = { + from?: number + to?: number + start?: number + includeParents?: boolean +} + +export function printTree( + cursor: TreeCursor | Tree | SyntaxNode, + input: Input | string, + { from, to, start = 0, includeParents }: PrintTreeOptions = {} +): string { + const inp = typeof input === 'string' ? new StringInput(input) : input + const text = Text.of(inp.read(0, inp.length).split('\n')) + const state = { + output: '', + prefixes: [] as string[], + hasNextSibling: false, + } + const validator = validatorTraversal(inp) + traverseTree(cursor, { + from, + to, + includeParents, + beforeEnter(cursor) { + state.hasNextSibling = cursor.nextSibling() && cursor.prevSibling() + }, + onEnter(node) { + validator.traversal.onEnter(node) + const isTop = state.output === '' + const hasPrefix = !isTop || node.from > 0 + if (hasPrefix) { + state.output += (!isTop ? '\n' : '') + state.prefixes.join('') + if (state.hasNextSibling) { + state.output += ' ├─ ' + state.prefixes.push(' │ ') + } else { + state.output += ' └─ ' + state.prefixes.push(' ') + } + } + const hasRange = node.from !== node.to + state.output += + (node.type.isError || !validator.state.valid + ? colorize('ERROR ' + node.type.name) + : node.type.name) + + ' ' + + (hasRange + ? '[' + + colorize(locAt(text, start + node.from)) + + '..' + + colorize(locAt(text, start + node.to)) + + ']' + : colorize(locAt(text, start + node.from))) + if (hasRange && node.isLeaf) { + state.output += + ': ' + colorize(JSON.stringify(inp.read(node.from, node.to))) + } + }, + onLeave(node) { + if (validator.traversal.onLeave) { + validator.traversal.onLeave(node) + } + state.prefixes.pop() + }, + }) + return state.output +} + +function locAt(text: Text, pos: number): string { + const line = text.lineAt(pos) + return line.number + ':' + (pos - line.from) +} + +export function logTree( + tree: TreeCursor | Tree | SyntaxNode, + input: string, + options?: PrintTreeOptions +): void { + console.log(printTree(tree, input, options)) +} diff --git a/services/web/frontend/js/features/source-editor/languages/latex/document-commands.ts b/services/web/frontend/js/features/source-editor/languages/latex/document-commands.ts new file mode 100644 index 0000000000..f7c94dd2ea --- /dev/null +++ b/services/web/frontend/js/features/source-editor/languages/latex/document-commands.ts @@ -0,0 +1,4 @@ +import { Command, enterNode } from '../../utils/tree-operations/commands' +import { makeProjectionStateField } from '../../utils/projection-state-field' + +export const documentCommands = makeProjectionStateField(enterNode) diff --git a/services/web/frontend/js/features/source-editor/languages/latex/document-environment-names.ts b/services/web/frontend/js/features/source-editor/languages/latex/document-environment-names.ts new file mode 100644 index 0000000000..aab7903440 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/languages/latex/document-environment-names.ts @@ -0,0 +1,8 @@ +import { + EnvironmentName, + enterNode, +} from '../../utils/tree-operations/environments' +import { makeProjectionStateField } from '../../utils/projection-state-field' + +export const documentEnvironmentNames = + makeProjectionStateField(enterNode) diff --git a/services/web/frontend/js/features/source-editor/languages/latex/document-outline.ts b/services/web/frontend/js/features/source-editor/languages/latex/document-outline.ts new file mode 100644 index 0000000000..f13738a5d6 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/languages/latex/document-outline.ts @@ -0,0 +1,6 @@ +import { FlatOutlineItem } from '../../utils/tree-query' +import { enterNode } from '../../utils/tree-operations/outline' +import { makeProjectionStateField } from '../../utils/projection-state-field' + +export const documentOutline = + makeProjectionStateField(enterNode) diff --git a/services/web/frontend/js/features/source-editor/languages/latex/index.ts b/services/web/frontend/js/features/source-editor/languages/latex/index.ts new file mode 100644 index 0000000000..429755231e --- /dev/null +++ b/services/web/frontend/js/features/source-editor/languages/latex/index.ts @@ -0,0 +1,46 @@ +import { latexIndentService } from './latex-indent-service' +import { shortcuts } from './shortcuts' +import { linting } from './linting' +import { LanguageSupport, indentUnit } from '@codemirror/language' +import { CompletionSource } from '@codemirror/autocomplete' +import { debugPanel } from './debug-panel' +import { openAutocomplete } from './open-autocomplete' +import { metadata } from './metadata' +import { + argumentCompletionSources, + explicitCommandCompletionSource, + inCommandCompletionSource, +} from './complete' +import { documentCommands } from './document-commands' +import importOverleafModules from '../../../../../macros/import-overleaf-module.macro' +import { documentOutline } from './document-outline' +import { LaTeXLanguage } from './latex-language' +import { documentEnvironmentNames } from './document-environment-names' + +const completionSources = importOverleafModules('sourceEditorCompletionSources') + .map((item: any) => item.import.default) + .concat( + argumentCompletionSources, + inCommandCompletionSource, + explicitCommandCompletionSource + ) as CompletionSource[] + +export const latex = () => { + return new LanguageSupport(LaTeXLanguage, [ + indentUnit.of(' '), // 4 spaces + shortcuts(), + documentOutline.extension, + documentCommands.extension, + documentEnvironmentNames.extension, + latexIndentService(), + linting(), + metadata(), + debugPanel(), + openAutocomplete(), + ...completionSources.map(completionSource => + LaTeXLanguage.data.of({ + autocomplete: completionSource, + }) + ), + ]) +} diff --git a/services/web/frontend/js/features/source-editor/languages/latex/latex-indent-service.ts b/services/web/frontend/js/features/source-editor/languages/latex/latex-indent-service.ts new file mode 100644 index 0000000000..e4d6c05867 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/languages/latex/latex-indent-service.ts @@ -0,0 +1,17 @@ +import { indentService } from '@codemirror/language' + +export const latexIndentService = () => { + return indentService.of((indentContext, pos) => { + try { + // match the indentation of the previous line (if present) + const previousLine = indentContext.state.doc.lineAt(pos) + const whitespace = previousLine.text.match(/^\s*/) + if (whitespace) { + return whitespace[0].length + } + } catch (err) { + console.error('Error in CM indentService', err) + } + return null + }) +} diff --git a/services/web/frontend/js/features/source-editor/languages/latex/latex-language.ts b/services/web/frontend/js/features/source-editor/languages/latex/latex-language.ts new file mode 100644 index 0000000000..53b01020fb --- /dev/null +++ b/services/web/frontend/js/features/source-editor/languages/latex/latex-language.ts @@ -0,0 +1,190 @@ +import { LRLanguage, foldNodeProp, foldInside } from '@codemirror/language' +import { parser } from '../../lezer-latex/latex.mjs' +import { styleTags, tags as t } from '@lezer/highlight' +import * as termsModule from '../../lezer-latex/latex.terms.mjs' +import { NodeProp } from '@lezer/common' +import { + Tokens, + commentIsOpenFold, + findClosingFoldComment, + getFoldRange, +} from '../../utils/tree-query' + +const styleOverrides: Record = { + DocumentClassCtrlSeq: t.keyword, + UsePackageCtrlSeq: t.keyword, + CiteCtrlSeq: t.keyword, + CiteStarrableCtrlSeq: t.keyword, + RefCtrlSeq: t.keyword, + RefStarrableCtrlSeq: t.keyword, + LabelCtrlSeq: t.keyword, +} + +const styleEntry = (token: string, defaultStyle: any) => { + return [token, styleOverrides[token] || defaultStyle] +} + +const Styles = { + ctrlSeq: Object.fromEntries( + Tokens.ctrlSeq.map(token => styleEntry(token, t.tagName)) + ), + ctrlSym: Object.fromEntries( + Tokens.ctrlSym.map(token => styleEntry(token, t.literal)) + ), + envName: Object.fromEntries( + Tokens.envName.map(token => styleEntry(token, t.attributeValue)) + ), +} + +const typeMap: Record = { + PartCtrlSeq: ['$SectioningCommand'], + ChapterCtrlSeq: ['$SectioningCommand'], + SectionCtrlSeq: ['$SectioningCommand'], + SubSectionCtrlSeq: ['$SectioningCommand'], + SubSubSectionCtrlSeq: ['$SectioningCommand'], + ParagraphCtrlSeq: ['$SectioningCommand'], + SubParagraphCtrlSeq: ['$SectioningCommand'], +} + +export const LaTeXLanguage = LRLanguage.define({ + parser: parser.configure({ + props: [ + foldNodeProp.add({ + Comment: (node, state) => { + if (commentIsOpenFold(node, state)) { + const closingFoldNode = findClosingFoldComment(node, state) + if (closingFoldNode) { + return getFoldRange(node, closingFoldNode, state) + } + } + return null + }, + Group: foldInside, + NonEmptyGroup: foldInside, + TextArgument: foldInside, + // TODO: Why isn't + // `Content: node => node,` + // enough? For some reason it doesn't work if there's a newline after + // \section{a}, but works for \section{a}b + $Environment: node => node.getChild('Content'), + $SectioningCommand: node => { + const BACKWARDS = -1 + const lastChild = node.resolveInner(node.to, BACKWARDS) + const content = node.getChild('Content') + if (!content) { + return null + } + if (lastChild.type.is(termsModule.NewLine)) { + // Ignore last newline for sectioning commands + return { from: content!.from, to: lastChild.from } + } + if (lastChild.type.is(termsModule.Whitespace)) { + // If the sectioningcommand is indented on a newline + let sibling = lastChild.prevSibling + while (sibling?.type.is(termsModule.Whitespace)) { + sibling = sibling.prevSibling + } + if (sibling?.type.is(termsModule.NewLine)) { + return { from: content!.from, to: sibling.from } + } + } + if (lastChild.type.is(termsModule.BlankLine)) { + // HACK: BlankLine can contain any number above 2 of \n's. + // Include every one except for the last one + return { from: content!.from, to: lastChild.to - 1 } + } + return content + }, + }), + // TODO: does this override groups defined in the grammar? + NodeProp.group.add(type => { + const types = [] + + if ( + Tokens.ctrlSeq.includes(type.name) || + Tokens.ctrlSym.includes(type.name) + ) { + types.push('$CtrlSeq') + } else if (Tokens.envName.includes(type.name)) { + types.push('$EnvName') + } else if (type.name.endsWith('Argument')) { + types.push('$Argument') + } else if (type.name.endsWith('Environment')) { + types.push('$Environment') + } else if (type.name.endsWith('Brace')) { + types.push('$Brace') + } else if ( + ['BracketMath', 'ParenMath', 'DollarMath'].includes(type.name) + ) { + types.push('$MathContainer') + } + + if (type.name in typeMap) { + types.push(...typeMap[type.name]) + } + + return types.length > 0 ? types : undefined + }), + styleTags({ + ...Styles.ctrlSeq, + ...Styles.ctrlSym, + ...Styles.envName, + 'HrefCommand/ShortTextArgument/ShortArg/...': t.link, + 'HrefCommand/UrlArgument/...': t.monospace, + 'CtrlSeq Csname': t.tagName, + 'DocumentClass/OptionalArgument/ShortOptionalArg/Normal': + t.attributeValue, + 'DocumentClass/ShortTextArgument/ShortArg/Normal': t.typeName, + Number: t.number, + OpenBrace: t.brace, + CloseBrace: t.brace, + OpenBracket: t.squareBracket, + CloseBracket: t.squareBracket, + Dollar: t.string, + Math: t.string, + 'Math/MathChar': t.string, + 'Math/MathSpecialChar': t.string, + 'Math/Number': t.string, + 'MathGroup/OpenBrace MathGroup/CloseBrace': t.string, + 'MathTextCommand/TextArgument/OpenBrace MathTextCommand/TextArgument/CloseBrace': + t.string, + 'MathOpening/LeftCtrlSeq MathClosing/RightCtrlSeq MathCommand/CtrlSeq MathTextCommand/CtrlSeq': + t.literal, + MathDelimiter: t.literal, + DoubleDollar: t.keyword, + Comment: t.comment, + 'UsePackage/OptionalArgument/ShortOptionalArg/Normal': t.attributeValue, + 'UsePackage/ShortTextArgument/ShortArg/Normal': t.tagName, + 'LiteralArgContent VerbContent VerbatimContent LstInlineContent': + t.string, + 'NewCommand/LiteralArgContent': t.typeName, + 'LabelArgument/ShortTextArgument/ShortArg/...': t.attributeValue, + 'RefArgument/ShortTextArgument/ShortArg/...': t.attributeValue, + 'BibKeyArgument/ShortTextArgument/ShortArg/...': t.attributeValue, + 'ShortTextArgument/ShortArg/Normal': t.monospace, + 'UrlArgument/LiteralArgContent': [t.attributeValue, t.url], + 'FilePathArgument/LiteralArgContent': t.attributeValue, + 'BareFilePathArgument/SpaceDelimitedLiteralArgContent': + t.attributeValue, + TrailingContent: t.comment, + // TODO: t.strong, t.emphasis + }), + ], + }), + languageData: { + commentTokens: { line: '%' }, + closeBrackets: { brackets: ['[', '{'] }, + closePrefixedBrackets: { + brackets: { + // $$ will produce $$ $$, but we set a single closing $ sign as the value + // because inserting $ will already have added a closing bracket. + $$: '$', + $: '$', + '\\(': '\\)', + '\\[': '\\]', + '\\$': false, + '\\{': false, + }, + }, + }, +}) diff --git a/services/web/frontend/js/features/source-editor/languages/latex/linter/README.md b/services/web/frontend/js/features/source-editor/languages/latex/linter/README.md new file mode 100644 index 0000000000..67726996b2 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/languages/latex/linter/README.md @@ -0,0 +1,122 @@ +# How the Ace latex linter works + +The purpose of the linter is to check the following + +- each open environment is closed in the correct order + - `\begin` .. `\end` + - `$` ... `$` # inline math + - `$$` ... `$$` # display math + - `{` ... `}` # grouping commands + - `\left` .. `\right` # bracket commands (in math-mode only) +- math-mode commands are only used in math-mode (e.g. `\alpha`, `^` and `_`) +- text-specific commands are only used outside math-mode (e.g. `\chapter` should not be used in math-mode) + +The general approach of the ace linter is as following + +- skip over all of the file apart from the relevant tokens +- iterate through the tokens keeping track of the current context (environment/math-mode) +- report an error if + - a token is not allowed in the current context + - an environment is closed incorrectly + +# Implementation + +The implementation has two main phases + +1. Tokenise +2. InterpretTokens + +The linter returns errors found in the InterpretTokens phase. + +## Tokenise - finding tokens + +The tokenizer starts from the beginning of the file, searching repeatedly for the next special character: `['\', '{', '}', '$', '&', '#', '^', '_', '~', '%']`. + +When a special character is found, it is inspected and additional characters consumed according to the following TeX rules: + +- `'\'` escape character: Handle TeX control sequences (`\foo`) and control symbols (`\@`). + - followed by `[a-zA-Z]+`: it's a control sequence, consume whitespace after it + - otherwise, it's a control symbol (single character) +- any other special character `['{', '}', '$', '&', '#', '^', '_', '~']`: push it as a token +- `'%'` comment: consume up to next newline. + Special cases: + - `%novalidate` disable validation in this file + - `%begin novalidate`, `%end novalidate` disable validation in a region +- anything else: throw an error for an unexpected character + +The content between special characters, and not consumed by any rules above or below, is marked as a `Text` token. + +After this stage, we have a list of the tokens (with their positions) and text regions. + +## InterpretTokens - from tokens to environments + +This function iterates over the tokens, looking for groups or environments to match. Each environment command is pushed onto the `Environments` array. As part of this process other commands are interpreted in order to skip over them (for example `\newcommand{\foo}{\begin{equation}}` is valid and should not trigger an "unmatched environment" error.). We consume these commands using custom argument functions. + +When we push an entry onto the `Environments` array we also keep track of the math mode (either `null`, `inline`, or `display`), and for some commands the mode of the next argument (`nextGroupMathMode`). For example, `\hbox` has `nextGroupMathMode` false since the next group is its argument `{...}` which will always be text. + +### Custom argument functions + +Tokens may be followed by various arguments, which can be optional. These are consumed by custom functions which look for the correct format: + +- `read1arg`: read an argument `FOO` to a either form of command `\newcommand\FOO...` or `\newcommand{\FOO}...`. Also support optional `*` form `\newcommand*`. +- `readLetDefinition`: read a let command (the equals sign is optional) `\let\foo=\bar`, `\let\foo=TOKEN`, `\let\foo\bar`, `\let\foo\TOKEN`. +- `read1name`: read an environemt name `FOO` in `\newenvironment{FOO}...`, also handle names like `FOO_BAR`. +- `read1filename`: read a filename like `foo_bar.tex` (may include `_`) +- `readOptionalParams`: read an optional parameter `[N]` where `N` is a number, used for `\newcommand{\foo}[2]...` meaning 2 parameters. Allow for additional arguments like `[1][key=value,key=value]` and skip over arbitrary arguments `[xxx][yyy][\foo{zzz}]{...` up to the first `{..` +- `readOptionalGeneric`: read a single optional parameter `[foo]` +- `readOptionalDef`:skip over the optional arguments of a definition `\def\foo#1.#2(#3){this is the macro #1 #2 #3}` to start looking at text immediately after `\def` command. +- `readDefinition`: read a definition as in `\newcommand{\FOO}{DEFN}` or `\newcommand{\FOO} {DEF}` (optional whitespace). Look ahead for argument, consuming whitespace, the definition is read looking for balanced `{` ... `}` braces. +- `readVerb`: read a verbatim argument `\verb@foo@` or `\verb*@foo@` where `@` is any character except `*` for `\verb`, `foo` is any sequence excluding end-of-line and the delimiter. A space does work for `@`, contrary to latex documentation. Note: this is only an approximation, because we have already tokenised the input stream, and we should really do that taking into account the effect of verb. For example \verb|%| will get confused because % is then a character. +- `readUrl`: read a url argument `\url|foo|`, `\url{foo}` Note: this is only an approximation, because we have already tokenised the input stream, so anything after a comment character % on the current line will not be present in the input stream. + +### Token interpretation + +The following tokens trigger special handling, either by starting or closing a group or environment, being a command that must be intepreted (to skip over arguments) or having special properties (such as only being permissible in certain environments). + +- `{` and `}` handle open and close group as a type of environment +- `\begin` and `\end` followed by a text token, taken as the environment name + - also allow repeated text tokens separated by `_` (e.g like `\begin{new_major_theorem}`) +- Parse bracket commands, treated as an environment since they must match + - `\left` and `\right` must be followed by one of `(){}[]<>/|\.` +- `\(` ... `\)` and `\[` ... `\]` handle open and close math-modes as a type of environment +- Parse command definitions in a limited way, to avoid falsely reporting errors from unmatched environments in the command definition e.g. `\newcommand{\foo}{\begin{equation}}` is valid and should not trigger an "unmatched environment" error. + - `newcommand`, `renewcommand`, `DeclareRobustCommand`: read1arg readOptionalParams readDefinition + - `def`: read1arg readOptionalDef readDefinition + - `let`: readLetDefinition + - `newcolumntype` read1name readOptionalParams readDefinition + - `newenvironment`, `renewenvironment` read1name readOptionalParams readDefinition(open) readDefinition(close) +- Parse special commands + - `verb`: readVerb (`\verb|....|` where `|` is any char) + - `url`: readUrl (`\url{...}` or `\url|....|` where `|` is any char) + - `input`: read1filename +- Parse text mode commands - the next group will be in text mode regardless + - `hbox`, `text`, `mbox`, `footnote`, `intertext`, `shortintertext`, `textnormal`, `tag`, `reflectbox`, `textrm` +- Parse graphics commands + - `tikz`: readOptionalGeneric + - `rotatebox`, `scalebox`, `feynmandiagram`: readOptionalGeneric readDefinition + - `resizebox`: readOptionalGeneric readDefinition(width) readDefinition(height) +- Parse math definition commands + - `DeclareMathOperator`: readDefinition(first arg) readDefinition(second arg) + - `DeclarePairedDelimiter`: readDefinition(first arg) readDefinition(second arg) readDefinition(third arg) +- Math-mode commands + - `(alpha|beta|gamma|delta|epsilon|varepsilon|zeta|eta|theta|vartheta|iota|kappa|lambda|mu|nu|xi|pi|varpi|rho|varrho|sigma|varsigma|tau|upsilon|phi|varphi|chi|psi|omega|Gamma|Delta|Theta|Lambda|Xi|Pi|Sigma|Upsilon|Phi|Psi|Omega)`: can only be used in math mode +- Text-mode commands + - `(chapter|section|subsection|subsubsection)`: cannot be used in math mode +- Any other unrecognised command + - `\[a-z]+`: if we see an unknown command \foo{...}{...} put the math mode for the next group into the 'undefined' state, because we do not know what math mode an arbitrary macro will use for its arguments. In the math mode 'undefined' state we don't report errors when we encounter math or text commands. +- Math-mode delimiters + - `$$` - display math environment + - `$` - inline math environment +- Subscript and superscript (must be inside math-mode) + - `^` and `_`: check for mathmode assuming environments are correct + +### Environment handling + +- must be outside math mode: `(document|figure|center|enumerate|itemize|table|abstract|proof|lemma|theorem|definition|proposition|corollary|remark|notation|thebibliography)` +- must be inside math mode: `(array|gathered|split|aligned|alignedat)\*?` +- must be outside math mode but starts it: `(math|displaymath|equation|eqnarray|multline|align|gather|flalign|alignat)\*?` +- start a verbatim environment: `(verbatim|boxedverbatim|lstlisting|minted|Verbatim)`. Any errors occurring in a verbatim environment are ignored. + +### Special cases + +If we encounter any tokens matching `(be|beq|beqa|bea)` or `(ee|eeq|eeqn|eeqa|eeqan|eea)` we filter out all errors relating to math-mode violations, since these are common user-defined macros to replace `\begin{equation}` etc. diff --git a/services/web/frontend/js/features/source-editor/languages/latex/linter/errors-to-diagnostics.ts b/services/web/frontend/js/features/source-editor/languages/latex/linter/errors-to-diagnostics.ts new file mode 100644 index 0000000000..f3e095c52c --- /dev/null +++ b/services/web/frontend/js/features/source-editor/languages/latex/linter/errors-to-diagnostics.ts @@ -0,0 +1,91 @@ +import { Diagnostic } from '@codemirror/lint' +import { Range } from '../../../utils/range' + +export type LintError = { + startPos: number + endPos: number + pos: number + suppressIfEditing: boolean + type: 'info' | 'warning' | 'error' + text: string +} + +// Convert errors generated by the linter to Diagnostic objects that CM6 +// expects. Filter and adjust positions based on cursor position. This is +// adapted from roughly equivalent Ace code: +// https://github.com/overleaf/ace/blob/31998cd6178c4115ad67f8e101f41590dcb32522/lib/ace/mode/latex.js#L58-L183 +export const errorsToDiagnostics = ( + errors: LintError[], + cursorPosition: number, + docLength: number +): Diagnostic[] => { + const diagnostics: Diagnostic[] = [] + const cursorAtEndOfDocument = cursorPosition === docLength + const suppressions = [] + + for (const error of errors) { + const errorRange = new Range(error.startPos, error.endPos) + + // Reject an error that starts or ends after the document length. This + // can happen if the document changed while the linter was running and + // would cause an exception if a diagnostic was created for it + if (errorRange.from > docLength || errorRange.to > docLength) { + continue + } + + const cursorInRange = errorRange.contains(cursorPosition) + const cursorAtStart = cursorPosition === errorRange.from + 1 // cursor after start not before + const cursorAtEnd = cursorPosition === errorRange.to + + // If the user is editing at the beginning or end of this error, suppress + // it from display + if (error.suppressIfEditing && (cursorAtStart || cursorAtEnd)) { + suppressions.push(errorRange) + continue + } + + // Otherwise, check if this error starts inside a + // suppressed error range (it's probably a cascading + // error, so we hide it while the user is typing) + let isCascadeError = false + for (const badRange of suppressions) { + if (badRange.intersects(errorRange)) { + isCascadeError = true + break + } + } + + // Hide cascade errors + if (isCascadeError) { + continue + } + + // Adjust the error range if the cursor is inside the range. + // + // If the cause of the error is at the beginning, move the end of the range + // to the cursor position. + // + // If the cause of the error is at the end, and the cursor is inside the + // range, move the beginning of the range to the cursor position. + // + // If the cursor is at the end of the document, make no adjustment because + // doing the regular adjustments doesn't always give intuitive results at + // the end of the document. + const errorAtStart = error.pos === error.startPos + const movableStart = + cursorInRange && !errorAtStart && !cursorAtEndOfDocument + const movableEnd = cursorInRange && errorAtStart && !cursorAtEndOfDocument + const newStart = movableStart ? cursorPosition : errorRange.from + const newEnd = movableEnd ? cursorPosition : errorRange.to + + // Create the diagnostic + diagnostics.push({ + from: newStart, + to: newEnd, + severity: error.type, + message: error.text, + }) + } + + return diagnostics +} diff --git a/services/web/frontend/js/features/source-editor/languages/latex/linter/latex-linter.ts b/services/web/frontend/js/features/source-editor/languages/latex/linter/latex-linter.ts new file mode 100644 index 0000000000..b440f21a36 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/languages/latex/linter/latex-linter.ts @@ -0,0 +1,89 @@ +import { createWorker } from '../../../../../utils/worker' +import { EditorView } from '@codemirror/view' +import { Diagnostic } from '@codemirror/lint' +import { errorsToDiagnostics, LintError } from './errors-to-diagnostics' +import { mergeCompatibleOverlappingDiagnostics } from './merge-overlapping-diagnostics' + +let lintWorker: Worker +createWorker(() => { + lintWorker = new Worker( + new URL('./latex-linter.worker.js', import.meta.url), + { type: 'module' } + ) +}) + +class Deferred { + public promise: Promise + public resolve?: (value: PromiseLike | Diagnostic[]) => void + public reject?: (reason?: any) => void + + constructor() { + this.promise = new Promise((resolve, reject) => { + this.resolve = resolve + this.reject = reject + }) + } +} + +let linterPromise: Promise | null = null // promise which will resolve to results of the current linting +let queuedRequest: Deferred | null = null // deferred promise for incoming linting requests while the current one is running +let currentView: EditorView | null = null + +let currentResolver: ((value: Diagnostic[]) => void) | null = null + +const runLinter = () => { + lintWorker.postMessage({ text: currentView!.state.doc.toString() }) + return new Promise(resolve => { + currentResolver = resolve + }) +} + +lintWorker!.addEventListener('message', event => { + if (event.data) { + const errors = event.data.errors as LintError[] + const editorState = currentView!.state + const doc = editorState.doc + const cursorPosition = editorState.selection.main.head + const diagnostics = errorsToDiagnostics(errors, cursorPosition, doc.length) + const mergedDiagnostics = mergeCompatibleOverlappingDiagnostics(diagnostics) + currentResolver!(mergedDiagnostics) + // make compile controller aware of lint errors via editor:lint event + const hasLintingError = errors.some(e => e.type !== 'info') + window.dispatchEvent( + new CustomEvent('editor:lint', { + detail: { hasLintingError }, + }) + ) + } +}) + +const executeQueuedAction = (deferred: Deferred) => { + runLinter().then(result => deferred.resolve!(result)) + return deferred.promise +} + +const processQueue = () => { + if (queuedRequest) { + linterPromise = executeQueuedAction(queuedRequest).then(processQueue) + queuedRequest = null + } else { + linterPromise = null + } +} + +export const latexLinter = (view: EditorView) => { + // always update the view, we use it to filter the results to the current buffer + currentView = view + // if a linting request isn't already running, start it running + if (!linterPromise) { + linterPromise = runLinter() + linterPromise.then(processQueue) + return linterPromise + } else { + // otherwise create a single deferred promise which we will return to all subsequent requests + if (!queuedRequest) { + queuedRequest = new Deferred() + } + return queuedRequest.promise + } +} diff --git a/services/web/frontend/js/features/source-editor/languages/latex/linter/latex-linter.worker.js b/services/web/frontend/js/features/source-editor/languages/latex/linter/latex-linter.worker.js new file mode 100644 index 0000000000..bbf042cd39 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/languages/latex/linter/latex-linter.worker.js @@ -0,0 +1,1964 @@ +const Tokenise = function (text) { + const Tokens = [] + const Comments = [] + let pos = -1 + const SPECIAL = /[\\{}$&#^_~%]/g // match TeX special characters + const NEXTCS = /[^a-zA-Z]/g // match characters which aren't part of a TeX control sequence + let idx = 0 + + let lineNumber = 0 // current line number when parsing tokens (zero-based) + const linePosition = [] // mapping from line number to absolute offset of line in text[] + linePosition[0] = 0 + + let checkingDisabled = false + let count = 0 // number of tokens parses + const MAX_TOKENS = 100000 + + // Main parsing loop, split into tokens on TeX special characters + // each token is pushed onto the Tokens array as follows + // + // special character: [lineNumber, charCode, start] + // control sequence: [lineNumber, "\", start, end, "foo"] + // control symbold: [lineNumber, "\", start, end, "@"] + // + // end position = (position of last character in the sequence) + 1 + // + // so text.substring(start,end) returns the "foo" for \foo + + while (true) { + count++ + + // Avoid infinite loops and excessively large documents + if (count > MAX_TOKENS) { + throw new Error('exceed max token count of ' + MAX_TOKENS) + } + const result = SPECIAL.exec(text) + + // If no more special characters found, must be text at end of file + if (result == null) { + if (idx < text.length) { + Tokens.push([lineNumber, 'Text', idx, text.length]) + // FIXME: could check if previous token was Text and merge + } + break + } + + // Break out of loop if not going forwards in the file (shouldn't happen) + if (result && result.index <= pos) { + throw new Error('infinite loop in parsing') + } + + // Move up to the position of the match + pos = result.index + + // Anything between special characters is text + if (pos > idx) { + // FIXME: check if previous token was Text and merge + Tokens.push([lineNumber, 'Text', idx, pos]) + } + + // Scan over the text and update the line count + for (let i = idx; i < pos; i++) { + if (text[i] === '\n') { + lineNumber++ + linePosition[lineNumber] = i + 1 + } + } + + const newIdx = SPECIAL.lastIndex + idx = newIdx + + // Inspect the special character and consume additional characters according to TeX rules + const code = result[0] + if (code === '%') { + // comment character + // Handle comments by consuming up to the next newline character + let newLinePos = text.indexOf('\n', idx) + if (newLinePos === -1) { + // reached end of file + newLinePos = text.length + } + // Check comment for our magic sequences %novalidate, %begin/%end novalidate + const commentString = text.substring(idx, newLinePos) + if (commentString.indexOf('%novalidate') === 0) { + return [] + } else if ( + !checkingDisabled && + commentString.indexOf('%begin novalidate') === 0 + ) { + checkingDisabled = true + } else if ( + checkingDisabled && + commentString.indexOf('%end novalidate') === 0 + ) { + checkingDisabled = false + } + // Update the line count + idx = SPECIAL.lastIndex = newLinePos + 1 + Comments.push([lineNumber, idx, newLinePos]) + lineNumber++ + linePosition[lineNumber] = idx + } else if (checkingDisabled) { + // do nothing + continue + } else if (code === '\\') { + // escape character + // Handle TeX control sequences (\foo) and control symbols (\@) + // Look ahead to find the next character not valid in a control sequence [^a-zA-Z] + NEXTCS.lastIndex = idx + const controlSequence = NEXTCS.exec(text) + let nextSpecialPos = + controlSequence === null ? idx : controlSequence.index + if (nextSpecialPos === idx) { + // it's a control symbol + Tokens.push([ + lineNumber, + code, + pos, + idx + 1, + text[idx], + 'control-symbol', + ]) + idx = SPECIAL.lastIndex = idx + 1 + const char = text[nextSpecialPos] + // update the line number if someone typed \ at the end of a line + if (char === '\n') { + lineNumber++ + linePosition[lineNumber] = nextSpecialPos + } + } else { + // it's a control sequence + Tokens.push([ + lineNumber, + code, + pos, + nextSpecialPos, + text.slice(idx, nextSpecialPos), + ]) + // consume whitespace after a control sequence (update the line number too) + let char + while ( + (char = text[nextSpecialPos]) === ' ' || + char === '\t' || + char === '\r' || + char === '\n' + ) { + nextSpecialPos++ + if (char === '\n') { + lineNumber++ + linePosition[lineNumber] = nextSpecialPos + } + } + idx = SPECIAL.lastIndex = nextSpecialPos + } + } else if (['{', '}', '$', '&', '#', '^', '_', '~'].indexOf(code) > -1) { + // special characters + Tokens.push([lineNumber, code, pos, pos + 1]) + } else { + throw new Error('unrecognised character ' + code) + } + } + + return { + tokens: Tokens, + comments: Comments, + linePosition, + lineNumber, + text, + } +} + +// Functions for consuming TeX arguments + +const read1arg = function (TokeniseResult, k, options) { + // read an argument FOO to a either form of command + // \newcommand\FOO... + // \newcommand{\FOO}... + // Also support \newcommand* + const Tokens = TokeniseResult.tokens + const text = TokeniseResult.text + + // check for optional * like \newcommand* + if (options && options.allowStar) { + const optional = Tokens[k + 1] + if (optional && optional[1] === 'Text') { + const optionalstr = text.substring(optional[2], optional[3]) + if (optionalstr === '*') { + k++ + } + } + } + + const open = Tokens[k + 1] + const delimiter = Tokens[k + 2] + const close = Tokens[k + 3] + // let delimiterName + + if (open && open[1] === '\\') { + // plain \FOO, isn't enclosed in braces + // delimiterName = open[4] // array element 4 is command sequence + return k + 1 + } else if ( + open && + open[1] === '{' && + delimiter && + delimiter[1] === '\\' && + close && + close[1] === '}' + ) { + // argument is in braces + // delimiterName = delimiter[4] // NOTE: if we were actually using this, keep track of * above + return k + 3 // array element 4 is command sequence + } else { + // couldn't find argument + return null + } +} + +const readLetDefinition = function (TokeniseResult, k) { + // read a let command (the equals sign is optional) + // \let\foo=\bar + // \let\foo=TOKEN + // \let\foo\bar + // \let\foo\TOKEN + + const Tokens = TokeniseResult.tokens + const text = TokeniseResult.text + + const first = Tokens[k + 1] + const second = Tokens[k + 2] + const third = Tokens[k + 3] + + if (first && first[1] === '\\' && second && second[1] === '\\') { + return k + 2 + } else if ( + first && + first[1] === '\\' && + second && + second[1] === 'Text' && + text.substring(second[2], second[3]) === '=' && + third && + third[1] === '\\' + ) { + return k + 3 + } else { + // couldn't find argument + return null + } +} + +const read1name = function (TokeniseResult, k) { + // read an environemt name FOO in + // \newenvironment{FOO}... + const Tokens = TokeniseResult.tokens + const text = TokeniseResult.text + + const open = Tokens[k + 1] + const delimiter = Tokens[k + 2] + const close = Tokens[k + 3] + + if ( + open && + open[1] === '{' && + delimiter && + delimiter[1] === 'Text' && + close && + close[1] === '}' + ) { + // let delimiterName = text.substring(delimiter[2], delimiter[3]) + return k + 3 + } else if (open && open[1] === '{' && delimiter && delimiter[1] === 'Text') { + // handle names like FOO_BAR + let delimiterName = '' + let j, tok + for (j = k + 2, tok; (tok = Tokens[j]); j++) { + if (tok[1] === 'Text') { + const str = text.substring(tok[2], tok[3]) + if (!str.match(/^\S*$/)) { + break + } + delimiterName = delimiterName + str + } else if (tok[1] === '_') { + delimiterName = delimiterName + '_' + } else { + break + } + } + if (tok && tok[1] === '}') { + return j // advance past these tokens + } else { + return null + } + } else { + // couldn't find environment name + return null + } +} + +const read1filename = function (TokeniseResult, k) { + // read an filename foo_bar.tex + const Tokens = TokeniseResult.tokens + const text = TokeniseResult.lett + + let fileName = '' + let j, tok + for (j = k + 1, tok; (tok = Tokens[j]); j++) { + if (tok[1] === 'Text') { + const str = text.substring(tok[2], tok[3]) + if (!str.match(/^\S*$/)) { + break + } + fileName = fileName + str + } else if (tok[1] === '_') { + fileName = fileName + '_' + } else { + break + } + } + if (fileName.length > 0) { + return j // advance past these tokens + } else { + return null + } +} + +const readOptionalParams = function (TokeniseResult, k) { + // read an optional parameter [N] where N is a number, used + // for \newcommand{\foo}[2]... meaning 2 parameters + const Tokens = TokeniseResult.tokens + const text = TokeniseResult.text + + const params = Tokens[k + 1] + + // Quick check for arguments like [1][key=value,key=value] + if (params && params[1] === 'Text') { + const paramNum = text.substring(params[2], params[3]) + if (paramNum.match(/^\[\d+\](\[[^\]]*\])*\s*$/)) { + return k + 1 // got it + } + } + + // Skip over arbitrary arguments [xxx][yyy][\foo{zzz}]{... up to the first {.. + let count = 0 + let nextToken = Tokens[k + 1] + if (!nextToken) { + return null + } + const pos = nextToken[2] + + for (let i = pos, end = text.length; i < end; i++) { + const char = text[i] + if (nextToken && i >= nextToken[2]) { + k++ + nextToken = Tokens[k + 1] + } + if (char === '[') { + count++ + } + if (char === ']') { + count-- + } + if (count === 0 && char === '{') { + return k - 1 + } + if (count > 0 && (char === '\r' || char === '\n')) { + return null + } + } + + // can't find an optional parameter + return null +} + +const readOptionalGeneric = function (TokeniseResult, k) { + // read an optional parameter [foo] + const Tokens = TokeniseResult.tokens + const text = TokeniseResult.text + + const params = Tokens[k + 1] + + if (params && params[1] === 'Text') { + const paramNum = text.substring(params[2], params[3]) + if (paramNum.match(/^(\[[^\]]*\])+\s*$/)) { + return k + 1 // got it + } + } + + // can't find an optional parameter + return null +} + +const readOptionalDef = function (TokeniseResult, k) { + // skip over the optional arguments of a definition + // \def\foo#1.#2(#3){this is the macro #1 #2 #3} + // start looking at text immediately after \def command + const Tokens = TokeniseResult.tokens + const text = TokeniseResult.text + + const defToken = Tokens[k] + const pos = defToken[3] + + const openBrace = '{' + let nextToken = Tokens[k + 1] + for (let i = pos, end = text.length; i < end; i++) { + const char = text[i] + if (nextToken && i >= nextToken[2]) { + k++ + nextToken = Tokens[k + 1] + } + if (char === openBrace) { + return k - 1 + } // move back to the last token of the optional arguments + if (char === '\r' || char === '\n') { + return null + } + } + + return null +} + +const readDefinition = function (TokeniseResult, k) { + // read a definition as in + // \newcommand{\FOO}{DEFN} + // \newcommand{\FOO} {DEF} (optional whitespace) + // look ahead for argument, consuming whitespace + // the definition is read looking for balanced { } braces. + const Tokens = TokeniseResult.tokens + const text = TokeniseResult.text + + k = k + 1 + let count = 0 + let nextToken = Tokens[k] + while (nextToken && nextToken[1] === 'Text') { + const start = nextToken[2] + const end = nextToken[3] + for (let i = start; i < end; i++) { + const char = text[i] + if (char === ' ' || char === '\t' || char === '\r' || char === '\n') { + continue + } + return null // bail out, should begin with a { + } + k++ + nextToken = Tokens[k] + } + + // Now we're at the start of the actual argument + if (nextToken && nextToken[1] === '{') { + count++ + // use simple bracket matching { } to find where the + // argument ends + while (count > 0) { + k++ + nextToken = Tokens[k] + if (!nextToken) { + break + } + if (nextToken[1] === '}') { + count-- + } + if (nextToken[1] === '{') { + count++ + } + } + return k + } + + return null +} + +const readVerb = function (TokeniseResult, k) { + // read a verbatim argument + // \verb@foo@ + // \verb*@foo@ + // where @ is any character except * for \verb + // foo is any sequence excluding end-of-line and the delimiter + // a space does work for @, contrary to latex documentation + + // Note: this is only an approximation, because we have already + // tokenised the input stream, and we should really do that taking + // into account the effect of verb. For example \verb|%| will get + // confused because % is then a character. + + const Tokens = TokeniseResult.tokens + const text = TokeniseResult.text + + const verbToken = Tokens[k] + // const verbStr = text.substring(verbToken[2], verbToken[3]) + + // start looking at text immediately after \verb command + let pos = verbToken[3] + if (text[pos] === '*') { + pos++ + } // \verb* form of command + const delimiter = text[pos] + pos++ + + let nextToken = Tokens[k + 1] + for (let i = pos, end = text.length; i < end; i++) { + const char = text[i] + if (nextToken && i >= nextToken[2]) { + k++ + nextToken = Tokens[k + 1] + } + if (char === delimiter) { + return k + } + if (char === '\r' || char === '\n') { + return null + } + } + + return null +} + +const readUrl = function (TokeniseResult, k) { + // read a url argument + // \url|foo| + // \url{foo} + + // Note: this is only an approximation, because we have already + // tokenised the input stream, so anything after a comment + // character % on the current line will not be present in the + // input stream. + + const Tokens = TokeniseResult.tokens + const text = TokeniseResult.text + + const urlToken = Tokens[k] + // const urlStr = text.substring(urlToken[2], urlToken[3]) + + // start looking at text immediately after \url command + let pos = urlToken[3] + const openDelimiter = text[pos] + const closeDelimiter = openDelimiter === '{' ? '}' : openDelimiter + + // Was the delimiter a token? if so, advance token index + let nextToken = Tokens[k + 1] + if (nextToken && pos === nextToken[2]) { + k++ + nextToken = Tokens[k + 1] + } + + // Now start looking at the enclosed text + pos++ + + let count = 1 + for (let i = pos, end = text.length; count > 0 && i < end; i++) { + const char = text[i] + if (nextToken && i >= nextToken[2]) { + k++ + nextToken = Tokens[k + 1] + } + if (char === closeDelimiter) { + count-- + } else if (char === openDelimiter) { + count++ + } + if (count === 0) { + return k + } + if (char === '\r' || char === '\n') { + return null + } + } + + return null +} + +const InterpretTokens = function (TokeniseResult, ErrorReporter) { + const Tokens = TokeniseResult.tokens + // var linePosition = TokeniseResult.linePosition + // var lineNumber = TokeniseResult.lineNumber + const text = TokeniseResult.text + + const TokenErrorFromTo = ErrorReporter.TokenErrorFromTo + const TokenError = ErrorReporter.TokenError + const Environments = new EnvHandler(TokeniseResult, ErrorReporter) + + let nextGroupMathMode = null // if the next group should have + // math mode on(=true) or + // off(=false) (for \hbox), or + // unknown(=undefined) or inherit + // the current math mode from the + // parent environment(=null) + const nextGroupMathModeStack = [] // tracking all nextGroupMathModes + let seenUserDefinedBeginEquation = false // if we have seen macros like \beq + let seenUserDefinedEndEquation = false // if we have seen macros like \eeq + + // Iterate over the tokens, looking for environments to match + // + // Push environment command found (\begin, \end) onto the + // Environments array. + + for (let i = 0, len = Tokens.length; i < len; i++) { + const token = Tokens[i] + // const line = token[0] + const type = token[1] + // const start = token[2] + // const end = token[3] + const seq = token[4] + + if (type === '{') { + // handle open group as a type of environment + Environments.push({ + command: '{', + token, + mathMode: nextGroupMathMode, + }) + // if previously encountered a macro with a known or + // unknow math mode set that, and put it on a stack to be + // used for subsequent arguments \foo{...}{...}{...} + nextGroupMathModeStack.push(nextGroupMathMode) + nextGroupMathMode = null + continue + } else if (type === '}') { + // handle close group as a type of environment + Environments.push({ command: '}', token }) + // retrieve the math mode of the current macro (if any) + // for subsequent arguments + nextGroupMathMode = nextGroupMathModeStack.pop() + continue + } else { + // we aren't opening or closing a group, so reset the + // nextGroupMathMode - the next group will not be in math + // mode or undefined unless otherwise specified below + nextGroupMathMode = null + } + + if (type === '\\') { + // Interpret each control sequence + if (seq === 'begin' || seq === 'end') { + // We've got a begin or end, now look ahead at the + // next three tokens which should be "{" "ENVNAME" "}" + const open = Tokens[i + 1] + const delimiter = Tokens[i + 2] + const close = Tokens[i + 3] + if ( + open && + open[1] === '{' && + delimiter && + delimiter[1] === 'Text' && + close && + close[1] === '}' + ) { + // We've got a valid environment command, push it onto the array. + const delimiterName = text.substring(delimiter[2], delimiter[3]) + Environments.push({ + command: seq, + name: delimiterName, + token, + closeToken: close, + }) + i = i + 3 // advance past these tokens + } else { + // Check for an environment command like \begin{new_major_theorem} + if (open && open[1] === '{' && delimiter && delimiter[1] === 'Text') { + let delimiterName = '' + let j, tok + for (j = i + 2, tok; (tok = Tokens[j]); j++) { + if (tok[1] === 'Text') { + const str = text.substring(tok[2], tok[3]) + if (!str.match(/^\S*$/)) { + break + } + delimiterName = delimiterName + str + } else if (tok[1] === '_') { + delimiterName = delimiterName + '_' + } else { + break + } + } + if (tok && tok[1] === '}') { + Environments.push({ + command: seq, + name: delimiterName, + token, + closeToken: close, + }) + i = j // advance past these tokens + continue + } + } + + // We're looking at an invalid environment command, read as far as we can in the sequence + // "{" "CHAR" "CHAR" "CHAR" ... to report an error for as much of the command as we can, + // bail out when we hit a space/newline. + let endToken = null + if (open && open[1] === '{') { + endToken = open // we've got a { + if (delimiter && delimiter[1] === 'Text') { + endToken = delimiter.slice() // we've got some text following the { + const start = endToken[2] + const end = endToken[3] + let j + for (j = start; j < end; j++) { + const char = text[j] + if ( + char === ' ' || + char === '\t' || + char === '\r' || + char === '\n' + ) { + break + } + } + endToken[3] = j // the end of partial token is as far as we got looking ahead + } + } + + if (endToken) { + TokenErrorFromTo( + token, + endToken, + 'invalid environment command ' + + text.substring(token[2], endToken[3] || endToken[2]) + ) + } else { + TokenError(token, 'invalid environment command') + } + } + } else if (typeof seq === 'string' && seq.match(/^(be|beq|beqa|bea)$/i)) { + // Environments.push({command: "begin", name: "user-defined-equation", token: token}); + seenUserDefinedBeginEquation = true + } else if ( + typeof seq === 'string' && + seq.match(/^(ee|eeq|eeqn|eeqa|eeqan|eea)$/i) + ) { + // Environments.push({command: "end", name: "user-defined-equation", token: token}); + seenUserDefinedEndEquation = true + } else if ( + seq === 'newcommand' || + seq === 'renewcommand' || + seq === 'DeclareRobustCommand' + ) { + // Parse command definitions in a limited way, to + // avoid falsely reporting errors from unmatched + // environments in the command definition + // + // e.g. \newcommand{\foo}{\begin{equation}} is valid + // and should not trigger an "unmatch environment" + // error + + // try to read first arg \newcommand{\foo}...., advance if found + // and otherwise bail out + let newPos = read1arg(TokeniseResult, i, { allowStar: true }) + if (newPos === null) { + continue + } else { + i = newPos + } + + // try to read any optional params [BAR]...., advance if found + newPos = readOptionalParams(TokeniseResult, i) + if (newPos === null) { + /* do nothing */ + } else { + i = newPos + } + + // try to read command defintion {....}, advance if found + newPos = readDefinition(TokeniseResult, i) + if (newPos === null) { + /* do nothing */ + } else { + i = newPos + } + } else if (seq === 'def') { + // try to read first arg \def\foo...., advance if found + // and otherwise bail out + let newPos = read1arg(TokeniseResult, i) + if (newPos === null) { + continue + } else { + i = newPos + } + + // try to read any optional params [BAR]...., advance if found + newPos = readOptionalDef(TokeniseResult, i) + if (newPos === null) { + /* do nothing */ + } else { + i = newPos + } + + // try to read command defintion {....}, advance if found + newPos = readDefinition(TokeniseResult, i) + if (newPos === null) { + /* do nothing */ + } else { + i = newPos + } + } else if (seq === 'let') { + // Parse any \let commands can be + // \let\foo\bar + // \let\foo=\bar + // \let\foo=TOKEN + const newPos = readLetDefinition(TokeniseResult, i) + if (newPos === null) { + continue + } else { + i = newPos + } + } else if (seq === 'newcolumntype') { + // try to read first arg \newcolumntype{T}...., advance if found + // and otherwise bail out + let newPos = read1name(TokeniseResult, i) + if (newPos === null) { + continue + } else { + i = newPos + } + + // try to read any optional params [BAR]...., advance if found + newPos = readOptionalParams(TokeniseResult, i) + if (newPos === null) { + /* do nothing */ + } else { + i = newPos + } + + // try to read command defintion {....}, advance if found + newPos = readDefinition(TokeniseResult, i) + if (newPos === null) { + /* do nothing */ + } else { + i = newPos + } + } else if (seq === 'newenvironment' || seq === 'renewenvironment') { + // Parse environment definitions in a limited way too + // \newenvironment{name}[3]{open}{close} + + // try to read first arg \newcommand{\foo}...., advance if found + // and otherwise bail out + let newPos = read1name(TokeniseResult, i) + if (newPos === null) { + continue + } else { + i = newPos + } + + // try to read any optional params [BAR]...., advance if found + newPos = readOptionalParams(TokeniseResult, i) + if (newPos === null) { + /* do nothing */ + } else { + i = newPos + } + + // try to read open defintion {....}, advance if found + newPos = readDefinition(TokeniseResult, i) + if (newPos === null) { + /* do nothing */ + } else { + i = newPos + } + + // try to read close defintion {....}, advance if found + newPos = readDefinition(TokeniseResult, i) + if (newPos === null) { + /* do nothing */ + } else { + i = newPos + } + } else if (seq === 'verb') { + // \verb|....| where | = any char + const newPos = readVerb(TokeniseResult, i) + if (newPos === null) { + TokenError(token, 'invalid verbatim command') + } else { + i = newPos + } + } else if (seq === 'url') { + // \url{...} or \url|....| where | = any char + const newPos = readUrl(TokeniseResult, i) + if (newPos === null) { + TokenError(token, 'invalid url command') + } else { + i = newPos + } + } else if (seq === 'left' || seq === 'right') { + // \left( and \right) + const nextToken = Tokens[i + 1] + let char = '' + if (nextToken && nextToken[1] === 'Text') { + char = text.substring(nextToken[2], nextToken[2] + 1) + } else if ( + nextToken && + nextToken[1] === '\\' && + nextToken[5] === 'control-symbol' + ) { + // control symbol + char = nextToken[4] + } else if (nextToken && nextToken[1] === '\\') { + char = 'unknown' + } + if ( + char === '' || + (char !== 'unknown' && '(){}[]<>/|\\.'.indexOf(char) === -1) + ) { + // unrecognized bracket - list of allowed delimiters from TeX By Topic (38.3.2 Delimiter codes) + TokenError(token, 'invalid bracket command') + } else { + i = i + 1 + Environments.push({ command: seq, token }) + } + } else if (seq === '(' || seq === ')' || seq === '[' || seq === ']') { + Environments.push({ command: seq, token }) + } else if (seq === 'input') { + // skip over filenames, may contain _ + const newPos = read1filename(TokeniseResult, i) + if (newPos === null) { + continue + } else { + i = newPos + } + } else if ( + seq === 'hbox' || + seq === 'text' || + seq === 'mbox' || + seq === 'footnote' || + seq === 'intertext' || + seq === 'shortintertext' || + seq === 'textnormal' || + seq === 'tag' || + seq === 'reflectbox' || + seq === 'textrm' + ) { + // next group will be in text mode regardless + nextGroupMathMode = false + } else if (seq === 'tikz') { + // try to read any optional params [BAR]...., advance if found + const newPos = readOptionalGeneric(TokeniseResult, i) + if (newPos === null) { + /* do nothing */ + } else { + i = newPos + } + nextGroupMathMode = false + } else if ( + seq === 'rotatebox' || + seq === 'scalebox' || + seq === 'feynmandiagram' + ) { + // try to read any optional params [BAR]...., advance if found + let newPos = readOptionalGeneric(TokeniseResult, i) + if (newPos === null) { + /* do nothing */ + } else { + i = newPos + } + // try to read parameter {....}, advance if found + newPos = readDefinition(TokeniseResult, i) + if (newPos === null) { + /* do nothing */ + } else { + i = newPos + } + nextGroupMathMode = false + } else if (seq === 'resizebox') { + // try to read any optional params [BAR]...., advance if found + let newPos = readOptionalGeneric(TokeniseResult, i) + if (newPos === null) { + /* do nothing */ + } else { + i = newPos + } + // try to read width parameter {....}, advance if found + newPos = readDefinition(TokeniseResult, i) + if (newPos === null) { + /* do nothing */ + } else { + i = newPos + } + // try to read height parameter {....}, advance if found + newPos = readDefinition(TokeniseResult, i) + if (newPos === null) { + /* do nothing */ + } else { + i = newPos + } + + nextGroupMathMode = false + } else if (seq === 'DeclareMathOperator') { + // try to read first arg {....}, advance if found + let newPos = readDefinition(TokeniseResult, i) + if (newPos === null) { + /* do nothing */ + } else { + i = newPos + } + + // try to read second arg {....}, advance if found + newPos = readDefinition(TokeniseResult, i) + if (newPos === null) { + /* do nothing */ + } else { + i = newPos + } + } else if (seq === 'DeclarePairedDelimiter') { + // try to read first arg {....}, advance if found + let newPos = readDefinition(TokeniseResult, i) + if (newPos === null) { + /* do nothing */ + } else { + i = newPos + } + + // try to read second arg {....}, advance if found + newPos = readDefinition(TokeniseResult, i) + if (newPos === null) { + /* do nothing */ + } else { + i = newPos + } + + // try to read third arg {....}, advance if found + newPos = readDefinition(TokeniseResult, i) + if (newPos === null) { + /* do nothing */ + } else { + i = newPos + } + } else if ( + typeof seq === 'string' && + seq.match( + /^(alpha|beta|gamma|delta|epsilon|varepsilon|zeta|eta|theta|vartheta|iota|kappa|lambda|mu|nu|xi|pi|varpi|rho|varrho|sigma|varsigma|tau|upsilon|phi|varphi|chi|psi|omega|Gamma|Delta|Theta|Lambda|Xi|Pi|Sigma|Upsilon|Phi|Psi|Omega)$/ + ) + ) { + const currentMathMode = Environments.getMathMode() // returns null / $(inline) / $$(display) + if (currentMathMode === null) { + TokenError(token, type + seq + ' must be inside math mode', { + mathMode: true, + }) + } + } else if ( + typeof seq === 'string' && + seq.match(/^(chapter|section|subsection|subsubsection)$/) + ) { + const currentMathMode = Environments.getMathMode() // returns null / $(inline) / $$(display) + if (currentMathMode) { + TokenError(token, type + seq + ' used inside math mode', { + mathMode: true, + }) + Environments.resetMathMode() + } + } else if (typeof seq === 'string' && seq.match(/^[a-z]+$/)) { + // if we see an unknown command \foo{...}{...} put the + // math mode for the next group into the 'undefined' + // state, because we do not know what math mode an + // arbitrary macro will use for its arguments. In the + // math mode 'undefined' state we don't report errors + // when we encounter math or text commands. + nextGroupMathMode = undefined + } + } else if (type === '$') { + const lookAhead = Tokens[i + 1] + const nextIsDollar = lookAhead && lookAhead[1] === '$' + const currentMathMode = Environments.getMathMode() // returns null / $(inline) / $$(display) + // If we have a $$ and we're not in displayMath, we go into that + // If we have a $$ and with not in math mode at all, we got into displayMath + if ( + nextIsDollar && + (!currentMathMode || currentMathMode.command === '$$') + ) { + let delimiterToken + if (currentMathMode && currentMathMode.command === '$$') { + // Use last $ as token if it's the end of math mode, so that we capture all content, including both $s + delimiterToken = lookAhead + } else { + delimiterToken = token + } + Environments.push({ command: '$$', token: delimiterToken }) + i = i + 1 + } else { + Environments.push({ command: '$', token }) + } + } else if (type === '^' || type === '_') { + // check for mathmode ASSUMING environments are correct + // if they aren't we'll catch it below + // we can maybe set a flag here for math mode state? + const currentMathMode = Environments.getMathMode() // returns null / $(inline) / $$(display) + // need to exclude cases like \cite{foo_bar} so ignore everything inside {...} + const insideGroup = Environments.insideGroup() // true if inside {....} + if (currentMathMode === null && !insideGroup) { + TokenError(token, type + ' must be inside math mode', { + mathMode: true, + }) + } + } + } + + if (seenUserDefinedBeginEquation && seenUserDefinedEndEquation) { + // there are commands like \beq or \eeq which are typically + // shortcuts for \begin{equation} and \end{equation}, so + // disable math errors + ErrorReporter.filterMath = true + } + + return Environments +} + +const DocumentTree = function (TokeniseResult) { + // Each environment and scope becomes and an entry in the tree, and can have + // child entries, e.g. an 'array' inside an 'equation' inside a 'document' environment. + // Entries can have multiple adjacent children. + const tree = { + children: [], + } + // The stack is just for easily moving up and down the tree. Popping off the stack + // moves us back up the context of the current environment. + const stack = [tree] + + this.openEnv = function (startDelimiter) { + const currentNode = this.getCurrentNode() + const newNode = { + startDelimiter, + children: [], + } + currentNode.children.push(newNode) + stack.push(newNode) + } + + this.closeEnv = function (endDelimiter) { + if (stack.length === 1) { + // Can't close root element + return null + } + const currentNode = stack.pop() + currentNode.endDelimiter = endDelimiter + return currentNode.startDelimiter + } + + this.getNthPreviousNode = function (n) { + const offset = stack.length - n - 1 + if (offset < 0) return null + return stack[offset] + } + + this.getCurrentNode = function () { + return this.getNthPreviousNode(0) + } + + this.getCurrentDelimiter = function () { + return this.getCurrentNode().startDelimiter + } + + this.getPreviousDelimiter = function () { + const node = this.getNthPreviousNode(1) + if (!node) return null + return node.startDelimiter + } + + this.getDepth = function () { + return stack.length - 1 // Root node doesn't count + } + + this.getContexts = function () { + const linePosition = TokeniseResult.linePosition + + function tokenToRange(token) { + const line = token[0] + const start = token[2] + let end = token[3] + const startCol = start - linePosition[line] + if (!end) { + end = start + 1 + } + const endCol = end - linePosition[line] + return { + start: { + row: line, + column: startCol, + }, + end: { + row: line, + column: endCol, + }, + } + } + + function getContextsFromNode(node) { + if (node.startDelimiter && node.startDelimiter.mathMode) { + const context = { + type: 'math', + range: { + start: tokenToRange(node.startDelimiter.token).start, + }, + } + if (node.endDelimiter) { + const closeToken = + node.endDelimiter.closeToken || node.endDelimiter.token + context.range.end = tokenToRange(closeToken).end + } + return [context] + } else { + let contexts = [] + for (let i = 0; i < node.children.length; i++) { + const child = node.children[i] + contexts = contexts.concat(getContextsFromNode(child)) + } + return contexts + } + } + + return getContextsFromNode(tree) + } +} + +const EnvHandler = function (TokeniseResult, ErrorReporter) { + // Loop through the Environments array keeping track of the state, + // pushing and popping environments onto the state[] array for each + // \begin and \end command + const ErrorTo = ErrorReporter.EnvErrorTo + const ErrorFromTo = ErrorReporter.EnvErrorFromTo + const ErrorFrom = ErrorReporter.EnvErrorFrom + + const delimiters = [] + + const document = new DocumentTree(TokeniseResult) + let documentClosed = null + let inVerbatim = false + const verbatimRanges = [] + + this.getDocument = function () { + return document + } + + this.push = function (newDelimiter) { + this.setDelimiterProps(newDelimiter) + this.checkAndUpdateState(newDelimiter) + delimiters.push(newDelimiter) + } + + this._endVerbatim = function (thisDelimiter) { + const lastDelimiter = document.getCurrentDelimiter() + if (lastDelimiter && lastDelimiter.name === thisDelimiter.name) { + // closed verbatim environment correctly + inVerbatim = false + document.closeEnv(thisDelimiter) + // keep track of all the verbatim ranges to filter out errors + verbatimRanges.push({ + start: lastDelimiter.token[2], + end: thisDelimiter.token[2], + }) + } + } + + const invalidEnvs = [] + + this._end = function (thisDelimiter) { + // check if environment or group is closed correctly + let retry + do { + retry = false + const lastDelimiter = document.getCurrentDelimiter() + let i + + if (closedBy(lastDelimiter, thisDelimiter)) { + // closed correctly + document.closeEnv(thisDelimiter) + if ( + thisDelimiter.command === 'end' && + thisDelimiter.name === 'document' && + !documentClosed + ) { + documentClosed = thisDelimiter + } + return + } else if (!lastDelimiter) { + // unexpected close, nothing was open! + if (documentClosed) { + ErrorFromTo( + documentClosed, + thisDelimiter, + '\\end{' + + documentClosed.name + + '} is followed by unexpected content', + { errorAtStart: true, type: 'info' } + ) + } else { + ErrorTo(thisDelimiter, 'unexpected ' + getName(thisDelimiter)) + } + } else if ( + invalidEnvs.length > 0 && + (i = indexOfClosingEnvInArray(invalidEnvs, thisDelimiter) > -1) + ) { + // got a match on an invalid env, so try to continue + invalidEnvs.splice(i, 1) + return + } else { + reportError(lastDelimiter, thisDelimiter) + if ( + delimiterPrecedence(lastDelimiter) < + delimiterPrecedence(thisDelimiter) + ) { + // discard the lastDelimiter then retry the match for thisDelimiter + document.closeEnv() + invalidEnvs.push(lastDelimiter) + retry = true + } else { + // tried to close a different environment for the one that is open + // Apply some heuristics to try to minimise cascading errors + // + // Consider cases of + // 1) Extra \end: \begin{A} \end{B} \end{A} + // 2) Extra \begin: \begin{A} \begin{B} \end{A} + // + // Case (2) try looking back to the previous \begin, + // if it gives a valid match, take it! + const prevDelimiter = document.getPreviousDelimiter() + if (prevDelimiter) { + if (thisDelimiter.name === prevDelimiter.name) { + // got a match on the previous environment + document.closeEnv() // Close current env + document.closeEnv(thisDelimiter) // Close previous env + return + } + } + // No match so put lastDelimiter back on a list of valid + // environments that we might be able to match on + // further errors + invalidEnvs.push(lastDelimiter) + } + } + } while (retry === true) + } + + const CLOSING_DELIMITER = { + '{': '}', + left: 'right', + '[': ']', + '(': ')', + $: '$', + $$: '$$', + } + + const closedBy = function (lastDelimiter, thisDelimiter) { + if (!lastDelimiter) { + return false + } else if (thisDelimiter.command === 'end') { + return ( + lastDelimiter.command === 'begin' && + lastDelimiter.name === thisDelimiter.name + ) + } else if ( + thisDelimiter.command === CLOSING_DELIMITER[lastDelimiter.command] + ) { + return true + } else { + return false + } + } + + const indexOfClosingEnvInArray = function (delimiters, thisDelimiter) { + for (let i = 0, n = delimiters.length; i < n; i++) { + if (closedBy(delimiters[i], thisDelimiter)) { + return i + } + } + return -1 + } + + const delimiterPrecedence = function (delimiter) { + const openScore = { + '{': 1, + left: 2, + $: 3, + $$: 4, + begin: 4, + } + const closeScore = { + '}': 1, + right: 2, + $: 3, + $$: 5, + end: 4, + } + if (delimiter.command) { + return openScore[delimiter.command] || closeScore[delimiter.command] + } else { + return 0 + } + } + + const getName = function (delimiter) { + const description = { + '{': 'open group {', + '}': 'close group }', + '[': 'open display math \\[', + ']': 'close display math \\]', + '(': 'open inline math \\(', + ')': 'close inline math \\)', + $: '$', + $$: '$$', + left: '\\left', + right: '\\right', + } + if (delimiter.command === 'begin' || delimiter.command === 'end') { + return '\\' + delimiter.command + '{' + delimiter.name + '}' + } else if (delimiter.command in description) { + return description[delimiter.command] + } else { + return delimiter.command + } + } + + const EXTRA_CLOSE = 1 + const UNCLOSED_GROUP = 2 + const UNCLOSED_ENV = 3 + + const reportError = function (lastDelimiter, thisDelimiter) { + if (!lastDelimiter) { + // unexpected close, nothing was open! + if (documentClosed) { + ErrorFromTo( + documentClosed, + thisDelimiter, + '\\end{' + + documentClosed.name + + '} is followed by unexpected end group }', + { errorAtStart: true, type: 'info' } + ) + } else { + ErrorTo(thisDelimiter, 'unexpected ' + getName(thisDelimiter)) + } + return EXTRA_CLOSE + } else if ( + lastDelimiter.command === '{' && + thisDelimiter.command === 'end' + ) { + ErrorFromTo( + lastDelimiter, + thisDelimiter, + 'unclosed ' + + getName(lastDelimiter) + + ' found at ' + + getName(thisDelimiter), + { suppressIfEditing: true, errorAtStart: true, type: 'warning' } + ) + // discard the open group by not pushing it back on the stack + return UNCLOSED_GROUP + } else { + ErrorFromTo( + lastDelimiter, + thisDelimiter, + 'unclosed ' + + getName(lastDelimiter) + + ' found at ' + + getName(thisDelimiter), + { suppressIfEditing: true, errorAtStart: true } + ) + ErrorFromTo( + lastDelimiter, + thisDelimiter, + 'unexpected ' + + getName(thisDelimiter) + + ' after ' + + getName(lastDelimiter) + ) + return UNCLOSED_ENV + } + } + + this._beginMathMode = function (thisDelimiter) { + // start a new math environment + const currentMathMode = this.getMathMode() // undefined, null, $, $$, name of mathmode env + if (currentMathMode) { + ErrorFrom( + thisDelimiter, + getName(thisDelimiter) + + ' used inside existing math mode ' + + getName(currentMathMode), + { suppressIfEditing: true, errorAtStart: true, mathMode: true } + ) + } + thisDelimiter.mathMode = thisDelimiter + document.openEnv(thisDelimiter) + } + + this._toggleMathMode = function (thisDelimiter) { + // math environments use the same for begin and end. + const lastDelimiter = document.getCurrentDelimiter() + if (closedBy(lastDelimiter, thisDelimiter)) { + // closed math environment correctly + document.closeEnv(thisDelimiter) + } else { + if (lastDelimiter && lastDelimiter.mathMode) { + // already in math mode + this._end(thisDelimiter) + } else { + // start a new math environment + thisDelimiter.mathMode = thisDelimiter + document.openEnv(thisDelimiter) + } + } + } + + this.getMathMode = function () { + // return the current mathmode. + // the mathmode is an object, it is the environment that opened the math mode + const currentDelimiter = document.getCurrentDelimiter() + if (currentDelimiter) { + return currentDelimiter.mathMode + } else { + return null + } + } + + this.insideGroup = function () { + const currentDelimiter = document.getCurrentDelimiter() + if (currentDelimiter) { + return currentDelimiter.command === '{' + } else { + return null + } + } + + const resetMathMode = function () { + // Wind back the current environment stack removing everything + // from the start of the current math mode + const currentDelimiter = document.getCurrentDelimiter() + if (currentDelimiter) { + const lastMathMode = currentDelimiter.mathMode + let lastDelimiter + do { + lastDelimiter = document.closeEnv() + } while (lastDelimiter && lastDelimiter !== lastMathMode) + } else { + // return + } + } + + this.resetMathMode = resetMathMode + + const getNewMathMode = function (currentMathMode, thisDelimiter) { + // look at math mode and transitions + // + // We have several cases + // + // 1. environments that can only be used outside math mode (document, quote, etc) + // 2. environments that can only be used inside math mode (array) + // 3. environments that start math mode (equation) + // 4. environments that are unknown (new_theorem) + let newMathMode = null + + if (thisDelimiter.command === '{') { + if (thisDelimiter.mathMode !== null) { + // the group is a special one with a definite mathmode e.g. \hbox + newMathMode = thisDelimiter.mathMode + } else { + newMathMode = currentMathMode + } + } else if (thisDelimiter.command === 'left') { + if (currentMathMode === null) { + ErrorFrom(thisDelimiter, '\\left can only be used in math mode', { + mathMode: true, + }) + } + newMathMode = currentMathMode + } else if (thisDelimiter.command === 'begin') { + const name = thisDelimiter.name + if (name) { + if ( + name.match( + /^(document|figure|center|enumerate|itemize|table|abstract|proof|lemma|theorem|definition|proposition|corollary|remark|notation|thebibliography)$/ + ) + ) { + // case 1, must be outside math mode + if (currentMathMode) { + ErrorFromTo( + currentMathMode, + thisDelimiter, + thisDelimiter.name + ' used inside ' + getName(currentMathMode), + { suppressIfEditing: true, errorAtStart: true, mathMode: true } + ) + resetMathMode() + } + newMathMode = null + } else if ( + name.match(/^(array|gathered|split|aligned|alignedat)\*?$/) + ) { + // case 2, must be inside math mode + if (currentMathMode === null) { + ErrorFrom( + thisDelimiter, + thisDelimiter.name + ' not inside math mode', + { mathMode: true } + ) + } + newMathMode = currentMathMode + } else if ( + name.match( + /^(math|displaymath|equation|eqnarray|multline|align|gather|flalign|alignat)\*?$/ + ) + ) { + // case 3, must be outside math mode but starts it + if (currentMathMode) { + ErrorFromTo( + currentMathMode, + thisDelimiter, + thisDelimiter.name + ' used inside ' + getName(currentMathMode), + { suppressIfEditing: true, errorAtStart: true, mathMode: true } + ) + resetMathMode() + } + newMathMode = thisDelimiter + } else { + // case 4, unknown environments + newMathMode = undefined // undefined means we don't know if we are in math mode or not + } + } + } + return newMathMode + } + + this.checkAndUpdateState = function (thisDelimiter) { + if (inVerbatim) { + if (thisDelimiter.command === 'end') { + this._endVerbatim(thisDelimiter) + } else { + // return // ignore anything in verbatim environments + } + } else if ( + thisDelimiter.command === 'begin' || + thisDelimiter.command === '{' || + thisDelimiter.command === 'left' + ) { + if (thisDelimiter.verbatim) { + inVerbatim = true + } + // push new environment onto stack + const currentMathMode = this.getMathMode() // undefined, null, $, $$, name of mathmode env + const newMathMode = getNewMathMode(currentMathMode, thisDelimiter) + thisDelimiter.mathMode = newMathMode + document.openEnv(thisDelimiter) + } else if (thisDelimiter.command === 'end') { + this._end(thisDelimiter) + } else if (thisDelimiter.command === '(' || thisDelimiter.command === '[') { + this._beginMathMode(thisDelimiter) + } else if (thisDelimiter.command === ')' || thisDelimiter.command === ']') { + this._end(thisDelimiter) + } else if (thisDelimiter.command === '}') { + this._end(thisDelimiter) + } else if (thisDelimiter.command === 'right') { + this._end(thisDelimiter) + } else if ( + thisDelimiter.command === '$' || + thisDelimiter.command === '$$' + ) { + this._toggleMathMode(thisDelimiter) + } + } + + this.close = function () { + // If there is anything left in the state at this point, there + // were unclosed environments or groups. + while (document.getDepth() > 0) { + const thisDelimiter = document.closeEnv() + if (thisDelimiter.command === '{') { + // Note that having an unclosed group does not stop + // compilation in TeX but we will highlight it as an error + ErrorFrom(thisDelimiter, 'unclosed group {', { type: 'warning' }) + } else { + ErrorFrom(thisDelimiter, 'unclosed ' + getName(thisDelimiter)) + } + } + + // Filter out any token errors inside verbatim environments + const vlen = verbatimRanges.length + const len = ErrorReporter.tokenErrors.length + if (vlen > 0 && len > 0) { + for (let i = 0; i < len; i++) { + const tokenError = ErrorReporter.tokenErrors[i] + const startPos = tokenError.startPos + // const endPos = tokenError.endPos + for (let j = 0; j < vlen; j++) { + if ( + startPos > verbatimRanges[j].start && + startPos < verbatimRanges[j].end + ) { + tokenError.ignore = true + break + } + } + } + } + } + + this.setDelimiterProps = function (delimiter) { + const name = delimiter.name + // flag any verbatim environments for special handling + if ( + name && + name.match(/^(verbatim|boxedverbatim|lstlisting|minted|Verbatim)$/) + ) { + delimiter.verbatim = true + } + } +} + +// Error reporting functions for tokens and environments +const ErrorReporter = function (TokeniseResult) { + // const text = TokeniseResult.text + const linePosition = TokeniseResult.linePosition + const lineNumber = TokeniseResult.lineNumber + + const errors = [] + const tokenErrors = [] + this.errors = errors + this.tokenErrors = tokenErrors + this.filterMath = false + + function pos(row, column) { + return linePosition[row] + column + } + + this.getErrors = function () { + const returnedErrors = [] + for (let i = 0, len = tokenErrors.length; i < len; i++) { + if (!tokenErrors[i].ignore) { + returnedErrors.push(tokenErrors[i]) + } + } + const allErrors = returnedErrors.concat(errors) + const result = [] + + // Find the total number of math errors and bail out if there are too many + let mathErrorCount = 0 + for (let i = 0, len = allErrors.length; i < len; i++) { + if (allErrors[i].mathMode) { + mathErrorCount++ + } + if (mathErrorCount > 10) { + // too many math errors, bailing out + return [] + } + } + + // If the user had \beq and \eeq commands filter out any math + // errors as we cannot reliably track math-mode when there are + // user-defined environments which turn it on and off + if (this.filterMath && mathErrorCount > 0) { + for (let i = 0, len = allErrors.length; i < len; i++) { + if (!allErrors[i].mathMode) { + result.push(allErrors[i]) + } + } + return result + } else { + return allErrors + } + } + + // Report an error in a single token + + this.TokenError = function (token, message, options) { + if (!options) { + options = { suppressIfEditing: true } + } + const line = token[0] + // const type = token[1] + const start = token[2] + let end = token[3] + const startCol = start - linePosition[line] + if (!end) { + end = start + 1 + } + const endCol = end - linePosition[line] + tokenErrors.push({ + row: line, + column: startCol, + start_row: line, + start_col: startCol, + end_row: line, + end_col: endCol, + type: 'error', + text: message, + pos: pos(line, startCol), + startPos: start, + endPos: end, + suppressIfEditing: options.suppressIfEditing, + mathMode: options.mathMode, + }) + } + + // Report an error over a range (from, to) + + this.TokenErrorFromTo = function (fromToken, toToken, message, options) { + if (!options) { + options = { suppressIfEditing: true } + } + const fromLine = fromToken[0] + const fromStart = fromToken[2] + // const fromEnd = fromToken[3] + const toLine = toToken[0] + const toStart = toToken[2] + let toEnd = toToken[3] + if (!toEnd) { + toEnd = toStart + 1 + } + const startCol = fromStart - linePosition[fromLine] + const endCol = toEnd - linePosition[toLine] + + tokenErrors.push({ + row: fromLine, + column: startCol, + start_row: fromLine, + start_col: startCol, + end_row: toLine, + end_col: endCol, + type: 'error', + text: message, + pos: pos(fromLine, startCol), + startPos: fromStart, + endPos: toEnd, + suppressIfEditing: options.suppressIfEditing, + mathMode: options.mathMode, + }) + } + + this.EnvErrorFromTo = function (fromEnv, toEnv, message, options) { + if (!options) { + options = {} + } + const fromToken = fromEnv.token + let toToken = toEnv.closeToken || toEnv.token + const fromLine = fromToken[0] + const fromStart = fromToken[2] + const fromEnd = (fromEnv.closeToken || fromEnv.token)[3] + if (!toToken) { + toToken = fromToken + } + const toLine = toToken[0] + const toStart = (toEnv.token || toEnv.closeToken)[2] + let toEnd = toToken[3] + if (!toEnd) { + toEnd = toStart + 1 + } + const startCol = fromStart - linePosition[fromLine] + const endCol = toEnd - linePosition[toLine] + const row = options.errorAtStart ? fromLine : toLine + const column = options.errorAtStart ? startCol : endCol + errors.push({ + row, + column, + start_row: fromLine, + start_col: startCol, + end_row: toLine, + end_col: endCol, + type: options.type ? options.type : 'error', + text: message, + startPos: options.errorAtStart ? fromStart : toStart, + endPos: options.errorAtStart ? fromEnd : toEnd, + pos: pos(row, column), + suppressIfEditing: options.suppressIfEditing, + mathMode: options.mathMode, + }) + } + + // Report an error up to a given environment (from the beginning of the document) + + this.EnvErrorTo = function (toEnv, message, options) { + if (!options) { + options = {} + } + const token = toEnv.closeToken || toEnv.token + const line = token[0] + // const type = token[1] + const start = token[2] + let end = token[3] + if (!end) { + end = start + 1 + } + const endCol = end - linePosition[line] + const err = { + row: line, + column: endCol, + start_row: 0, + start_col: 0, + end_row: line, + end_col: endCol, + startPos: start, + endPos: end, + pos: pos(line, endCol), + type: options.type ? options.type : 'error', + text: message, + mathMode: options.mathMode, + } + errors.push(err) + } + + // Report an error from a given environment (up to the end of the document) + + this.EnvErrorFrom = function (delimiter, message, options) { + if (!options) { + options = {} + } + const token = delimiter.token + const closeToken = delimiter.closeToken + const line = token[0] + // const type = token[1] + const start = token[2] + const end = (closeToken || token)[3] + const startCol = start - linePosition[line] + const endCol = Infinity + errors.push({ + row: line, + column: startCol, + start_row: line, + start_col: startCol, + end_row: lineNumber, + end_col: endCol, + startPos: start, + endPos: end, + pos: pos(line, startCol), + type: options.type ? options.type : 'error', + text: message, + mathMode: options.mathMode, + }) + } +} + +const Parse = function (text) { + const TokeniseResult = Tokenise(text) + const Reporter = new ErrorReporter(TokeniseResult) + const Environments = InterpretTokens(TokeniseResult, Reporter) + Environments.close() + return { + errors: Reporter.getErrors().sort((a, b) => a.startPos - b.startPos), + contexts: Environments.getDocument().getContexts(), + } +} + +let latestLintResult = null + +// Define an onmessage handler if this file is loaded in a Worker context +if (typeof onmessage !== 'undefined') { + onmessage = function (event) { + if (event.data && event.type === 'message') { + let workerResult = {} + const text = event.data.text + if (latestLintResult && latestLintResult.text === text) { + workerResult = latestLintResult.workerResult + } else { + try { + workerResult = Parse(event.data.text) + latestLintResult = { text, workerResult } + } catch (err) { + console.error('error in linting', err) + workerResult = { errors: [], contexts: [] } + } + } + postMessage(workerResult) + } + } +} +// export dummy class for testing +export default class LintWorker { + postMessage(message) {} + addEventListener(eventName, listener) {} + Parse(text) { + return Parse(text) + } +} diff --git a/services/web/frontend/js/features/source-editor/languages/latex/linter/merge-overlapping-diagnostics.ts b/services/web/frontend/js/features/source-editor/languages/latex/linter/merge-overlapping-diagnostics.ts new file mode 100644 index 0000000000..6a0a0ee508 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/languages/latex/linter/merge-overlapping-diagnostics.ts @@ -0,0 +1,86 @@ +import { Diagnostic } from '@codemirror/lint' +import { Range } from '../../../utils/range' + +const diagnosticsTouchOrOverlap = (d1: Diagnostic, d2: Diagnostic) => { + return new Range(d1.from, d1.to).touchesOrIntersects( + new Range(d2.from, d2.to) + ) +} + +const mergeDiagnostics = (d1: Diagnostic, d2: Diagnostic) => { + const diagnostic: Diagnostic = { + from: Math.min(d1.from, d2.from), + to: Math.max(d1.to, d2.to), + severity: d1.severity, + message: d1.message, + } + if ('source' in d1) { + diagnostic.source = d1.source + } + return diagnostic +} + +const mergeOverlappingDiagnostics = (diagnostics: Diagnostic[]) => { + const diagnosticsByMessage = new Map() + for (const diagnostic of diagnostics) { + let diagnosticsForMessage = diagnosticsByMessage.get(diagnostic.message) + if (diagnosticsForMessage) { + diagnosticsForMessage.push(diagnostic) + diagnosticsForMessage.sort( + (d1: Diagnostic, d2: Diagnostic) => d1.from - d2.from + ) + for (let i = 1; i < diagnosticsForMessage.length; ) { + const d1 = diagnosticsForMessage[i - 1] + const d2 = diagnosticsForMessage[i] + if (diagnosticsTouchOrOverlap(d1, d2)) { + // Merge second diagnostic into first and remove it + diagnosticsForMessage[i - 1] = mergeDiagnostics(d1, d2) + diagnosticsForMessage.splice(i, 1) + } else { + ++i + } + } + } else { + diagnosticsForMessage = [diagnostic] + diagnosticsByMessage.set(diagnostic.message, diagnosticsForMessage) + } + } + return Array.from(diagnosticsByMessage.values()).flat() +} + +// Group objects of a specified type by a single property and return an array +// of arrays, one array per property value +const groupBy = function (arr: T[], prop: keyof T) { + const grouped = new Map() + for (const item of arr) { + const key = item[prop] + let group = grouped.get(key) + if (!group) { + group = [] as T[] + grouped.set(key, group) + } + group.push(item) + } + return Array.from(grouped.values()) +} + +export const mergeCompatibleOverlappingDiagnostics = ( + diagnostics: Diagnostic[] +) => { + const allMergedDiagnostics = [] + + // Partition by diagnostic source (compiler or linter) + for (const diagnosticsForSource of groupBy(diagnostics, 'source')) { + // Partition into severities + const diagnosticsBySeverity = groupBy(diagnosticsForSource, 'severity') + + // Merge overlapping diagnostics for each severity in turn + for (const diagnosticsForSeverity of diagnosticsBySeverity) { + allMergedDiagnostics.push( + ...mergeOverlappingDiagnostics(diagnosticsForSeverity) + ) + } + } + + return allMergedDiagnostics +} diff --git a/services/web/frontend/js/features/source-editor/languages/latex/linting.ts b/services/web/frontend/js/features/source-editor/languages/latex/linting.ts new file mode 100644 index 0000000000..a1e5d6e79a --- /dev/null +++ b/services/web/frontend/js/features/source-editor/languages/latex/linting.ts @@ -0,0 +1,41 @@ +import { Compartment, EditorState } from '@codemirror/state' +import { setSyntaxValidationEffect } from '../../extensions/language' +import { linter } from '@codemirror/lint' +import { latexLinter } from './linter/latex-linter' +import { lintSourceConfig } from '../../extensions/annotations' + +export const linting = () => { + const latexLintSourceConf = new Compartment() + + return [ + latexLintSourceConf.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: latexLintSourceConf.reconfigure( + effect.value ? linter(latexLinter, lintSourceConfig) : [] + ), + } + } + } + + return null + }), + + // TODO: enable this once https://github.com/overleaf/internal/issues/10055 is fixed + // ViewPlugin.define(view => { + // return { + // update(update) { + // // force the linter to run if the selection has changed + // if (update.selectionSet) { + // // note: no timeout needed as this is already asynchronous + // forceLinting(view, true) // TODO: true to force run even if doc hasn't changed + // } + // }, + // } + // }), + ] +} diff --git a/services/web/frontend/js/features/source-editor/languages/latex/metadata.ts b/services/web/frontend/js/features/source-editor/languages/latex/metadata.ts new file mode 100644 index 0000000000..4e9cd10af7 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/languages/latex/metadata.ts @@ -0,0 +1,44 @@ +import { EditorView } from '@codemirror/view' +import { Transaction, Text } from '@codemirror/state' + +const metadataChangeRe = /\\(documentclass|usepackage|RequirePackage|label)\b/ + +export const metadata = () => [ + // trigger metadata reload if edited line contains metadata-related commands + EditorView.updateListener.of(update => { + if (update.docChanged) { + let needsMetadataUpdate = false + + for (const transaction of update.transactions) { + // ignore remote changes + if (transaction.annotation(Transaction.remote)) { + continue + } + + transaction.changes.iterChangedRanges((fromA, toA, fromB, toB) => { + const docs: [Text, number, number][] = [ + [update.startState.doc, fromA, toA], + [update.state.doc, fromB, toB], + ] + + for (const [doc, from, to] of docs) { + const fromLine = doc.lineAt(from).number + const toLine = doc.lineAt(to).number + + for (const line of doc.iterLines(fromLine, toLine + 1)) { + if (metadataChangeRe.test(line)) { + needsMetadataUpdate = true + return + } + } + } + }) + + if (needsMetadataUpdate) { + window.dispatchEvent(new CustomEvent('editor:metadata-outdated')) + break + } + } + } + }), +] diff --git a/services/web/frontend/js/features/source-editor/languages/latex/open-autocomplete.ts b/services/web/frontend/js/features/source-editor/languages/latex/open-autocomplete.ts new file mode 100644 index 0000000000..8abaf0a9c4 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/languages/latex/open-autocomplete.ts @@ -0,0 +1,17 @@ +import { EditorView } from '@codemirror/view' +import { startCompletion } from '@codemirror/autocomplete' +import { Transaction } from '@codemirror/state' +import { isInEmptyArgumentNodeForAutocomplete } from '../../utils/tree-query' + +// start autocompletion when the cursor enters an empty pair of braces +export const openAutocomplete = () => { + return EditorView.updateListener.of(update => { + if (update.selectionSet || update.docChanged) { + if (!update.transactions.some(tr => tr.annotation(Transaction.remote))) { + if (isInEmptyArgumentNodeForAutocomplete(update.state)) { + startCompletion(update.view) + } + } + } + }) +} diff --git a/services/web/frontend/js/features/source-editor/languages/latex/shortcuts.ts b/services/web/frontend/js/features/source-editor/languages/latex/shortcuts.ts new file mode 100644 index 0000000000..61c5649680 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/languages/latex/shortcuts.ts @@ -0,0 +1,22 @@ +import { Prec } from '@codemirror/state' +import { keymap } from '@codemirror/view' +import { wrapRanges } from '../../commands/ranges' + +export const shortcuts = () => { + return Prec.high( + keymap.of([ + { + key: 'Ctrl-b', + mac: 'Mod-b', + preventDefault: true, + run: wrapRanges('\\textbf{', '}'), + }, + { + key: 'Ctrl-i', + mac: 'Mod-i', + preventDefault: true, + run: wrapRanges('\\textit{', '}'), + }, + ]) + ) +} diff --git a/services/web/frontend/js/features/source-editor/languages/latex/snippets.ts b/services/web/frontend/js/features/source-editor/languages/latex/snippets.ts new file mode 100644 index 0000000000..43be026905 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/languages/latex/snippets.ts @@ -0,0 +1,5 @@ +// Add a final placeholder at the end of the snippet to allow for +// shift-tabbing back from the penultimate placeholder. See #8697. +export const prepareSnippetTemplate = (template: string): string => { + return template.replace(/\$(\d+)/g, '#{$1}') + '${}' +} diff --git a/services/web/frontend/js/features/source-editor/languages/markdown/index.ts b/services/web/frontend/js/features/source-editor/languages/markdown/index.ts new file mode 100644 index 0000000000..e59e429a4c --- /dev/null +++ b/services/web/frontend/js/features/source-editor/languages/markdown/index.ts @@ -0,0 +1,15 @@ +import { markdown as markdownLanguage } from '@codemirror/lang-markdown' +import { defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language' +import { shortcuts } from './shortcuts' +import { languages } from '../index' +import { Extension } from '@codemirror/state' + +export const markdown = (): Extension => { + return [ + markdownLanguage({ + codeLanguages: languages, + }), + shortcuts(), + syntaxHighlighting(defaultHighlightStyle), + ] +} diff --git a/services/web/frontend/js/features/source-editor/languages/markdown/shortcuts.ts b/services/web/frontend/js/features/source-editor/languages/markdown/shortcuts.ts new file mode 100644 index 0000000000..157d02ea9c --- /dev/null +++ b/services/web/frontend/js/features/source-editor/languages/markdown/shortcuts.ts @@ -0,0 +1,33 @@ +import { Prec } from '@codemirror/state' +import { keymap } from '@codemirror/view' +import { wrapRanges } from '../../commands/ranges' +import { indentLess, indentMore } from '@codemirror/commands' + +export const shortcuts = () => { + return Prec.high( + keymap.of([ + { + key: 'Ctrl-b', + mac: 'Mod-b', + preventDefault: true, + run: wrapRanges('**', '**'), + }, + { + key: 'Ctrl-i', + mac: 'Mod-i', + preventDefault: true, + run: wrapRanges('_', '_'), + }, + { + key: 'Tab', + preventDefault: true, + run: indentMore, + }, + { + key: 'Shift-Tab', + preventDefault: true, + run: indentLess, + }, + ]) + ) +} diff --git a/services/web/frontend/js/features/source-editor/lezer-latex/README.md b/services/web/frontend/js/features/source-editor/lezer-latex/README.md new file mode 100644 index 0000000000..a9aad9b32b --- /dev/null +++ b/services/web/frontend/js/features/source-editor/lezer-latex/README.md @@ -0,0 +1,82 @@ +# Lezer-LaTeX, a LaTeX Parser + +Lezer-LaTeX is a LaTeX parser implemented with [lezer](https://lezer.codemirror.net/), the parser system used by [CodeMirror 6](https://codemirror.net/6/). + +The parser is written in a "grammar" file, (and a "tokens" file with custom tokenizer logic) which is then compiled by `@lezer/generator` into a parser module and a "terms" module. The parser module is then loaded by the CodeMirror 6 in the web frontend codebase. + + +## Important files + +- Source files: + - `./latex.grammar`: The grammar file, containing the specification for the parser + - `./tokens.mjs`: The custom tokenizer logic, required by some rules in the grammar + +- Generated files: + - `./latex.mjs`: The generated parser + - `./latex.terms.mjs`: The generated terms file + - (these files are ignored by git, eslint, and prettier) + +- Scripts: + - `web/scripts/lezer-latex/generate.js`: A script which runs the generator on the grammar, producing the generated parser/terms files + - `web/scripts/lezer-latex/run.mjs`: A script that runs the parser against a supplied file, and prints the tree to the terminal + +- Webpack plugins: + - `web/webpack-plugins/lezer-grammar-compiler.js`: A webpack plugin that calls the generator as part of the webpack build. In dev, it will automatically re-build the parser when the grammar file changes. + + +## NPM tasks + +- `lezer-latex:generate`: Generate the parser files from the grammar + - (Calls `lezer-latex/generate.js`) + - This should be run whenever the grammar changes + +- `lezer-latex:run`: Run the parser against a file + - (Calls `lezer-latex/run.js`) + + +### Generating the parser + +From the monorepo root: + +``` sh +bin/npm -w services/web run 'lezer-latex:generate' +``` + + +## Tests + +Unit tests for the parser live in `web/test/unit/src/LezerLatex`. There are three kinds of test, in three subdirectories: + +- `corpus/`: A set of tests using lezer's test framework, consisting of example text and the expected parse tree +- `examples/`: A set of realistic LaTeX documents. These tests pass if the files parse with no errors +- `regressions/`: Like `examples/`, these are expected to parse without error, but they are not realistic documents. + +These tests run as part of `test_frontend`. You can run these tests alone by invoking: + +``` sh +make test_unit MOCHA_GREP='lezer-latex' +``` + + +## Trying the parser + +While developing the parser, you can run it against a file by calling the `lezer-latex:run` task. There are +some example files in the test suite, at `web/test/frontend/shared/lezer-latex/examples/`. + +For example: + +``` sh +bin/npm -w services/web run 'lezer-latex:run' web/test/unit/src/LezerLatex/examples/amsmath.tex +``` + +If you omit the file path, the default file (`examples/demo.tex`) will be run. + + +## Integration into web + +The web frontend imports the parser (from `latex.mjs`), in `frontend/js/features/source-editor/languages/latex/index.ts`. +The parser is then plugged in to the CM6 language system. + +### The web build + +In `web/Dockerfile`, we have a `RUN` command that calls `lezer-latex:generate` as part of the build. This is necessary to ensure the parser is built before the CI tests run (notably: we can't do the build during the tests, because we can't write to disk during that stage of CI). diff --git a/services/web/frontend/js/features/source-editor/lezer-latex/latex.grammar b/services/web/frontend/js/features/source-editor/lezer-latex/latex.grammar new file mode 100644 index 0000000000..8362e7f43e --- /dev/null +++ b/services/web/frontend/js/features/source-editor/lezer-latex/latex.grammar @@ -0,0 +1,662 @@ +// Track environments + +@context elementContext from "./tokens.mjs" + +// External tokens must be defined before normal @tokens to take precedence +// over them. + +@external tokens verbTokenizer from "./tokens.mjs" { + VerbContent +} + +@external tokens lstinlineTokenizer from "./tokens.mjs" { + LstInlineContent +} + +@external tokens literalArgTokenizer from "./tokens.mjs" { + LiteralArgContent +} + +@external tokens spaceDelimitedLiteralArgTokenizer from "./tokens.mjs" { + SpaceDelimitedLiteralArgContent +} + +@external tokens verbatimTokenizer from "./tokens.mjs" { + VerbatimContent +} + +@external tokens mathDelimiterTokenizer from "./tokens.mjs" { + MathDelimiter +} + +// external tokenizer to read control sequence names including @ signs +// (which are often used in TeX definitions). +@external tokens csnameTokenizer from "./tokens.mjs" { + Csname +} + +@external tokens trailingContentTokenizer from "./tokens.mjs" { + TrailingWhitespaceOnly, + TrailingContent +} + +// It doesn't seem to be possible to access specialized tokens in the context tracker. +// They have id's which are not exported in the latex.terms.js file. +// This is a workaround: use an external specializer to explicitly choose the terms +// to use for the specialized tokens. +@external specialize {CtrlSeq} specializeCtrlSeq from "./tokens.mjs" { + Begin, + End, + RefCtrlSeq, + RefStarrableCtrlSeq, + CiteCtrlSeq, + CiteStarrableCtrlSeq, + LabelCtrlSeq, + MathTextCtrlSeq, + HboxCtrlSeq, + TitleCtrlSeq, + DocumentClassCtrlSeq, + UsePackageCtrlSeq, + HrefCtrlSeq, + VerbCtrlSeq, + LstInlineCtrlSeq, + IncludeGraphicsCtrlSeq, + CaptionCtrlSeq, + DefCtrlSeq, + LeftCtrlSeq, + RightCtrlSeq, + NewCommandCtrlSeq, + RenewCommandCtrlSeq, + NewEnvironmentCtrlSeq, + RenewEnvironmentCtrlSeq, + // services/web/frontend/js/features/outline/outline-parser.js + BookCtrlSeq, + PartCtrlSeq, + ChapterCtrlSeq, + SectionCtrlSeq, + SubSectionCtrlSeq, + SubSubSectionCtrlSeq, + ParagraphCtrlSeq, + SubParagraphCtrlSeq, + InputCtrlSeq, + IncludeCtrlSeq, + ItemCtrlSeq, + CenteringCtrlSeq, + BibliographyCtrlSeq, + BibliographyStyleCtrlSeq, + AuthorCtrlSeq +} + +@external specialize {EnvName} specializeEnvName from "./tokens.mjs" { + DocumentEnvName, + TabularEnvName, + EquationEnvName, + EquationArrayEnvName, + VerbatimEnvName, + TikzPictureEnvName, + FigureEnvName, + ListEnvName +} + +@external specialize {CtrlSym} specializeCtrlSym from "./tokens.mjs" { + OpenParenCtrlSym, + CloseParenCtrlSym, + OpenBracketCtrlSym, + CloseBracketCtrlSym +} + +@tokens { + CtrlSeq { "\\" $[a-zA-Z]+ } + CtrlSym { "\\" ![a-zA-Z] } + + // tokens for paragraphs + Whitespace { $[ \t]+ } + NewLine { "\n" } + BlankLine { "\n" "\n"+ } + Normal { ![\\{}\[\]$&#^_% \t\n] ![\\{}\[\]$&#^_%\t\n]* } // allow ~ space in normal text + @precedence { CtrlSeq, CtrlSym, BlankLine, NewLine, Whitespace, Normal } + + OpenBrace[closedBy=CloseBrace] { "{" } + CloseBrace[openedBy=OpenBrace] { "}" } + OpenBracket[closedBy=CloseBracket] { "[" } + CloseBracket[openedBy=OpenBracket] { "]" } + + Comment { "%" ![\n]* "\n"? } + + Dollar { "$" } + + Number { $[0-9]+ ("." $[0-9]*)? } + MathSpecialChar { $[^_=<>()\-+/*]+ } // FIXME not all of these are special + MathChar { ![0-9^_=<>()\-+/*\\{}\[\]\n$%&]+ } + + @precedence { Number, MathSpecialChar, MathChar } + + Ampersand { "&" } + + EnvName { $[a-zA-Z]+ $[*]? } +} + +@top LaTeX { + Text +} + +@skip { Comment } + +// TEXT MODE + +optionalWhitespace { + !argument Whitespace +} + +OptionalArgument { + !argument OpenBracket ShortOptionalArg CloseBracket +} + +TextArgument { + !argument OpenBrace LongArg CloseBrace +} + +SectioningArgument { + !argument OpenBrace LongArg CloseBrace +} + +LabelArgument { + !argument ShortTextArgument +} + +RefArgument { + !argument ShortTextArgument +} + +BibKeyArgument { + !argument ShortTextArgument +} + +PackageArgument { + !argument ShortTextArgument +} + +UrlArgument { + OpenBrace LiteralArgContent CloseBrace +} + +FilePathArgument { + OpenBrace LiteralArgContent CloseBrace +} + +BareFilePathArgument { + Whitespace SpaceDelimitedLiteralArgContent +} + +DefinitionArgument { + !argument NewLine? Whitespace* OpenBrace DefinitionFragment CloseBrace +} + +argument[@isGroup="$Argument"] { + TextArgument + | SectioningArgument + | LabelArgument + | RefArgument + | BibKeyArgument + | PackageArgument + | UrlArgument + | FilePathArgument + | BareFilePathArgument + | DefinitionArgument +} + +MacroParameter { + "#" ("1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9") +} + +// The autocompletion code in services/web/frontend/js/features/source-editor/utils/tree-operations/commands.ts +// depends on following the `KnownCommand { Command { CommandCtrlSeq [args] } }` +// structure +KnownCommand { + Title { + TitleCtrlSeq optionalWhitespace? OptionalArgument? TextArgument + } | + Author { + AuthorCtrlSeq optionalWhitespace? OptionalArgument? optionalWhitespace? TextArgument + } | + DocumentClass { + DocumentClassCtrlSeq optionalWhitespace? OptionalArgument? + DocumentClassArgument { ShortTextArgument } + } | + BibliographyCommand { + BibliographyCtrlSeq optionalWhitespace? + BibliographyArgument { ShortTextArgument } + } | + BibliographyStyleCommand { + BibliographyStyleCtrlSeq optionalWhitespace? + BibliographyStyleArgument { ShortTextArgument } + } | + UsePackage { + UsePackageCtrlSeq optionalWhitespace? OptionalArgument? + PackageArgument + } | + HrefCommand { + HrefCtrlSeq optionalWhitespace? UrlArgument ShortTextArgument + } | + VerbCommand { + VerbCtrlSeq VerbContent + } | + LstInlineCommand { + LstInlineCtrlSeq optionalWhitespace? OptionalArgument? LstInlineContent + } | + IncludeGraphics { + IncludeGraphicsCtrlSeq optionalWhitespace? OptionalArgument? + IncludeGraphicsArgument { FilePathArgument } + } | + Caption { + CaptionCtrlSeq optionalWhitespace? OptionalArgument? TextArgument + } | + Label { + LabelCtrlSeq optionalWhitespace? LabelArgument + } | + Ref { + (RefCtrlSeq | RefStarrableCtrlSeq "*"?) optionalWhitespace? OptionalArgument? optionalWhitespace? OptionalArgument? optionalWhitespace? RefArgument + } | + Cite { + (CiteCtrlSeq | CiteStarrableCtrlSeq "*"?) optionalWhitespace? OptionalArgument? optionalWhitespace? OptionalArgument? optionalWhitespace? BibKeyArgument + } | + Def { + // allow more general Csname argument to \def commands, since other symbols such as '@' are often used in definitions + DefCtrlSeq (Csname | CtrlSym) MacroParameter* optionalWhitespace? DefinitionArgument + } | + Hbox { + HboxCtrlSeq optionalWhitespace? TextArgument + } | + Left { + LeftCtrlSeq optionalWhitespace? + } | + Right { + RightCtrlSeq optionalWhitespace? + } | + NewCommand { + NewCommandCtrlSeq optionalWhitespace? + (Csname | OpenBrace LiteralArgContent CloseBrace) + (OptionalArgument)* + DefinitionArgument + } | + RenewCommand { + RenewCommandCtrlSeq optionalWhitespace? + (Csname | OpenBrace LiteralArgContent CloseBrace) + (OptionalArgument)* + DefinitionArgument + } | + NewEnvironment { + NewEnvironmentCtrlSeq optionalWhitespace? + (OpenBrace LiteralArgContent CloseBrace) + (OptionalArgument)* + DefinitionArgument + DefinitionArgument + } | + RenewEnvironment { + RenewEnvironmentCtrlSeq optionalWhitespace? + (Csname | OpenBrace LiteralArgContent CloseBrace) + (OptionalArgument)* + DefinitionArgument + DefinitionArgument + } | + Input { + InputCtrlSeq InputArgument { ( FilePathArgument | BareFilePathArgument ) } + } | + Include { + IncludeCtrlSeq IncludeArgument { FilePathArgument } + } | + Centering { + CenteringCtrlSeq + } | + Item { + ItemCtrlSeq optionalWhitespace? + } +} + +UnknownCommand { + (CtrlSeq !argument Whitespace (OptionalArgument | TextArgument)+) + | ((CtrlSeq | MathTextCtrlSeq) (OptionalArgument | TextArgument)+) + | CtrlSeq Whitespace? + | CtrlSym +} + +Command { + KnownCommand + | UnknownCommand +} + +textBase { + ( Command + | DollarMath + | BracketMath + | ParenMath + | NewLine + | Normal + | Whitespace + | Ampersand + ) +} + +textWithBrackets { + ( textBase + | OpenBracket + | CloseBracket + ) +} + +textWithEnvironmentsAndBlankLines { + ( BlankLine + | KnownEnvironment + | Environment + | textWithBrackets + ) +} + +textWithGroupsEnvironmentsAndBlankLines { + textWithEnvironmentsAndBlankLines + | Group +} + +Content { + Element +} + +SectioningCommand { + Command optionalWhitespace? "*"? optionalWhitespace? OptionalArgument? optionalWhitespace? SectioningArgument +} + +documentSection { + SectioningCommand Content<(sectionText | !section Next)*> +} +Book[@isGroup="$SectioningCommand"] { documentSection } +Part[@isGroup="$SectioningCommand"] { documentSection } +Chapter[@isGroup="$SectioningCommand"] { documentSection } +Section[@isGroup="$SectioningCommand"] { documentSection } +SubSection[@isGroup="$SectioningCommand"] { documentSection } +SubSubSection[@isGroup="$SectioningCommand"] { documentSection } +Paragraph[@isGroup="$SectioningCommand"] { documentSection } +SubParagraph[@isGroup="$SectioningCommand"] { SectioningCommand Content } + +sectioningCommand { + Book | Part | Chapter | Section | SubSection | SubSubSection | Paragraph | SubParagraph +} + +sectionText { + !section ( + textWithGroupsEnvironmentsAndBlankLines + )+ +} + +Text { + ( sectionText + | sectioningCommand)+ +} + +LongArg { + ( textWithBrackets + | NonEmptyGroup + | KnownEnvironment + | Environment + | BlankLine + | "#" // macro character + | "_" | "^" // other math chars + )* +} + +ShortTextArgument { + OpenBrace ShortArg CloseBrace +} + +ShortArg { + ( textWithBrackets + | NonEmptyGroup + | "#" // macro character + | "_" | "^" // other math chars + )* +} + +ShortOptionalArg { + ( textBase + | NonEmptyGroup + | "#" // macro character + )* +} + +TikzPictureContent { /// same as Text but with added allowed characters + ( textWithEnvironmentsAndBlankLines + | NonEmptyGroup + | "#" // macro character + | "_" | "^" // other math chars + )+ +} + +DefinitionFragment { + ( KnownCommand + | CtrlSeq optionalWhitespace? + | CtrlSym + | Begin + | End + | NonEmptyGroup + | Dollar + | OpenParenCtrlSym + | CloseParenCtrlSym + | OpenBracketCtrlSym + | CloseBracketCtrlSym + | BlankLine + | NewLine + | Normal + | Whitespace + | OpenBracket + | CloseBracket + | "#" // macro character + | Ampersand // for tables + | "_" | "^" // other math chars + | SectioningCommand< + BookCtrlSeq | + PartCtrlSeq | + ChapterCtrlSeq | + SectionCtrlSeq | + SubSectionCtrlSeq | + SubSubSectionCtrlSeq | + ParagraphCtrlSeq | + SubParagraphCtrlSeq + > + )* + +} + +KnownEnvironment { + ( DocumentEnvironment + | TabularEnvironment + | EquationEnvironment + | EquationArrayEnvironment + | VerbatimEnvironment + | TikzPictureEnvironment + | FigureEnvironment + | ListEnvironment + ) +} + +BeginEnv { + Begin + EnvNameGroup + OptionalArgument? + (!argument TextArgument)* +} + +EndEnv { + End + EnvNameGroup +} + +DocumentEnvironment[@isGroup="$Environment"] { + BeginEnv + Content + EndEnv + (TrailingWhitespaceOnly | TrailingContent)? +} + +TabularEnvironment[@isGroup="$Environment"] { + BeginEnv + Content + EndEnv +} + +EquationEnvironment[@isGroup="$Environment"] { + BeginEnv + Content + EndEnv +} + +EquationArrayEnvironment[@isGroup="$Environment"] { + BeginEnv + Content + EndEnv +} + +VerbatimEnvironment[@isGroup="$Environment"] { + BeginEnv + Content + EndEnv +} + +TikzPictureEnvironment[@isGroup="$Environment"] { + BeginEnv + Content + EndEnv +} + +FigureEnvironment[@isGroup="$Environment"] { + BeginEnv + Content + EndEnv +} + +ListEnvironment[@isGroup="$Environment"] { + BeginEnv + Content + EndEnv +} + +EnvNameGroup { + OpenBrace name CloseBrace +} + +Environment[@isGroup="$Environment"] { + BeginEnv + Content + EndEnv +} + +Group { + OpenBrace GroupContent? CloseBrace +} + +NonEmptyGroup { + OpenBrace GroupContent CloseBrace +} + +/// MATH MODE + +DollarMath { + Dollar (InlineMath | DisplayMath) Dollar +} + +InlineMath { + Math +} + +DisplayMath { + Dollar Math? Dollar +} + + +OpenParenMath[closedBy=CloseParenMath] { + OpenParenCtrlSym +} + +CloseParenMath[openedBy=OpenParenMath] { + CloseParenCtrlSym +} + +// alternative syntax \( math \) for inline math, it is the same as $ math $ +ParenMath { + OpenParenMath + Math? + CloseParenMath +} + +OpenBracketMath[closedBy=CloseBracketMath] { + OpenBracketCtrlSym +} + +CloseBracketMath[openedBy=OpenBracketMath] { + CloseBracketCtrlSym +} + +// alternative syntax \[ math \] for display math, it is the same as $$ math $$ +BracketMath { + OpenBracketMath + Math? + CloseBracketMath +} + +// FIXME: we should have separate math modes for inline and display math, +// because display math can contain blank lines while inline math cannot. + +Math { + ( MathTextCommand + | MathCommand + | MathCtrlSym + | MathGroup + | MathDelimitedGroup + | MathSpecialChar + | Number + | NewLine + | KnownEnvironment + | Environment + | MathChar + | OpenBracket + | CloseBracket + | Ampersand + | Label { + LabelCtrlSeq optionalWhitespace? OptionalArgument? LabelArgument + } + )+ +} + +MathTextCommand { + (MathTextCtrlSeq | HboxCtrlSeq) optionalWhitespace? "*"? TextArgument +} + +MathCommand { CtrlSeq } + +MathCtrlSym { CtrlSym } + +MathGroup { + OpenBrace Math? CloseBrace +} + +MathDelimitedGroup { + MathOpening Math? MathClosing +} + +// FIXME: we have the same problem with specialize on \left,\right as the delimiters +MathOpening { + LeftCtrlSeq optionalWhitespace? MathDelimiter +} + +MathClosing { + RightCtrlSeq optionalWhitespace? MathDelimiter +} + +// NOTE: precedence works differently for rules and token, in the rule +// you have to give a specifier !foo which is defined in the @precedence +// block here. + +@precedence { + section @left, + argument @left // make CtrlSeq arguments left associative +} diff --git a/services/web/frontend/js/features/source-editor/lezer-latex/print-tree.mjs b/services/web/frontend/js/features/source-editor/lezer-latex/print-tree.mjs new file mode 100644 index 0000000000..eed79404f4 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/lezer-latex/print-tree.mjs @@ -0,0 +1,215 @@ +// from https://gist.github.com/msteen/e4828fbf25d6efef73576fc43ac479d2 +// https://discuss.codemirror.net/t/whats-the-best-to-test-and-debug-grammars/2542/5 +// MIT License +// +// Copyright (c) 2021 Matthijs Steen +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +import { Text } from '@codemirror/state' +import { Tree, TreeCursor } from '@lezer/common' + +class StringInput { + constructor(input) { + this.input = input + this.lineChunks = false + } + + get length() { + return this.input.length + } + + chunk(from) { + return this.input.slice(from) + } + + read(from, to) { + return this.input.slice(from, to) + } +} + +function cursorNode({ type, from, to }, isLeaf = false) { + return { type, from, to, isLeaf } +} +function traverseTree( + cursor, + { + from = -Infinity, + to = Infinity, + includeParents = false, + beforeEnter, + onEnter, + onLeave, + } +) { + if (!(cursor instanceof TreeCursor)) + cursor = cursor instanceof Tree ? cursor.cursor() : cursor.cursor() + for (;;) { + let node = cursorNode(cursor) + let leave = false + if (node.from <= to && node.to >= from) { + const enter = + !node.type.isAnonymous && + (includeParents || (node.from >= from && node.to <= to)) + if (enter && beforeEnter) beforeEnter(cursor) + node.isLeaf = !cursor.firstChild() + if (enter) { + leave = true + if (onEnter(node) === false) return + } + if (!node.isLeaf) continue + } + for (;;) { + node = cursorNode(cursor, node.isLeaf) + if (leave && onLeave) if (onLeave(node) === false) return + leave = cursor.type.isAnonymous + node.isLeaf = false + if (cursor.nextSibling()) break + if (!cursor.parent()) return + leave = true + } + } +} +function isChildOf(child, parent) { + return ( + child.from >= parent.from && + child.from <= parent.to && + child.to <= parent.to && + child.to >= parent.from + ) +} +function validatorTraversal(input, { fullMatch = true } = {}) { + if (typeof input === 'string') input = new StringInput(input) + const state = { + valid: true, + parentNodes: [], + lastLeafTo: 0, + } + return { + state, + traversal: { + onEnter(node) { + state.valid = true + if (!node.isLeaf) state.parentNodes.unshift(node) + if (node.from > node.to || node.from < state.lastLeafTo) { + state.valid = false + } else if (node.isLeaf) { + if ( + state.parentNodes.length && + !isChildOf(node, state.parentNodes[0]) + ) + state.valid = false + state.lastLeafTo = node.to + } else { + if (state.parentNodes.length) { + if (!isChildOf(node, state.parentNodes[0])) state.valid = false + } else if ( + fullMatch && + (node.from !== 0 || node.to !== input.length) + ) { + state.valid = false + } + } + }, + onLeave(node) { + if (!node.isLeaf) state.parentNodes.shift() + }, + }, + } +} + +let Color +;(function (Color) { + Color[(Color.Red = 31)] = 'Red' + Color[(Color.Green = 32)] = 'Green' + Color[(Color.Yellow = 33)] = 'Yellow' +})(Color || (Color = {})) + +function colorize(value, color) { + return '\u001b[' + color + 'm' + String(value) + '\u001b[39m' +} + +function printTree( + cursor, + input, + { from, to, start = 0, includeParents } = {} +) { + const inp = typeof input === 'string' ? new StringInput(input) : input + const text = Text.of(inp.read(0, inp.length).split('\n')) + const state = { + output: '', + prefixes: [], + hasNextSibling: false, + } + const validator = validatorTraversal(inp) + traverseTree(cursor, { + from, + to, + includeParents, + beforeEnter(cursor) { + state.hasNextSibling = cursor.nextSibling() && cursor.prevSibling() + }, + onEnter(node) { + validator.traversal.onEnter(node) + const isTop = state.output === '' + const hasPrefix = !isTop || node.from > 0 + if (hasPrefix) { + state.output += (!isTop ? '\n' : '') + state.prefixes.join('') + if (state.hasNextSibling) { + state.output += ' ├─ ' + state.prefixes.push(' │ ') + } else { + state.output += ' └─ ' + state.prefixes.push(' ') + } + } + const hasRange = node.from !== node.to + state.output += + (node.type.isError || !validator.state.valid + ? colorize('ERROR ' + node.type.name, Color.Red) + : node.type.name) + + ' ' + + (hasRange + ? '[' + + colorize(locAt(text, start + node.from), Color.Yellow) + + '..' + + colorize(locAt(text, start + node.to), Color.Yellow) + + ']' + : colorize(locAt(text, start + node.from), Color.Yellow)) + if (hasRange && node.isLeaf) { + state.output += + ': ' + + colorize(JSON.stringify(inp.read(node.from, node.to)), Color.Green) + } + }, + onLeave(node) { + validator.traversal.onLeave(node) + state.prefixes.pop() + }, + }) + return state.output +} + +function locAt(text, pos) { + const line = text.lineAt(pos) + return line.number + ':' + (pos - line.from) +} + +export function logTree(tree, input, options) { + console.log(printTree(tree, input, options)) +} diff --git a/services/web/frontend/js/features/source-editor/lezer-latex/tokens.mjs b/services/web/frontend/js/features/source-editor/lezer-latex/tokens.mjs new file mode 100644 index 0000000000..f5cd7d16d2 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/lezer-latex/tokens.mjs @@ -0,0 +1,770 @@ +/* Hand-written tokenizer for LaTeX. */ + +import { ExternalTokenizer, ContextTracker } from '@lezer/lr' + +import { + LiteralArgContent, + SpaceDelimitedLiteralArgContent, + VerbContent, + VerbatimContent, + LstInlineContent, + Begin, + End, + KnownEnvironment, + MathDelimiter, + Csname, + TrailingWhitespaceOnly, + TrailingContent, + RefCtrlSeq, + RefStarrableCtrlSeq, + CiteCtrlSeq, + CiteStarrableCtrlSeq, + LabelCtrlSeq, + MathTextCtrlSeq, + HboxCtrlSeq, + TitleCtrlSeq, + AuthorCtrlSeq, + DocumentClassCtrlSeq, + UsePackageCtrlSeq, + HrefCtrlSeq, + VerbCtrlSeq, + LstInlineCtrlSeq, + IncludeGraphicsCtrlSeq, + CaptionCtrlSeq, + DefCtrlSeq, + LeftCtrlSeq, + RightCtrlSeq, + NewCommandCtrlSeq, + RenewCommandCtrlSeq, + NewEnvironmentCtrlSeq, + RenewEnvironmentCtrlSeq, + DocumentEnvName, + TabularEnvName, + EquationEnvName, + EquationArrayEnvName, + VerbatimEnvName, + TikzPictureEnvName, + FigureEnvName, + OpenParenCtrlSym, + CloseParenCtrlSym, + OpenBracketCtrlSym, + CloseBracketCtrlSym, + // Sectioning commands + BookCtrlSeq, + PartCtrlSeq, + ChapterCtrlSeq, + SectionCtrlSeq, + SubSectionCtrlSeq, + SubSubSectionCtrlSeq, + ParagraphCtrlSeq, + SubParagraphCtrlSeq, + InputCtrlSeq, + IncludeCtrlSeq, + ItemCtrlSeq, + BibliographyCtrlSeq, + BibliographyStyleCtrlSeq, + CenteringCtrlSeq, + ListEnvName, +} from './latex.terms.mjs' + +function nameChar(ch) { + // we accept A-Z a-z 0-9 * + @ in environment names + return ( + (ch >= 65 && ch <= 90) || + (ch >= 97 && ch <= 122) || + (ch >= 48 && ch <= 57) || + ch === 42 || + ch === 43 || + ch === 64 + ) +} + +// match [a-zA-Z] +function alphaChar(ch) { + return (ch >= 65 && ch <= 90) || (ch >= 97 && ch <= 122) +} + +let cachedName = null +let cachedInput = null +let cachedPos = 0 +function envNameAfter(input, offset) { + const pos = input.pos + offset + if (cachedInput === input && cachedPos === pos) { + return cachedName + } + if (input.peek(offset) !== '{'.charCodeAt(0)) return + offset++ + let name = '' + for (;;) { + const next = input.peek(offset) + if (!nameChar(next)) break + name += String.fromCharCode(next) + offset++ + } + cachedInput = input + cachedPos = pos + return (cachedName = name || null) +} + +function ElementContext(name, parent) { + this.name = name + this.parent = parent + this.hash = parent ? parent.hash : 0 + for (let i = 0; i < name.length; i++) + this.hash += + (this.hash << 4) + name.charCodeAt(i) + (name.charCodeAt(i) << 8) +} + +export const elementContext = new ContextTracker({ + start: null, + shift(context, term, stack, input) { + return term === Begin + ? new ElementContext(envNameAfter(input, '\\begin'.length) || '', context) + : context + }, + reduce(context, term) { + return term === KnownEnvironment && context ? context.parent : context + }, + reuse(context, node, _stack, input) { + const type = node.type.id + return type === Begin + ? new ElementContext(envNameAfter(input, 0) || '', context) + : context + }, + hash(context) { + return context ? context.hash : 0 + }, + strict: false, +}) + +// tokenizer for \verb|...| commands +export const verbTokenizer = new ExternalTokenizer( + (input, stack) => { + if (input.next === '*'.charCodeAt(0)) input.advance() + const delimiter = input.next + if (delimiter === -1) return // hit end of file + if (/\s|\*/.test(String.fromCharCode(delimiter))) return // invalid delimiter + input.advance() + for (;;) { + const next = input.next + if (next === -1 || next === CHAR_NEWLINE) return + input.advance() + if (next === delimiter) break + } + return input.acceptToken(VerbContent) + }, + { contextual: false } +) + +// tokenizer for \lstinline|...| commands +export const lstinlineTokenizer = new ExternalTokenizer( + (input, stack) => { + let delimiter = input.next + if (delimiter === -1) return // hit end of file + if (/\s/.test(String.fromCharCode(delimiter))) { + return // invalid delimiter + } + if (delimiter === CHAR_OPEN_BRACE) { + delimiter = CHAR_CLOSE_BRACE + } + input.advance() + let content = '' + for (;;) { + let next = input.next + if (next === -1 || next === CHAR_NEWLINE) return + content += String.fromCharCode(next) + input.advance() + if (next === delimiter) break + } + return input.acceptToken(LstInlineContent) + }, + { contextual: false } +) + +const matchForward = (input, expected, offset = 0) => { + for (let i = 0; i < expected.length; i++) { + if (String.fromCharCode(input.peek(offset + i)) !== expected[i]) { + return false + } + } + return true +} + +// tokenizer for \begin{verbatim}...\end{verbatim} environments +export const verbatimTokenizer = new ExternalTokenizer( + (input, stack) => { + const delimiter = '\\end{' + stack.context.name + '}' + let offset = 0 + let end = -1 + for (;;) { + const next = input.peek(offset) + if (next === -1) { + end = offset - 1 + break + } + if (matchForward(input, delimiter, offset)) { + // Found the end marker + end = offset - 1 + break + } + offset++ + } + return input.acceptToken(VerbatimContent, end + 1) + }, + { contextual: false } +) + +// tokenizer for \href{...} and similar commands +export const literalArgTokenizer = new ExternalTokenizer( + (input, stack) => { + const delimiter = '}' + let content = '' + let offset = 0 + let end = -1 + for (;;) { + const next = input.peek(offset) + if (next === -1) { + end = offset - 1 + break + } + content += String.fromCharCode(next) + if (content.slice(-delimiter.length) === delimiter) { + // found the '}' + end = offset - delimiter.length + break + } + offset++ + } + return input.acceptToken(LiteralArgContent, end + 1) + }, + { contextual: false } +) + +// tokenizer for literal content delimited by whitespace, such as in `\input foo.tex` +export const spaceDelimitedLiteralArgTokenizer = new ExternalTokenizer( + (input, stack) => { + let content = '' + let offset = 0 + let end = -1 + for (;;) { + const next = input.peek(offset) + if (next === -1) { + end = offset - 1 + break + } + content += String.fromCharCode(next) + if (content.slice(-1) === ' ' || content.slice(-1) === '\n') { + // found the whitespace + end = offset - 1 + break + } + offset++ + } + return input.acceptToken(SpaceDelimitedLiteralArgContent, end + 1) + }, + { contextual: false } +) + +// helper function to look up charCodes +function _char(s) { + return s.charCodeAt(0) +} + +// Allowed delimiters, from the LaTeX manual, table 3.10 +// ( ) [ ] / | \{ \} \| and additional names below +// The empty delimiter . is also allowed + +const CHAR_SLASH = _char('/') +const CHAR_PIPE = _char('|') +const CHAR_OPEN_PAREN = _char('(') +const CHAR_CLOSE_PAREN = _char(')') +const CHAR_OPEN_BRACKET = _char('[') +const CHAR_CLOSE_BRACKET = _char(']') +const CHAR_FULL_STOP = _char('.') +const CHAR_BACKSLASH = _char('\\') +const CHAR_OPEN_BRACE = _char('{') +const CHAR_CLOSE_BRACE = _char('}') + +const ALLOWED_DELIMITER_NAMES = [ + 'lfloor', + 'rfloor', + 'lceil', + 'rceil', + 'langle', + 'rangle', + 'backslash', + 'uparrow', + 'downarrow', + 'Uparrow', + 'Downarrow', + 'updownarrow', + 'Updownarrow', + 'lvert', + 'rvert', + 'lVert', + 'rVert', +] + +// Given a list of allowed command names, return those with leading characters that are the same as the matchString +function findPartialMatches(list, matchString) { + const size = matchString.length + return list.filter( + entry => entry.length >= size && entry.substring(0, size) === matchString + ) +} + +// tokenizer for \leftX ... \rightX delimiter tokens +export const mathDelimiterTokenizer = new ExternalTokenizer( + (input, stack) => { + let content = '' + let offset = 0 + let end = -1 + // look at the first character, we only accept the following /|()[]. + let next = input.peek(offset) + if (next === -1) { + return + } + if ( + next === CHAR_SLASH || + next === CHAR_PIPE || + next === CHAR_OPEN_PAREN || + next === CHAR_CLOSE_PAREN || + next === CHAR_OPEN_BRACKET || + next === CHAR_CLOSE_BRACKET || + next === CHAR_FULL_STOP + ) { + return input.acceptToken(MathDelimiter, 1) + } + // reject anything else not starting with a backslash, + // we only accept control symbols or control sequences + if (next !== CHAR_BACKSLASH) { + return + } + // look at the second character, we only accept \{ and \} and \| as control symbols + offset++ + next = input.peek(offset) + if (next === -1) { + return + } + if ( + next === CHAR_OPEN_BRACE || + next === CHAR_CLOSE_BRACE || + next === CHAR_PIPE + ) { + return input.acceptToken(MathDelimiter, 2) + } + // We haven't matched any symbols, so now try matching command names. + // Is this character a potential match to the remaining allowed delimiter names? + content = String.fromCharCode(next) + let candidates = findPartialMatches(ALLOWED_DELIMITER_NAMES, content) + if (!candidates.length) return + // we have some candidates, look at subsequent characters + offset++ + for (;;) { + const next = input.peek(offset) + // stop when we reach the end of file or a non-alphabetic character + if (next === -1 || !nameChar(next)) { + end = offset - 1 + break + } + content += String.fromCharCode(next) + // find how many candidates remain with the new input + candidates = findPartialMatches(candidates, content) + if (!candidates.length) return // no matches remaining + end = offset + offset++ + } + if (!candidates.includes(content)) return // not a valid delimiter + // accept the content as a valid delimiter + return input.acceptToken(MathDelimiter, end + 1) + }, + { contextual: false } +) + +const CHAR_AT_SYMBOL = _char('@') + +export const csnameTokenizer = new ExternalTokenizer((input, stack) => { + let offset = 0 + let end = -1 + // look at the first character, we are looking for acceptable control sequence names + // including @ signs, \\[a-zA-Z@]+ + const next = input.peek(offset) + if (next === -1) { + return + } + // reject anything not starting with a backslash, + // we only accept control sequences + if (next !== CHAR_BACKSLASH) { + return + } + offset++ + for (;;) { + const next = input.peek(offset) + // stop when we reach the end of file or a non-csname character + if (next === -1 || !(alphaChar(next) || next === CHAR_AT_SYMBOL)) { + end = offset - 1 + break + } + end = offset + offset++ + } + if (end === -1) return + // accept the content as a valid control sequence + return input.acceptToken(Csname, end + 1) +}) + +const CHAR_SPACE = _char(' ') +const CHAR_NEWLINE = _char('\n') +const END_DOCUMENT_MARK = '\\end{document}'.split('').reverse() + +export const trailingContentTokenizer = new ExternalTokenizer( + (input, stack) => { + if (input.next === -1) return // no trailing content + // Look back for end-document mark, bail out if any characters do not match + for (let i = 1; i < END_DOCUMENT_MARK.length + 1; i++) { + if (String.fromCharCode(input.peek(-i)) !== END_DOCUMENT_MARK[i - 1]) { + return + } + } + while (input.next === CHAR_SPACE || input.next === CHAR_NEWLINE) { + const next = input.advance() + if (next === -1) return input.acceptToken(TrailingWhitespaceOnly) // trailing whitespace only + } + // accept the all content up to the end of the document + while (input.advance() !== -1) { + // + } + return input.acceptToken(TrailingContent) + } +) + +const refCommands = new Set([ + '\\fullref', + '\\Vref', + '\\autopageref', + '\\autoref', + '\\eqref', + '\\labelcpageref', + '\\labelcref', + '\\lcnamecref', + '\\lcnamecrefs', + '\\namecref', + '\\nameCref', + '\\namecrefs', + '\\nameCrefs', + '\\thnameref', + '\\thref', + '\\titleref', + '\\vrefrange', + '\\Crefrange', + '\\Crefrang', +]) + +const refStarrableCommands = new Set([ + '\\vpageref', + '\\vref', + '\\zcpageref', + '\\zcref', + '\\zfullref', + '\\zref', + '\\zvpageref', + '\\zvref', + '\\cref', + '\\Cref', + '\\pageref', + '\\ref', + '\\Ref', + '\\zpageref', + '\\ztitleref', + '\\vpagerefrange', + '\\zvpagerefrange', + '\\zvrefrange', + '\\crefrange', +]) + +const citeCommands = new Set([ + '\\autocites', + '\\Autocites', + '\\Cite', + '\\citeA', + '\\citealp', + '\\Citealp', + '\\citealt', + '\\Citealt', + '\\citeauthorNP', + '\\citeauthorp', + '\\Citeauthorp', + '\\citeauthort', + '\\Citeauthort', + '\\citeNP', + '\\citenum', + '\\cites', + '\\Cites', + '\\citeurl', + '\\citeyearpar', + '\\defcitealias', + '\\fnotecite', + '\\footcite', + '\\footcitetext', + '\\footfullcite', + '\\footnotecites', + '\\Footnotecites', + '\\fullcite', + '\\fullciteA', + '\\fullciteauthor', + '\\fullciteauthorNP', + '\\maskcite', + '\\maskciteA', + '\\maskcitealp', + '\\maskCitealp', + '\\maskcitealt', + '\\maskCitealt', + '\\maskciteauthor', + '\\maskciteauthorNP', + '\\maskciteauthorp', + '\\maskCiteauthorp', + '\\maskciteauthort', + '\\maskCiteauthort', + '\\maskciteNP', + '\\maskcitenum', + '\\maskcitep', + '\\maskCitep', + '\\maskcitepalias', + '\\maskcitet', + '\\maskCitet', + '\\maskcitetalias', + '\\maskciteyear', + '\\maskciteyearNP', + '\\maskciteyearpar', + '\\maskfullcite', + '\\maskfullciteA', + '\\maskfullciteauthor', + '\\maskfullciteauthorNP', + '\\masknocite', + '\\maskshortcite', + '\\maskshortciteA', + '\\maskshortciteauthor', + '\\maskshortciteauthorNP', + '\\maskshortciteNP', + '\\mautocite', + '\\Mautocite', + '\\mcite', + '\\Mcite', + '\\mfootcite', + '\\mfootcitetext', + '\\mparencite', + '\\Mparencite', + '\\msupercite', + '\\mtextcite', + '\\Mtextcite', + '\\nocite', + '\\nocitemeta', + '\\notecite', + '\\Parencite', + '\\parencites', + '\\Parencites', + '\\pnotecite', + '\\shortcite', + '\\shortciteA', + '\\shortciteauthor', + '\\shortciteauthorNP', + '\\shortciteNP', + '\\smartcite', + '\\Smartcite', + '\\smartcites', + '\\Smartcites', + '\\supercite', + '\\supercites', + '\\textcite', + '\\Textcite', + '\\textcites', + '\\Textcites', +]) + +const citeStarredCommands = new Set([ + '\\cite', + '\\citeauthor', + '\\Citeauthor', + '\\citedate', + '\\citep', + '\\Citep', + '\\citetitle', + '\\citeyear', + '\\parencite', + '\\citet', + '\\autocite', + '\\Autocite', +]) + +const labelCommands = new Set(['\\label', '\\thlabel', '\\zlabel']) + +const mathTextCommands = new Set(['\\text', '\\tag', '\\textrm', '\\intertext']) + +const otherKnowncommands = { + '\\hbox': HboxCtrlSeq, + '\\title': TitleCtrlSeq, + '\\author': AuthorCtrlSeq, + '\\documentclass': DocumentClassCtrlSeq, + '\\usepackage': UsePackageCtrlSeq, + '\\href': HrefCtrlSeq, + '\\verb': VerbCtrlSeq, + '\\lstinline': LstInlineCtrlSeq, + '\\includegraphics': IncludeGraphicsCtrlSeq, + '\\caption': CaptionCtrlSeq, + '\\def': DefCtrlSeq, + '\\left': LeftCtrlSeq, + '\\right': RightCtrlSeq, + '\\newcommand': NewCommandCtrlSeq, + '\\renewcommand': RenewCommandCtrlSeq, + '\\newenvironment': NewEnvironmentCtrlSeq, + '\\renewenvironment': RenewEnvironmentCtrlSeq, + '\\book': BookCtrlSeq, + '\\part': PartCtrlSeq, + '\\addpart': PartCtrlSeq, + '\\chapter': ChapterCtrlSeq, + '\\addchap': ChapterCtrlSeq, + '\\section': SectionCtrlSeq, + '\\addseq': SectionCtrlSeq, + '\\subsection': SubSectionCtrlSeq, + '\\subsubsection': SubSubSectionCtrlSeq, + '\\paragraph': ParagraphCtrlSeq, + '\\subparagraph': SubParagraphCtrlSeq, + '\\input': InputCtrlSeq, + '\\include': IncludeCtrlSeq, + '\\item': ItemCtrlSeq, + '\\centering': CenteringCtrlSeq, + '\\bibliography': BibliographyCtrlSeq, + '\\bibliographystyle': BibliographyStyleCtrlSeq, +} +// specializer for control sequences +// return new tokens for specific control sequences +export const specializeCtrlSeq = (name, terms) => { + if (name === '\\begin') return Begin + if (name === '\\end') return End + if (refCommands.has(name)) { + return RefCtrlSeq + } + if (refStarrableCommands.has(name)) { + return RefStarrableCtrlSeq + } + if (citeCommands.has(name)) { + return CiteCtrlSeq + } + if (citeStarredCommands.has(name)) { + return CiteStarrableCtrlSeq + } + if (labelCommands.has(name)) { + return LabelCtrlSeq + } + if (mathTextCommands.has(name)) { + return MathTextCtrlSeq + } + return otherKnowncommands[name] || -1 +} + +const tabularEnvNames = new Set([ + 'tabular', + 'xltabular', + 'tabularx', + 'longtable', +]) + +const equationEnvNames = new Set([ + 'equation', + 'equation*', + 'displaymath', + 'displaymath*', + 'math', + 'math*', + 'multline', + 'multline*', + 'matrix', + 'tikzcd', +]) + +const equationArrayEnvNames = new Set([ + 'array', + 'eqnarray', + 'eqnarray*', + 'align', + 'align*', + 'alignat', + 'alignat*', + 'flalign', + 'flalign*', + 'gather', + 'gather*', + 'pmatrix', + 'pmatrix*', + 'bmatrix', + 'bmatrix*', + 'Bmatrix', + 'Bmatrix*', + 'vmatrix', + 'vmatrix*', + 'Vmatrix', + 'Vmatrix*', + 'smallmatrix', + 'smallmatrix*', + 'split', + 'split*', + 'gathered', + 'gathered*', + 'aligned', + 'aligned*', + 'alignedat', + 'alignedat*', + 'cases', + 'cases*', + 'dcases', + 'dcases*', + 'IEEEeqnarray', + 'IEEEeqnarray*', +]) + +const verbatimEnvNames = new Set([ + 'verbatim', + 'boxedverbatim', + 'lstlisting', + 'minted', + 'Verbatim', + 'lstlisting', + 'codeexample', + 'comment', +]) + +const otherKnownEnvNames = { + document: DocumentEnvName, + tikzpicture: TikzPictureEnvName, + figure: FigureEnvName, + subfigure: FigureEnvName, + enumerate: ListEnvName, + itemize: ListEnvName, +} + +export const specializeEnvName = (name, terms) => { + if (tabularEnvNames.has(name)) { + return TabularEnvName + } + if (equationEnvNames.has(name)) { + return EquationEnvName + } + if (equationArrayEnvNames.has(name)) { + return EquationArrayEnvName + } + if (verbatimEnvNames.has(name)) { + return VerbatimEnvName + } + return otherKnownEnvNames[name] || -1 +} + +const otherKnownCtrlSyms = { + '\\(': OpenParenCtrlSym, + '\\)': CloseParenCtrlSym, + '\\[': OpenBracketCtrlSym, + '\\]': CloseBracketCtrlSym, +} + +export const specializeCtrlSym = (name, terms) => { + return otherKnownCtrlSyms[name] || -1 +} diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/ambiance-license.txt b/services/web/frontend/js/features/source-editor/themes/cm6/ambiance-license.txt new file mode 100644 index 0000000000..0d8c3f1d28 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/ambiance-license.txt @@ -0,0 +1,28 @@ +Conversion by Overleaf from Ace to CodeMirror 6. + +Source: https://github.com/ajaxorg/ace/ + +The theme's original license is copied below: + +***** BEGIN LICENSE BLOCK ***** +Distributed under the BSD license: + +Copyright 2011 Irakli Gozalishvili. All rights reserved. +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to +deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +IN THE SOFTWARE. +***** END LICENSE BLOCK ***** \ No newline at end of file diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/ambiance.json b/services/web/frontend/js/features/source-editor/themes/cm6/ambiance.json new file mode 100644 index 0000000000..c04e995b3b --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/ambiance.json @@ -0,0 +1,79 @@ +{ + "theme": { + ".cm-gutters": { + "backgroundColor": "#3d3d3d", + "borderRightColor": "transparent", + "backgroundImage": "linear-gradient(left, #3D3D3D, #333)", + "backgroundRepeat": "repeat-x", + "borderRight": "1px solid #4d4d4d", + "textShadow": "0px 1px 1px #4d4d4d", + "color": "#222" + }, + ".cm-activeLineGutter": { + "backgroundColor": "#3F3F3F" + }, + "&": { + "color": "#E6E1DC", + "backgroundColor": "#202020" + }, + ".cm-cursor, .cm-dropCursor": { + "borderLeft": "1px solid #7991E8" + }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection, .cm-searchMatch.cm-searchMatch.cm-searchMatch-selected": { + "background": "rgba(221, 240, 255, 0.20)" + }, + ".cm-selectionMatch.cm-selectionMatch, .cm-searchMatch.cm-searchMatch": { + "borderRadius": 0, + "boxShadow": "0 0 4px black", + "outline": "8px solid #3f475d", + "margin": 0, + "outline-width": "2px" + }, + "&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": { + "margin": 0, + "outline": "1px solid rgba(255, 255, 255, 0.25)" + }, + ".cm-activeLine": { + "background": "rgba(255, 255, 255, 0.031)" + } + }, + "highlightStyle": { + ".tok-keyword": { + "color": "#cda869" + }, + ".tok-operator": { + "color": "#fa8d6a" + }, + ".tok-string": { + "color": "#8f9d6a" + }, + ".tok-labelName": { + "color": "#CF7EA9" + }, + ".tok-literal": { + "color": "#78CF8A" + }, + ".tok-invalid": { + "textDecoration": "underline", + "fontStyle": "italic", + "color": "#D2A8A1" + }, + ".tok-regexp": { + "color": "#DAD085" + }, + ".tok-comment": { + "fontStyle": "italic", + "color": "#555" + }, + ".tok-typeName": { + "color": "#aac6e3" + }, + ".tok-attributeValue": { + "color": "#9999cc" + }, + ".tok-variableName": { + "color": "#9b859d" + } + }, + "dark": true +} diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/chaos-license.txt b/services/web/frontend/js/features/source-editor/themes/cm6/chaos-license.txt new file mode 100644 index 0000000000..0d8c3f1d28 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/chaos-license.txt @@ -0,0 +1,28 @@ +Conversion by Overleaf from Ace to CodeMirror 6. + +Source: https://github.com/ajaxorg/ace/ + +The theme's original license is copied below: + +***** BEGIN LICENSE BLOCK ***** +Distributed under the BSD license: + +Copyright 2011 Irakli Gozalishvili. All rights reserved. +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to +deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +IN THE SOFTWARE. +***** END LICENSE BLOCK ***** \ No newline at end of file diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/chaos.json b/services/web/frontend/js/features/source-editor/themes/cm6/chaos.json new file mode 100644 index 0000000000..6568495683 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/chaos.json @@ -0,0 +1,73 @@ +{ + "theme": { + ".cm-gutters": { + "backgroundColor": "transparent", + "borderRightColor": "transparent", + "background": "#141414", + "color": "#595959", + "borderRight": "1px solid #282828" + }, + "&": { + "backgroundColor": "#161616", + "color": "#E6E1DC" + }, + ".cm-cursor, .cm-dropCursor": { + "borderLeft": "2px solid #FFFFFF" + }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection, .cm-searchMatch.cm-searchMatch.cm-searchMatch-selected": { + "background": "#494836" + }, + "&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": { + "margin": 0, + "outline": "1px solid #FCE94F" + }, + ".cm-activeLine": { + "background": "#333" + }, + ".cm-activeLineGutter": { + "backgroundColor": "#222" + }, + ".cm-foldPlaceholder": { + "background": "#222", + "borderRadius": "3px", + "color": "#7AF", + "border": "none" + }, + ".cm-selectionMatch.cm-selectionMatch, .cm-searchMatch.cm-searchMatch": { + "margin": 0 + } + }, + "highlightStyle": { + ".tok-keyword": { + "color": "#00698F" + }, + ".tok-operator": { + "color": "#FF308F" + }, + ".tok-labelName": { + "color": "#1EDAFB" + }, + ".tok-literal": { + "color": "#58C554" + }, + ".tok-invalid": { + "color": "#FFFFFF", + "backgroundColor": "#990000" + }, + ".tok-string": { + "color": "#58C554" + }, + ".tok-comment": { + "color": "#555", + "fontStyle": "italic", + "paddingBottom": "0px" + }, + ".tok-attributeValue": { + "color": "#997744" + }, + ".tok-attributeName": { + "color": "#FFFF89" + } + }, + "dark": true +} diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/chrome-license.txt b/services/web/frontend/js/features/source-editor/themes/cm6/chrome-license.txt new file mode 100644 index 0000000000..d70d8cae37 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/chrome-license.txt @@ -0,0 +1,35 @@ +Conversion by Overleaf from Ace to CodeMirror 6. + +Source: https://github.com/ajaxorg/ace/ + +The theme's original license is copied below: + +***** BEGIN LICENSE BLOCK ***** +Distributed under the BSD license: + +Copyright (c) 2010, Ajax.org B.V. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Ajax.org B.V. nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +***** END LICENSE BLOCK ***** \ No newline at end of file diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/chrome.json b/services/web/frontend/js/features/source-editor/themes/cm6/chrome.json new file mode 100644 index 0000000000..1f18f24ec4 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/chrome.json @@ -0,0 +1,78 @@ +{ + "theme": { + ".cm-gutters": { + "backgroundColor": "transparent", + "borderRightColor": "transparent", + "background": "#ebebeb", + "color": "#333", + "overflow": "hidden" + }, + "&": { + "backgroundColor": "#FFFFFF", + "color": "black" + }, + ".cm-cursor, .cm-dropCursor": { + "color": "black" + }, + ".cm-foldPlaceholder": {}, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection, .cm-searchMatch.cm-searchMatch.cm-searchMatch-selected": { + "background": "rgb(181, 213, 255)" + }, + "&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": { + "margin": 0, + "outline": "1px solid rgb(192, 192, 192)" + }, + ".cm-activeLine": { + "background": "rgba(0, 0, 0, 0.07)" + }, + ".cm-activeLineGutter": { + "backgroundColor": "#dcdcdc" + }, + ".cm-selectionMatch.cm-selectionMatch, .cm-searchMatch.cm-searchMatch": { + "background": "rgba(250, 250, 255, 0.5)", + "outline": "1px solid rgb(200, 200, 250)", + "margin": 0 + } + }, + "highlightStyle": { + ".tok-literal": { + "color": "rgb(0, 0, 205)" + }, + ".tok-invalid": { + "backgroundColor": "rgb(153, 0, 0)", + "color": "white" + }, + ".tok-attributeValue": { + "fontStyle": "italic", + "color": "rgb(49, 132, 149)" + }, + ".tok-operator": { + "color": "rgb(104, 118, 135)" + }, + ".tok-comment": { + "color": "#236e24" + }, + ".tok-function": { + "color": "#0000A2" + }, + ".tok-heading": { + "color": "rgb(12, 7, 255)" + }, + ".tok-list": { + "color": "rgb(185, 6, 144)" + }, + ".tok-typeName": { + "color": "rgb(147, 15, 128)" + }, + ".tok-keyword": { + "color": "rgb(147, 15, 128)" + }, + ".tok-string": { + "color": "#1A1AA6" + }, + ".tok-attributeName": { + "color": "#994409" + } + }, + "dark": false +} diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/clouds-license.txt b/services/web/frontend/js/features/source-editor/themes/cm6/clouds-license.txt new file mode 100644 index 0000000000..d70d8cae37 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/clouds-license.txt @@ -0,0 +1,35 @@ +Conversion by Overleaf from Ace to CodeMirror 6. + +Source: https://github.com/ajaxorg/ace/ + +The theme's original license is copied below: + +***** BEGIN LICENSE BLOCK ***** +Distributed under the BSD license: + +Copyright (c) 2010, Ajax.org B.V. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Ajax.org B.V. nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +***** END LICENSE BLOCK ***** \ No newline at end of file diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/clouds.json b/services/web/frontend/js/features/source-editor/themes/cm6/clouds.json new file mode 100644 index 0000000000..27af98815b --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/clouds.json @@ -0,0 +1,68 @@ +{ + "theme": { + ".cm-gutters": { + "backgroundColor": "transparent", + "borderRightColor": "transparent", + "background": "#ebebeb", + "color": "#333" + }, + "&": { + "backgroundColor": "#FFFFFF", + "color": "#000000" + }, + ".cm-cursor, .cm-dropCursor": { + "color": "#000000" + }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection, .cm-searchMatch.cm-searchMatch.cm-searchMatch-selected": { + "background": "#BDD5FC" + }, + "&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": { + "margin": 0, + "outline": "1px solid #BFBFBF" + }, + ".cm-activeLine": { + "background": "#FFFBD1" + }, + ".cm-activeLineGutter": { + "backgroundColor": "#dcdcdc" + }, + ".cm-selectionMatch.cm-selectionMatch, .cm-searchMatch.cm-searchMatch": { + "outline": "1px solid #BDD5FC", + "margin": 0 + }, + ".cm-foldPlaceholder": { + "backgroundColor": "#AF956F", + "borderColor": "#000000" + } + }, + "highlightStyle": { + ".tok-keyword": { + "color": "#AF956F" + }, + ".tok-operator": { + "color": "#484848" + }, + ".tok-literal": { + "color": "#46A609" + }, + ".tok-invalid": { + "backgroundColor": "#FF002A" + }, + ".tok-typeName": { + "color": "#C52727" + }, + ".tok-string": { + "color": "#5D90CD" + }, + ".tok-comment": { + "color": "#BCC8BA" + }, + ".tok-tagName": { + "color": "#606060" + }, + ".tok-attributeName": { + "color": "#606060" + } + }, + "dark": false +} diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/clouds_midnight-license.txt b/services/web/frontend/js/features/source-editor/themes/cm6/clouds_midnight-license.txt new file mode 100644 index 0000000000..d70d8cae37 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/clouds_midnight-license.txt @@ -0,0 +1,35 @@ +Conversion by Overleaf from Ace to CodeMirror 6. + +Source: https://github.com/ajaxorg/ace/ + +The theme's original license is copied below: + +***** BEGIN LICENSE BLOCK ***** +Distributed under the BSD license: + +Copyright (c) 2010, Ajax.org B.V. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Ajax.org B.V. nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +***** END LICENSE BLOCK ***** \ No newline at end of file diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/clouds_midnight.json b/services/web/frontend/js/features/source-editor/themes/cm6/clouds_midnight.json new file mode 100644 index 0000000000..e6c8f56ab4 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/clouds_midnight.json @@ -0,0 +1,69 @@ +{ + "theme": { + ".cm-gutters": { + "backgroundColor": "transparent", + "borderRightColor": "transparent", + "background": "#232323", + "color": "#929292" + }, + "&": { + "backgroundColor": "#191919", + "color": "#929292" + }, + ".cm-cursor, .cm-dropCursor": { + "color": "#7DA5DC" + }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection, .cm-searchMatch.cm-searchMatch.cm-searchMatch-selected": { + "background": "#000000" + }, + "&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": { + "margin": 0, + "outline": "1px solid #BFBFBF" + }, + ".cm-activeLine": { + "background": "rgba(215, 215, 215, 0.031)" + }, + ".cm-activeLineGutter": { + "backgroundColor": "rgba(215, 215, 215, 0.031)" + }, + ".cm-selectionMatch.cm-selectionMatch, .cm-searchMatch.cm-searchMatch": { + "outline": "1px solid #000000", + "margin": 0 + }, + ".cm-foldPlaceholder": { + "backgroundColor": "#927C5D", + "borderColor": "#929292" + } + }, + "highlightStyle": { + ".tok-keyword": { + "color": "#927C5D" + }, + ".tok-operator": { + "color": "#4B4B4B" + }, + ".tok-literal": { + "color": "#46A609" + }, + ".tok-invalid": { + "color": "#FFFFFF", + "backgroundColor": "#E92E2E" + }, + ".tok-typeName": { + "color": "#E92E2E" + }, + ".tok-string": { + "color": "#5D90CD" + }, + ".tok-comment": { + "color": "#3C403B" + }, + ".tok-tagName": { + "color": "#606060" + }, + ".tok-attributeName": { + "color": "#606060" + } + }, + "dark": true +} diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/cobalt-license.txt b/services/web/frontend/js/features/source-editor/themes/cm6/cobalt-license.txt new file mode 100644 index 0000000000..d70d8cae37 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/cobalt-license.txt @@ -0,0 +1,35 @@ +Conversion by Overleaf from Ace to CodeMirror 6. + +Source: https://github.com/ajaxorg/ace/ + +The theme's original license is copied below: + +***** BEGIN LICENSE BLOCK ***** +Distributed under the BSD license: + +Copyright (c) 2010, Ajax.org B.V. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Ajax.org B.V. nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +***** END LICENSE BLOCK ***** \ No newline at end of file diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/cobalt.json b/services/web/frontend/js/features/source-editor/themes/cm6/cobalt.json new file mode 100644 index 0000000000..48bc5bbed1 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/cobalt.json @@ -0,0 +1,80 @@ +{ + "theme": { + ".cm-gutters": { + "backgroundColor": "transparent", + "borderRightColor": "transparent", + "background": "#011e3a", + "color": "rgb(128,145,160)" + }, + "&": { + "backgroundColor": "#002240", + "color": "#FFFFFF" + }, + ".cm-cursor, .cm-dropCursor": { + "color": "#FFFFFF" + }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection, .cm-searchMatch.cm-searchMatch.cm-searchMatch-selected": { + "background": "rgba(179, 101, 57, 0.75)" + }, + "&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": { + "margin": 0, + "outline": "1px solid rgba(255, 255, 255, 0.15)" + }, + ".cm-activeLine": { + "background": "rgba(0, 0, 0, 0.35)" + }, + ".cm-activeLineGutter": { + "backgroundColor": "rgba(0, 0, 0, 0.35)" + }, + ".cm-selectionMatch.cm-selectionMatch, .cm-searchMatch.cm-searchMatch": { + "outline": "1px solid rgba(179, 101, 57, 0.75)", + "margin": 0 + }, + ".cm-foldPlaceholder": { + "backgroundColor": "#FF9D00", + "borderColor": "#FFFFFF" + } + }, + "highlightStyle": { + ".tok-keyword": { + "color": "#FF9D00" + }, + ".tok-labelName": { + "color": "#FF628C" + }, + ".tok-literal": { + "color": "#FF628C" + }, + ".tok-invalid": { + "color": "#F8F8F8", + "backgroundColor": "#800F00" + }, + ".tok-typeName": { + "color": "#FFEE80" + }, + ".tok-string": { + "color": "#3AD900" + }, + ".tok-regexp": { + "color": "#80FFC2" + }, + ".tok-comment": { + "fontStyle": "italic", + "color": "#0088FF" + }, + ".tok-heading": { + "color": "#C8E4FD", + "backgroundColor": "#001221" + }, + ".tok-list": { + "backgroundColor": "#130D26" + }, + ".tok-attributeValue": { + "color": "#CCCCCC" + }, + ".tok-variableName": { + "color": "#FF80E1" + } + }, + "dark": true +} diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/crimson_editor-license.txt b/services/web/frontend/js/features/source-editor/themes/cm6/crimson_editor-license.txt new file mode 100644 index 0000000000..d70d8cae37 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/crimson_editor-license.txt @@ -0,0 +1,35 @@ +Conversion by Overleaf from Ace to CodeMirror 6. + +Source: https://github.com/ajaxorg/ace/ + +The theme's original license is copied below: + +***** BEGIN LICENSE BLOCK ***** +Distributed under the BSD license: + +Copyright (c) 2010, Ajax.org B.V. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Ajax.org B.V. nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +***** END LICENSE BLOCK ***** \ No newline at end of file diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/crimson_editor.json b/services/web/frontend/js/features/source-editor/themes/cm6/crimson_editor.json new file mode 100644 index 0000000000..77c1302019 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/crimson_editor.json @@ -0,0 +1,62 @@ +{ + "theme": { + ".cm-gutters": { + "backgroundColor": "transparent", + "borderRightColor": "transparent", + "background": "#ebebeb", + "color": "#333", + "overflow": "hidden" + }, + "&": { + "backgroundColor": "#FFFFFF", + "color": "rgb(64, 64, 64)" + }, + ".cm-cursor, .cm-dropCursor": { + "color": "black" + }, + ".cm-foldPlaceholder": {}, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection, .cm-searchMatch.cm-searchMatch.cm-searchMatch-selected": { + "background": "rgb(181, 213, 255)" + }, + "&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": { + "margin": 0, + "outline": "1px solid rgb(192, 192, 192)" + }, + ".cm-activeLine": { + "background": "rgb(232, 242, 254)" + }, + ".cm-activeLineGutter": { + "backgroundColor": "#dcdcdc" + }, + ".cm-selectionMatch.cm-selectionMatch, .cm-searchMatch.cm-searchMatch": { + "background": "rgb(250, 250, 255)", + "outline": "1px solid rgb(200, 200, 250)", + "margin": 0 + } + }, + "highlightStyle": { + ".tok-string": { + "color": "rgb(128, 0, 128)" + }, + ".tok-keyword": { + "color": "blue" + }, + ".tok-literal": { + "color": "rgb(0, 0, 64)" + }, + ".tok-invalid": { + "textDecoration": "line-through", + "color": "rgb(224, 0, 0)" + }, + ".tok-operator": { + "color": "rgb(49, 132, 149)" + }, + ".tok-comment": { + "color": "rgb(76, 136, 107)" + }, + ".tok-attributeValue": { + "color": "rgb(0, 64, 128)" + } + }, + "dark": false +} diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/dawn-license.txt b/services/web/frontend/js/features/source-editor/themes/cm6/dawn-license.txt new file mode 100644 index 0000000000..d70d8cae37 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/dawn-license.txt @@ -0,0 +1,35 @@ +Conversion by Overleaf from Ace to CodeMirror 6. + +Source: https://github.com/ajaxorg/ace/ + +The theme's original license is copied below: + +***** BEGIN LICENSE BLOCK ***** +Distributed under the BSD license: + +Copyright (c) 2010, Ajax.org B.V. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Ajax.org B.V. nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +***** END LICENSE BLOCK ***** \ No newline at end of file diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/dawn.json b/services/web/frontend/js/features/source-editor/themes/cm6/dawn.json new file mode 100644 index 0000000000..9fba586647 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/dawn.json @@ -0,0 +1,73 @@ +{ + "theme": { + ".cm-gutters": { + "backgroundColor": "transparent", + "borderRightColor": "transparent", + "background": "#ebebeb", + "color": "#333" + }, + "&": { + "backgroundColor": "#F9F9F9", + "color": "#080808" + }, + ".cm-cursor, .cm-dropCursor": { + "color": "#000000" + }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection, .cm-searchMatch.cm-searchMatch.cm-searchMatch-selected": { + "background": "rgba(39, 95, 255, 0.30)" + }, + "&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": { + "margin": 0, + "outline": "1px solid rgba(75, 75, 126, 0.50)" + }, + ".cm-activeLine": { + "background": "rgba(36, 99, 180, 0.12)" + }, + ".cm-activeLineGutter": { + "backgroundColor": "#dcdcdc" + }, + ".cm-selectionMatch.cm-selectionMatch, .cm-searchMatch.cm-searchMatch": { + "outline": "1px solid rgba(39, 95, 255, 0.30)", + "margin": 0 + }, + ".cm-foldPlaceholder": { + "backgroundColor": "#794938", + "borderColor": "#080808" + } + }, + "highlightStyle": { + ".tok-keyword": { + "color": "#794938" + }, + ".tok-labelName": { + "color": "#811F24" + }, + ".tok-literal": { + "color": "#811F24" + }, + ".tok-list": { + "color": "#693A17" + }, + ".tok-typeName": { + "fontStyle": "italic", + "color": "#A71D5D" + }, + ".tok-string": { + "color": "#0B6125" + }, + ".tok-regexp": { + "color": "#CF5628" + }, + ".tok-comment": { + "fontStyle": "italic", + "color": "#5A525F" + }, + ".tok-heading": { + "color": "#19356D" + }, + ".tok-attributeValue": { + "color": "#234A97" + } + }, + "dark": false +} diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/dracula-license.txt b/services/web/frontend/js/features/source-editor/themes/cm6/dracula-license.txt new file mode 100644 index 0000000000..d70d8cae37 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/dracula-license.txt @@ -0,0 +1,35 @@ +Conversion by Overleaf from Ace to CodeMirror 6. + +Source: https://github.com/ajaxorg/ace/ + +The theme's original license is copied below: + +***** BEGIN LICENSE BLOCK ***** +Distributed under the BSD license: + +Copyright (c) 2010, Ajax.org B.V. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Ajax.org B.V. nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +***** END LICENSE BLOCK ***** \ No newline at end of file diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/dracula.json b/services/web/frontend/js/features/source-editor/themes/cm6/dracula.json new file mode 100644 index 0000000000..8f6c725fa4 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/dracula.json @@ -0,0 +1,75 @@ +{ + "theme": { + ".cm-gutters": { + "backgroundColor": "transparent", + "borderRightColor": "transparent", + "background": "#282a36", + "color": "rgb(144,145,148)" + }, + "&": { + "backgroundColor": "#282a36", + "color": "#f8f8f2" + }, + ".cm-cursor, .cm-dropCursor": { + "color": "#f8f8f0" + }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection, .cm-searchMatch.cm-searchMatch.cm-searchMatch-selected": { + "background": "#44475a" + }, + "&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": { + "margin": 0, + "outline": "1px solid #a29709" + }, + ".cm-activeLine": { + "background": "#44475a" + }, + ".cm-activeLineGutter": { + "backgroundColor": "#44475a" + }, + ".cm-selectionMatch.cm-selectionMatch, .cm-searchMatch.cm-searchMatch": { + "boxShadow": "0px 0px 0px 1px inset #a29709", + "borderRadius": "3px", + "margin": 0 + }, + ".cm-foldPlaceholder": { + "backgroundColor": "#50fa7b", + "borderColor": "#f8f8f2" + } + }, + "highlightStyle": { + ".tok-keyword": { + "color": "#ff79c6" + }, + ".tok-literal": { + "color": "#ff79c6" + }, + ".tok-typeName": { + "color": "#8be9fd", + "fontStyle": "italic" + }, + ".tok-invalid": { + "color": "#F8F8F0", + "backgroundColor": "#ff79c6" + }, + ".tok-string": { + "color": "#f1fa8c" + }, + ".tok-comment": { + "color": "#6272a4" + }, + ".tok-attributeValue": { + "color": "#ffb86c", + "fontStyle": "italic" + }, + ".tok-attributeName": { + "color": "#50fa7b" + }, + ".tok-function": { + "color": "#50fa7b" + }, + ".tok-tagName": { + "color": "#ff79c6" + } + }, + "dark": true +} diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/dreamweaver-license.txt b/services/web/frontend/js/features/source-editor/themes/cm6/dreamweaver-license.txt new file mode 100644 index 0000000000..d70d8cae37 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/dreamweaver-license.txt @@ -0,0 +1,35 @@ +Conversion by Overleaf from Ace to CodeMirror 6. + +Source: https://github.com/ajaxorg/ace/ + +The theme's original license is copied below: + +***** BEGIN LICENSE BLOCK ***** +Distributed under the BSD license: + +Copyright (c) 2010, Ajax.org B.V. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Ajax.org B.V. nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +***** END LICENSE BLOCK ***** \ No newline at end of file diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/dreamweaver.json b/services/web/frontend/js/features/source-editor/themes/cm6/dreamweaver.json new file mode 100644 index 0000000000..473384d49a --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/dreamweaver.json @@ -0,0 +1,75 @@ +{ + "theme": { + ".cm-gutters": { + "backgroundColor": "transparent", + "borderRightColor": "transparent", + "background": "#e8e8e8", + "color": "#333" + }, + "&": { + "backgroundColor": "#FFFFFF", + "color": "black" + }, + ".cm-foldPlaceholder": { + "backgroundColor": "#757AD8" + }, + ".cm-cursor, .cm-dropCursor": { + "color": "black" + }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection, .cm-searchMatch.cm-searchMatch.cm-searchMatch-selected": { + "background": "rgb(181, 213, 255)" + }, + "&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": { + "margin": 0, + "outline": "1px solid rgb(192, 192, 192)" + }, + ".cm-activeLine": { + "background": "rgba(0, 0, 0, 0.07)" + }, + ".cm-activeLineGutter": { + "backgroundColor": "#DCDCDC" + }, + ".cm-selectionMatch.cm-selectionMatch, .cm-searchMatch.cm-searchMatch": { + "background": "rgb(250, 250, 255)", + "outline": "1px solid rgb(200, 200, 250)", + "margin": 0 + } + }, + "highlightStyle": { + ".tok-typeName": { + "color": "blue" + }, + ".tok-keyword": { + "color": "blue" + }, + ".tok-literal": { + "color": "rgb(0, 0, 205)" + }, + ".tok-invalid": { + "backgroundColor": "rgb(153, 0, 0)", + "color": "white" + }, + ".tok-operator": { + "color": "rgb(104, 118, 135)" + }, + ".tok-string": { + "color": "#00F" + }, + ".tok-comment": { + "color": "rgb(76, 136, 107)" + }, + ".tok-attributeValue": { + "color": "#06F" + }, + ".tok-function": { + "color": "#00F" + }, + ".tok-heading": { + "color": "rgb(12, 7, 255)" + }, + ".tok-list": { + "color": "rgb(185, 6, 144)" + } + }, + "dark": false +} diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/eclipse-license.txt b/services/web/frontend/js/features/source-editor/themes/cm6/eclipse-license.txt new file mode 100644 index 0000000000..d70d8cae37 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/eclipse-license.txt @@ -0,0 +1,35 @@ +Conversion by Overleaf from Ace to CodeMirror 6. + +Source: https://github.com/ajaxorg/ace/ + +The theme's original license is copied below: + +***** BEGIN LICENSE BLOCK ***** +Distributed under the BSD license: + +Copyright (c) 2010, Ajax.org B.V. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Ajax.org B.V. nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +***** END LICENSE BLOCK ***** \ No newline at end of file diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/eclipse.json b/services/web/frontend/js/features/source-editor/themes/cm6/eclipse.json new file mode 100644 index 0000000000..741f813396 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/eclipse.json @@ -0,0 +1,59 @@ +{ + "theme": { + ".cm-gutters": { + "backgroundColor": "transparent", + "borderRightColor": "transparent", + "background": "#ebebeb", + "borderRight": "1px solid rgb(159, 159, 159)", + "color": "rgb(136, 136, 136)" + }, + "&": { + "backgroundColor": "#FFFFFF", + "color": "black" + }, + ".cm-foldPlaceholder": { + "backgroundColor": "rgb(60, 76, 114)" + }, + ".cm-cursor, .cm-dropCursor": { + "color": "black" + }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection, .cm-searchMatch.cm-searchMatch.cm-searchMatch-selected": { + "background": "rgb(181, 213, 255)" + }, + "&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": { + "margin": 0, + "outline": "1px solid rgb(192, 192, 192)" + }, + ".cm-activeLineGutter": { + "backgroundColor": "#DADADA" + }, + ".cm-selectionMatch.cm-selectionMatch, .cm-searchMatch.cm-searchMatch": { + "outline": "1px solid rgb(181, 213, 255)", + "margin": 0 + } + }, + "highlightStyle": { + ".tok-typeName": { + "color": "rgb(127, 0, 127)" + }, + ".tok-keyword": { + "color": "rgb(127, 0, 85)" + }, + ".tok-attributeValue": { + "color": "rgb(127, 0, 85)" + }, + ".tok-string": { + "color": "rgb(42, 0, 255)" + }, + ".tok-comment": { + "color": "rgb(113, 150, 130)" + }, + ".tok-literal": { + "color": "darkblue" + }, + ".tok-attributeName": { + "color": "rgb(127, 0, 127)" + } + }, + "dark": false +} diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/github-license.txt b/services/web/frontend/js/features/source-editor/themes/cm6/github-license.txt new file mode 100644 index 0000000000..d70d8cae37 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/github-license.txt @@ -0,0 +1,35 @@ +Conversion by Overleaf from Ace to CodeMirror 6. + +Source: https://github.com/ajaxorg/ace/ + +The theme's original license is copied below: + +***** BEGIN LICENSE BLOCK ***** +Distributed under the BSD license: + +Copyright (c) 2010, Ajax.org B.V. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Ajax.org B.V. nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +***** END LICENSE BLOCK ***** \ No newline at end of file diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/github.json b/services/web/frontend/js/features/source-editor/themes/cm6/github.json new file mode 100644 index 0000000000..7d142145f4 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/github.json @@ -0,0 +1,59 @@ +{ + "theme": { + ".cm-gutters": { + "backgroundColor": "transparent", + "borderRightColor": "transparent", + "background": "#e8e8e8", + "color": "#AAA" + }, + "&": { + "background": "#fff", + "color": "#000" + }, + ".cm-cursor, .cm-dropCursor": { + "color": "black" + }, + ".cm-activeLine": { + "background": "rgb(245, 245, 245)" + }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection, .cm-searchMatch.cm-searchMatch.cm-searchMatch-selected": { + "background": "rgb(181, 213, 255)" + }, + "&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": { + "margin": 0, + "outline": "1px solid rgb(192, 192, 192)" + }, + ".cm-activeLineGutter": { + "backgroundColor": "rgba(0, 0, 0, 0.07)" + }, + ".cm-selectionMatch.cm-selectionMatch, .cm-searchMatch.cm-searchMatch": { + "background": "rgb(250, 250, 255)", + "outline": "1px solid rgb(200, 200, 250)", + "margin": 0 + } + }, + "highlightStyle": { + ".tok-keyword": { + "fontWeight": "bold" + }, + ".tok-string": { + "color": "#D14" + }, + ".tok-literal": { + "color": "#099", + "fontWeight": "bold" + }, + ".tok-comment": { + "color": "#998", + "fontStyle": "italic" + }, + ".tok-variableName": { + "color": "#0086B3" + }, + ".tok-regexp": { + "color": "#009926", + "fontWeight": "normal" + } + }, + "dark": false +} diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/gob-license.txt b/services/web/frontend/js/features/source-editor/themes/cm6/gob-license.txt new file mode 100644 index 0000000000..d70d8cae37 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/gob-license.txt @@ -0,0 +1,35 @@ +Conversion by Overleaf from Ace to CodeMirror 6. + +Source: https://github.com/ajaxorg/ace/ + +The theme's original license is copied below: + +***** BEGIN LICENSE BLOCK ***** +Distributed under the BSD license: + +Copyright (c) 2010, Ajax.org B.V. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Ajax.org B.V. nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +***** END LICENSE BLOCK ***** \ No newline at end of file diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/gob.json b/services/web/frontend/js/features/source-editor/themes/cm6/gob.json new file mode 100644 index 0000000000..0f9d84186d --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/gob.json @@ -0,0 +1,77 @@ +{ + "theme": { + ".cm-gutters": { + "backgroundColor": "transparent", + "borderRightColor": "transparent", + "background": "#0B1818", + "color": "#03EE03" + }, + "&": { + "backgroundColor": "#0B0B0B", + "color": "#00FF00" + }, + ".cm-cursor, .cm-dropCursor": { + "borderColor": "rgba(16, 248, 255, 0.90)", + "backgroundColor": "rgba(16, 240, 248, 0.70)", + "opacity": "0.4" + }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection, .cm-searchMatch.cm-searchMatch.cm-searchMatch-selected": { + "background": "rgba(221, 240, 255, 0.20)" + }, + "&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": { + "margin": 0, + "outline": "1px solid rgba(64, 255, 255, 0.25)" + }, + ".cm-activeLine": { + "background": "rgba(255, 255, 255, 0.04)" + }, + ".cm-activeLineGutter": { + "backgroundColor": "rgba(255, 255, 255, 0.04)" + }, + ".cm-selectionMatch.cm-selectionMatch, .cm-searchMatch.cm-searchMatch": { + "outline": "1px solid rgba(192, 240, 255, 0.20)", + "margin": 0 + }, + ".cm-foldPlaceholder": { + "backgroundColor": "#50B8B8", + "borderColor": "#70F8F8" + } + }, + "highlightStyle": { + ".tok-keyword": { + "color": "#10D8E8" + }, + ".tok-labelName": { + "color": "#10F0A0" + }, + ".tok-literal": { + "color": "#10F0A0" + }, + ".tok-heading": { + "color": "#10F0A0" + }, + ".tok-list": { + "color": "#10FF98" + }, + ".tok-typeName": { + "color": "#10FF98" + }, + ".tok-function": { + "color": "#00F868" + }, + ".tok-attributeValue": { + "color": "#00F888" + }, + ".tok-string": { + "color": "#10F060" + }, + ".tok-regexp": { + "color": "#20F090" + }, + ".tok-comment": { + "fontStyle": "italic", + "color": "#00E060" + } + }, + "dark": true +} diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/gruvbox-license.txt b/services/web/frontend/js/features/source-editor/themes/cm6/gruvbox-license.txt new file mode 100644 index 0000000000..0d8c3f1d28 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/gruvbox-license.txt @@ -0,0 +1,28 @@ +Conversion by Overleaf from Ace to CodeMirror 6. + +Source: https://github.com/ajaxorg/ace/ + +The theme's original license is copied below: + +***** BEGIN LICENSE BLOCK ***** +Distributed under the BSD license: + +Copyright 2011 Irakli Gozalishvili. All rights reserved. +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to +deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +IN THE SOFTWARE. +***** END LICENSE BLOCK ***** \ No newline at end of file diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/gruvbox.json b/services/web/frontend/js/features/source-editor/themes/cm6/gruvbox.json new file mode 100644 index 0000000000..df8325464e --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/gruvbox.json @@ -0,0 +1,61 @@ +{ + "theme": { + ".cm-gutters": { + "backgroundColor": "transparent", + "borderRightColor": "transparent" + }, + ".cm-activeLineGutter": { + "backgroundColor": "#3C3836" + }, + "&": { + "color": "#EBDAB4", + "backgroundColor": "#1D2021" + }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection, .cm-searchMatch.cm-searchMatch.cm-searchMatch-selected": { + "background": "rgba(179, 101, 57, 0.75)" + }, + ".cm-activeLine": { + "background": "#3C3836" + }, + ".cm-selectionMatch.cm-selectionMatch, .cm-searchMatch.cm-searchMatch": { + "borderRadius": 0, + "outline": "8px solid #3f475d", + "margin": 0, + "outline-width": "2px" + }, + "&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": { + "margin": 0 + } + }, + "highlightStyle": { + ".tok-keyword": { + "color": "#8ec07c" + }, + ".tok-comment": { + "fontStyle": "italic", + "color": "#928375" + }, + ".tok-attributeValue": { + "color": "#84A598" + }, + ".tok-variableName": { + "color": "#D2879B" + }, + ".tok-labelName": { + "color": "#C2859A" + }, + ".tok-literal": { + "color": "#C2859A" + }, + ".tok-string": { + "color": "#B8BA37" + }, + ".tok-typeName": { + "color": "#8FBF7F" + }, + ".tok-operator": { + "color": "#EBDAB4" + } + }, + "dark": true +} diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/idle_fingers-license.txt b/services/web/frontend/js/features/source-editor/themes/cm6/idle_fingers-license.txt new file mode 100644 index 0000000000..d70d8cae37 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/idle_fingers-license.txt @@ -0,0 +1,35 @@ +Conversion by Overleaf from Ace to CodeMirror 6. + +Source: https://github.com/ajaxorg/ace/ + +The theme's original license is copied below: + +***** BEGIN LICENSE BLOCK ***** +Distributed under the BSD license: + +Copyright (c) 2010, Ajax.org B.V. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Ajax.org B.V. nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +***** END LICENSE BLOCK ***** \ No newline at end of file diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/idle_fingers.json b/services/web/frontend/js/features/source-editor/themes/cm6/idle_fingers.json new file mode 100644 index 0000000000..34623c5001 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/idle_fingers.json @@ -0,0 +1,67 @@ +{ + "theme": { + ".cm-gutters": { + "backgroundColor": "transparent", + "borderRightColor": "transparent", + "background": "#3b3b3b", + "color": "rgb(153,153,153)" + }, + "&": { + "backgroundColor": "#323232", + "color": "#FFFFFF" + }, + ".cm-cursor, .cm-dropCursor": { + "color": "#91FF00" + }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection, .cm-searchMatch.cm-searchMatch.cm-searchMatch-selected": { + "background": "rgba(90, 100, 126, 0.88)" + }, + "&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": { + "margin": 0, + "outline": "1px solid #404040" + }, + ".cm-activeLine": { + "background": "#353637" + }, + ".cm-activeLineGutter": { + "backgroundColor": "#353637" + }, + ".cm-selectionMatch.cm-selectionMatch, .cm-searchMatch.cm-searchMatch": { + "outline": "1px solid rgba(90, 100, 126, 0.88)", + "margin": 0 + }, + ".cm-foldPlaceholder": { + "backgroundColor": "#CC7833", + "borderColor": "#FFFFFF" + } + }, + "highlightStyle": { + ".tok-keyword": { + "color": "#CC7833" + }, + ".tok-labelName": { + "color": "#6C99BB" + }, + ".tok-literal": { + "color": "#6C99BB" + }, + ".tok-invalid": { + "color": "#FFFFFF", + "backgroundColor": "#FF0000" + }, + ".tok-attributeValue": { + "fontStyle": "italic" + }, + ".tok-string": { + "color": "#A5C261" + }, + ".tok-regexp": { + "color": "#CCCC33" + }, + ".tok-comment": { + "fontStyle": "italic", + "color": "#BC9458" + } + }, + "dark": true +} diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/iplastic-license.txt b/services/web/frontend/js/features/source-editor/themes/cm6/iplastic-license.txt new file mode 100644 index 0000000000..d70d8cae37 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/iplastic-license.txt @@ -0,0 +1,35 @@ +Conversion by Overleaf from Ace to CodeMirror 6. + +Source: https://github.com/ajaxorg/ace/ + +The theme's original license is copied below: + +***** BEGIN LICENSE BLOCK ***** +Distributed under the BSD license: + +Copyright (c) 2010, Ajax.org B.V. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Ajax.org B.V. nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +***** END LICENSE BLOCK ***** \ No newline at end of file diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/iplastic.json b/services/web/frontend/js/features/source-editor/themes/cm6/iplastic.json new file mode 100644 index 0000000000..51f85b56b9 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/iplastic.json @@ -0,0 +1,87 @@ +{ + "theme": { + ".cm-gutters": { + "backgroundColor": "transparent", + "borderRightColor": "transparent", + "background": "#dddddd", + "color": "#666666" + }, + "&": { + "backgroundColor": "#eeeeee", + "color": "#333333" + }, + ".cm-cursor, .cm-dropCursor": { + "color": "#333" + }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection, .cm-searchMatch.cm-searchMatch.cm-searchMatch-selected": { + "background": "#BAD6FD" + }, + "&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": { + "margin": 0, + "background": "#FFF799", + "outline": "1px solid #49483E" + }, + ".cm-activeLine": { + "background": "#e5e5e5" + }, + ".cm-activeLineGutter": { + "backgroundColor": "#eeeeee" + }, + ".cm-selectionMatch.cm-selectionMatch, .cm-searchMatch.cm-searchMatch": { + "borderRadius": "4px", + "outline": "1px solid #555555", + "margin": 0 + }, + ".cm-foldPlaceholder": { + "backgroundColor": "#464646", + "borderColor": "#F8F8F2" + } + }, + "highlightStyle": { + ".tok-tagName": { + "color": "#0000FF" + }, + ".tok-keyword": { + "color": "#0000FF" + }, + ".tok-typeName": { + "color": "#3333fc", + "fontWeight": "700" + }, + ".tok-punctuation": { + "color": "#000" + }, + ".tok-labelName": { + "color": "#333333", + "fontWeight": "700" + }, + ".tok-literal": { + "color": "#0066FF", + "fontWeight": "100" + }, + ".tok-invalid": { + "color": "#F8F8F0", + "backgroundColor": "#F92672" + }, + ".tok-function": { + "color": "#3366cc", + "fontStyle": "italic" + }, + ".tok-attributeName": { + "color": "#3366cc", + "fontStyle": "italic" + }, + ".tok-attributeValue": { + "color": "#2469E0", + "fontStyle": "italic" + }, + ".tok-string": { + "color": "#a55f03" + }, + ".tok-comment": { + "color": "#777777", + "fontStyle": "italic" + } + }, + "dark": false +} diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/katzenmilch-license.txt b/services/web/frontend/js/features/source-editor/themes/cm6/katzenmilch-license.txt new file mode 100644 index 0000000000..d70d8cae37 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/katzenmilch-license.txt @@ -0,0 +1,35 @@ +Conversion by Overleaf from Ace to CodeMirror 6. + +Source: https://github.com/ajaxorg/ace/ + +The theme's original license is copied below: + +***** BEGIN LICENSE BLOCK ***** +Distributed under the BSD license: + +Copyright (c) 2010, Ajax.org B.V. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Ajax.org B.V. nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +***** END LICENSE BLOCK ***** \ No newline at end of file diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/katzenmilch.json b/services/web/frontend/js/features/source-editor/themes/cm6/katzenmilch.json new file mode 100644 index 0000000000..09d835177c --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/katzenmilch.json @@ -0,0 +1,87 @@ +{ + "theme": { + ".cm-gutters": { + "backgroundColor": "transparent", + "borderRightColor": "transparent", + "background": "#e8e8e8", + "color": "#333" + }, + "&": { + "backgroundColor": "#f3f2f3", + "color": "rgba(15, 0, 9, 1.0)" + }, + ".cm-cursor, .cm-dropCursor": { + "borderLeft": "2px solid #100011" + }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection, .cm-searchMatch.cm-searchMatch.cm-searchMatch-selected": { + "background": "rgba(100, 5, 208, 0.27)" + }, + "&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": { + "margin": 0, + "outline": "1px solid rgba(0, 0, 0, 0.33)" + }, + ".cm-activeLine": { + "background": "rgb(232, 242, 254)" + }, + ".cm-activeLineGutter": { + "backgroundColor": "rgb(232, 242, 254)" + }, + ".cm-selectionMatch.cm-selectionMatch, .cm-searchMatch.cm-searchMatch": { + "outline": "1px solid rgba(100, 5, 208, 0.27)", + "margin": 0 + }, + ".cm-foldPlaceholder": { + "backgroundColor": "rgba(2, 95, 73, 0.97)", + "borderColor": "rgba(15, 0, 9, 1.0)" + } + }, + "highlightStyle": { + ".tok-keyword": { + "color": "#674Aa8", + "rbackgroundColor": "rgba(163, 170, 216, 0.055)" + }, + ".tok-literal": { + "color": "rgba(2, 95, 105, 1.0)", + "rbackgroundColor": "rgba(127, 34, 153, 0.063)" + }, + ".tok-typeName": { + "color": "rgba(123, 92, 191, 1.0)", + "rbackgroundColor": "rgba(139, 93, 223, 0.051)" + }, + ".tok-invalid": { + "color": "#DFDFD5", + "rbackgroundColor": "#CC1B27" + }, + ".tok-string": { + "color": "#5a5f9b", + "rbackgroundColor": "rgba(170, 175, 219, 0.035)" + }, + ".tok-comment": { + "fontStyle": "italic", + "color": "rgba(64, 79, 80, 0.67)", + "rbackgroundColor": "rgba(95, 15, 255, 0.0078)" + }, + ".tok-function": { + "color": "rgba(2, 95, 73, 0.97)", + "rbackgroundColor": "rgba(34, 255, 73, 0.12)" + }, + ".tok-attributeValue": { + "color": "rgba(51, 150, 159, 0.87)", + "rbackgroundColor": "rgba(5, 214, 249, 0.043)", + "fontStyle": "italic" + }, + ".tok-variableName": { + "color": "#316fcf", + "rbackgroundColor": "rgba(58, 175, 255, 0.039)" + }, + ".tok-attributeName": { + "color": "rgba(73, 70, 194, 0.93)", + "rbackgroundColor": "rgba(73, 134, 194, 0.035)" + }, + ".tok-tagName": { + "color": "#3976a2", + "rbackgroundColor": "rgba(73, 166, 210, 0.039)" + } + }, + "dark": false +} diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/kr_theme-license.txt b/services/web/frontend/js/features/source-editor/themes/cm6/kr_theme-license.txt new file mode 100644 index 0000000000..d70d8cae37 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/kr_theme-license.txt @@ -0,0 +1,35 @@ +Conversion by Overleaf from Ace to CodeMirror 6. + +Source: https://github.com/ajaxorg/ace/ + +The theme's original license is copied below: + +***** BEGIN LICENSE BLOCK ***** +Distributed under the BSD license: + +Copyright (c) 2010, Ajax.org B.V. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Ajax.org B.V. nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +***** END LICENSE BLOCK ***** \ No newline at end of file diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/kr_theme.json b/services/web/frontend/js/features/source-editor/themes/cm6/kr_theme.json new file mode 100644 index 0000000000..9c81815a76 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/kr_theme.json @@ -0,0 +1,76 @@ +{ + "theme": { + ".cm-gutters": { + "backgroundColor": "transparent", + "borderRightColor": "transparent", + "background": "#1c1917", + "color": "#FCFFE0" + }, + "&": { + "backgroundColor": "#0B0A09", + "color": "#FCFFE0" + }, + ".cm-cursor, .cm-dropCursor": { + "color": "#FF9900" + }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection, .cm-searchMatch.cm-searchMatch.cm-searchMatch-selected": { + "background": "rgba(170, 0, 255, 0.45)" + }, + "&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": { + "margin": 0, + "outline": "1px solid rgba(255, 177, 111, 0.32)" + }, + ".cm-activeLine": { + "background": "#38403D" + }, + ".cm-activeLineGutter": { + "backgroundColor": "#38403D" + }, + ".cm-selectionMatch.cm-selectionMatch, .cm-searchMatch.cm-searchMatch": { + "outline": "1px solid rgba(170, 0, 255, 0.45)", + "margin": 0 + }, + ".cm-foldPlaceholder": { + "backgroundColor": "#949C8B", + "borderColor": "#FCFFE0" + } + }, + "highlightStyle": { + ".tok-keyword": { + "color": "#949C8B" + }, + ".tok-labelName": { + "color": "rgba(210, 117, 24, 0.76)" + }, + ".tok-literal": { + "color": "rgba(210, 117, 24, 0.76)" + }, + ".tok-invalid": { + "color": "#F8F8F8", + "backgroundColor": "#A41300" + }, + ".tok-typeName": { + "color": "#FFEE80" + }, + ".tok-string": { + "color": "rgba(164, 161, 181, 0.8)" + }, + ".tok-regexp": { + "color": "rgba(125, 255, 192, 0.65)" + }, + ".tok-comment": { + "fontStyle": "italic", + "color": "#706D5B" + }, + ".tok-attributeValue": { + "color": "#D1A796" + }, + ".tok-list": { + "backgroundColor": "#0F0040" + }, + ".tok-variableName": { + "color": "#FF80E1" + } + }, + "dark": true +} diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/kuroir-license.txt b/services/web/frontend/js/features/source-editor/themes/cm6/kuroir-license.txt new file mode 100644 index 0000000000..d70d8cae37 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/kuroir-license.txt @@ -0,0 +1,35 @@ +Conversion by Overleaf from Ace to CodeMirror 6. + +Source: https://github.com/ajaxorg/ace/ + +The theme's original license is copied below: + +***** BEGIN LICENSE BLOCK ***** +Distributed under the BSD license: + +Copyright (c) 2010, Ajax.org B.V. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Ajax.org B.V. nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +***** END LICENSE BLOCK ***** \ No newline at end of file diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/kuroir.json b/services/web/frontend/js/features/source-editor/themes/cm6/kuroir.json new file mode 100644 index 0000000000..6b23a8aeb4 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/kuroir.json @@ -0,0 +1,70 @@ +{ + "theme": { + ".cm-gutters": { + "backgroundColor": "transparent", + "borderRightColor": "transparent", + "background": "#e8e8e8", + "color": "#333" + }, + "&": { + "backgroundColor": "#E8E9E8", + "color": "#363636" + }, + ".cm-cursor, .cm-dropCursor": { + "color": "#202020" + }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection, .cm-searchMatch.cm-searchMatch.cm-searchMatch-selected": { + "background": "rgba(245, 170, 0, 0.57)" + }, + "&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": { + "margin": 0, + "outline": "1px solid rgba(0, 0, 0, 0.29)" + }, + ".cm-activeLine": { + "background": "rgba(203, 220, 47, 0.22)" + }, + ".cm-activeLineGutter": { + "backgroundColor": "rgba(203, 220, 47, 0.22)" + }, + ".cm-selectionMatch.cm-selectionMatch, .cm-searchMatch.cm-searchMatch": { + "outline": "1px solid rgba(245, 170, 0, 0.57)", + "margin": 0 + }, + ".cm-foldPlaceholder": { + "borderColor": "#363636" + } + }, + "highlightStyle": { + ".tok-labelName": { + "color": "#CD6839" + }, + ".tok-literal": { + "color": "#9A5925" + }, + ".tok-typeName": { + "color": "#A52A2A" + }, + ".tok-string": { + "color": "#639300" + }, + ".tok-regexp": { + "color": "#417E00", + "backgroundColor": "#C9D4BE" + }, + ".tok-comment": { + "color": "rgba(148, 148, 148, 0.91)", + "backgroundColor": "rgba(220, 220, 220, 0.56)" + }, + ".tok-attributeValue": { + "color": "#009ACD" + }, + ".tok-heading": { + "color": "#B8012D", + "backgroundColor": "rgba(191, 97, 51, 0.051)" + }, + ".tok-list": { + "color": "#8F5B26" + } + }, + "dark": false +} diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/merbivore-license.txt b/services/web/frontend/js/features/source-editor/themes/cm6/merbivore-license.txt new file mode 100644 index 0000000000..d70d8cae37 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/merbivore-license.txt @@ -0,0 +1,35 @@ +Conversion by Overleaf from Ace to CodeMirror 6. + +Source: https://github.com/ajaxorg/ace/ + +The theme's original license is copied below: + +***** BEGIN LICENSE BLOCK ***** +Distributed under the BSD license: + +Copyright (c) 2010, Ajax.org B.V. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Ajax.org B.V. nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +***** END LICENSE BLOCK ***** \ No newline at end of file diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/merbivore.json b/services/web/frontend/js/features/source-editor/themes/cm6/merbivore.json new file mode 100644 index 0000000000..e771e0eec1 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/merbivore.json @@ -0,0 +1,70 @@ +{ + "theme": { + ".cm-gutters": { + "backgroundColor": "transparent", + "borderRightColor": "transparent", + "background": "#202020", + "color": "#E6E1DC" + }, + "&": { + "backgroundColor": "#161616", + "color": "#E6E1DC" + }, + ".cm-cursor, .cm-dropCursor": { + "color": "#FFFFFF" + }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection, .cm-searchMatch.cm-searchMatch.cm-searchMatch-selected": { + "background": "#454545" + }, + "&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": { + "margin": 0, + "outline": "1px solid #404040" + }, + ".cm-activeLine": { + "background": "#333435" + }, + ".cm-activeLineGutter": { + "backgroundColor": "#333435" + }, + ".cm-selectionMatch.cm-selectionMatch, .cm-searchMatch.cm-searchMatch": { + "outline": "1px solid #454545", + "margin": 0 + }, + ".cm-foldPlaceholder": { + "backgroundColor": "#FC6F09", + "borderColor": "#E6E1DC" + } + }, + "highlightStyle": { + ".tok-tagName": { + "color": "#FC6F09" + }, + ".tok-keyword": { + "color": "#FC6F09" + }, + ".tok-typeName": { + "color": "#FC6F09" + }, + ".tok-labelName": { + "color": "#1EDAFB" + }, + ".tok-literal": { + "color": "#58C554" + }, + ".tok-string": { + "color": "#8DFF0A" + }, + ".tok-invalid": { + "color": "#FFFFFF", + "backgroundColor": "#990000" + }, + ".tok-comment": { + "fontStyle": "italic", + "color": "#AD2EA4" + }, + ".tok-attributeName": { + "color": "#FFFF89" + } + }, + "dark": true +} diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/merbivore_soft-license.txt b/services/web/frontend/js/features/source-editor/themes/cm6/merbivore_soft-license.txt new file mode 100644 index 0000000000..d70d8cae37 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/merbivore_soft-license.txt @@ -0,0 +1,35 @@ +Conversion by Overleaf from Ace to CodeMirror 6. + +Source: https://github.com/ajaxorg/ace/ + +The theme's original license is copied below: + +***** BEGIN LICENSE BLOCK ***** +Distributed under the BSD license: + +Copyright (c) 2010, Ajax.org B.V. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Ajax.org B.V. nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +***** END LICENSE BLOCK ***** \ No newline at end of file diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/merbivore_soft.json b/services/web/frontend/js/features/source-editor/themes/cm6/merbivore_soft.json new file mode 100644 index 0000000000..a41f43842c --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/merbivore_soft.json @@ -0,0 +1,70 @@ +{ + "theme": { + ".cm-gutters": { + "backgroundColor": "transparent", + "borderRightColor": "transparent", + "background": "#262424", + "color": "#E6E1DC" + }, + "&": { + "backgroundColor": "#1C1C1C", + "color": "#E6E1DC" + }, + ".cm-cursor, .cm-dropCursor": { + "color": "#FFFFFF" + }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection, .cm-searchMatch.cm-searchMatch.cm-searchMatch-selected": { + "background": "#494949" + }, + "&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": { + "margin": 0, + "outline": "1px solid #404040" + }, + ".cm-activeLine": { + "background": "#333435" + }, + ".cm-activeLineGutter": { + "backgroundColor": "#333435" + }, + ".cm-selectionMatch.cm-selectionMatch, .cm-searchMatch.cm-searchMatch": { + "outline": "1px solid #494949", + "margin": 0 + }, + ".cm-foldPlaceholder": { + "backgroundColor": "#FC803A", + "borderColor": "#E6E1DC" + } + }, + "highlightStyle": { + ".tok-tagName": { + "color": "#FC803A" + }, + ".tok-keyword": { + "color": "#FC803A" + }, + ".tok-typeName": { + "color": "#FC803A" + }, + ".tok-labelName": { + "color": "#68C1D8" + }, + ".tok-literal": { + "color": "#7FC578" + }, + ".tok-string": { + "color": "#8EC65F" + }, + ".tok-invalid": { + "color": "#FFFFFF", + "backgroundColor": "#FE3838" + }, + ".tok-comment": { + "fontStyle": "italic", + "color": "#AC4BB8" + }, + ".tok-attributeName": { + "color": "#EAF1A3" + } + }, + "dark": true +} diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/mono_industrial-license.txt b/services/web/frontend/js/features/source-editor/themes/cm6/mono_industrial-license.txt new file mode 100644 index 0000000000..d70d8cae37 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/mono_industrial-license.txt @@ -0,0 +1,35 @@ +Conversion by Overleaf from Ace to CodeMirror 6. + +Source: https://github.com/ajaxorg/ace/ + +The theme's original license is copied below: + +***** BEGIN LICENSE BLOCK ***** +Distributed under the BSD license: + +Copyright (c) 2010, Ajax.org B.V. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Ajax.org B.V. nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +***** END LICENSE BLOCK ***** \ No newline at end of file diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/mono_industrial.json b/services/web/frontend/js/features/source-editor/themes/cm6/mono_industrial.json new file mode 100644 index 0000000000..63922dfc4e --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/mono_industrial.json @@ -0,0 +1,83 @@ +{ + "theme": { + ".cm-gutters": { + "backgroundColor": "transparent", + "borderRightColor": "transparent", + "background": "#1d2521", + "color": "#C5C9C9" + }, + "&": { + "backgroundColor": "#222C28", + "color": "#FFFFFF" + }, + ".cm-cursor, .cm-dropCursor": { + "color": "#FFFFFF" + }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection, .cm-searchMatch.cm-searchMatch.cm-searchMatch-selected": { + "background": "rgba(145, 153, 148, 0.40)" + }, + "&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": { + "margin": 0, + "outline": "1px solid rgba(102, 108, 104, 0.50)" + }, + ".cm-activeLine": { + "background": "rgba(12, 13, 12, 0.25)" + }, + ".cm-activeLineGutter": { + "backgroundColor": "rgba(12, 13, 12, 0.25)" + }, + ".cm-selectionMatch.cm-selectionMatch, .cm-searchMatch.cm-searchMatch": { + "outline": "1px solid rgba(145, 153, 148, 0.40)", + "margin": 0 + }, + ".cm-foldPlaceholder": { + "backgroundColor": "#A8B3AB", + "borderColor": "#FFFFFF" + } + }, + "highlightStyle": { + ".tok-string": { + "backgroundColor": "#151C19", + "color": "#FFFFFF" + }, + ".tok-keyword": { + "color": "#A39E64" + }, + ".tok-labelName": { + "color": "#E98800" + }, + ".tok-literal": { + "color": "#E98800" + }, + ".tok-function": { + "color": "#A8B3AB" + }, + ".tok-operator": { + "color": "#A8B3AB" + }, + ".tok-attributeValue": { + "color": "#648BD2" + }, + ".tok-invalid": { + "color": "#FFFFFF", + "backgroundColor": "rgba(153, 0, 0, 0.68)" + }, + ".tok-typeName": { + "color": "#C23B00" + }, + ".tok-variableName": { + "color": "#648BD2" + }, + ".tok-comment": { + "color": "#666C68", + "backgroundColor": "#151C19" + }, + ".tok-attributeName": { + "color": "#909993" + }, + ".tok-tagName": { + "color": "#A65EFF" + } + }, + "dark": true +} diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/monokai-license.txt b/services/web/frontend/js/features/source-editor/themes/cm6/monokai-license.txt new file mode 100644 index 0000000000..d70d8cae37 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/monokai-license.txt @@ -0,0 +1,35 @@ +Conversion by Overleaf from Ace to CodeMirror 6. + +Source: https://github.com/ajaxorg/ace/ + +The theme's original license is copied below: + +***** BEGIN LICENSE BLOCK ***** +Distributed under the BSD license: + +Copyright (c) 2010, Ajax.org B.V. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Ajax.org B.V. nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +***** END LICENSE BLOCK ***** \ No newline at end of file diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/monokai.json b/services/web/frontend/js/features/source-editor/themes/cm6/monokai.json new file mode 100644 index 0000000000..1e45d6240b --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/monokai.json @@ -0,0 +1,77 @@ +{ + "theme": { + ".cm-gutters": { + "backgroundColor": "transparent", + "borderRightColor": "transparent", + "background": "#2F3129", + "color": "#8F908A" + }, + "&": { + "backgroundColor": "#272822", + "color": "#F8F8F2" + }, + ".cm-cursor, .cm-dropCursor": { + "color": "#F8F8F0" + }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection, .cm-searchMatch.cm-searchMatch.cm-searchMatch-selected": { + "background": "#49483E" + }, + "&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": { + "margin": 0, + "outline": "1px solid #49483E" + }, + ".cm-activeLine": { + "background": "#202020" + }, + ".cm-activeLineGutter": { + "backgroundColor": "#272727" + }, + ".cm-selectionMatch.cm-selectionMatch, .cm-searchMatch.cm-searchMatch": { + "outline": "1px solid #49483E", + "margin": 0 + }, + ".cm-foldPlaceholder": { + "backgroundColor": "#A6E22E", + "borderColor": "#F8F8F2" + } + }, + "highlightStyle": { + ".tok-tagName": { + "color": "#F92672" + }, + ".tok-keyword": { + "color": "#F92672" + }, + ".tok-typeName": { + "color": "#66D9EF", + "fontStyle": "italic" + }, + ".tok-punctuation": { + "color": "#fff" + }, + ".tok-literal": { + "color": "#AE81FF" + }, + ".tok-invalid": { + "color": "#F8F8F0", + "backgroundColor": "#F92672" + }, + ".tok-function": { + "color": "#A6E22E" + }, + ".tok-attributeName": { + "color": "#A6E22E" + }, + ".tok-attributeValue": { + "color": "#FD971F", + "fontStyle": "italic" + }, + ".tok-string": { + "color": "#E6DB74" + }, + ".tok-comment": { + "color": "#75715E" + } + }, + "dark": true +} diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/nord_dark-license.txt b/services/web/frontend/js/features/source-editor/themes/cm6/nord_dark-license.txt new file mode 100644 index 0000000000..d70d8cae37 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/nord_dark-license.txt @@ -0,0 +1,35 @@ +Conversion by Overleaf from Ace to CodeMirror 6. + +Source: https://github.com/ajaxorg/ace/ + +The theme's original license is copied below: + +***** BEGIN LICENSE BLOCK ***** +Distributed under the BSD license: + +Copyright (c) 2010, Ajax.org B.V. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Ajax.org B.V. nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +***** END LICENSE BLOCK ***** \ No newline at end of file diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/nord_dark.json b/services/web/frontend/js/features/source-editor/themes/cm6/nord_dark.json new file mode 100644 index 0000000000..4c8a66577e --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/nord_dark.json @@ -0,0 +1,73 @@ +{ + "theme": { + ".cm-gutters": { + "backgroundColor": "transparent", + "borderRightColor": "transparent", + "color": "#616e88" + }, + "&": { + "backgroundColor": "#2e3440", + "color": "#d8dee9" + }, + ".cm-cursor, .cm-dropCursor": { + "color": "#d8dee9" + }, + ".cm-activeLine": { + "background": "#434c5ecc" + }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection, .cm-searchMatch.cm-searchMatch.cm-searchMatch-selected": { + "background": "#434c5ecc" + }, + "&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": { + "margin": 0, + "outline": "1px solid #88c0d066" + }, + ".cm-activeLineGutter": { + "backgroundColor": "#434c5ecc" + }, + ".cm-selectionMatch.cm-selectionMatch, .cm-searchMatch.cm-searchMatch": { + "outline": "1px solid #88c0d066", + "margin": 0 + }, + ".cm-foldPlaceholder": { + "backgroundColor": "#4c566a", + "borderColor": "#d8dee9" + } + }, + "highlightStyle": { + ".tok-attributeName": { + "color": "#d8dee9" + }, + ".tok-typeName": { + "color": "#d8dee9" + }, + ".tok-regexp": { + "color": "#bf616a" + }, + ".tok-keyword": { + "color": "#81a1c1" + }, + ".tok-literal": { + "color": "#b48ead" + }, + ".tok-function": { + "color": "#8fbcbb" + }, + ".tok-tagName": { + "color": "#8fbcbb" + }, + ".tok-attributeValue": { + "color": "#8fbcbb" + }, + ".tok-variableName": { + "color": "#8fbcbb" + }, + ".tok-string": { + "color": "#a3be8c" + }, + ".tok-comment": { + "color": "#616e88" + } + }, + "dark": true +} diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/one_dark-license.txt b/services/web/frontend/js/features/source-editor/themes/cm6/one_dark-license.txt new file mode 100644 index 0000000000..d70d8cae37 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/one_dark-license.txt @@ -0,0 +1,35 @@ +Conversion by Overleaf from Ace to CodeMirror 6. + +Source: https://github.com/ajaxorg/ace/ + +The theme's original license is copied below: + +***** BEGIN LICENSE BLOCK ***** +Distributed under the BSD license: + +Copyright (c) 2010, Ajax.org B.V. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Ajax.org B.V. nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +***** END LICENSE BLOCK ***** \ No newline at end of file diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/one_dark.json b/services/web/frontend/js/features/source-editor/themes/cm6/one_dark.json new file mode 100644 index 0000000000..d693295111 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/one_dark.json @@ -0,0 +1,81 @@ +{ + "theme": { + ".cm-gutters": { + "backgroundColor": "transparent", + "borderRightColor": "transparent", + "background": "#282c34", + "color": "#6a6f7a" + }, + "&": { + "backgroundColor": "#282c34", + "color": "#abb2bf" + }, + ".cm-cursor, .cm-dropCursor": { + "color": "#528bff" + }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection, .cm-searchMatch.cm-searchMatch.cm-searchMatch-selected": { + "background": "#3d4350" + }, + "&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": { + "margin": "-1px 0 0 -1px", + "border": "1px solid #747369" + }, + ".cm-activeLine": { + "background": "rgba(76, 87, 103, .19)" + }, + ".cm-activeLineGutter": { + "backgroundColor": "rgba(76, 87, 103, .19)" + }, + ".cm-selectionMatch.cm-selectionMatch, .cm-searchMatch.cm-searchMatch": { + "border": "1px solid #3d4350" + }, + ".cm-foldPlaceholder": { + "backgroundColor": "#61afef", + "borderColor": "#abb2bf" + } + }, + "highlightStyle": { + ".tok-keyword": { + "color": "#c678dd" + }, + ".tok-operator": { + "color": "#c678dd" + }, + ".tok-literal": { + "color": "#56b6c2" + }, + ".tok-typeName": { + "color": "#c678dd" + }, + ".tok-invalid": { + "color": "#fff", + "backgroundColor": "#f2777a" + }, + ".tok-string": { + "color": "#98c379" + }, + ".tok-regexp": { + "color": "#e06c75" + }, + ".tok-comment": { + "fontStyle": "italic", + "color": "#5c6370" + }, + ".tok-attributeValue": { + "color": "#d19a66" + }, + ".tok-attributeName": { + "color": "#e06c75" + }, + ".tok-function": { + "color": "#61afef" + }, + ".tok-tagName": { + "color": "#e06c75" + }, + ".tok-heading": { + "color": "#98c379" + } + }, + "dark": false +} diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/overleaf.json b/services/web/frontend/js/features/source-editor/themes/cm6/overleaf.json new file mode 100644 index 0000000000..72ac58906e --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/overleaf.json @@ -0,0 +1,63 @@ +{ + "theme": { + ".cm-gutters": { + "backgroundColor": "transparent", + "borderRightColor": "transparent", + "background": "#f0f0f0", + "color": "#333" + }, + "&": { + "backgroundColor": "#FFFFFF", + "color": "black" + }, + ".cm-cursor, .cm-dropCursor": { + "color": "black" + }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection, .cm-searchMatch.cm-searchMatch.cm-searchMatch-selected": { + "background": "rgb(181, 213, 255)" + }, + "&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": { + "outline": "1px solid #5A5CAD", + "margin": 0 + }, + ".cm-activeLine": { + "background": "rgba(0, 0, 0, 0.07)" + }, + ".cm-activeLineGutter": { + "backgroundColor": "#dcdcdc" + }, + ".cm-selectionMatch.cm-selectionMatch, .cm-searchMatch.cm-searchMatch": { + "background": "rgba(250, 250, 255, 0.5)", + "outline": "1px solid rgb(200, 200, 250)", + "margin": 0 + }, + ".cm-foldPlaceholder": { + "backgroundColor": "#6B72E6" + } + }, + "highlightStyle": { + ".tok-comment": { + "color": "#0080FF", + "fontStyle": "italic" + }, + ".tok-typeName": { + "color": "#3F7F7F" + }, + ".tok-keyword": { + "color": "#3F7F7F" + }, + ".tok-attributeValue": { + "color": "#5A5CAD" + }, + ".tok-string": { + "color": "#5A5CAD" + }, + ".tok-labelName": { + "color": "#3F7F7F" + }, + ".tok-number": { + "color": "#5A5CAD" + } + }, + "dark": false +} diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/pastel_on_dark-license.txt b/services/web/frontend/js/features/source-editor/themes/cm6/pastel_on_dark-license.txt new file mode 100644 index 0000000000..d70d8cae37 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/pastel_on_dark-license.txt @@ -0,0 +1,35 @@ +Conversion by Overleaf from Ace to CodeMirror 6. + +Source: https://github.com/ajaxorg/ace/ + +The theme's original license is copied below: + +***** BEGIN LICENSE BLOCK ***** +Distributed under the BSD license: + +Copyright (c) 2010, Ajax.org B.V. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Ajax.org B.V. nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +***** END LICENSE BLOCK ***** \ No newline at end of file diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/pastel_on_dark.json b/services/web/frontend/js/features/source-editor/themes/cm6/pastel_on_dark.json new file mode 100644 index 0000000000..fba3da70a8 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/pastel_on_dark.json @@ -0,0 +1,72 @@ +{ + "theme": { + ".cm-gutters": { + "backgroundColor": "transparent", + "borderRightColor": "transparent", + "background": "#353030", + "color": "#8F938F" + }, + "&": { + "backgroundColor": "#2C2828", + "color": "#8F938F" + }, + ".cm-cursor, .cm-dropCursor": { + "color": "#A7A7A7" + }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection, .cm-searchMatch.cm-searchMatch.cm-searchMatch-selected": { + "background": "rgba(221, 240, 255, 0.20)" + }, + "&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": { + "margin": 0, + "outline": "1px solid rgba(255, 255, 255, 0.25)" + }, + ".cm-activeLine": { + "background": "rgba(255, 255, 255, 0.031)" + }, + ".cm-activeLineGutter": { + "backgroundColor": "rgba(255, 255, 255, 0.031)" + }, + ".cm-selectionMatch.cm-selectionMatch, .cm-searchMatch.cm-searchMatch": { + "outline": "1px solid rgba(221, 240, 255, 0.20)", + "margin": 0 + }, + ".cm-foldPlaceholder": { + "backgroundColor": "#757aD8", + "borderColor": "#8F938F" + } + }, + "highlightStyle": { + ".tok-keyword": { + "color": "#757aD8" + }, + ".tok-labelName": { + "color": "#4FB7C5" + }, + ".tok-literal": { + "color": "#CCCCCC" + }, + ".tok-operator": { + "color": "#797878" + }, + ".tok-invalid": { + "color": "#F8F8F8", + "backgroundColor": "rgba(86, 45, 86, 0.75)" + }, + ".tok-string": { + "color": "#66A968" + }, + ".tok-regexp": { + "color": "#E9C062" + }, + ".tok-comment": { + "color": "#A6C6FF" + }, + ".tok-attributeValue": { + "color": "#BEBF55" + }, + ".tok-variableName": { + "color": "#C1C144" + } + }, + "dark": true +} diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/solarized_dark-license.txt b/services/web/frontend/js/features/source-editor/themes/cm6/solarized_dark-license.txt new file mode 100644 index 0000000000..d70d8cae37 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/solarized_dark-license.txt @@ -0,0 +1,35 @@ +Conversion by Overleaf from Ace to CodeMirror 6. + +Source: https://github.com/ajaxorg/ace/ + +The theme's original license is copied below: + +***** BEGIN LICENSE BLOCK ***** +Distributed under the BSD license: + +Copyright (c) 2010, Ajax.org B.V. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Ajax.org B.V. nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +***** END LICENSE BLOCK ***** \ No newline at end of file diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/solarized_dark.json b/services/web/frontend/js/features/source-editor/themes/cm6/solarized_dark.json new file mode 100644 index 0000000000..357bd9111b --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/solarized_dark.json @@ -0,0 +1,75 @@ +{ + "theme": { + ".cm-gutters": { + "backgroundColor": "transparent", + "borderRightColor": "transparent", + "background": "#01313f", + "color": "#d0edf7" + }, + "&": { + "backgroundColor": "#002B36", + "color": "#93A1A1" + }, + ".cm-cursor, .cm-dropCursor": { + "color": "#D30102" + }, + ".cm-activeLine": { + "background": "rgba(255, 255, 255, 0.1)" + }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection, .cm-searchMatch.cm-searchMatch.cm-searchMatch-selected": { + "background": "rgba(255, 255, 255, 0.1)" + }, + "&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": { + "margin": 0, + "outline": "1px solid rgba(147, 161, 161, 0.50)" + }, + ".cm-activeLineGutter": { + "backgroundColor": "#0d3440" + }, + ".cm-selectionMatch.cm-selectionMatch, .cm-searchMatch.cm-searchMatch": { + "outline": "1px solid #073642", + "margin": 0 + }, + ".cm-foldPlaceholder": { + "backgroundColor": "#268BD2", + "borderColor": "#93A1A1" + } + }, + "highlightStyle": { + ".tok-attributeName": { + "color": "#93A1A1" + }, + ".tok-typeName": { + "color": "#93A1A1" + }, + ".tok-regexp": { + "color": "#D30102" + }, + ".tok-keyword": { + "color": "#859900" + }, + ".tok-literal": { + "color": "#D33682" + }, + ".tok-function": { + "color": "#268BD2" + }, + ".tok-tagName": { + "color": "#268BD2" + }, + ".tok-attributeValue": { + "color": "#268BD2" + }, + ".tok-variableName": { + "color": "#268BD2" + }, + ".tok-string": { + "color": "#2AA198" + }, + ".tok-comment": { + "fontStyle": "italic", + "color": "#657B83" + } + }, + "dark": true +} diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/solarized_light-license.txt b/services/web/frontend/js/features/source-editor/themes/cm6/solarized_light-license.txt new file mode 100644 index 0000000000..d70d8cae37 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/solarized_light-license.txt @@ -0,0 +1,35 @@ +Conversion by Overleaf from Ace to CodeMirror 6. + +Source: https://github.com/ajaxorg/ace/ + +The theme's original license is copied below: + +***** BEGIN LICENSE BLOCK ***** +Distributed under the BSD license: + +Copyright (c) 2010, Ajax.org B.V. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Ajax.org B.V. nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +***** END LICENSE BLOCK ***** \ No newline at end of file diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/solarized_light.json b/services/web/frontend/js/features/source-editor/themes/cm6/solarized_light.json new file mode 100644 index 0000000000..88f60f2edd --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/solarized_light.json @@ -0,0 +1,74 @@ +{ + "theme": { + ".cm-gutters": { + "backgroundColor": "transparent", + "borderRightColor": "transparent", + "background": "#fbf1d3", + "color": "#333" + }, + "&": { + "backgroundColor": "#FDF6E3", + "color": "#586E75" + }, + ".cm-cursor, .cm-dropCursor": { + "color": "#000000" + }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection, .cm-searchMatch.cm-searchMatch.cm-searchMatch-selected": { + "background": "rgba(7, 54, 67, 0.09)" + }, + "&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": { + "margin": 0, + "outline": "1px solid rgba(147, 161, 161, 0.50)" + }, + ".cm-activeLine": { + "background": "#EEE8D5" + }, + ".cm-activeLineGutter": { + "backgroundColor": "#EDE5C1" + }, + ".cm-selectionMatch.cm-selectionMatch, .cm-searchMatch.cm-searchMatch": { + "outline": "1px solid #7f9390", + "margin": 0 + }, + ".cm-foldPlaceholder": { + "backgroundColor": "#268BD2", + "borderColor": "#586E75" + } + }, + "highlightStyle": { + ".tok-keyword": { + "color": "#859900" + }, + ".tok-literal": { + "color": "#D33682" + }, + ".tok-function": { + "color": "#268BD2" + }, + ".tok-tagName": { + "color": "#268BD2" + }, + ".tok-attributeValue": { + "color": "#268BD2" + }, + ".tok-variableName": { + "color": "#268BD2" + }, + ".tok-typeName": { + "color": "#073642" + }, + ".tok-string": { + "color": "#2AA198" + }, + ".tok-regexp": { + "color": "#D30102" + }, + ".tok-comment": { + "color": "#93A1A1" + }, + ".tok-attributeName": { + "color": "#93A1A1" + } + }, + "dark": false +} diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/sqlserver-license.txt b/services/web/frontend/js/features/source-editor/themes/cm6/sqlserver-license.txt new file mode 100644 index 0000000000..d70d8cae37 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/sqlserver-license.txt @@ -0,0 +1,35 @@ +Conversion by Overleaf from Ace to CodeMirror 6. + +Source: https://github.com/ajaxorg/ace/ + +The theme's original license is copied below: + +***** BEGIN LICENSE BLOCK ***** +Distributed under the BSD license: + +Copyright (c) 2010, Ajax.org B.V. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Ajax.org B.V. nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +***** END LICENSE BLOCK ***** \ No newline at end of file diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/sqlserver.json b/services/web/frontend/js/features/source-editor/themes/cm6/sqlserver.json new file mode 100644 index 0000000000..f3f4ead419 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/sqlserver.json @@ -0,0 +1,86 @@ +{ + "theme": { + ".cm-gutters": { + "backgroundColor": "transparent", + "borderRightColor": "transparent", + "background": "#ebebeb", + "color": "#333", + "overflow": "hidden" + }, + "&": { + "backgroundColor": "#FFFFFF", + "color": "black" + }, + ".cm-cursor, .cm-dropCursor": { + "color": "black" + }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection, .cm-searchMatch.cm-searchMatch.cm-searchMatch-selected": { + "background": "rgb(181, 213, 255)" + }, + "&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": { + "margin": 0, + "outline": "1px solid rgb(192, 192, 192)" + }, + ".cm-activeLine": { + "background": "rgba(0, 0, 0, 0.07)" + }, + ".cm-activeLineGutter": { + "backgroundColor": "#dcdcdc" + }, + ".cm-selectionMatch.cm-selectionMatch, .cm-searchMatch.cm-searchMatch": { + "background": "rgb(250, 250, 255)", + "outline": "1px solid rgb(200, 200, 250)", + "margin": 0 + } + }, + "highlightStyle": { + ".tok-string": { + "color": "#FF0000" + }, + ".tok-keyword": { + "color": "#0000FF" + }, + ".tok-number": { + "color": "black" + }, + ".tok-typeName": { + "color": "#11B7BE" + }, + ".tok-operator": { + "color": "#808080" + }, + ".tok-paren": { + "color": "#808080" + }, + ".tok-punctuation": { + "color": "#808080" + }, + ".tok-literal": { + "color": "black" + }, + ".tok-invalid": { + "backgroundColor": "rgb(153, 0, 0)", + "color": "white" + }, + ".tok-class": { + "color": "#008080" + }, + ".tok-attributeValue": { + "fontStyle": "italic", + "color": "rgb(49, 132, 149)" + }, + ".tok-comment": { + "color": "#008000" + }, + ".tok-heading": { + "color": "rgb(12, 7, 255)" + }, + ".tok-list": { + "color": "rgb(185, 6, 144)" + }, + ".tok-attributeName": { + "color": "#994409" + } + }, + "dark": false +} diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/terminal-license.txt b/services/web/frontend/js/features/source-editor/themes/cm6/terminal-license.txt new file mode 100644 index 0000000000..d70d8cae37 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/terminal-license.txt @@ -0,0 +1,35 @@ +Conversion by Overleaf from Ace to CodeMirror 6. + +Source: https://github.com/ajaxorg/ace/ + +The theme's original license is copied below: + +***** BEGIN LICENSE BLOCK ***** +Distributed under the BSD license: + +Copyright (c) 2010, Ajax.org B.V. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Ajax.org B.V. nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +***** END LICENSE BLOCK ***** \ No newline at end of file diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/terminal.json b/services/web/frontend/js/features/source-editor/themes/cm6/terminal.json new file mode 100644 index 0000000000..495dc72a72 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/terminal.json @@ -0,0 +1,85 @@ +{ + "theme": { + ".cm-gutters": { + "backgroundColor": "transparent", + "borderRightColor": "transparent", + "background": "#1a0005", + "color": "steelblue" + }, + "&": { + "backgroundColor": "black", + "color": "#DEDEDE" + }, + ".cm-cursor, .cm-dropCursor": { + "color": "#9F9F9F" + }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection, .cm-searchMatch.cm-searchMatch.cm-searchMatch-selected": { + "background": "#424242" + }, + "&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": { + "background": "#090", + "margin": 0 + }, + ".cm-nonmatchingBracket": { + "margin": "-1px 0 0 -1px", + "border": "1px solid #900" + }, + ".cm-activeLine": { + "background": "#2A2A2A" + }, + ".cm-activeLineGutter": { + "backgroundColor": "#2A112A" + }, + ".cm-selectionMatch.cm-selectionMatch, .cm-searchMatch.cm-searchMatch": { + "outline": "1px solid #424242", + "margin": 0 + }, + ".cm-foldPlaceholder": { + "backgroundColor": "#7AA6DA", + "borderColor": "#DEDEDE" + } + }, + "highlightStyle": { + ".tok-keyword": { + "color": "tomato" + }, + ".tok-typeName": { + "color": "tomato" + }, + ".tok-operator": { + "color": "deeppink" + }, + ".tok-literal": { + "color": "gold" + }, + ".tok-attributeValue": { + "color": "#D54E53" + }, + ".tok-invalid": { + "color": "yellow", + "backgroundColor": "red" + }, + ".tok-function": { + "color": "#7AA6DA" + }, + ".tok-heading": { + "color": "#B9CA4A" + }, + ".tok-string": { + "color": "#B9CA4A" + }, + ".tok-tagName": { + "color": "#D54E53" + }, + ".tok-attributeName": { + "color": "#D54E53" + }, + ".tok-regexp": { + "color": "#D54E53" + }, + ".tok-comment": { + "color": "orangered" + } + }, + "dark": true +} diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/textmate-license.txt b/services/web/frontend/js/features/source-editor/themes/cm6/textmate-license.txt new file mode 100644 index 0000000000..d70d8cae37 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/textmate-license.txt @@ -0,0 +1,35 @@ +Conversion by Overleaf from Ace to CodeMirror 6. + +Source: https://github.com/ajaxorg/ace/ + +The theme's original license is copied below: + +***** BEGIN LICENSE BLOCK ***** +Distributed under the BSD license: + +Copyright (c) 2010, Ajax.org B.V. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Ajax.org B.V. nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +***** END LICENSE BLOCK ***** \ No newline at end of file diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/textmate.json b/services/web/frontend/js/features/source-editor/themes/cm6/textmate.json new file mode 100644 index 0000000000..d5e21bfd4b --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/textmate.json @@ -0,0 +1,78 @@ +{ + "theme": { + ".cm-gutters": { + "backgroundColor": "transparent", + "borderRightColor": "transparent", + "background": "#f0f0f0", + "color": "#333" + }, + ".cm-foldPlaceholder": { + "backgroundColor": "#6B72E6" + }, + "&": { + "backgroundColor": "#FFFFFF", + "color": "black" + }, + ".cm-cursor, .cm-dropCursor": { + "color": "black" + }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection, .cm-searchMatch.cm-searchMatch.cm-searchMatch-selected": { + "background": "rgb(181, 213, 255)" + }, + "&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": { + "margin": 0, + "outline": "1px solid rgb(192, 192, 192)" + }, + ".cm-activeLine": { + "background": "rgba(0, 0, 0, 0.07)" + }, + ".cm-activeLineGutter": { + "backgroundColor": "#dcdcdc" + }, + ".cm-selectionMatch.cm-selectionMatch, .cm-searchMatch.cm-searchMatch": { + "background": "rgba(250, 250, 255, 0.5)", + "outline": "1px solid rgb(200, 200, 250)", + "margin": 0 + } + }, + "highlightStyle": { + ".tok-typeName": { + "color": "blue" + }, + ".tok-keyword": { + "color": "blue" + }, + ".tok-labelName": { + "color": "#833FBA" + }, + ".tok-literal": { + "color": "#833FBA" + }, + ".tok-invalid": { + "backgroundColor": "rgba(255, 0, 0, 0.1)", + "color": "red" + }, + ".tok-operator": { + "color": "rgb(104, 118, 135)" + }, + ".tok-string": { + "color": "rgb(3, 106, 7)" + }, + ".tok-comment": { + "color": "rgb(76, 136, 107)" + }, + ".tok-attributeValue": { + "color": "rgb(49, 132, 149)" + }, + ".tok-function": { + "color": "#0000A2" + }, + ".tok-heading": { + "color": "rgb(12, 7, 255)" + }, + ".tok-list": { + "color": "rgb(185, 6, 144)" + } + }, + "dark": false +} diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/tomorrow-license.txt b/services/web/frontend/js/features/source-editor/themes/cm6/tomorrow-license.txt new file mode 100644 index 0000000000..d70d8cae37 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/tomorrow-license.txt @@ -0,0 +1,35 @@ +Conversion by Overleaf from Ace to CodeMirror 6. + +Source: https://github.com/ajaxorg/ace/ + +The theme's original license is copied below: + +***** BEGIN LICENSE BLOCK ***** +Distributed under the BSD license: + +Copyright (c) 2010, Ajax.org B.V. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Ajax.org B.V. nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +***** END LICENSE BLOCK ***** \ No newline at end of file diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/tomorrow.json b/services/web/frontend/js/features/source-editor/themes/cm6/tomorrow.json new file mode 100644 index 0000000000..671e95ea66 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/tomorrow.json @@ -0,0 +1,81 @@ +{ + "theme": { + ".cm-gutters": { + "backgroundColor": "transparent", + "borderRightColor": "transparent", + "background": "#f6f6f6", + "color": "#4D4D4C" + }, + "&": { + "backgroundColor": "#FFFFFF", + "color": "#4D4D4C" + }, + ".cm-cursor, .cm-dropCursor": { + "color": "#AEAFAD" + }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection, .cm-searchMatch.cm-searchMatch.cm-searchMatch-selected": { + "background": "#D6D6D6" + }, + "&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": { + "margin": 0, + "outline": "1px solid #D1D1D1" + }, + ".cm-activeLine": { + "background": "#EFEFEF" + }, + ".cm-activeLineGutter": { + "backgroundColor": "#dcdcdc" + }, + ".cm-selectionMatch.cm-selectionMatch, .cm-searchMatch.cm-searchMatch": { + "outline": "1px solid #D6D6D6", + "margin": 0 + }, + ".cm-foldPlaceholder": { + "backgroundColor": "#4271AE", + "borderColor": "#4D4D4C" + } + }, + "highlightStyle": { + ".tok-keyword": { + "color": "#8959A8" + }, + ".tok-typeName": { + "color": "#8959A8" + }, + ".tok-operator": { + "color": "#3E999F" + }, + ".tok-literal": { + "color": "#666969" + }, + ".tok-attributeValue": { + "color": "#C82829" + }, + ".tok-invalid": { + "color": "#FFFFFF", + "backgroundColor": "#C82829" + }, + ".tok-function": { + "color": "#4271AE" + }, + ".tok-heading": { + "color": "#718C00" + }, + ".tok-string": { + "color": "#718C00" + }, + ".tok-tagName": { + "color": "#C82829" + }, + ".tok-attributeName": { + "color": "#C82829" + }, + ".tok-regexp": { + "color": "#C82829" + }, + ".tok-comment": { + "color": "#8E908C" + } + }, + "dark": false +} diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/tomorrow_night-license.txt b/services/web/frontend/js/features/source-editor/themes/cm6/tomorrow_night-license.txt new file mode 100644 index 0000000000..d70d8cae37 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/tomorrow_night-license.txt @@ -0,0 +1,35 @@ +Conversion by Overleaf from Ace to CodeMirror 6. + +Source: https://github.com/ajaxorg/ace/ + +The theme's original license is copied below: + +***** BEGIN LICENSE BLOCK ***** +Distributed under the BSD license: + +Copyright (c) 2010, Ajax.org B.V. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Ajax.org B.V. nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +***** END LICENSE BLOCK ***** \ No newline at end of file diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/tomorrow_night.json b/services/web/frontend/js/features/source-editor/themes/cm6/tomorrow_night.json new file mode 100644 index 0000000000..9b2d198909 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/tomorrow_night.json @@ -0,0 +1,81 @@ +{ + "theme": { + ".cm-gutters": { + "backgroundColor": "transparent", + "borderRightColor": "transparent", + "background": "#25282c", + "color": "#C5C8C6" + }, + "&": { + "backgroundColor": "#1D1F21", + "color": "#C5C8C6" + }, + ".cm-cursor, .cm-dropCursor": { + "color": "#AEAFAD" + }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection, .cm-searchMatch.cm-searchMatch.cm-searchMatch-selected": { + "background": "#373B41" + }, + "&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": { + "margin": 0, + "outline": "1px solid #4B4E55" + }, + ".cm-activeLine": { + "background": "#282A2E" + }, + ".cm-activeLineGutter": { + "backgroundColor": "#282A2E" + }, + ".cm-selectionMatch.cm-selectionMatch, .cm-searchMatch.cm-searchMatch": { + "outline": "1px solid #373B41", + "margin": 0 + }, + ".cm-foldPlaceholder": { + "backgroundColor": "#81A2BE", + "borderColor": "#C5C8C6" + } + }, + "highlightStyle": { + ".tok-keyword": { + "color": "#B294BB" + }, + ".tok-typeName": { + "color": "#B294BB" + }, + ".tok-operator": { + "color": "#8ABEB7" + }, + ".tok-literal": { + "color": "#CED1CF" + }, + ".tok-attributeValue": { + "color": "#CC6666" + }, + ".tok-invalid": { + "color": "#CED2CF", + "backgroundColor": "#DF5F5F" + }, + ".tok-function": { + "color": "#81A2BE" + }, + ".tok-heading": { + "color": "#B5BD68" + }, + ".tok-string": { + "color": "#B5BD68" + }, + ".tok-tagName": { + "color": "#CC6666" + }, + ".tok-attributeName": { + "color": "#CC6666" + }, + ".tok-regexp": { + "color": "#CC6666" + }, + ".tok-comment": { + "color": "#969896" + } + }, + "dark": true +} diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/tomorrow_night_blue-license.txt b/services/web/frontend/js/features/source-editor/themes/cm6/tomorrow_night_blue-license.txt new file mode 100644 index 0000000000..d70d8cae37 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/tomorrow_night_blue-license.txt @@ -0,0 +1,35 @@ +Conversion by Overleaf from Ace to CodeMirror 6. + +Source: https://github.com/ajaxorg/ace/ + +The theme's original license is copied below: + +***** BEGIN LICENSE BLOCK ***** +Distributed under the BSD license: + +Copyright (c) 2010, Ajax.org B.V. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Ajax.org B.V. nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +***** END LICENSE BLOCK ***** \ No newline at end of file diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/tomorrow_night_blue.json b/services/web/frontend/js/features/source-editor/themes/cm6/tomorrow_night_blue.json new file mode 100644 index 0000000000..d261784922 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/tomorrow_night_blue.json @@ -0,0 +1,81 @@ +{ + "theme": { + ".cm-gutters": { + "backgroundColor": "transparent", + "borderRightColor": "transparent", + "background": "#00204b", + "color": "#7388b5" + }, + "&": { + "backgroundColor": "#002451", + "color": "#FFFFFF" + }, + ".cm-cursor, .cm-dropCursor": { + "color": "#FFFFFF" + }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection, .cm-searchMatch.cm-searchMatch.cm-searchMatch-selected": { + "background": "#003F8E" + }, + "&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": { + "margin": 0, + "outline": "1px solid #404F7D" + }, + ".cm-activeLine": { + "background": "#00346E" + }, + ".cm-activeLineGutter": { + "backgroundColor": "#022040" + }, + ".cm-selectionMatch.cm-selectionMatch, .cm-searchMatch.cm-searchMatch": { + "outline": "1px solid #003F8E", + "margin": 0 + }, + ".cm-foldPlaceholder": { + "backgroundColor": "#BBDAFF", + "borderColor": "#FFFFFF" + } + }, + "highlightStyle": { + ".tok-literal": { + "color": "#FFC58F" + }, + ".tok-keyword": { + "color": "#EBBBFF" + }, + ".tok-typeName": { + "color": "#EBBBFF" + }, + ".tok-operator": { + "color": "#99FFFF" + }, + ".tok-attributeValue": { + "color": "#FF9DA4" + }, + ".tok-invalid": { + "color": "#FFFFFF", + "backgroundColor": "#F99DA5" + }, + ".tok-function": { + "color": "#BBDAFF" + }, + ".tok-heading": { + "color": "#D1F1A9" + }, + ".tok-string": { + "color": "#D1F1A9" + }, + ".tok-tagName": { + "color": "#FF9DA4" + }, + ".tok-attributeName": { + "color": "#FF9DA4" + }, + ".tok-regexp": { + "color": "#FF9DA4" + }, + ".tok-comment": { + "color": "#7285B7" + } + }, + "dark": true +} diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/tomorrow_night_bright-license.txt b/services/web/frontend/js/features/source-editor/themes/cm6/tomorrow_night_bright-license.txt new file mode 100644 index 0000000000..d70d8cae37 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/tomorrow_night_bright-license.txt @@ -0,0 +1,35 @@ +Conversion by Overleaf from Ace to CodeMirror 6. + +Source: https://github.com/ajaxorg/ace/ + +The theme's original license is copied below: + +***** BEGIN LICENSE BLOCK ***** +Distributed under the BSD license: + +Copyright (c) 2010, Ajax.org B.V. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Ajax.org B.V. nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +***** END LICENSE BLOCK ***** \ No newline at end of file diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/tomorrow_night_bright.json b/services/web/frontend/js/features/source-editor/themes/cm6/tomorrow_night_bright.json new file mode 100644 index 0000000000..ca1b28e3e9 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/tomorrow_night_bright.json @@ -0,0 +1,81 @@ +{ + "theme": { + ".cm-gutters": { + "backgroundColor": "transparent", + "borderRightColor": "transparent", + "background": "#1a1a1a", + "color": "#DEDEDE" + }, + "&": { + "backgroundColor": "#000000", + "color": "#DEDEDE" + }, + ".cm-cursor, .cm-dropCursor": { + "color": "#9F9F9F" + }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection, .cm-searchMatch.cm-searchMatch.cm-searchMatch-selected": { + "background": "#424242" + }, + "&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": { + "margin": 0, + "outline": "1px solid #888888" + }, + ".cm-activeLine": { + "background": "#2A2A2A" + }, + ".cm-activeLineGutter": { + "backgroundColor": "#2A2A2A" + }, + ".cm-selectionMatch.cm-selectionMatch, .cm-searchMatch.cm-searchMatch": { + "outline": "1px solid #888888", + "margin": 0 + }, + ".cm-foldPlaceholder": { + "backgroundColor": "#7AA6DA", + "borderColor": "#DEDEDE" + } + }, + "highlightStyle": { + ".tok-keyword": { + "color": "#C397D8" + }, + ".tok-typeName": { + "color": "#C397D8" + }, + ".tok-operator": { + "color": "#70C0B1" + }, + ".tok-literal": { + "color": "#EEEEEE" + }, + ".tok-attributeValue": { + "color": "#D54E53" + }, + ".tok-invalid": { + "color": "#CED2CF", + "backgroundColor": "#DF5F5F" + }, + ".tok-function": { + "color": "#7AA6DA" + }, + ".tok-heading": { + "color": "#B9CA4A" + }, + ".tok-string": { + "color": "#B9CA4A" + }, + ".tok-tagName": { + "color": "#D54E53" + }, + ".tok-attributeName": { + "color": "#D54E53" + }, + ".tok-regexp": { + "color": "#D54E53" + }, + ".tok-comment": { + "color": "#969896" + } + }, + "dark": true +} diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/tomorrow_night_eighties-license.txt b/services/web/frontend/js/features/source-editor/themes/cm6/tomorrow_night_eighties-license.txt new file mode 100644 index 0000000000..d70d8cae37 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/tomorrow_night_eighties-license.txt @@ -0,0 +1,35 @@ +Conversion by Overleaf from Ace to CodeMirror 6. + +Source: https://github.com/ajaxorg/ace/ + +The theme's original license is copied below: + +***** BEGIN LICENSE BLOCK ***** +Distributed under the BSD license: + +Copyright (c) 2010, Ajax.org B.V. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Ajax.org B.V. nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +***** END LICENSE BLOCK ***** \ No newline at end of file diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/tomorrow_night_eighties.json b/services/web/frontend/js/features/source-editor/themes/cm6/tomorrow_night_eighties.json new file mode 100644 index 0000000000..f43e6e64ba --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/tomorrow_night_eighties.json @@ -0,0 +1,78 @@ +{ + "theme": { + ".cm-gutters": { + "backgroundColor": "transparent", + "borderRightColor": "transparent", + "background": "#272727", + "color": "#CCC" + }, + "&": { + "backgroundColor": "#2D2D2D", + "color": "#CCCCCC" + }, + ".cm-cursor, .cm-dropCursor": { + "color": "#CCCCCC" + }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection, .cm-searchMatch.cm-searchMatch.cm-searchMatch-selected": { + "background": "#515151" + }, + "&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": { + "margin": 0, + "outline": "1px solid #6A6A6A" + }, + ".cm-activeLine": { + "background": "#393939" + }, + ".cm-activeLineGutter": { + "backgroundColor": "#393939" + }, + ".cm-selectionMatch.cm-selectionMatch, .cm-searchMatch.cm-searchMatch": { + "outline": "1px solid #515151", + "margin": 0 + }, + ".cm-foldPlaceholder": { + "backgroundColor": "#6699CC", + "borderColor": "#CCCCCC" + } + }, + "highlightStyle": { + ".tok-literal": { + "color": "#F99157" + }, + ".tok-keyword": { + "color": "#CC99CC" + }, + ".tok-typeName": { + "color": "#CC99CC" + }, + ".tok-operator": { + "color": "#66CCCC" + }, + ".tok-attributeValue": { + "color": "#F2777A" + }, + ".tok-invalid": { + "color": "#CDCDCD", + "backgroundColor": "#F2777A" + }, + ".tok-function": { + "color": "#6699CC" + }, + ".tok-heading": { + "color": "#99CC99" + }, + ".tok-string": { + "color": "#99CC99" + }, + ".tok-comment": { + "color": "#999999" + }, + ".tok-tagName": { + "color": "#F2777A" + }, + ".tok-attributeName": { + "color": "#F2777A" + } + }, + "dark": true +} diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/twilight-license.txt b/services/web/frontend/js/features/source-editor/themes/cm6/twilight-license.txt new file mode 100644 index 0000000000..d70d8cae37 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/twilight-license.txt @@ -0,0 +1,35 @@ +Conversion by Overleaf from Ace to CodeMirror 6. + +Source: https://github.com/ajaxorg/ace/ + +The theme's original license is copied below: + +***** BEGIN LICENSE BLOCK ***** +Distributed under the BSD license: + +Copyright (c) 2010, Ajax.org B.V. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Ajax.org B.V. nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +***** END LICENSE BLOCK ***** \ No newline at end of file diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/twilight.json b/services/web/frontend/js/features/source-editor/themes/cm6/twilight.json new file mode 100644 index 0000000000..0d8996c46d --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/twilight.json @@ -0,0 +1,75 @@ +{ + "theme": { + ".cm-gutters": { + "backgroundColor": "transparent", + "borderRightColor": "transparent", + "background": "#232323", + "color": "#E2E2E2" + }, + "&": { + "backgroundColor": "#141414", + "color": "#F8F8F8" + }, + ".cm-cursor, .cm-dropCursor": { + "color": "#A7A7A7" + }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection, .cm-searchMatch.cm-searchMatch.cm-searchMatch-selected": { + "background": "rgba(221, 240, 255, 0.20)" + }, + "&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": { + "margin": 0, + "outline": "1px solid rgba(255, 255, 255, 0.25)" + }, + ".cm-activeLine": { + "background": "rgba(255, 255, 255, 0.031)" + }, + ".cm-activeLineGutter": { + "backgroundColor": "rgba(255, 255, 255, 0.031)" + }, + ".cm-selectionMatch.cm-selectionMatch, .cm-searchMatch.cm-searchMatch": { + "outline": "1px solid rgba(221, 240, 255, 0.20)", + "margin": 0 + }, + ".cm-foldPlaceholder": { + "backgroundColor": "#AC885B", + "borderColor": "#F8F8F8" + } + }, + "highlightStyle": { + ".tok-keyword": { + "color": "#CDA869" + }, + ".tok-labelName": { + "color": "#CF6A4C" + }, + ".tok-literal": { + "color": "#CF6A4C" + }, + ".tok-heading": { + "color": "#CF6A4C" + }, + ".tok-list": { + "color": "#F9EE98" + }, + ".tok-typeName": { + "color": "#F9EE98" + }, + ".tok-function": { + "color": "#AC885B" + }, + ".tok-attributeValue": { + "color": "#7587A6" + }, + ".tok-string": { + "color": "#8F9D6A" + }, + ".tok-regexp": { + "color": "#E9C062" + }, + ".tok-comment": { + "fontStyle": "italic", + "color": "#5F5A60" + } + }, + "dark": true +} diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/vibrant_ink-license.txt b/services/web/frontend/js/features/source-editor/themes/cm6/vibrant_ink-license.txt new file mode 100644 index 0000000000..d70d8cae37 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/vibrant_ink-license.txt @@ -0,0 +1,35 @@ +Conversion by Overleaf from Ace to CodeMirror 6. + +Source: https://github.com/ajaxorg/ace/ + +The theme's original license is copied below: + +***** BEGIN LICENSE BLOCK ***** +Distributed under the BSD license: + +Copyright (c) 2010, Ajax.org B.V. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Ajax.org B.V. nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +***** END LICENSE BLOCK ***** \ No newline at end of file diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/vibrant_ink.json b/services/web/frontend/js/features/source-editor/themes/cm6/vibrant_ink.json new file mode 100644 index 0000000000..c999a175c4 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/vibrant_ink.json @@ -0,0 +1,74 @@ +{ + "theme": { + ".cm-gutters": { + "backgroundColor": "transparent", + "borderRightColor": "transparent", + "background": "#1a1a1a", + "color": "#BEBEBE" + }, + "&": { + "backgroundColor": "#0F0F0F", + "color": "#FFFFFF" + }, + ".cm-cursor, .cm-dropCursor": { + "color": "#FFFFFF" + }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection, .cm-searchMatch.cm-searchMatch.cm-searchMatch-selected": { + "background": "#6699CC" + }, + "&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": { + "margin": 0, + "outline": "1px solid #404040" + }, + ".cm-activeLine": { + "background": "#333333" + }, + ".cm-activeLineGutter": { + "backgroundColor": "#333333" + }, + ".cm-selectionMatch.cm-selectionMatch, .cm-searchMatch.cm-searchMatch": { + "outline": "1px solid #6699CC", + "margin": 0 + }, + ".cm-foldPlaceholder": { + "backgroundColor": "#FFCC00", + "borderColor": "#FFFFFF" + } + }, + "highlightStyle": { + ".tok-keyword": { + "color": "#FF6600" + }, + ".tok-labelName": { + "color": "#339999" + }, + ".tok-literal": { + "color": "#99CC99" + }, + ".tok-invalid": { + "color": "#CCFF33", + "backgroundColor": "#000000" + }, + ".tok-function": { + "color": "#FFCC00" + }, + ".tok-attributeValue": { + "color": "#FFCC00", + "fontStyle": "italic" + }, + ".tok-string": { + "color": "#66FF00" + }, + ".tok-regexp": { + "color": "#44B4CC" + }, + ".tok-comment": { + "color": "#9933CC" + }, + ".tok-attributeName": { + "fontStyle": "italic", + "color": "#99CC99" + } + }, + "dark": true +} diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/xcode-license.txt b/services/web/frontend/js/features/source-editor/themes/cm6/xcode-license.txt new file mode 100644 index 0000000000..d70d8cae37 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/xcode-license.txt @@ -0,0 +1,35 @@ +Conversion by Overleaf from Ace to CodeMirror 6. + +Source: https://github.com/ajaxorg/ace/ + +The theme's original license is copied below: + +***** BEGIN LICENSE BLOCK ***** +Distributed under the BSD license: + +Copyright (c) 2010, Ajax.org B.V. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Ajax.org B.V. nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +***** END LICENSE BLOCK ***** \ No newline at end of file diff --git a/services/web/frontend/js/features/source-editor/themes/cm6/xcode.json b/services/web/frontend/js/features/source-editor/themes/cm6/xcode.json new file mode 100644 index 0000000000..5dc47ff210 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/cm6/xcode.json @@ -0,0 +1,65 @@ +{ + "theme": { + ".cm-gutters": { + "backgroundColor": "transparent", + "borderRightColor": "transparent", + "background": "#e8e8e8", + "color": "#333" + }, + "&": { + "backgroundColor": "#FFFFFF", + "color": "#000000" + }, + ".cm-cursor, .cm-dropCursor": { + "color": "#000000" + }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection, .cm-searchMatch.cm-searchMatch.cm-searchMatch-selected": { + "background": "#B5D5FF" + }, + "&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": { + "margin": 0, + "outline": "1px solid #BFBFBF" + }, + ".cm-activeLine": { + "background": "rgba(0, 0, 0, 0.071)" + }, + ".cm-activeLineGutter": { + "backgroundColor": "rgba(0, 0, 0, 0.071)" + }, + ".cm-selectionMatch.cm-selectionMatch, .cm-searchMatch.cm-searchMatch": { + "outline": "1px solid #B5D5FF", + "margin": 0 + }, + ".cm-foldPlaceholder": { + "backgroundColor": "#C800A4", + "borderColor": "#000000" + } + }, + "highlightStyle": { + ".tok-literal": { + "color": "#3A00DC" + }, + ".tok-keyword": { + "color": "#C800A4" + }, + ".tok-variableName": { + "color": "#C800A4" + }, + ".tok-attributeName": { + "color": "#450084" + }, + ".tok-tagName": { + "color": "#790EAD" + }, + ".tok-typeName": { + "color": "#C900A4" + }, + ".tok-string": { + "color": "#DF0002" + }, + ".tok-comment": { + "color": "#008E00" + } + }, + "dark": false +} diff --git a/services/web/frontend/js/features/source-editor/themes/convert.js b/services/web/frontend/js/features/source-editor/themes/convert.js new file mode 100644 index 0000000000..bbec503af4 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/convert.js @@ -0,0 +1,247 @@ +/** +Convert Ace themes to CodeMirror 6 + +Tokens: +https://github.com/ajaxorg/ace/wiki/Creating-or-Extending-an-Edit-Mode#common-tokens + +Highlight Rules: +https://github.com/overleaf/ace/blob/overleaf/lib/ace/mode/latex_highlight_rules.js + +Conversion of TextMate themes to Ace: +https://github.com/ajaxorg/ace/wiki/Importing-.tmtheme-and-.tmlanguage-Files-into-Ace +https://github.com/ajaxorg/ace/blob/master/tool/tmtheme.js +*/ + +const fs = require('fs') +const globby = require('globby') +const mensch = require('mensch') +const path = require('path') +const overrides = require('./overrides.json') +const { merge } = require('lodash') + +// CSS files from https://github.com/overleaf/ace/tree/overleaf/lib/ace/theme copied into the "ace" folder +const themePaths = globby.sync(['ace/*.css'], { cwd: __dirname }) + +const outputDir = path.join(__dirname, 'cm6') + +// from js/ide.js +const darkThemes = [ + 'ambiance', + 'chaos', + 'clouds_midnight', + 'cobalt', + 'dracula', + 'gob', + 'gruvbox', + 'idle_fingers', + 'kr_theme', + 'merbivore', + 'merbivore_soft', + 'mono_industrial', + 'monokai', + 'nord_dark', + 'pastel_on_dark', + 'solarized_dark', + 'terminal', + 'tomorrow_night', + 'tomorrow_night_blue', + 'tomorrow_night_bright', + 'tomorrow_night_eighties', + 'twilight', + 'vibrant_ink', +] + +// manual mapping of Ace selectors to CM6 theme selectors +const themeMapping = new Map([ + ['.ace_gutter', '.cm-gutters'], + ['.ace_cursor', '.cm-cursor, .cm-dropCursor'], + [ + '.ace_marker-layer .ace_selection', + '&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection, .cm-searchMatch.cm-searchMatch.cm-searchMatch-selected', + ], + [ + '.ace_marker-layer .ace_selected-word', + '.cm-selectionMatch.cm-selectionMatch, .cm-searchMatch.cm-searchMatch', // doubled to increase specificity over defaults + ], + [ + '.ace_marker-layer .ace_bracket', + '&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket', + ], + ['.ace_marker-layer .ace_bracket-unmatched', '.cm-nonmatchingBracket'], + ['.ace_marker-layer .ace_active-line', '.cm-activeLine'], + ['.ace_gutter-active-line', '.cm-activeLineGutter'], + ['.ace_fold', '.cm-foldPlaceholder'], +]) + +const propertyRemapping = new Map([ + [ + '&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket', + [['border', 'outline']], + ], + [ + '.cm-selectionMatch.cm-selectionMatch, .cm-searchMatch.cm-searchMatch', + [['border', 'outline']], + ], +]) + +function remap(selector, rule) { + const remappings = propertyRemapping.get(selector) ?? [] + for (const [oldKey, newKey] of remappings) { + if (newKey in rule) { + throw new Error( + `Invalid remapping. Property ${newKey} already exists in rule '${selector}'` + ) + } + if (oldKey in rule) { + rule[newKey] = rule[oldKey] + delete rule[oldKey] + } + } + return rule +} +// manual mapping of Ace selectors to CM6 highlight style selectors +// https://codemirror.net/6/docs/ref/#highlight.tags +// (the classHighlightStyle extension adds the class names for styling) +const highlightStyleMapping = new Map([ + // ['.ace_support.ace_type', '.tok-typeName'], + ['.ace_class', '.tok-class'], + ['.ace_comment', '.tok-comment'], + ['.ace_constant', '.tok-labelName'], + ['.ace_constant.ace_character', '.tok-literal'], + ['.ace_constant.ace_character.ace_escape', '.tok-literal'], // escape + ['.ace_constant.ace_language', '.tok-literal'], // constant + ['.ace_constant.ace_numeric', '.tok-literal'], // number + ['.ace_constant.ace_other', '.tok-literal'], // constant + ['.ace_entity.ace_name.ace_function', '.tok-function'], + ['.ace_entity.ace_name.ace_tag', '.tok-tagName'], + ['.ace_entity.ace_other.ace_attribute-name', '.tok-attributeName'], + ['.ace_heading', '.tok-heading'], + ['.ace_identifier', '.tok-string'], // TODO: identifier? + ['.ace_invalid', '.tok-invalid'], + ['.ace_keyword', '.tok-keyword'], // typeName? + ['.ace_keyword.ace_operator', '.tok-operator'], + ['.ace_list', '.tok-list'], + ['.ace_lparen', '.tok-paren'], + ['.ace_markup.ace_heading', '.tok-heading'], + ['.ace_markup.ace_list', '.tok-list'], + ['.ace_numeric', '.tok-number'], + ['.ace_punctuation', '.tok-punctuation'], + ['.ace_regexp', '.tok-string2'], + ['.ace_rparen', '.tok-paren'], + ['.ace_storage', '.tok-typeName'], + ['.ace_storage.ace_type', '.tok-typeName'], + ['.ace_string', '.tok-string'], + ['.ace_string.ace_regexp', '.tok-regexp'], + // ['.ace_support.ace_class', '.tok-className'], + // ['.ace_support.ace_constant', '.tok-constant'], + // ['.ace_support.ace_function', '.tok-function'], + // ['.ace_support.ace_type', '.tok-function'], + ['.ace_type', '.tok-typeName'], + ['.ace_variable', '.tok-attributeValue'], // keyword // variableName + ['.ace_variable.ace_language', '.tok-variableName'], + ['.ace_variable.ace_parameter', '.tok-attributeValue'], // string +]) + +for (const themePath of themePaths) { + console.log(themePath) + + const input = fs.readFileSync(path.join(__dirname, themePath), 'utf-8') + + const ast = mensch.parse(input) + + const themeStyles = { + // these styles should only be set if they're defined in a theme + '.cm-gutters': { + backgroundColor: 'transparent', + borderRightColor: 'transparent', + }, + } + const highlightStyles = {} + + for (const rule of ast.stylesheet.rules) { + const declarations = {} + + rule.declarations + .filter(item => item.type === 'property') + .forEach(declaration => { + // convert CSS property to snake case + const property = declaration.name.replace(/-(\w)/g, (_, letter) => { + return letter.toUpperCase() + }) + declarations[property] = declaration.value + }) + + for (const item of rule.selectors) { + // ignore the first selector, which is the theme class + const selector = item.split(/\s+/).slice(1).join(' ') + + // an empty selector was the theme class selector for the whole editor + if (selector === '') { + themeStyles['&'] = { + ...themeStyles['&'], + ...declarations, + } + continue + } + + if (themeMapping.has(selector)) { + const key = themeMapping.get(selector) + themeStyles[key] = remap(key, { + ...themeStyles[key], + ...declarations, + }) + } else if (highlightStyleMapping.has(selector)) { + const key = highlightStyleMapping.get(selector) + highlightStyles[key] = remap(key, { + ...highlightStyles[key], + ...declarations, + }) + } + } + } + + console.log('theme', themeStyles) + console.log('highlight', highlightStyles) + + const basename = path.basename(themePath, '.css') + + const themeOverrides = merge({}, overrides.all, overrides[basename]) + + const theme = merge({}, themeStyles, themeOverrides.theme) + + const highlightStyle = merge( + {}, + highlightStyles, + themeOverrides.highlightStyle + ) + + const dark = darkThemes.includes(basename) + + const output = JSON.stringify({ theme, highlightStyle, dark }, null, 2) + + const outputPath = path.join(outputDir, `${basename}.json`) + + fs.writeFileSync(outputPath, output + '\n') + + if (basename !== 'overleaf') { + copyLicense(basename) + } +} + +function copyLicense(basename) { + const jsFilePath = path.join(__dirname, 'ace', `${basename}.js`) + if (fs.existsSync(jsFilePath)) { + const js = fs.readFileSync(jsFilePath, 'utf-8') + const match = js.match(/\*+ BEGIN LICENSE BLOCK .+? END LICENSE BLOCK \*+/s) + if (match) { + const license = match[0].replace(/\n \* ?/g, '\n') + const output = `Conversion by Overleaf from Ace to CodeMirror 6.\n\nSource: https://github.com/ajaxorg/ace/\n\nThe theme's original license is copied below:\n\n${license}` + const licenseOutputPath = path.join(outputDir, `${basename}-license.txt`) + fs.writeFileSync(licenseOutputPath, output) + } else { + console.warn(`No license in ${jsFilePath}`) + } + } else { + console.warn(`No license file for ${basename}`) + } +} diff --git a/services/web/frontend/js/features/source-editor/themes/overrides.json b/services/web/frontend/js/features/source-editor/themes/overrides.json new file mode 100644 index 0000000000..2e03ba3ece --- /dev/null +++ b/services/web/frontend/js/features/source-editor/themes/overrides.json @@ -0,0 +1,80 @@ +{ + "all": { + "theme": { + ".cm-selectionMatch.cm-selectionMatch, .cm-searchMatch.cm-searchMatch": { + "margin": 0 + }, + "&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": { + "margin": 0 + } + } + }, + "overleaf": { + "highlightStyle": { + ".tok-labelName": { + "color": "#3F7F7F" + }, + ".tok-number": { + "color": "#5A5CAD" + } + }, + "theme": { + ".cm-selectionMatch.cm-selectionMatch, .cm-searchMatch.cm-searchMatch": { + "background": "rgba(250, 250, 255, 0.5)", + "outline": "1px solid rgb(200, 200, 250)" + } + } + }, + "textmate": { + "highlightStyle": { + ".tok-labelName": { + "color": "#833FBA" + }, + ".tok-literal": { + "color": "#833FBA" + } + }, + "theme": { + ".cm-selectionMatch.cm-selectionMatch, .cm-searchMatch.cm-searchMatch": { + "background": "rgba(250, 250, 255, 0.5)", + "outline": "1px solid rgb(200, 200, 250)" + } + } + }, + "chrome": { + "theme": { + ".cm-selectionMatch.cm-selectionMatch, .cm-searchMatch.cm-searchMatch": { + "background": "rgba(250, 250, 255, 0.5)", + "outline": "1px solid rgb(200, 200, 250)" + } + } + }, + "dracula": { + "highlightStyle": { + ".tok-literal": { + "color": "#ff79c6" + } + }, + "theme": { + ".cm-selectionMatch.cm-selectionMatch, .cm-searchMatch.cm-searchMatch": { + "boxShadow": "0px 0px 0px 1px inset #a29709" + } + } + }, + "gruvbox": { + "theme": { + ".cm-selectionMatch.cm-selectionMatch, .cm-searchMatch.cm-searchMatch": { + "outline-width": "2px", + "borderRadius": 0 + } + } + }, + "ambiance": { + "theme": { + ".cm-selectionMatch.cm-selectionMatch, .cm-searchMatch.cm-searchMatch": { + "outline-width": "2px", + "borderRadius": 0 + } + } + } +} diff --git a/services/web/frontend/js/features/source-editor/utils/effects.ts b/services/web/frontend/js/features/source-editor/utils/effects.ts new file mode 100644 index 0000000000..c7f159d416 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/utils/effects.ts @@ -0,0 +1,19 @@ +import { StateEffectType, Transaction } from '@codemirror/state' +import { ViewUpdate } from '@codemirror/view' + +export const hasEffect = + (effectType: StateEffectType) => + (tr: Transaction) => + tr.effects.some(effect => effect.is(effectType)) + +export const updateHasEffect = + (effectType: StateEffectType) => + (update: ViewUpdate) => + update.transactions.some(tr => + tr.effects.some(effect => effect.is(effectType)) + ) + +export const findEffect = + (effectType: StateEffectType) => + (tr: Transaction) => + tr.effects.find(effect => effect.is(effectType)) diff --git a/services/web/frontend/js/features/source-editor/utils/layer.ts b/services/web/frontend/js/features/source-editor/utils/layer.ts new file mode 100644 index 0000000000..8e96ecb39d --- /dev/null +++ b/services/web/frontend/js/features/source-editor/utils/layer.ts @@ -0,0 +1,301 @@ +/** + * This file is adapted from CodeMirror 6, licensed under the MIT license: + * https://github.com/codemirror/view/blob/main/src/layer.ts + */ +import { + BlockInfo, + BlockType, + Direction, + EditorView, + Rect, + RectangleMarker, +} from '@codemirror/view' +import { EditorSelection, SelectionRange } from '@codemirror/state' +import { isVisual } from '../extensions/visual/visual' +import { round } from 'lodash' + +function canAssumeUniformLineHeights(view: EditorView) { + return !isVisual(view) +} + +export const rectangleMarkerForRange = ( + view: EditorView, + className: string, + range: SelectionRange +): readonly RectangleMarker[] => { + if (range.empty) { + const pos = fullHeightCoordsAtPos(view, range.head, range.assoc || 1) + + if (!pos) { + return [] + } + + const base = getBase(view) + + return [ + new RectangleMarker( + className, + pos.left - base.left, + pos.top - base.top, + null, + pos.bottom - pos.top + ), + ] + } + + return rectanglesForRange(view, className, range) +} + +export function getBase(view: EditorView) { + const rect = view.scrollDOM.getBoundingClientRect() + const left = + view.textDirection === Direction.LTR + ? rect.left + : rect.right - view.scrollDOM.clientWidth + return { + left: left - view.scrollDOM.scrollLeft, + top: rect.top - view.scrollDOM.scrollTop, + } +} + +function wrappedLine( + view: EditorView, + pos: number, + inside: { from: number; to: number } +) { + const range = EditorSelection.cursor(pos) + return { + from: Math.max( + inside.from, + view.moveToLineBoundary(range, false, true).from + ), + to: Math.min(inside.to, view.moveToLineBoundary(range, true, true).from), + type: BlockType.Text, + } +} + +function blockAt(view: EditorView, pos: number): BlockInfo { + const line = view.lineBlockAt(pos) + if (Array.isArray(line.type)) + for (const l of line.type) { + if ( + l.to > pos || + (l.to === pos && (l.to === line.to || l.type === BlockType.Text)) + ) + return l + } + return line as any +} + +// Like coordsAtPos, provides screen coordinates for a document position, but +// unlike coordsAtPos, the top and bottom represent the full height of the +// visual line rather than the top and bottom of the text. To do this, it relies +// on the assumption that all text in the document has the same height and that +// the line contains no widget or decoration that changes the height of the +// line. This is, I am fairly certain, a safe assumption in source mode but not +// in rich text, so in rich text mode this function just returns coordsAtPos. +export function fullHeightCoordsAtPos( + view: EditorView, + pos: number, + side?: -2 | -1 | 1 | 2 | undefined +): Rect | null { + // @ts-ignore CodeMirror has incorrect type on coordsAtPos + const coords = view.coordsAtPos(pos, side) + if (!coords) { + return null + } + + if (!canAssumeUniformLineHeights(view)) { + return coords + } + + const { left, right } = coords + const halfLeading = + (view.defaultLineHeight - (coords.bottom - coords.top)) / 2 + + return { + left, + right, + top: round(coords.top - halfLeading, 2), + bottom: round(coords.bottom + halfLeading, 2), + } +} + +// Added to range rectangle's vertical extent to prevent rounding +// errors from introducing gaps in the rendered content. +const Epsilon = 0.01 + +function rectanglesForRange( + view: EditorView, + className: string, + range: SelectionRange +): RectangleMarker[] { + if (range.to <= view.viewport.from || range.from >= view.viewport.to) { + return [] + } + const from = Math.max(range.from, view.viewport.from) + const to = Math.min(range.to, view.viewport.to) + + const ltr = view.textDirection === Direction.LTR + const content = view.contentDOM + const contentRect = content.getBoundingClientRect() + const base = getBase(view) + + const lineElt = content.querySelector('.cm-line') + const lineStyle = lineElt && window.getComputedStyle(lineElt) + const leftSide = + contentRect.left + + (lineStyle + ? parseInt(lineStyle.paddingLeft) + + Math.min(0, parseInt(lineStyle.textIndent)) + : 0) + const rightSide = + contentRect.right - (lineStyle ? parseInt(lineStyle.paddingRight) : 0) + + const startBlock = blockAt(view, from) + const endBlock = blockAt(view, to) + let visualStart: { from: number; to: number } | null = + startBlock.type === BlockType.Text ? startBlock : null + let visualEnd: { from: number; to: number } | null = + endBlock.type === BlockType.Text ? endBlock : null + if (view.lineWrapping) { + if (visualStart) visualStart = wrappedLine(view, from, visualStart) + if (visualEnd) visualEnd = wrappedLine(view, to, visualEnd) + } + if (visualStart && visualEnd && visualStart.from === visualEnd.from) { + return pieces(drawForLine(range.from, range.to, visualStart)) + } else { + const top = visualStart + ? drawForLine(range.from, null, visualStart) + : drawForWidget(startBlock, false) + const bottom = visualEnd + ? drawForLine(null, range.to, visualEnd) + : drawForWidget(endBlock, true) + const between = [] + + if ((visualStart || startBlock).to < (visualEnd || endBlock).from - 1) + between.push(piece(leftSide, top.bottom, rightSide, bottom.top)) + else if ( + top.bottom < bottom.top && + view.elementAtHeight((top.bottom + bottom.top) / 2).type === + BlockType.Text + ) + top.bottom = bottom.top = (top.bottom + bottom.top) / 2 + return pieces(top).concat(between).concat(pieces(bottom)) + } + + function piece(left: number, top: number, right: number, bottom: number) { + return new RectangleMarker( + className, + left - base.left, + top - base.top - Epsilon, + right - left, + bottom - top + Epsilon + ) + } + + function pieces({ + top, + bottom, + horizontal, + }: { + top: number + bottom: number + horizontal: number[] + }) { + const pieces = [] + for (let i = 0; i < horizontal.length; i += 2) + pieces.push(piece(horizontal[i], top, horizontal[i + 1], bottom)) + return pieces + } + + // Gets passed from/to in line-local positions + function drawForLine( + from: null | number, + to: null | number, + line: { from: number; to: number } + ) { + let top = 1e9 + let bottom = -1e9 + const horizontal: number[] = [] + + function addSpan( + from: number, + fromOpen: boolean, + to: number, + toOpen: boolean, + dir: Direction + ) { + // Passing 2/-2 is a kludge to force the view to return + // coordinates on the proper side of block widgets, since + // normalizing the side there, though appropriate for most + // coordsAtPos queries, would break selection drawing. + const fromCoords = fullHeightCoordsAtPos( + view, + from, + (from === line.to ? -2 : 2) as any + ) + const toCoords = fullHeightCoordsAtPos( + view, + to, + (to === line.from ? 2 : -2) as any + ) + // coordsAtPos can sometimes return null even when the document position + // is within the viewport. It's not clear exactly when this happens; + // sometimes, the editor has previously failed to complete a measure. + if (!fromCoords || !toCoords) { + return + } + top = Math.min(fromCoords.top, toCoords.top, top) + bottom = Math.max(fromCoords.bottom, toCoords.bottom, bottom) + if (dir === Direction.LTR) + horizontal.push( + ltr && fromOpen ? leftSide : fromCoords.left, + ltr && toOpen ? rightSide : toCoords.right + ) + else + horizontal.push( + !ltr && toOpen ? leftSide : toCoords.left, + !ltr && fromOpen ? rightSide : fromCoords.right + ) + } + + const start = from ?? line.from + const end = to ?? line.to + // Split the range by visible range and document line + for (const r of view.visibleRanges) + if (r.to > start && r.from < end) { + for ( + let pos = Math.max(r.from, start), endPos = Math.min(r.to, end); + ; + + ) { + const docLine = view.state.doc.lineAt(pos) + for (const span of view.bidiSpans(docLine)) { + const spanFrom = span.from + docLine.from + const spanTo = span.to + docLine.from + if (spanFrom >= endPos) break + if (spanTo > pos) + addSpan( + Math.max(spanFrom, pos), + from === null && spanFrom <= start, + Math.min(spanTo, endPos), + to === null && spanTo >= end, + span.dir + ) + } + pos = docLine.to + 1 + if (pos >= endPos) break + } + } + if (horizontal.length === 0) + addSpan(start, from === null, end, to === null, view.textDirection) + + return { top, bottom, horizontal } + } + + function drawForWidget(block: BlockInfo, top: boolean) { + const y = contentRect.top + (top ? block.top : block.bottom) + return { top: y, bottom: y, horizontal: [] } + } +} diff --git a/services/web/frontend/js/features/source-editor/utils/position.ts b/services/web/frontend/js/features/source-editor/utils/position.ts new file mode 100644 index 0000000000..22f9e7041e --- /dev/null +++ b/services/web/frontend/js/features/source-editor/utils/position.ts @@ -0,0 +1,19 @@ +import { Text } from '@codemirror/state' + +export const findValidPosition = ( + doc: Text, + lineNumber: number, // 1-indexed + columnNumber: number // 0-indexed +): number => { + const lines = doc.lines + + if (lineNumber > lines) { + // end of the doc + return doc.length + } + + const line = doc.line(lineNumber) + + // requested line and column, or the end of the line + return Math.min(line.from + columnNumber, line.to) +} diff --git a/services/web/frontend/js/features/source-editor/utils/projection-state-field.ts b/services/web/frontend/js/features/source-editor/utils/projection-state-field.ts new file mode 100644 index 0000000000..bd908947b7 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/utils/projection-state-field.ts @@ -0,0 +1,74 @@ +import { ChangeSet, StateField } from '@codemirror/state' +import { + ProjectionItem, + ProjectionResult, + getUpdatedProjection, + EnterNodeFn, + ProjectionStatus, +} from './tree-operations/projection' + +export function mergeChangeRanges(changes: ChangeSet) { + let fromA = Number.MAX_VALUE + let fromB = Number.MAX_VALUE + let toA = Number.MIN_VALUE + let toB = Number.MIN_VALUE + changes.iterChangedRanges( + (changeFromA, changeToA, changeFromB, changeToB) => { + fromA = Math.min(changeFromA, fromA) + fromB = Math.min(changeFromB, fromB) + toA = Math.max(changeToA, toA) + toB = Math.max(changeToB, toB) + } + ) + return { fromA, toA, fromB, toB } +} + +/** + * Creates a StateField to manage a 'projection' of the document. Type T is the subclass of + * ProjectionItem that we will extract from the document. + * + * @param enterNode A function to call when 'enter'ing a node while traversing the syntax tree, + * Used to identify nodes we are interested in, and create instances of T. + */ +export function makeProjectionStateField( + enterNode: EnterNodeFn +): StateField> { + const field = StateField.define>({ + create(state) { + const projection = getUpdatedProjection( + state, + 0, + state.doc.length, + 0, + state.doc.length, + true, + enterNode + ) + return projection + }, + update(currentProjection, transaction) { + if ( + transaction.docChanged || + currentProjection.status !== ProjectionStatus.Complete + ) { + const { fromA, toA, fromB, toB } = mergeChangeRanges( + transaction.changes + ) + const list = getUpdatedProjection( + transaction.state, + fromA, + toA, + fromB, + toB, + false, + enterNode, + transaction, + currentProjection + ) + return list + } + return currentProjection + }, + }) + return field +} diff --git a/services/web/frontend/js/features/source-editor/utils/range.ts b/services/web/frontend/js/features/source-editor/utils/range.ts new file mode 100644 index 0000000000..9f93b2c31d --- /dev/null +++ b/services/web/frontend/js/features/source-editor/utils/range.ts @@ -0,0 +1,24 @@ +export class Range { + from: number + to: number + + constructor(from: number, to: number) { + this.from = from + this.to = to + } + + contains(pos: number, allowBoundaries = true) { + return allowBoundaries + ? pos >= this.from && pos <= this.to + : pos > this.from && pos < this.to + } + + // Ranges that touch but don't overlap are not considered to intersect + intersects(range: Range) { + return this.contains(range.from, false) || this.contains(range.to, false) + } + + touchesOrIntersects(range: Range) { + return this.contains(range.from, true) || this.contains(range.to, true) + } +} diff --git a/services/web/frontend/js/features/source-editor/utils/tree-operations/ancestors.ts b/services/web/frontend/js/features/source-editor/utils/tree-operations/ancestors.ts new file mode 100644 index 0000000000..01038c4a6c --- /dev/null +++ b/services/web/frontend/js/features/source-editor/utils/tree-operations/ancestors.ts @@ -0,0 +1,244 @@ +import { ensureSyntaxTree, syntaxTree } from '@codemirror/language' +import { EditorSelection, EditorState, SelectionRange } from '@codemirror/state' +import { SyntaxNode, Tree } from '@lezer/common' +import { isUnknownCommandWithName } from './common' +import { ListEnvironment } from '../../lezer-latex/latex.terms.mjs' + +const HUNDRED_MS = 100 + +export type AncestorItem = { + node: SyntaxNode + label: string + type?: string + from: number + to: number +} + +/** + * Get the stack of 'ancestor' nodes at the given position. + * The first element is the most distant ancestor, while the last element + * is the node at the position. + */ +export function getAncestorStack( + state: EditorState, + pos: number +): AncestorItem[] | null { + const tree = ensureSyntaxTree(state, pos, HUNDRED_MS) + + if (!tree) { + return null + } + + const stack: AncestorItem[] = [] + const selectedNode = tree.resolve(pos, 0) + + let node: SyntaxNode | null = selectedNode + while (node) { + const name = node.type.name + switch (name) { + case 'Environment': + { + const data: AncestorItem = { + node, + label: name, + from: node.from, + to: node.to, + } + + const child = node.getChild('EnvNameGroup') + if (child) { + data.type = state.doc.sliceString(child.from + 1, child.to - 1) + } + stack.push(data) + } + break + + default: + stack.push({ node, label: name, from: node.from, to: node.to }) + break + } + + node = node.parent + } + + return stack.reverse() +} + +export const ancestorNodeOfType = ( + state: EditorState, + pos: number, + type: string | number, + side: -1 | 0 | 1 = 0 +): SyntaxNode | null => { + const node: SyntaxNode | null = syntaxTree(state).resolveInner(pos, side) + return ancestorOfNodeWithType(node, type) +} + +export function* ancestorsOfNodeWithType( + node: SyntaxNode | null, + type: string | number +): Generator { + for (let ancestor = node; ancestor; ancestor = ancestor.parent) { + if (ancestor.type.is(type)) { + yield ancestor + } + } +} + +export const ancestorOfNodeWithType = ( + node: SyntaxNode | null | undefined, + ...types: (string | number)[] +): SyntaxNode | null => { + for (let ancestor = node; ancestor; ancestor = ancestor.parent) { + for (const type of types) { + if (ancestor.type.is(type)) { + return ancestor + } + } + } + return null +} + +export const descendantsOfNodeWithType = ( + node: SyntaxNode, + type: string | number, + nested = false +): SyntaxNode[] => { + const children: SyntaxNode[] = [] + + node.cursor().iterate(nodeRef => { + if (nodeRef.type.is(type)) { + children.push(nodeRef.node) + if (!nested) { + return false + } + } + }) + + return children +} + +export const getBibkeyArgumentNode = (state: EditorState, pos: number) => { + return ( + ancestorNodeOfType(state, pos, 'BibKeyArgument', -1) ?? + ancestorNodeOfType(state, pos, 'BibKeyArgument') + ) +} + +export function* ancestorsOfSelectionWithType( + tree: Tree, + selection: EditorSelection, + type: string | number +) { + for (const range of selection.ranges) { + const node = tree.resolveInner(range.anchor) + for (const ancestor of ancestorsOfNodeWithType(node, type)) { + if (ancestor) { + yield ancestor + } + } + } +} + +export const matchingAncestor = ( + node: SyntaxNode, + predicate: (node: SyntaxNode) => boolean +) => { + for ( + let ancestor: SyntaxNode | null | undefined = node; + ancestor; + ancestor = ancestor.parent + ) { + if (predicate(ancestor)) { + return ancestor + } + } + return null +} + +export const ancestorWithType = ( + state: EditorState, + nodeType: string | number +) => { + const tree = syntaxTree(state) + + const ancestors = ancestorsOfSelectionWithType( + tree, + state.selection, + nodeType + ) + + return ancestors.next().value +} + +export const commonAncestor = ( + nodeA: SyntaxNode, + nodeB: SyntaxNode +): SyntaxNode | null => { + let cursorA: SyntaxNode | null = nodeA + let cursorB: SyntaxNode | null = nodeB + while (cursorA && cursorB) { + if (cursorA === cursorB) { + return cursorA + } + if (cursorA.from < cursorB.from) { + cursorB = cursorB.parent + } else { + cursorA = cursorA.parent + } + } + return null +} + +export const withinFormattingCommand = (state: EditorState) => { + const tree = syntaxTree(state) + + return (command: string): boolean => { + const isFormattedText = (range: SelectionRange): boolean => { + const nodeLeft = tree.resolveInner(range.from, -1) + const formattingCommandLeft = matchingAncestor(nodeLeft, node => + isUnknownCommandWithName(node, command, state) + ) + if (!formattingCommandLeft) { + return false + } + + // We need to check the other end of the selection, and ensure that they + // share a common formatting command ancestor + const nodeRight = tree.resolveInner(range.to, 1) + const ancestor = commonAncestor(formattingCommandLeft, nodeRight) + if (!ancestor) { + return false + } + + const formattingAncestor = matchingAncestor(ancestor, node => + isUnknownCommandWithName(node, command, state) + ) + return Boolean(formattingAncestor) + } + + return state.selection.ranges.every(isFormattedText) + } +} + +export type ListEnvironmentName = 'itemize' | 'enumerate' + +export const listDepthForNode = (node: SyntaxNode) => { + let depth = 0 + for (const ancestor of ancestorsOfNodeWithType(node, ListEnvironment)) { + if (ancestor) { + depth++ + } + } + return depth +} + +export const minimumListDepthForSelection = (state: EditorState) => { + const depths = [] + for (const range of state.selection.ranges) { + const tree = syntaxTree(state) + const node = tree.resolveInner(range.anchor) + depths.push(listDepthForNode(node)) + } + return Math.min(...depths) +} diff --git a/services/web/frontend/js/features/source-editor/utils/tree-operations/commands.ts b/services/web/frontend/js/features/source-editor/utils/tree-operations/commands.ts new file mode 100644 index 0000000000..1709fe53b0 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/utils/tree-operations/commands.ts @@ -0,0 +1,119 @@ +import { EditorState } from '@codemirror/state' +import { SyntaxNode, SyntaxNodeRef } from '@lezer/common' +import { getOptionalArgumentText } from './common' +import { NodeIntersectsChangeFn, ProjectionItem } from './projection' + +/** + * A projection of a command in the document + */ +export class Command extends ProjectionItem { + title = '' + optionalArgCount = 0 + requiredArgCount = 0 +} + +/** + * Extracts Command instances from the syntax tree. + * `\newcommand` and `\renewcommand` are treated specially + */ +export const enterNode = ( + state: EditorState, + node: SyntaxNodeRef, + items: Command[], + nodeIntersectsChange: NodeIntersectsChangeFn +): any => { + if (node.type.is('NewCommand') || node.type.is('RenewCommand')) { + if (!nodeIntersectsChange(node.node)) { + // This should already be in `items` + return + } + let commandName = node.node.getChild('LiteralArgContent') + if (!commandName) { + commandName = node.node.getChild('Csname') + } + if (!commandName) { + return + } + const commandNameText = state.doc.sliceString( + commandName.from, + commandName.to + ) + + if (commandNameText.length < 1) { + return + } + + const optionalArguments = node.node.getChildren('OptionalArgument') + + let argCountNumber = 0 + if (optionalArguments.length > 0) { + const argumentCountNode = optionalArguments[0] + const argCountText = getOptionalArgumentText(state, argumentCountNode) + if (argCountText) { + try { + argCountNumber = parseInt(argCountText, 10) + } catch (err) {} + } + } + + const commandDefinitionHasOptionalArgument = optionalArguments.length === 2 + + if (commandDefinitionHasOptionalArgument && argCountNumber > 0) { + argCountNumber-- + } + + const thisCommand = { + line: state.doc.lineAt(node.from).number, + title: commandNameText, + from: node.from, + to: node.to, + optionalArgCount: commandDefinitionHasOptionalArgument ? 1 : 0, + requiredArgCount: argCountNumber, + } + + items.push(thisCommand) + } else if ( + node.type.is('UnknownCommand') || + node.type.is('MathCommand') || + node.type.is('KnownCommand') + ) { + let commandNode: SyntaxNode | null = node.node + if (node.type.is('KnownCommand')) { + // KnownCommands are defined as + // + // KnownCommand { + // CommandName { + // CommandCtrlSeq [args] + // } + // } + // So for a KnownCommand, use the first child as the actual command node + commandNode = commandNode.firstChild + } + + if (!commandNode) { + return + } + if (!nodeIntersectsChange(node.node)) { + // This should already be in `items` + return + } + const ctrlSeq = commandNode.getChild('$CtrlSeq') + if (!ctrlSeq) { + return + } + + const optionalArguments = commandNode.getChildren('OptionalArgument') + const commandArguments = commandNode.getChildren('$Argument') + const text = state.doc.sliceString(ctrlSeq.from, ctrlSeq.to) + + const thisCommand = { + line: state.doc.lineAt(commandNode.from).number, + title: text, + from: commandNode.from, + to: commandNode.to, + optionalArgCount: optionalArguments.length, + requiredArgCount: commandArguments.length, + } + items.push(thisCommand) + } +} diff --git a/services/web/frontend/js/features/source-editor/utils/tree-operations/comments.ts b/services/web/frontend/js/features/source-editor/utils/tree-operations/comments.ts new file mode 100644 index 0000000000..688c6721ad --- /dev/null +++ b/services/web/frontend/js/features/source-editor/utils/tree-operations/comments.ts @@ -0,0 +1,96 @@ +import { ensureSyntaxTree } from '@codemirror/language' +import { EditorState } from '@codemirror/state' +import { SyntaxNode } from '@lezer/common' + +const HUNDRED_MS = 100 + +/** + * Does this Comment node look like '% {' ? + * */ +export const commentIsOpenFold = ( + node: SyntaxNode, + state: EditorState +): boolean => { + const content = state.doc.sliceString(node.from, node.to) + return !!content.match(/%\s*\{\s*/) +} + +/** + * Does this Comment node look like '% }' ? + * */ +export const commentIsCloseFold = ( + node: SyntaxNode, + state: EditorState +): boolean => { + const content = state.doc.sliceString(node.from, node.to) + return !!content.match(/%\s*\}\s*/) +} + +const SEARCH_FORWARD_LIMIT = 6000 + +/** + * Given an opening fold Comment, find its corresponding closing Comment, + * accounting for nesting. + * */ +export const findClosingFoldComment = ( + node: SyntaxNode, + state: EditorState +): SyntaxNode | undefined => { + const start = node.to + 1 + const upto = Math.min(start + SEARCH_FORWARD_LIMIT, state.doc.length) + const tree = ensureSyntaxTree(state, upto, HUNDRED_MS) + if (!tree) { + return + } + let closingFoldNode: SyntaxNode | undefined + let nestingLevel = 0 + tree.iterate({ + from: start, + to: upto, + enter: n => { + if (closingFoldNode) { + return false + } + if (n.node.type.is('Comment')) { + if (commentIsOpenFold(n.node, state)) { + nestingLevel++ + } else if (commentIsCloseFold(n.node, state)) { + if (nestingLevel > 0) { + nestingLevel-- + } else { + closingFoldNode = n.node + return false + } + } + } + }, + }) + return closingFoldNode +} + +/** + * Given two Comment nodes, get the positions we want to actually fold between, + * accounting for the opening and closing brace. + * + * The resulting fold looks like `% {----}` in the editor. + * + */ +export const getFoldRange = ( + startNode: SyntaxNode, + endNode: SyntaxNode, + state: EditorState +): { from: number; to: number } | null => { + const startContent = state.doc.sliceString(startNode.from, startNode.to) + const endContent = state.doc.sliceString(endNode.from, endNode.to) + + const openBracePos = startContent.indexOf('{') + const closeBracePos = endContent.indexOf('}') + if (openBracePos < 0 || closeBracePos < 0) { + return null + } + + return { + from: startNode.from + openBracePos + 1, + to: endNode.from + closeBracePos, + } +} diff --git a/services/web/frontend/js/features/source-editor/utils/tree-operations/common.ts b/services/web/frontend/js/features/source-editor/utils/tree-operations/common.ts new file mode 100644 index 0000000000..c607eee85f --- /dev/null +++ b/services/web/frontend/js/features/source-editor/utils/tree-operations/common.ts @@ -0,0 +1,92 @@ +import { ensureSyntaxTree } from '@codemirror/language' +import { EditorState } from '@codemirror/state' +import { IterMode, SyntaxNode, SyntaxNodeRef, Tree } from '@lezer/common' + +const HUNDRED_MS = 100 + +export function iterateDescendantsOf( + tree: Tree, + ancestors: (string | number)[], + spec: { + enter(node: SyntaxNodeRef): boolean | void + leave?(node: SyntaxNodeRef): void + from?: number | undefined + to?: number | undefined + mode?: IterMode | undefined + } +) { + const filteredEnter = (node: SyntaxNodeRef): boolean | void => { + if (!ancestors.some(x => node.type.is(x))) { + return false + } + return spec.enter(node) + } + tree.iterate({ ...spec, enter: filteredEnter }) +} + +export const previousSiblingIs = ( + state: EditorState, + pos: number, + expectedName: string +): boolean | null => { + const tree = ensureSyntaxTree(state, pos, HUNDRED_MS) + if (!tree) { + return null + } + const thisNode = tree.resolve(pos) + const previousNode = thisNode?.prevSibling + return previousNode?.type.name === expectedName +} + +export const nextSiblingIs = ( + state: EditorState, + pos: number, + expectedName: string +): boolean | null => { + const tree = ensureSyntaxTree(state, pos, HUNDRED_MS) + if (!tree) { + return null + } + const thisNode = tree.resolve(pos) + const previousNode = thisNode?.nextSibling + return previousNode?.type.name === expectedName +} + +export const getOptionalArgumentText = ( + state: EditorState, + optionalArgumentNode: SyntaxNode +): string | undefined => { + const shortArgNode = optionalArgumentNode.getChild('ShortOptionalArg') + if (shortArgNode) { + const shortArgNodeText = state.doc.sliceString( + shortArgNode.from, + shortArgNode.to + ) + return shortArgNodeText + } +} + +export const resolveNodeAtPos = ( + state: EditorState, + pos: number, + side?: -1 | 0 | 1 +) => ensureSyntaxTree(state, pos, HUNDRED_MS)?.resolveInner(pos, side) ?? null + +export const isUnknownCommandWithName = ( + node: SyntaxNode, + command: string, + state: EditorState +): boolean => { + if (!node.type.is('UnknownCommand')) { + return false + } + const commandNameNode = node.getChild('CtrlSeq') + if (!commandNameNode) { + return false + } + const commandName = state.doc.sliceString( + commandNameNode.from, + commandNameNode.to + ) + return commandName === command +} diff --git a/services/web/frontend/js/features/source-editor/utils/tree-operations/completions.ts b/services/web/frontend/js/features/source-editor/utils/tree-operations/completions.ts new file mode 100644 index 0000000000..997eddb403 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/utils/tree-operations/completions.ts @@ -0,0 +1,63 @@ +import { CompletionContext, CompletionSource } from '@codemirror/autocomplete' +import { syntaxTree } from '@codemirror/language' +import { EditorState } from '@codemirror/state' +import { SyntaxNode } from '@lezer/common' +import { ancestorOfNodeWithType } from './ancestors' + +export const ifInType = ( + type: string, + source: CompletionSource +): CompletionSource => { + return (context: CompletionContext) => { + const tree = syntaxTree(context.state) + let node: SyntaxNode | null = tree.resolveInner(context.pos, -1) + while (node) { + if (node.type.is(type)) { + return source(context) + } + node = node.parent + } + return null + } +} + +export function isInEmptyArgumentNodeForAutocomplete(state: EditorState) { + const main = state.selection.main + if (!main.empty) { + return false + } + + const pos = main.anchor + const tree = syntaxTree(state) + + if (tree.length < pos) { + return false + } + + const nodeLeft = tree.resolveInner(pos, -1) + if (!nodeLeft.type.is('OpenBrace')) { + return false + } + + const nodeRight = tree.resolveInner(pos, 1) + if (!nodeRight.type.is('CloseBrace')) { + return false + } + + const ancestor = ancestorOfNodeWithType( + nodeLeft, + 'EnvNameGroup', + 'BibliographyStyleArgument', + 'BibliographyArgument', + 'BibKeyArgument', + 'DocumentClassArgument', + 'FilePathArgument', + 'RefArgument', + 'PackageArgument' + ) + if (!ancestor) { + return false + } + + return ancestor.from === nodeLeft.from && ancestor.to === nodeRight.to +} diff --git a/services/web/frontend/js/features/source-editor/utils/tree-operations/environments.ts b/services/web/frontend/js/features/source-editor/utils/tree-operations/environments.ts new file mode 100644 index 0000000000..db0c4e924c --- /dev/null +++ b/services/web/frontend/js/features/source-editor/utils/tree-operations/environments.ts @@ -0,0 +1,192 @@ +import { ensureSyntaxTree } from '@codemirror/language' +import { EditorState } from '@codemirror/state' +import { SyntaxNode, SyntaxNodeRef } from '@lezer/common' +import { previousSiblingIs } from './common' +import { NodeIntersectsChangeFn, ProjectionItem } from './projection' + +const HUNDRED_MS = 100 + +export class EnvironmentName extends ProjectionItem { + title = '' +} + +export const enterNode = ( + state: EditorState, + node: SyntaxNodeRef, + items: EnvironmentName[], + nodeIntersectsChange: NodeIntersectsChangeFn +): any => { + if (node.type.is('EnvNameGroup')) { + if (!nodeIntersectsChange(node.node)) { + return false + } + if (!node.node.prevSibling?.type.is('Begin')) { + return false + } + const openBraceNode = node.node.getChild('OpenBrace') + if (!openBraceNode) { + return false + } + const envNameNode = openBraceNode.node.nextSibling + if (!envNameNode) { + return false + } + const envNameText = state.doc.sliceString(envNameNode.from, envNameNode.to) + + if (envNameText.length < 1) { + return false + } + + const thisEnvironmentName = { + title: envNameText, + from: envNameNode.from, + to: envNameNode.to, + line: state.doc.lineAt(envNameNode.from).number, + } + + items.push(thisEnvironmentName) + } else if ( + node.type.is('NewEnvironment') || + node.type.is('RenewEnvironment') + ) { + if (!nodeIntersectsChange(node.node)) { + // This should already be in `items` + return false + } + + const envNameNode = node.node.getChild('LiteralArgContent') + if (!envNameNode) { + return + } + const envNameText = state.doc.sliceString(envNameNode.from, envNameNode.to) + + if (!envNameText) { + return + } + + const thisEnvironmentName = { + title: envNameText, + from: envNameNode.from, + to: envNameNode.to, + line: state.doc.lineAt(envNameNode.from).number, + } + + items.push(thisEnvironmentName) + } +} + +export const cursorIsAtBeginEnvironment = ( + state: EditorState, + pos: number +): boolean | undefined => { + const tree = ensureSyntaxTree(state, pos, HUNDRED_MS) + if (!tree) { + return + } + let thisNode = tree.resolve(pos) + if (!thisNode) { + return + } + if ( + thisNode.type.is('EnvNameGroup') && + previousSiblingIs(state, pos, 'Begin') + ) { + return true + } else if ( + thisNode.type.is('$Environment') || + (thisNode.type.is('LaTeX') && pos === state.doc.length) // We're at the end of the document + ) { + // We're at a malformed `\begin{`, resolve leftward + thisNode = tree.resolve(pos, -1) + if (!thisNode) { + return + } + // TODO: may need to handle various envnames + if (thisNode.type.is('OpenBrace') || thisNode.type.is('$EnvName')) { + return true + } + } +} + +export const cursorIsAtEndEnvironment = ( + state: EditorState, + pos: number +): boolean | undefined => { + const tree = ensureSyntaxTree(state, pos, HUNDRED_MS) + if (!tree) { + return + } + let thisNode = tree.resolve(pos) + if (!thisNode) { + return + } + if ( + thisNode.type.is('EnvNameGroup') && + previousSiblingIs(state, pos, 'End') + ) { + return true + } else if (thisNode.type.is('$Environment') || thisNode.type.is('Content')) { + // We're at a malformed `\end{`, resolve leftward + thisNode = tree.resolve(pos, -1) + if (!thisNode) { + return + } + // TODO: may need to handle various envnames + if (thisNode.type.is('OpenBrace') || thisNode.type.is('EnvName')) { + return true + } + } +} + +export const findDocumentEnvironment = (state: EditorState): number | null => { + const tree = ensureSyntaxTree(state, state.doc.length, HUNDRED_MS) + let position: number | null = null + tree?.iterate({ + enter(nodeRef) { + if (position !== null) { + return false + } + if (nodeRef.type.is('DocumentEnvironment')) { + position = nodeRef.node.getChild('Content')?.from || null + return false + } + }, + }) + return position +} + +/** + * + * @param node A node of type `$Environment`, `BeginEnv`, or `EndEnv` + * @param state The editor state to read the name from + * @returns The editor name or null if a name cannot be found + */ +export function getEnvironmentName( + node: SyntaxNode | null, + state: EditorState +): string | null { + if (node?.type.is('$Environment')) { + node = node.getChild('BeginEnv') + } + + if (!node?.type.is('BeginEnv') && !node?.type.is('EndEnv')) { + return null + } + + const nameNode = node + ?.getChild('EnvNameGroup') + ?.getChild('OpenBrace')?.nextSibling + if (!nameNode) { + return null + } + // the name node is a parameter in the grammar, so we have no good way to + // target the specific type + if (nameNode.type.is('CloseBrace')) { + return null + } + return state.sliceDoc(nameNode.from, nameNode.to) +} + +export function getEnvironmentArguments(environmentNode: SyntaxNode) { + return environmentNode.getChild('BeginEnv')?.getChildren('TextArgument') +} diff --git a/services/web/frontend/js/features/source-editor/utils/tree-operations/figure.ts b/services/web/frontend/js/features/source-editor/utils/tree-operations/figure.ts new file mode 100644 index 0000000000..456641cf26 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/utils/tree-operations/figure.ts @@ -0,0 +1,27 @@ +import { SyntaxNode, SyntaxNodeRef } from '@lezer/common' +import { CenteringCtrlSeq } from '../../lezer-latex/latex.terms.mjs' + +export function centeringNodeForEnvironment( + node: SyntaxNodeRef +): SyntaxNode | null { + let centeringNode: SyntaxNode | null = null + const cursor = node.node.cursor() + cursor.next() + cursor.iterate(nodeRef => { + if (centeringNode) { + return false + } + if (nodeRef.from > node.to) { + return false + } + if (nodeRef.type.is(CenteringCtrlSeq)) { + centeringNode = nodeRef.node + return false + } + // don't descend into nested environments + if (nodeRef.type.is('$Environment')) { + return false + } + }) + return centeringNode +} diff --git a/services/web/frontend/js/features/source-editor/utils/tree-operations/outline.ts b/services/web/frontend/js/features/source-editor/utils/tree-operations/outline.ts new file mode 100644 index 0000000000..e353df50ce --- /dev/null +++ b/services/web/frontend/js/features/source-editor/utils/tree-operations/outline.ts @@ -0,0 +1,227 @@ +import { EditorState } from '@codemirror/state' +import { SyntaxNode, SyntaxNodeRef } from '@lezer/common' +import { NodeIntersectsChangeFn, ProjectionItem } from './projection' +import * as tokens from '../../lezer-latex/latex.terms.mjs' +import { getEnvironmentArguments, getEnvironmentName } from './environments' + +export type Outline = { + line: number + title: string + level: number + children?: Outline[] +} + +/** + * A projection of a part of the file outline, typically a (sub)section heading + */ +export class FlatOutlineItem extends ProjectionItem { + level = 0 + title = '' +} + +export type FlatOutline = FlatOutlineItem[] + +/* eslint-disable no-unused-vars */ +enum NestingLevel { + Book = 1, + Part = 2, + Chapter = 3, + Section = 4, + SubSection = 5, + SubSubSection = 6, + Paragraph = 7, + SubParagraph = 8, + Frame = 9, + Invalid = -1, +} + +const fallbackSectionNames: { [index: string]: NestingLevel } = { + book: NestingLevel.Book, + part: NestingLevel.Part, + chapter: NestingLevel.Part, + section: NestingLevel.Section, + subsection: NestingLevel.SubSection, + subsubsection: NestingLevel.SubSubSection, + paragraph: NestingLevel.Paragraph, + subparagraph: NestingLevel.SubParagraph, + frame: NestingLevel.Frame, +} + +export const getNestingLevel = (token: number | string): NestingLevel => { + if (typeof token === 'string') { + return fallbackSectionNames[token] ?? NestingLevel.Invalid + } + switch (token) { + case tokens.Book: + return NestingLevel.Book + case tokens.Part: + return NestingLevel.Part + case tokens.Chapter: + return NestingLevel.Chapter + case tokens.Section: + return NestingLevel.Section + case tokens.SubSection: + return NestingLevel.SubSection + case tokens.SubSubSection: + return NestingLevel.SubSubSection + case tokens.Paragraph: + return NestingLevel.Paragraph + case tokens.SubParagraph: + return NestingLevel.SubParagraph + default: + return NestingLevel.Invalid + } +} + +const getEntryText = (state: EditorState, node: SyntaxNodeRef): string => { + const titleParts: string[] = [] + node.node.cursor().iterate(token => { + // For some reason, iterate can possibly visit sibling nodes as well as + // child nodes + if (token.from >= node.to) { + return false + } + + // Hide label definitions within the sectioning command + if (token.type.is('Label')) { + return false + } + + // Only add text from leaf nodes + if (token.node.firstChild) { + return true + } + + titleParts.push(state.doc.sliceString(token.from, token.to)) + }) + return titleParts.join('') +} + +/** + * Extracts FlatOutlineItem instances from the syntax tree + */ +export const enterNode = ( + state: EditorState, + node: SyntaxNodeRef, + items: FlatOutlineItem[], + nodeIntersectsChange: NodeIntersectsChangeFn +): any => { + if (node.type.is('SectioningCommand')) { + const command = node.node + const parent = command.parent + + if (!nodeIntersectsChange(command)) { + // This should already be in `items` + return + } + const name = + command.getChild('OptionalArgument')?.getChild('ShortOptionalArg') ?? + command.getChild('SectioningArgument')?.getChild('LongArg') + + if (name == null || command == null) { + return + } + + // Filter out descendants of newcommand/renewcommand + for ( + let ancestor: SyntaxNode | null = parent; + ancestor; + ancestor = ancestor.parent + ) { + if (ancestor.type.is('NewCommand') || ancestor.type.is('RenewCommand')) { + return false + } + } + + const getCommandName = () => { + const ctrlSeq = command.firstChild + if (!ctrlSeq) return '' + // Ignore the \ + return state.doc.sliceString(ctrlSeq.from + 1, ctrlSeq.to) + } + + const nestingLevel = parent?.type.is('$SectioningCommand') + ? getNestingLevel(parent.type.id) + : getNestingLevel(getCommandName()) + + const thisNode = { + line: state.doc.lineAt(command.from).number, + title: getEntryText(state, name), + from: command.from, + to: command.to, + level: nestingLevel, + } + + items.push(thisNode) + } + if (node.type.is('$Environment')) { + const environmentNode = node.node + if (getEnvironmentName(environmentNode, state) === 'frame') { + const beginEnv = environmentNode.getChild('BeginEnv')! + if (!nodeIntersectsChange(beginEnv)) { + // This should already be in `items` + return + } + const args = getEnvironmentArguments(environmentNode)?.map(textArg => + textArg.getChild('LongArg') + ) + if (args?.length) { + const titleNode = args[0] + const title = titleNode + ? state.sliceDoc(titleNode.from, titleNode.to) + : '' + const thisNode = { + line: state.doc.lineAt(beginEnv.from).number, + title, + from: beginEnv.from, + to: beginEnv.to, + level: NestingLevel.Frame, + } + items.push(thisNode) + } + } + } +} + +const flatItemToOutline = (item: { + title: string + line: number + level: number +}): Outline => { + const { title, line, level } = item + return { title, line, level } +} + +export const nestOutline = ( + flatOutline: { title: string; line: number; level: number }[] +): Outline[] => { + const parentStack: Outline[] = [] + const outline = [] + + for (const item of flatOutline) { + const outlineItem = flatItemToOutline(item) + + // Pop all higher-leveled potential parents from the parent stack + while ( + parentStack.length && + parentStack[parentStack.length - 1].level >= outlineItem.level + ) { + parentStack.pop() + } + + // Append to parent if any, and otherwise add root element + if (!parentStack.length) { + parentStack.push(outlineItem) + outline.push(outlineItem) + } else { + const parent = parentStack[parentStack.length - 1] + if (!parent.children) { + parent.children = [outlineItem] + } else { + parent.children.push(outlineItem) + } + parentStack.push(outlineItem) + } + } + return outline +} diff --git a/services/web/frontend/js/features/source-editor/utils/tree-operations/projection.ts b/services/web/frontend/js/features/source-editor/utils/tree-operations/projection.ts new file mode 100644 index 0000000000..1614ee8df1 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/utils/tree-operations/projection.ts @@ -0,0 +1,152 @@ +import { ensureSyntaxTree } from '@codemirror/language' +import { EditorState, Transaction } from '@codemirror/state' +import { IterMode, SyntaxNodeRef } from '@lezer/common' + +const TWENTY_MS = 20 +const FIVE_HUNDRED_MS = 500 + +/** + * A single item in the projection + */ +export abstract class ProjectionItem { + from = 0 + to = 0 + line = 0 +} + +/* eslint-disable no-unused-vars */ +export enum ProjectionStatus { + Pending, + Partial, + Complete, +} +/* eslint-enable no-unused-vars */ + +/* + * Result of extracting a projection from the document. + * Holds the list of ProjectionItems and the status of + * the projection + */ +export interface ProjectionResult { + items: T[] + status: ProjectionStatus +} + +const intersects = (fromA: number, toA: number, fromB: number, toB: number) => { + return !(toA < fromB || fromA > toB) +} + +export type NodeIntersectsChangeFn = (node: SyntaxNodeRef) => boolean + +export function updatePosition( + item: T, + transaction?: Transaction +): T { + if (!transaction) { + return item + } + const { from, to } = item + const newFrom = transaction.changes.mapPos(from) + return { + ...item, + from: newFrom, + to: transaction.changes.mapPos(to), + line: transaction.state.doc.lineAt(newFrom).number, + } +} + +export type EnterNodeFn = ( + state: EditorState, + node: SyntaxNodeRef, + items: T[], + nodeIntersectsChange: NodeIntersectsChangeFn +) => any + +/** + * Calculates an updated projection of an editor state. Passing a previous ProjectionResult + * will reuse the existing projection elements (though updating their position to + * point correctly into the latest EditorState), outside of the changed range. + * + * @param state The current editor state + * @param fromA The start of the modified range in the previous state. + * Ignored if `previousResult` is not provided + * @param toA The end of the modified range in the previous state. + * Ignored if `previousResult` is not provided + * @param fromB The start of the modified range in the `state` + * @param toB The end of the modified range in the `state` + * @param initialParse If this is the intial parse of the document. If that's + * the case, we allow 500ms parse time instead of 20ms + * @param enterNode A function to call when 'enter'ing a node while traversing the syntax tree, + * used to identify nodes we are interested in. + * @param transaction Optional, used to update item positions in `previousResult` + * @param previousResult A previous ProjectionResult that will be reused for + * projection elements outside of the range of [fromA; toA] + * @returns A ProjectionResult pointing to locations in `state` + */ +export function getUpdatedProjection( + state: EditorState, + fromA: number, + toA: number, + fromB: number, + toB: number, + initialParse = false, + enterNode: EnterNodeFn, + transaction?: Transaction, + previousResult: ProjectionResult = { + items: [], + status: ProjectionStatus.Pending, + } +): ProjectionResult { + // Only reuse results from a Complete parse, otherwise we may drop entries. + // We keep items that lie outside the change range, and update their positions. + const items: T[] = + previousResult.status === ProjectionStatus.Complete + ? previousResult + .items!.filter(item => !intersects(item.from, item.to, fromA, toA)) + .map(x => updatePosition(x, transaction)) + : [] + + if (previousResult.status !== ProjectionStatus.Complete) { + // We have previously tried to compute the projection, but unsuccessfully, + // so we should try to parse the whole file again. + toB = state.doc.length + fromB = 0 + } + const tree = ensureSyntaxTree( + state, + toB, + initialParse ? FIVE_HUNDRED_MS : TWENTY_MS + ) + if (tree) { + tree.iterate({ + from: fromB, + to: toB, + enter(node) { + const nodeIntersectsChange = (n: SyntaxNodeRef) => { + return intersects(n.from, n.to, fromB, toB) + } + return enterNode(state, node, items, nodeIntersectsChange) + }, + mode: IterMode.IgnoreMounts | IterMode.IgnoreOverlays, + }) + // We know the exact projection. Return it. + return { + status: ProjectionStatus.Complete, + items: items.sort((a, b) => a.from - b.from), + } + } else if (previousResult.status !== ProjectionStatus.Pending) { + // We don't know the latest projection, but we have an idea of a previous + // projection. + return { + status: ProjectionStatus.Partial, + items: previousResult.items, + } + } else { + // We have no previous projection, and no idea of the current projection. + // Return pending. + return { + items: [], + status: ProjectionStatus.Pending, + } + } +} diff --git a/services/web/frontend/js/features/source-editor/utils/tree-operations/text.ts b/services/web/frontend/js/features/source-editor/utils/tree-operations/text.ts new file mode 100644 index 0000000000..fc7b6e8aa0 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/utils/tree-operations/text.ts @@ -0,0 +1,86 @@ +import { syntaxTree } from '@codemirror/language' +import { Line } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import { SyntaxNodeRef } from '@lezer/common' +import OError from '@overleaf/o-error' + +/* A convenient wrapper around 'Normal' tokens */ +export class NormalTextSpan { + public from: number + public to: number + public lineNumber: number + public text: string + public node: SyntaxNodeRef + + constructor(options: { + from: number + to: number + lineNumber: number + text: string + node: SyntaxNodeRef + }) { + const { from, to, lineNumber, text, node } = options + if ( + text == null || + from == null || + to == null || + lineNumber == null || + node == null + ) { + throw new OError('TreeQuery: invalid NormalTextSpan').withInfo({ + options, + }) + } + this.from = from + this.to = to + this.text = text + this.node = node + this.lineNumber = lineNumber + } +} + +const NotNormalNodeAncestors = [ + 'Include', + 'Input', + 'IncludeGraphics', + 'Cite', + 'LabelArgument', + 'UsePackage', + 'PackageArgument', + 'EnvNameGroup', + 'RefArgument', +] + +export const getNormalTextSpansFromLine = ( + view: EditorView, + line: Line +): Array => { + const lineNumber = line.number + const lineStart = line.from + const lineEnd = line.to + const tree = syntaxTree(view.state) + const normalTextSpans: Array = [] + tree?.iterate({ + from: lineStart, + to: lineEnd, + enter: (node: SyntaxNodeRef) => { + if (NotNormalNodeAncestors.includes(node.type.name)) { + return false + } + if (node.type.name === 'Normal') { + normalTextSpans.push( + new NormalTextSpan({ + from: node.from, + to: node.to, + text: view.state.doc.sliceString(node.from, node.to), + lineNumber, + node, + }) + ) + return false + } + return true + }, + }) + return normalTextSpans +} diff --git a/services/web/frontend/js/features/source-editor/utils/tree-operations/tokens.ts b/services/web/frontend/js/features/source-editor/utils/tree-operations/tokens.ts new file mode 100644 index 0000000000..11ad229b78 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/utils/tree-operations/tokens.ts @@ -0,0 +1,9 @@ +import * as termsModule from '../../lezer-latex/latex.terms.mjs' + +export const tokenNames: Array = Object.keys(termsModule) + +export const Tokens: Record> = { + ctrlSeq: tokenNames.filter(name => name.match(/^(Begin|End|.*CtrlSeq)$/)), + ctrlSym: tokenNames.filter(name => name.match(/^.*CtrlSym$/)), + envName: tokenNames.filter(name => name.match(/^.*EnvName$/)), +} diff --git a/services/web/frontend/js/features/source-editor/utils/tree-query.ts b/services/web/frontend/js/features/source-editor/utils/tree-query.ts new file mode 100644 index 0000000000..267ac4b4b3 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/utils/tree-query.ts @@ -0,0 +1,48 @@ +export { getNestingLevel, nestOutline } from './tree-operations/outline' + +export type { + FlatOutline, + FlatOutlineItem, + Outline, +} from './tree-operations/outline' + +export { + iterateDescendantsOf, + previousSiblingIs, + nextSiblingIs, + isUnknownCommandWithName, +} from './tree-operations/common' + +export { + cursorIsAtBeginEnvironment, + cursorIsAtEndEnvironment, + getEnvironmentArguments, +} from './tree-operations/environments' + +export { + getAncestorStack, + ancestorNodeOfType, + ancestorOfNodeWithType, + getBibkeyArgumentNode, + descendantsOfNodeWithType, + matchingAncestor, +} from './tree-operations/ancestors' + +export { + NormalTextSpan, + getNormalTextSpansFromLine, +} from './tree-operations/text' + +export { + ifInType, + isInEmptyArgumentNodeForAutocomplete, +} from './tree-operations/completions' + +export { tokenNames, Tokens } from './tree-operations/tokens' + +export { + commentIsOpenFold, + commentIsCloseFold, + findClosingFoldComment, + getFoldRange, +} from './tree-operations/comments' diff --git a/services/web/frontend/js/ide.js b/services/web/frontend/js/ide.js index 160f76ff7c..de0c7b1b23 100644 --- a/services/web/frontend/js/ide.js +++ b/services/web/frontend/js/ide.js @@ -59,6 +59,7 @@ import './main/event' import './main/account-upgrade-angular' import './main/system-messages' import '../../modules/modules-ide.js' +import './features/source-editor/ide' import './shared/context/controllers/root-context-controller' import './features/editor-navigation-toolbar/controllers/editor-navigation-toolbar-controller' import './features/pdf-preview/controllers/pdf-preview-controller' diff --git a/services/web/frontend/js/ide/editor/EditorManager.js b/services/web/frontend/js/ide/editor/EditorManager.js index 8a6fdd9cb5..4b3b35f6c1 100644 --- a/services/web/frontend/js/ide/editor/EditorManager.js +++ b/services/web/frontend/js/ide/editor/EditorManager.js @@ -195,11 +195,6 @@ export default EditorManager = (function () { } newSourceEditor() { - // the new source editor is not available at the moment in CE - if (!getMeta('ol-hasNewSourceEditor')) { - return false - } - // Use the new source editor if the legacy editor is disabled if (!getMeta('ol-showLegacySourceEditor')) { return true diff --git a/services/web/frontend/stories/source-editor/source-editor.stories.tsx b/services/web/frontend/stories/source-editor/source-editor.stories.tsx new file mode 100644 index 0000000000..648ace2662 --- /dev/null +++ b/services/web/frontend/stories/source-editor/source-editor.stories.tsx @@ -0,0 +1,265 @@ +import SourceEditor from '../../js/features/source-editor/components/source-editor' +import { ScopeDecorator } from '../decorators/scope' +import { useScope } from '../hooks/use-scope' +import { useMeta } from '../hooks/use-meta' + +export default { + title: 'Editor / Source Editor', + component: SourceEditor, + decorators: [ + ScopeDecorator, + (Story: any) => ( +
+ +
+ ), + ], +} + +const settings = { + fontSize: 12, + fontFamily: 'monaco', + lineHeight: 'normal', + editorTheme: 'textmate', + overallTheme: '', + mode: 'default', + autoComplete: true, + autoPairDelimiters: true, + trackChanges: true, + syntaxValidation: false, +} + +export const Latex = (args: any, { globals: { theme } }: any) => { + useScope({ + editor: { + sharejs_doc: mockDoc(content.tex), + open_doc_name: 'example.tex', + }, + settings: { + ...settings, + overallTheme: theme === 'default-' ? '' : theme, + }, + }) + + return +} + +export const Markdown = (args: any, { globals: { theme } }: any) => { + useScope({ + editor: { + sharejs_doc: mockDoc(content.md), + open_doc_name: 'example.md', + }, + settings: { + ...settings, + overallTheme: theme === 'default-' ? '' : theme, + }, + }) + + return +} + +export const Visual = (args: any, { globals: { theme } }: any) => { + useScope({ + editor: { + sharejs_doc: mockDoc(content.tex), + open_doc_name: 'example.tex', + showVisual: true, + }, + settings: { + ...settings, + overallTheme: theme === 'default-' ? '' : theme, + }, + }) + + useMeta({ + 'ol-showSymbolPalette': true, + 'ol-mathJax3Path': 'https://unpkg.com/mathjax@3.2.2/es5/tex-svg-full.js', + }) + + return +} + +const MAX_DOC_LENGTH = 2 * 1024 * 1024 // window.maxDocLength + +const mockDoc = (content: string) => { + const mockShareJSDoc = { + getText() { + return content + }, + on() { + // do nothing + }, + insert() { + // do nothing + }, + del() { + // do nothing + }, + emit: (...args: any[]) => { + console.log(...args) + }, + } + + return { + doc_id: 'story-doc', + getSnapshot: () => { + return content + }, + attachToCM6: (cm6: any) => { + cm6.attachShareJs(mockShareJSDoc, MAX_DOC_LENGTH) + }, + detachFromCM6: () => { + // Do nothing + }, + on: () => { + // Do nothing + }, + off: () => { + // Do nothing + }, + ranges: { + changes: [ + { + id: '1', + op: { + i: 'Your introduction goes here! Simply start writing your document and use the Recompile button to view the updated PDF preview. Examples of commonly used commands and features are listed below, to help you get started.', + p: 583, + }, + meta: { + user_id: '1', + ts: new Date().toString(), + }, + }, + ], + comments: [], + }, + } +} + +const content = { + tex: `\\documentclass{article} + +% Language setting +% Replace \`english' with e.g. \`spanish' to change the document language +\\usepackage[english]{babel} + +% Set page size and margins +% Replace \`letterpaper' with \`a4paper' for UK/EU standard size +\\usepackage[letterpaper,top=2cm,bottom=2cm,left=3cm,right=3cm,marginparwidth=1.75cm]{geometry} + +% Useful packages +\\usepackage{amsmath} +\\usepackage{graphicx} +\\usepackage[colorlinks=true, allcolors=blue]{hyperref} + +\\title{Your Paper} +\\author{You} + +\\begin{document} +\\maketitle + +\\begin{abstract} +Your abstract. +\\end{abstract} + +\\section{Introduction} + +Your introduction goes here! Simply start writing your document and use the Recompile button to view the updated PDF preview. Examples of commonly used commands and features are listed below, to help you get started. + +Once you're familiar with the editor, you can find various project settings in the Overleaf menu, accessed via the button in the very top left of the editor. To view tutorials, user guides, and further documentation, please visit our \\href{https://www.overleaf.com/learn}{help library}, or head to our plans page to \\href{https://www.overleaf.com/user/subscription/plans}{choose your plan}. + +\\section{Some examples to get started} + +\\subsection{How to create Sections and Subsections} + +Simply use the section and subsection commands, as in this example document! With Overleaf, all the formatting and numbering is handled automatically according to the template you've chosen. If you're using Rich Text mode, you can also create new section and subsections via the buttons in the editor toolbar. + +\\subsection{How to include Figures} + +First you have to upload the image file from your computer using the upload link in the file-tree menu. Then use the includegraphics command to include it in your document. Use the figure environment and the caption command to add a number and a caption to your figure. See the code for Figure \\ref{fig:frog} in this section for an example. + +Note that your figure will automatically be placed in the most appropriate place for it, given the surrounding text and taking into account other figures or tables that may be close by. You can find out more about adding images to your documents in this help article on \\href{https://www.overleaf.com/learn/how-to/Including_images_on_Overleaf}{including images on Overleaf}. + +\\begin{figure} +\\centering +\\includegraphics[width=0.3\\textwidth]{frog.jpg} +\\caption{\\label{fig:frog}This frog was uploaded via the file-tree menu.} +\\end{figure} + +\\subsection{How to add Tables} + +Use the table and tabular environments for basic tables --- see Table~\\ref{tab:widgets}, for example. For more information, please see this help article on \\href{https://www.overleaf.com/learn/latex/tables}{tables}. + +\\begin{table} +\\centering +\\begin{tabular}{l|r} +Item & Quantity \\\\\\hline +Widgets & 42 \\\\ +Gadgets & 13 +\\end{tabular} +\\caption{\\label{tab:widgets}An example table.} +\\end{table} + +\\subsection{How to add Comments and Track Changes} + +Comments can be added to your project by highlighting some text and clicking \`\`Add comment'' in the top right of the editor pane. To view existing comments, click on the Review menu in the toolbar above. To reply to a comment, click on the Reply button in the lower right corner of the comment. You can close the Review pane by clicking its name on the toolbar when you're done reviewing for the time being. + +Track changes are available on all our \\href{https://www.overleaf.com/user/subscription/plans}{premium plans}, and can be toggled on or off using the option at the top of the Review pane. Track changes allow you to keep track of every change made to the document, along with the person making the change. + +\\subsection{How to add Lists} + +You can make lists with automatic numbering \\dots + +\\begin{enumerate} +\\item Like this, +\\item and like this. +\\end{enumerate} +\\dots or bullet points \\dots +\\begin{itemize} +\\item Like this, +\\item and like this. +\\end{itemize} + +\\subsection{How to write Mathematics} + +\\LaTeX{} is great at typesetting mathematics. Let $X_1, X_2, \\ldots, X_n$ be a sequence of independent and identically distributed random variables with $\\text{E}[X_i] = \\mu$ and $\\text{Var}[X_i] = \\sigma^2 < \\infty$, and let +\\[S_n = \\frac{X_1 + X_2 + \\cdots + X_n}{n} + = \\frac{1}{n}\\sum_{i}^{n} X_i\\] +denote their mean. Then as $n$ approaches infinity, the random variables $\\sqrt{n}(S_n - \\mu)$ converge in distribution to a normal $\\mathcal{N}(0, \\sigma^2)$. + + +\\subsection{How to change the margins and paper size} + +Usually the template you're using will have the page margins and paper size set correctly for that use-case. For example, if you're using a journal article template provided by the journal publisher, that template will be formatted according to their requirements. In these cases, it's best not to alter the margins directly. + +If however you're using a more general template, such as this one, and would like to alter the margins, a common way to do so is via the geometry package. You can find the geometry package loaded in the preamble at the top of this example file, and if you'd like to learn more about how to adjust the settings, please visit this help article on \\href{https://www.overleaf.com/learn/latex/page_size_and_margins}{page size and margins}. + +\\subsection{How to change the document language and spell check settings} + +Overleaf supports many different languages, including multiple different languages within one document. + +To configure the document language, simply edit the option provided to the babel package in the preamble at the top of this example project. To learn more about the different options, please visit this help article on \\href{https://www.overleaf.com/learn/latex/International_language_support}{international language support}. + +To change the spell check language, simply open the Overleaf menu at the top left of the editor window, scroll down to the spell check setting, and adjust accordingly. + +\\subsection{How to add Citations and a References List} + +You can simply upload a \\verb|.bib| file containing your BibTeX entries, created with a tool such as JabRef. You can then cite entries from it, like this: \\cite{greenwade93}. Just remember to specify a bibliography style, as well as the filename of the \\verb|.bib|. You can find a \\href{https://www.overleaf.com/help/97-how-to-include-a-bibliography-using-bibtex}{video tutorial here} to learn more about BibTeX. + +If you have an \\href{https://www.overleaf.com/user/subscription/plans}{upgraded account}, you can also import your Mendeley or Zotero library directly as a \\verb|.bib| file, via the upload menu in the file-tree. + +\\subsection{Good luck!} + +We hope you find Overleaf useful, and do take a look at our \\href{https://www.overleaf.com/learn}{help library} for more tutorials and user guides! Please also let us know if you have any feedback using the Contact Us link at the bottom of the Overleaf menu --- or use the contact form at \\url{https://www.overleaf.com/contact}. + +\\bibliographystyle{alpha} +\\bibliography{sample} + +\\end{document}`, + md: `# Heading + +This is **bold** + +This is _italic_`, +} diff --git a/services/web/package.json b/services/web/package.json index f73b8f8caf..5a02c88630 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -31,15 +31,15 @@ "type-check": "tsc --noEmit", "extract-translations": "i18next-scanner", "migrations": "east", - "convert-themes": "node modules/source-editor/frontend/js/themes/convert.js", + "convert-themes": "node frontend/js/features/source-editor/themes/convert.js", "cypress:open-ct": "SHARELATEX_CONFIG=$PWD/config/settings.webpack.js cypress open --component", "cypress:run-ct": "SHARELATEX_CONFIG=$PWD/config/settings.webpack.js cypress run --component", "cypress:docker:open-ct": "DOCKER_USER=\"$(id -u):$(id -g)\" docker-compose -f docker-compose.cypress.yml run --rm cypress run cypress:open-ct", "cypress:docker:run-ct": "DOCKER_USER=\"$(id -u):$(id -g)\" docker-compose -f docker-compose.cypress.yml run --rm cypress run cypress:run-ct", - "lezer-latex:generate": "if [ ! -d $(pwd)/modules/source-editor ]; then echo \"'source-editor' module is not available\"; exit 0; fi; node modules/source-editor/scripts/lezer-latex/generate.js", - "lezer-latex:run": "node modules/source-editor/scripts/lezer-latex/run.mjs", - "lezer-latex:benchmark": "node modules/source-editor/scripts/lezer-latex/benchmark.mjs", - "lezer-latex:benchmark-incremental": "node modules/source-editor/scripts/lezer-latex/test-incremental-parser.mjs", + "lezer-latex:generate": "node scripts/lezer-latex/generate.js", + "lezer-latex:run": "node scripts/lezer-latex/run.mjs", + "lezer-latex:benchmark": "node scripts/lezer-latex/benchmark.mjs", + "lezer-latex:benchmark-incremental": "node scripts/lezer-latex/test-incremental-parser.mjs", "routes": "bin/routes", "local:nodemon": "set -a;. ../../config/dev-environment.env;. ./docker-compose.common.env;. ../../config/local-dev.env;. ./local-dev.env;. ../../config/local.env; set +a; echo $SHARELATEX_CONFIG; WEB_PORT=13000 LISTEN_ADDRESS=0.0.0.0 npm run nodemon", "local:webpack": "set -a;. ../../config/dev-environment.env;. ./docker-compose.common.env;. ../../config/local-dev.env;. ./local-dev.env;. ../../config/local.env; set +a; PORT=13808 SHARELATEX_CONFIG=$(pwd)/config/settings.webpack.js npm run webpack", diff --git a/services/web/scripts/lezer-latex/benchmark.mjs b/services/web/scripts/lezer-latex/benchmark.mjs new file mode 100644 index 0000000000..26dad5c474 --- /dev/null +++ b/services/web/scripts/lezer-latex/benchmark.mjs @@ -0,0 +1,66 @@ +import { parser } from '../../frontend/js/features/source-editor/lezer-latex/latex.mjs' + +import * as fs from 'fs' +import * as path from 'path' +import { fileURLToPath } from 'url' +import minimist from 'minimist' + +const argv = minimist(process.argv.slice(2)) +const NUMBER_OF_OPS = argv.ops || 100 +const CSV_OUTPUT = argv.csv || false + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +const examplesDir = path.join( + __dirname, + '../../test/unit/src/lezer-latex/examples' +) + +const strictParser = parser.configure({ strict: true }) // throw exception for invalid documents + +if (!fs.existsSync(examplesDir)) { + console.error('No examples directory') + process.exit() +} + +function dumpParserStats(parser) { + console.log('Parser size:') + console.dir({ + states: parser.states.length, + data: parser.data.length, + goto: parser.goto.length, + }) +} + +dumpParserStats(strictParser) + +const folder = examplesDir +for (const file of fs.readdirSync(folder).sort()) { + if (!/\.tex$/.test(file)) continue + const name = /^[^.]*/.exec(file)[0] + const content = fs.readFileSync(path.join(folder, file), 'utf8') + + benchmark(name, content) +} + +function benchmark(name, content) { + let timeSum = 0 + try { + for (let i = 0; i < NUMBER_OF_OPS; ++i) { + const startTime = performance.now() + strictParser.parse(content) + const endTime = performance.now() + timeSum += endTime - startTime + } + const avgTime = timeSum / NUMBER_OF_OPS + if (CSV_OUTPUT) { + console.log(`${name},${avgTime.toFixed(2)},${content.length}`) + } else { + console.log( + `${name.padEnd(20)} time to run (ms):\t ${avgTime.toFixed(2)}` + ) + } + } catch (error) { + console.error(`${name.padEnd(20)} ${error}`) + } +} diff --git a/services/web/scripts/lezer-latex/generate.js b/services/web/scripts/lezer-latex/generate.js new file mode 100644 index 0000000000..d7fd6d8412 --- /dev/null +++ b/services/web/scripts/lezer-latex/generate.js @@ -0,0 +1,53 @@ +const { buildParserFile } = require('@lezer/generator') +const { writeFileSync, readFileSync } = require('fs') +const path = require('path') + +const options = { + grammarPath: path.resolve( + __dirname, + '../../frontend/js/features/source-editor/lezer-latex/latex.grammar' + ), + parserOutputPath: path.resolve( + __dirname, + '../../frontend/js/features/source-editor/lezer-latex/latex.mjs' + ), + termsOutputPath: path.resolve( + __dirname, + '../../frontend/js/features/source-editor/lezer-latex/latex.terms.mjs' + ), +} + +function compile() { + const { grammarPath, termsOutputPath, parserOutputPath } = options + const moduleStyle = 'es' + console.info(`Compiling ${grammarPath}`) + + const grammarText = readFileSync(grammarPath, 'utf8') + console.info(`Loaded grammar from ${grammarPath}`) + + const { parser, terms } = buildParserFile(grammarText, { + fileName: grammarPath, + moduleStyle, + }) + console.info(`Built parser`) + + writeFileSync(parserOutputPath, parser) + console.info(`Wrote parser to ${parserOutputPath}`) + + writeFileSync(termsOutputPath, terms) + console.info(`Wrote terms to ${termsOutputPath}`) + + console.info('Done!') +} + +module.exports = { compile, options } + +if (require.main === module) { + try { + compile() + process.exit(0) + } catch (err) { + console.error(err) + process.exit(1) + } +} diff --git a/services/web/scripts/lezer-latex/run.mjs b/services/web/scripts/lezer-latex/run.mjs new file mode 100644 index 0000000000..a16731a8bc --- /dev/null +++ b/services/web/scripts/lezer-latex/run.mjs @@ -0,0 +1,76 @@ +import { readFileSync } from 'fs' +import { logTree } from '../../frontend/js/features/source-editor/lezer-latex/print-tree.mjs' +import { parser } from '../../frontend/js/features/source-editor/lezer-latex/latex.mjs' + +// Runs the lezer-latex parser on a supplied file, and prints the resulting +// parse tree to stdout +// +// show parse tree: lezer-latex-run.js test/frontend/shared/lezer-latex/examples/amsmath.tex +// show error summary: lezer-latex-run.js coverage test/frontend/shared/lezer-latex/examples/amsmath.tex + +let files = process.argv.slice(2) +if (!files.length) { + files = ['test/unit/src/LezerLatex/examples/demo.tex'] +} + +let coverage = false +if (files[0] === 'coverage') { + // count errors + coverage = true + files.shift() +} + +function reportErrorCounts(output) { + if (coverage) process.stdout.write(output) +} + +function parseFile(filename) { + const text = readFileSync(filename).toString() + const t0 = process.hrtime() + const tree = parser.parse(text) + const dt = process.hrtime(t0) + const timeTaken = dt[0] + dt[1] * 1e-9 + let errorCount = 0 + let nodeCount = 0 + tree.iterate({ + enter: syntaxNodeRef => { + nodeCount++ + if (syntaxNodeRef.type.isError) { + errorCount++ + } + }, + }) + if (!coverage) logTree(tree, text) + return { nodeCount, errorCount, timeTaken, bytes: text.length } +} + +let totalErrors = 0 +let totalTime = 0 +let totalBytes = 0 +for (const file of files) { + const { nodeCount, errorCount, timeTaken, bytes } = parseFile(file) + const errorRate = Math.round((100 * errorCount) / nodeCount) + totalErrors += errorCount + totalTime += timeTaken + totalBytes += bytes + reportErrorCounts( + `${errorCount} errors`.padStart(12) + + `${nodeCount} nodes`.padStart(12) + + `(${errorRate}%)`.padStart(6) + + `${(1000 * timeTaken).toFixed(1)} ms`.padStart(8) + + `${(bytes / 1024).toFixed(1)} KB`.padStart(8) + + ` ${file}\n` + ) +} +const timeInMilliseconds = 1000 * totalTime +const hundredKBs = totalBytes / (100 * 1024) + +reportErrorCounts( + `\ntotal errors ${totalErrors}, performance ${( + timeInMilliseconds / hundredKBs + ).toFixed(1)} ms/100KB \n` +) + +if (totalErrors > 0) { + process.exit(1) // return non-zero exit status for tests +} diff --git a/services/web/scripts/lezer-latex/test-incremental-parser.mjs b/services/web/scripts/lezer-latex/test-incremental-parser.mjs new file mode 100644 index 0000000000..2fcdde564a --- /dev/null +++ b/services/web/scripts/lezer-latex/test-incremental-parser.mjs @@ -0,0 +1,157 @@ +import { parser } from '../../frontend/js/features/source-editor/lezer-latex/latex.mjs' + +import * as fs from 'fs' +import * as path from 'path' +import { fileURLToPath } from 'url' +import { TreeFragment } from '@lezer/common' +import minimist from 'minimist' + +const argv = minimist(process.argv.slice(2)) +const NUMBER_OF_OPS = argv.ops || 1000 +const CSV_OUTPUT = argv.csv || false + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +const examplesDir = path.join( + __dirname, + '../../test/unit/src/lezer-latex/examples' +) + +const folder = examplesDir +for (const file of fs.readdirSync(folder).sort()) { + if (!/\.tex$/.test(file)) continue + const name = /^[^.]*/.exec(file)[0] + const content = fs.readFileSync(path.join(folder, file), 'utf8') + runPerformanceTests(name, content) +} + +function runPerformanceTests(name, content) { + const insertEnd = writeTextAt( + content, + content.length, + content.substring(0, NUMBER_OF_OPS) + ) + const insertBeginning = writeTextAt( + content, + 0, + content.substring(0, NUMBER_OF_OPS) + ) + const insertMiddle = writeTextAt( + content, + Math.floor(content.length / 2), + content.substring(0, NUMBER_OF_OPS) + ) + const randomDelete = randomDeletions(content, NUMBER_OF_OPS) + const middleDelete = deletionsFromMiddle(content, NUMBER_OF_OPS) + const randomInsert = randomInsertions(content, NUMBER_OF_OPS) + + if (CSV_OUTPUT) { + console.log( + [ + name, + insertBeginning.average, + insertMiddle.average, + insertEnd.average, + randomInsert.average, + randomDelete.average, + middleDelete.average, + content.length, + ].join(',') + ) + } else { + console.log({ + name, + insertAtEnd: insertEnd.average, + insertAtBeginning: insertBeginning.average, + insertAtMiddle: insertMiddle.average, + randomDelete: randomDelete.average, + middleDelete: middleDelete.average, + randomInsert: randomInsert.average, + docLength: content.length, + }) + } +} + +function timedChanges(document, changes, changeFn) { + let totalParseTime = 0 + + // Do a fresh parse to get TreeFragments + const initialTree = parser.parse(document) + let fragments = TreeFragment.addTree(initialTree) + let currentDoc = document + + for (let i = 0; i < changes; ++i) { + const change = changeFn(currentDoc, i) + currentDoc = change.text + // Do a timed parse + const start = performance.now() + fragments = TreeFragment.applyChanges(fragments, [change.range]) + const tree = parser.parse(currentDoc, fragments) + fragments = TreeFragment.addTree(tree, fragments) + const end = performance.now() + totalParseTime += end - start + } + return { + total: totalParseTime, + average: totalParseTime / changes, + ops: changes, + fragments: fragments.length, + } +} + +// Write and parse after every character insertion +function writeTextAt(document, position, text) { + return timedChanges(document, text.length, (currentDoc, index) => + insertAt(currentDoc, position + index, text[index]) + ) +} + +function randomInsertions(document, num) { + return timedChanges(document, num, currentDoc => + insertAt(currentDoc, Math.floor(Math.random() * currentDoc.length), 'a') + ) +} + +function randomDeletions(document, num) { + return timedChanges(document, num, currentDoc => + deleteAt(currentDoc, Math.floor(Math.random() * currentDoc.length), 1) + ) +} + +function deletionsFromMiddle(document, num) { + const deletionPoint = Math.floor(document.length / 2) + const deletions = Math.min(num, deletionPoint - 1) + return timedChanges(document, deletions, (currentDoc, index) => + deleteAt(currentDoc, deletionPoint - index, 1) + ) +} + +function insertAt(document, position, text) { + const start = document.substring(0, position) + const end = document.substring(position) + + return { + text: start + text + end, + range: { + fromA: position, + toA: position, + fromB: position, + toB: position + text.length, + }, + } +} + +function deleteAt(document, position, length = 1) { + const start = document.substring(0, position) + const end = document.substring(position + length) + + return { + text: start + end, + range: { + fromA: position, + toA: position + length, + fromB: position, + toB: position, + }, + } +} diff --git a/services/web/test/frontend/features/source-editor/commands/ranges.test.ts b/services/web/test/frontend/features/source-editor/commands/ranges.test.ts new file mode 100644 index 0000000000..88029a1b83 --- /dev/null +++ b/services/web/test/frontend/features/source-editor/commands/ranges.test.ts @@ -0,0 +1,165 @@ +import { expect, use } from 'chai' +import { toggleRanges } from '../../../../../frontend/js/features/source-editor/commands/ranges' +import { CodemirrorTestSession, viewHelpers } from '../helpers/codemirror' + +use(viewHelpers) + +const BOLD_COMMAND = toggleRanges('\\textbf') + +describe('toggleRanges', function () { + describe('when text outside of a command is selected', function () { + it('wraps the selection in a command', function () { + const cm = new CodemirrorTestSession(['this range']) + cm.applyCommand(BOLD_COMMAND) + expect(cm).line(1).to.equal('this \\textbf{} range') + }) + + describe('when it is an empty selection', function () { + it('inserts a wrapping command and keep cursor inside the argument', function () { + const cm = new CodemirrorTestSession(['this is | my range']) + cm.applyCommand(BOLD_COMMAND) + expect(cm).line(1).to.equal('this is \\textbf{|} my range') + }) + }) + + describe('when it is an empty selection before a command', function () { + it('inserts a wrapping command and keep cursor inside the argument', function () { + const cm = new CodemirrorTestSession(['this is |\\textbf{my range}']) + cm.applyCommand(BOLD_COMMAND) + expect(cm).line(1).to.equal('this is \\textbf{|}\\textbf{my range}') + }) + }) + }) + + describe('when text inside a command is selected', function () { + describe('if the whole command is selected', function () { + it('removes the wrapping command', function () { + const cm = new CodemirrorTestSession(['this \\textbf{} range']) + cm.applyCommand(BOLD_COMMAND) + expect(cm).line(1).to.equal('this range') + }) + }) + + describe('if the command is empty', function () { + it('removes the command', function () { + const cm = new CodemirrorTestSession(['\\textbf{|}']) + cm.applyCommand(BOLD_COMMAND) + expect(cm).line(1).to.equal('|') + }) + }) + + describe('if the selection is at the beginning of a wrapping command', function () { + it('shifts the start of the command', function () { + const cm = new CodemirrorTestSession(['\\textbf{ my} range']) + cm.applyCommand(BOLD_COMMAND) + expect(cm).line(1).to.equal('this is\\textbf{ my} range') + }) + }) + + describe('if the selection is at the end of a wrapping command', function () { + it('shifts the end of the command', function () { + const cm = new CodemirrorTestSession(['\\textbf{this } range']) + cm.applyCommand(BOLD_COMMAND) + expect(cm).line(1).to.equal('\\textbf{this } range') + }) + }) + + describe('if the selection is in the middle of a wrapping command', function () { + it('splits command in two with non-empty selection', function () { + const cm = new CodemirrorTestSession(['\\textbf{this range}']) + cm.applyCommand(BOLD_COMMAND) + expect(cm).line(1).to.equal('\\textbf{this }\\textbf{ range}') + }) + + it('splits command in two with empty selection', function () { + const cm = new CodemirrorTestSession(['\\textbf{this is | my range}']) + cm.applyCommand(BOLD_COMMAND) + expect(cm).line(1).to.equal('\\textbf{this is }|\\textbf{ my range}') + }) + }) + }) + + describe('when selection spans between two wrapping commands', function () { + it('joins the two commands into one', function () { + const cm = new CodemirrorTestSession([ + '\\textbf{this ge}', + ]) + cm.applyCommand(BOLD_COMMAND) + expect(cm).line(1).to.equal('\\textbf{this ge}') + }) + }) + + describe('when selection spans across a wrapping command', function () { + it('extends to the left', function () { + const cm = new CodemirrorTestSession([' range}']) + cm.applyCommand(BOLD_COMMAND) + expect(cm).line(1).to.equal('\\textbf{ range}') + }) + + it('extends to the right', function () { + const cm = new CodemirrorTestSession(['\\textbf{this is ']) + cm.applyCommand(BOLD_COMMAND) + expect(cm).line(1).to.equal('\\textbf{this is }') + }) + }) + + describe('when selection includes more than content', function () { + describe('when selection contains command', function () { + it('still unbolds', function () { + const cm = new CodemirrorTestSession(['<\\textbf{this is my range>}']) + cm.applyCommand(BOLD_COMMAND) + expect(cm).line(1).to.equal('') + }) + }) + + describe('when selection contains opening bracket', function () { + it('still unbolds', function () { + const cm = new CodemirrorTestSession(['\\textbf<{this is my range>}']) + cm.applyCommand(BOLD_COMMAND) + expect(cm).line(1).to.equal('') + }) + }) + + describe('when selection contains closing bracket', function () { + it('still unbolds', function () { + const cm = new CodemirrorTestSession(['\\textbf{']) + cm.applyCommand(BOLD_COMMAND) + expect(cm).line(1).to.equal('') + }) + }) + + describe('when selection contains both brackets', function () { + it('still unbolds', function () { + const cm = new CodemirrorTestSession(['\\textbf<{this is my range}>']) + cm.applyCommand(BOLD_COMMAND) + expect(cm).line(1).to.equal('') + }) + }) + + describe('when selection contains entire command', function () { + it('still unbolds', function () { + const cm = new CodemirrorTestSession(['<\\textbf{this is my range}>']) + cm.applyCommand(BOLD_COMMAND) + expect(cm).line(1).to.equal('') + }) + }) + + describe('when toggling outer command', function () { + it('it functions on the outer command', function () { + const cm = new CodemirrorTestSession([ + '\\textbf{\\textit{}}', + ]) + cm.applyCommand(BOLD_COMMAND) + expect(cm).line(1).to.equal('<\\textit{this is my range}>') + }) + + it('prevents breaking commands', function () { + const cm = new CodemirrorTestSession([ + '\\textbf{\\textit{this ', + ]) + cm.applyCommand(BOLD_COMMAND) + expect(cm).line(1).to.equal('\\textbf{\\textit{this ') + }) + }) + }) +}) diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-autocomplete.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-autocomplete.spec.tsx new file mode 100644 index 0000000000..d2c4f305c9 --- /dev/null +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-autocomplete.spec.tsx @@ -0,0 +1,1048 @@ +import { FC } from 'react' +import { Folder } from '../../../../../types/folder' +import { docId, mockDocContent } from '../helpers/mock-doc' +import { Metadata } from '../../../../../types/metadata' +import { mockScope } from '../helpers/mock-scope' +import { EditorProviders } from '../../../helpers/editor-providers' +import CodeMirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor' +import { activeEditorLine } from '../helpers/active-editor-line' +import { User } from '../../../../../types/user' + +const Container: FC = ({ children }) => ( +
{children}
+) + +describe('autocomplete', { scrollBehavior: false }, function () { + beforeEach(function () { + window.metaAttributesCache.set('ol-preventCompileOnLoad', true) + cy.interceptEvents() + cy.interceptSpelling() + }) + + afterEach(function () { + window.metaAttributesCache = new Map() + }) + + it('opens autocomplete on matched text', function () { + const rootFolder: Folder[] = [ + { + _id: 'root-folder-id', + name: 'rootFolder', + docs: [ + { + _id: docId, + name: 'main.tex', + }, + ], + folders: [ + { + _id: 'test-folder-id', + name: 'test-folder', + docs: [ + { + _id: 'test-doc-in-folder', + name: 'example.tex', + }, + ], + fileRefs: [ + { + _id: 'test-file-in-folder', + name: 'example.png', + }, + ], + folders: [], + }, + ], + fileRefs: [ + { + _id: 'test-image-file', + name: 'frog.jpg', + }, + ], + }, + ] + + const metadataManager: { metadata: { state: Metadata } } = { + metadata: { + state: { + documents: { + [docId]: { + labels: ['fig:frog'], + // TODO: add tests for packages and referencesKeys autocompletions + packages: { + foo: [ + { + caption: 'a caption', + meta: 'foo-cmd', + score: 0.1, + snippet: 'a caption{$1}', + }, + ], + }, + }, + }, + references: [], + fileTreeData: rootFolder[0], + }, + }, + } + + const scope = mockScope() + scope.$root._references.keys = ['foo'] + scope.project.rootFolder = rootFolder + + cy.mount( + + + + + + ) + + cy.get('.cm-editor').as('editor') + + cy.contains('\\section{Results}') + + // no autocomplete + cy.findAllByRole('listbox').should('have.length', 0) + + // put the cursor on a blank line to type in + cy.get('.cm-line').eq(16).click().as('line') + + // single backslash + cy.get('@line').type('\\') + + // autocomplete + cy.findAllByRole('listbox').should('have.length', 1) + + // another backslash + cy.get('@line').type('\\') + + // no autocomplete open + cy.findAllByRole('listbox').should('have.length', 0) + + // a space then another backslash + cy.get('@line').type(' \\') + + // autocomplete open + cy.findAllByRole('listbox').should('have.length', 1) + + // start a command + cy.get('@line').type('includegr') + + // select option from autocomplete + // disabled as selector not working (Cypress bug?) + // cy.findByRole('listbox') + // .findByRole('option', { + // name: '\\includegraphics[]{}', + // selected: true, + // }) + // .click() + cy.contains('\\includegraphics[]{}').click() + + // start a command in the optional argument + cy.get('@line').type('width=0.3\\text') + + // select option from autocomplete + // disabled as selector not working (Cypress bug?) + // cy.findByRole('listbox') + // .findByRole('option', { + // name: '\\textwidth', + // selected: false, + // }) + // .click() + cy.contains('\\textwidth').click() + + // move to required argument and start a label + cy.get('@line') + // .type('{tab}') // Tab not supported in Cypress + .type('{rightArrow}{rightArrow}') + .type('fr') + + // select option from autocomplete + // disabled as selector not working (Cypress bug?) + // cy.findByRole('listbox') + // .findByRole('option', { + // name: 'frog.jpg', + // selected: true, + // }) + // .click() + cy.contains('frog.jpg').click() + + cy.contains('\\includegraphics[width=0.3\\textwidth]{frog.jpg}') + + // start a new line and select an "includegraphics" command completion + cy.get('@line').type('{rightArrow}{Enter}') + activeEditorLine().type('\\includegr') + cy.contains('\\includegraphics[]{}').click() + + // select a completion for a file in a folder, without typing the folder name + activeEditorLine() + .type('{rightArrow}{rightArrow}') + .type('examp') + .type('{Backspace}') + .type('ple') + cy.contains('test-folder/example.png').click() + cy.contains('\\includegraphics[]{test-folder/example.png}') + + activeEditorLine() + .type(`${'{leftArrow}'.repeat('test-folder/example.png'.length)}fr`) + .type('{ctrl+ }') + + cy.findAllByRole('listbox').should('have.length', 1) + cy.findByRole('listbox').contains('frog.jpg').click() + activeEditorLine().should('have.text', '\\includegraphics[]{frog.jpg}') + }) + + it('opens autocomplete on begin environment', function () { + const rootFolder: Folder[] = [ + { + _id: 'root-folder-id', + name: 'rootFolder', + docs: [ + { + _id: docId, + name: 'main.tex', + }, + ], + folders: [ + { + _id: 'test-folder-id', + name: 'test-folder', + docs: [ + { + _id: 'test-doc-in-folder', + name: 'example.tex', + }, + ], + fileRefs: [ + { + _id: 'test-file-in-folder', + name: 'example.png', + }, + ], + folders: [], + }, + ], + fileRefs: [ + { + _id: 'test-image-file', + name: 'frog.jpg', + }, + ], + }, + ] + + const metadataManager: { metadata: { state: Metadata } } = { + metadata: { + state: { + documents: { + [docId]: { + labels: ['fig:frog'], + // TODO: add tests for packages and referencesKeys autocompletions + packages: { + foo: [ + { + caption: 'a caption', + meta: 'foo-cmd', + score: 0.1, + snippet: 'a caption{$1}', + }, + ], + }, + }, + }, + references: [], + fileTreeData: rootFolder[0], + }, + }, + } + + const scope = mockScope() + scope.$root._references.keys = ['foo'] + + cy.mount( + + + + + + ) + + cy.get('.cm-editor').as('editor') + + cy.contains('\\section{Results}') + + // put the cursor on a blank line to type in + cy.get('.cm-line').eq(16).click().as('line') + + // ---- Basic autocomplete of environments + cy.get('@line').type('\\begin{itemi') + cy.findAllByRole('option').contains('\\begin{itemize}').click() + cy.get('.cm-line').eq(16).contains('\\begin{itemize}') + cy.get('.cm-line').eq(17).contains('\\item') + cy.get('.cm-line').eq(18).contains('\\end{itemize}') + + // ---- Autocomplete on a malformed `\begin{` + // first, ensure that the "abcdef" environment is present in the doc + cy.get('.cm-line') + .eq(20) + .type('\\begin{{}abcdef}{Enter}{Enter}\\end{{}abcdef}{Enter}{Enter}') + + cy.get('.cm-line').eq(24).as('line') + + cy.get('@line').type('\\begin{abcdef') + cy.findAllByRole('option').contains('\\begin{abcdef}').click() + + cy.get('.cm-line').eq(24).contains('\\begin{abcdef}') + cy.get('.cm-line').eq(26).contains('\\end{abcdef}') + + // ---- Autocomplete starting from end of `\begin` + cy.get('.cm-line').eq(22).type('{Enter}{Enter}{Enter}') + + cy.get('.cm-line').eq(24).as('line') + cy.get('@line').type('\\begin {leftArrow}{leftArrow}') + cy.get('@line').type('{ctrl} ') + + cy.findAllByRole('option').contains('\\begin{align}').click() + + cy.get('.cm-line').eq(24).contains('\\begin{align}') + cy.get('.cm-line').eq(26).contains('\\end{align}') + + // ---- Start typing a begin command + cy.get('.cm-line').eq(28).click().as('line') + cy.get('@line').type('\\begin{{}ab') + cy.findAllByRole('option').as('options') + cy.get('@options').should('have.length', 4) + + // ---- The environment being typed should not appear in the list + cy.get('@options').contains('\\begin{ab}').should('not.exist') + + // ---- A new environment used elsewhere in the doc should appear next + cy.get('@options') + .eq(0) + .invoke('text') + .should('match', /^\\begin\{abcdef}/) + + // ---- The built-in environments should appear at the top of the list + cy.get('@options') + .eq(1) + .invoke('text') + .should('match', /^\\begin\{abstract}/) + }) + + it('opens autocomplete using metadata for usepackage parameter', function () { + const rootFolder: Folder[] = [ + { + _id: 'root-folder-id', + name: 'rootFolder', + docs: [ + { + _id: docId, + name: 'main.tex', + }, + ], + folders: [], + fileRefs: [], + }, + ] + + const metadataManager: { metadata: { state: Metadata } } = { + metadata: { + state: { + documents: { + [docId]: { + labels: [], + packages: { + foo: [ + { + caption: 'a caption', + meta: 'foo-cmd', + score: 0.1, + snippet: 'a caption{$1}', + }, + ], + }, + }, + }, + references: [], + fileTreeData: rootFolder[0], + }, + }, + } + + const scope = mockScope() + + cy.mount( + + + + + + ) + + cy.get('.cm-editor').as('editor') + + // no autocomplete + cy.findAllByRole('listbox').should('have.length', 0) + + // put the cursor on a blank line to type in + cy.get('.cm-line').eq(16).click().as('line') + + // a usepackage command + cy.get('@line').type('\\usepackage') + + // autocomplete open + cy.findAllByRole('listbox').should('have.length', 1) + + cy.findAllByRole('option').eq(0).should('contain.text', '\\usepackage{}') + cy.findAllByRole('option').eq(1).should('contain.text', '\\usepackage[]{}') + + // the start of a package name from the metadata + cy.get('@line').type('{fo') + + // autocomplete open + cy.findAllByRole('listbox') + .should('have.length', 1) + .type('{downArrow}{downArrow}{Enter}') + + cy.contains('\\usepackage{foo}') + }) + + it('opens autocomplete using metadata for cite parameter', function () { + const rootFolder: Folder[] = [ + { + _id: 'root-folder-id', + name: 'rootFolder', + docs: [ + { + _id: docId, + name: 'main.tex', + }, + ], + folders: [], + fileRefs: [], + }, + ] + + const metadataManager: { metadata: { state: Metadata } } = { + metadata: { + state: { + documents: { + [docId]: { + labels: [], + packages: {}, + }, + }, + references: [], + fileTreeData: rootFolder[0], + }, + }, + } + + const scope = mockScope() + scope.$root._references.keys = ['ref-1', 'ref-2', 'ref-3'] + + cy.mount( + + + + + + ) + + cy.get('.cm-editor').as('editor') + + // no autocomplete + cy.findAllByRole('listbox').should('have.length', 0) + + // put the cursor on a blank line to type in + cy.get('.cm-line').eq(16).click().as('line') + + // a cite command with no opening brace + cy.get('@line').type('\\cite') + + // select completion + cy.findAllByRole('listbox').contains('\\cite{}').click() + cy.get('@line').contains('\\cite{}') + + // autocomplete open again + cy.findAllByRole('listbox').contains('ref-2').click() + cy.get('@line').contains('\\cite{ref-2}') + + // start typing another reference + cy.get('@line').type(', re') + + // autocomplete open again + cy.findAllByRole('listbox').contains('ref-3').click() + cy.get('@line').contains('\\cite{ref-2, ref-3}') + }) + + it('autocomplete stops after space after command', function () { + const rootFolder: Folder[] = [ + { + _id: 'root-folder-id', + name: 'rootFolder', + docs: [ + { + _id: docId, + name: 'main.tex', + }, + ], + folders: [], + fileRefs: [], + }, + ] + + const metadataManager: { metadata: { state: Metadata } } = { + metadata: { + state: { + documents: {}, + references: [], + fileTreeData: rootFolder[0], + }, + }, + } + + const scope = mockScope() + scope.$root._references.keys = ['foo'] + scope.project.rootFolder = rootFolder + + cy.mount( + + + + + + ) + + cy.get('.cm-editor').as('editor') + + cy.contains('\\section{Results}') + + // no autocomplete + cy.findAllByRole('listbox').should('have.length', 0) + + // put the cursor on a blank line to type in + cy.get('.cm-line').eq(16).click().as('line') + + // single backslash + cy.get('@line').type('\\') + + // autocomplete + cy.findAllByRole('listbox').should('have.length', 1) + + // start a command + cy.get('@line').type('ite') + + // offers completion for item + cy.contains(/\\item.*cmd/).click() + + cy.get('@line').type('{Enter}{Enter}\\item ') + cy.contains('\\begin{itemize').should('not.exist') + }) + + it('autocomplete does not remove closing brackets in commands with multiple braces {}{}', function () { + const scope = mockScope() + + cy.mount( + + + + + + ) + + // put the cursor on a blank line to type in + cy.get('.cm-line').eq(16).click().as('line') + + cy.get('@line').type('\\frac') + + // select completion + cy.findAllByRole('listbox').contains('\\frac{}{}').click() + + cy.get('@line').type('\\textbf') + + // select completion + cy.findAllByRole('listbox').contains('\\textbf{}').click() + + cy.get('@line').should('contain.text', '\\frac{\\textbf{}}{}') + + // go to new line + cy.get('@line').click().type('{enter}') + + cy.get('.cm-line').eq(17).click().as('line') + cy.get('@line').type('\\frac') + + // select completion + cy.findAllByRole('listbox').contains('\\frac{}{}').click() + + cy.get('@line').type('\\partial') + + // select completion + cy.findAllByRole('listbox').contains('\\partial').click() + + cy.get('@line').should('contain.text', '\\frac{\\partial}{}') + }) + + it('autocomplete does not remove paired closing brackets in nested commands', function () { + const scope = mockScope() + + cy.mount( + + + + + + ) + + // put the cursor on a blank line to type in + cy.get('.cm-line').eq(16).click().as('line') + + // type some commands + // note: '{{}' is a single opening brace + cy.get('@line').type('\\sqrt{{}2} some more text \\sqrt{{}\\sqrt{{}}}') + + // put the cursor inside the middle pair of braces + cy.get('@line').type('{leftArrow}{leftArrow}') + + // start a command + cy.get('@line').type('\\sqrt') + + // select completion + cy.findAllByRole('listbox').contains('\\sqrt{}').click() + + // assert that the existing closing brace hasn't been removed + cy.get('@line').should( + 'have.text', + '\\sqrt{2} some more text \\sqrt{\\sqrt{\\sqrt{}}}' + ) + }) + + it('autocomplete does remove unpaired closing brackets in nested commands', function () { + const scope = mockScope() + + cy.mount( + + + + + + ) + + // put the cursor on a blank line to type in + cy.get('.cm-line').eq(16).click().as('line') + + // type some commands + // note: '{{}' is a single opening brace + cy.get('@line').type('\\sqrt{{}2} some more text \\sqrt{{}\\sqrt{{}}}}') + + // put the cursor inside the middle pair of braces + cy.get('@line').type('{leftArrow}{leftArrow}{leftArrow}') + + // start a command + cy.get('@line').type('\\sqrt') + + // select completion + cy.findAllByRole('listbox').contains('\\sqrt{}').click() + + // assert that the existing closing brace hasn't been removed + cy.get('@line').should( + 'have.text', + '\\sqrt{2} some more text \\sqrt{\\sqrt{\\sqrt{}}}' + ) + }) + + it('displays completions for existing commands with multiple parameters', function () { + const scope = mockScope() + + cy.mount( + + + + + + ) + + cy.get('.cm-editor').as('editor') + + // put the cursor on a blank line to type in + cy.get('.cm-line').eq(16).click().as('line') + + // a new command, then the start of the command on a new blank line + cy.get('@line').type('\\foo[bar]{{}baz}{{}zap}') + + // enter, to create a new line + cy.get('@editor').trigger('keydown', { key: 'Enter' }) + + // put the cursor on the new line to type in + cy.get('.cm-line').eq(17).click().as('line') + + // the start of the command + cy.get('@line').type('\\foo') + + // select the new completion + cy.findAllByRole('listbox').contains('\\foo[]{}{}').click() + + // fill in the optional parameter + cy.get('@line').type('bar') + + cy.get('@editor').contains('\\foo[bar]{}{}') + }) + + it('displays completions for existing commands in math mode', function () { + const scope = mockScope() + + cy.mount( + + + + + + ) + + cy.get('.cm-editor').as('editor') + + // put the cursor on a blank line to type in + cy.get('.cm-line').eq(16).click().as('line') + + // a new command, then the start of the command on a new blank line + cy.get('@line').type('$\\somemathcommand$') + + // enter, to create a new line + cy.get('@editor').trigger('keydown', { key: 'Enter' }) + + // put the cursor on the new line to type in + cy.get('.cm-line').eq(17).click().as('line') + + // the start of the command + cy.get('@line').type('hello \\somema') + + // select the new completion + cy.findAllByRole('listbox').contains('\\somemathcommand').click() + + cy.get('@editor').contains('hello \\somemathcommand') + }) + + it('displays completions for nested existing commands', function () { + const scope = mockScope() + + cy.mount( + + + + + + ) + + cy.get('.cm-editor').as('editor') + + // put the cursor on a blank line to type in + cy.get('.cm-line').eq(16).click().as('line') + + // a new command, then the start of the command on a new blank line + cy.get('@line').type('\\newcommand{{}\\foo}[1]{{}#1}') + + // enter, to create a new line + cy.get('@editor').trigger('keydown', { key: 'Enter' }) + + // put the cursor on the new line to type in + cy.get('.cm-line').eq(17).click().as('line') + + // the start of the command + cy.get('@line').type('\\fo') + + // select the new completion + cy.findAllByRole('listbox') + .contains(/\\foo{}\s*cmd/) + .click() + + cy.get('@line').contains('\\foo') + }) + + it('displays unique completions for commands', function () { + const metadataManager: { metadata: { state: Metadata } } = { + metadata: { + state: { + documents: { + [docId]: { + labels: [], + packages: { + amsmath: [ + { + caption: '\\label{}', + meta: 'amsmath-cmd', + score: 1, + snippet: '\\label{$1}', + }, + ], + }, + }, + }, + references: [], + fileTreeData: { + _id: 'root-folder-id', + name: 'rootFolder', + docs: [ + { + _id: docId, + name: 'main.tex', + }, + ], + folders: [], + fileRefs: [], + }, + }, + }, + } + + const scope = mockScope() + + cy.mount( + + + + + + ) + + cy.get('.cm-editor').as('editor') + + // put the cursor on a blank line to type in + cy.get('.cm-line').eq(16).click().as('line') + + // start typing a command + cy.get('@line').type('\\label') + + cy.findAllByRole('option').contains('\\label{}').should('have.length', 1) + }) + + it('displays symbol completions in autocomplete when the feature is enabled', function () { + const scope = mockScope() + + const createUser = (values: Partial): User => ({ + id: '123abd', + email: 'testuser@example.com', + ...values, + }) + + const testSymbolAutocomplete = (user: User) => { + cy.mount( + + + + + + ) + // put the cursor on a blank line to type in + cy.get('.cm-line').eq(16).click().as('line') + + // type the name of a symbol + cy.get('@line').type(' \\alpha') + + cy.findAllByRole('listbox').should('have.length', 1) + } + + // when the feature is not enabled, the symbol completion must not appear + testSymbolAutocomplete(createUser({ features: { symbolPalette: false } })) + + cy.findAllByRole('option', { + name: /^\\alpha\s+Greek$/, + }).should('have.length', 0) + + // when the feature is enabled, the symbol completion must appear + testSymbolAutocomplete(createUser({ features: { symbolPalette: true } })) + + // the symbol completion should exist + cy.findAllByRole('option', { + name: /^\\alpha\s+Greek$/, + }).should('have.length', 1) + + cy.get('body').should('contain', 'Lowercase Greek letter alpha') + }) + + it('displays environment completion when typing up to closing brace', function () { + const scope = mockScope() + + cy.mount( + + + + + + ) + + // Put the cursor on a blank line to type in + cy.get('.cm-line').eq(16).click().as('line') + + // Type \begin{itemize}. + // Note: '{{}' is a single opening brace + cy.get('@line').type('\\begin{{}itemize}', { + delay: 100, + }) + + cy.findAllByRole('listbox').should('have.length', 1) + }) + + it('displays environment completion when typing inside \\begin{}', function () { + const scope = mockScope(mockDocContent('\\begin{}')) + + cy.mount( + + + + + + ) + + // Put the cursor on a blank line above target line + cy.get('.cm-line').eq(20).click().as('line') + + // Move to the position between the braces then type 'itemize' + cy.get('@line').type(`{downArrow}${'{rightArrow}'.repeat(7)}itemize`, { + delay: 100, + }) + + cy.findAllByRole('listbox').should('have.length', 1) + }) + + it('displays environment completion when typing after \\begin{', function () { + const scope = mockScope(mockDocContent('\\begin{')) + + cy.mount( + + + + + + ) + + // Put the cursor on a blank line above target line + cy.get('.cm-line').eq(20).click().as('line') + + // Move to the position after the opening brace then type 'itemize}' + cy.get('@line').type(`{downArrow}${'{rightArrow}'.repeat(7)}itemize}`, { + delay: 100, + }) + + cy.findAllByRole('listbox').should('have.length', 1) + }) + + it('removes .tex but not .txt file extension from \\include and \\input', function () { + const rootFolder: Folder[] = [ + { + _id: 'root-folder-id', + name: 'rootFolder', + docs: [ + { + _id: docId, + name: 'main.tex', + }, + { + _id: 'test-include-tex-doc', + name: 'example.tex', + }, + { + _id: 'test-include-txt', + name: 'sometext.txt', + }, + ], + folders: [], + fileRefs: [], + }, + ] + + const metadataManager: { metadata: { state: Metadata } } = { + metadata: { + state: { + documents: {}, + references: [], + fileTreeData: rootFolder[0], + }, + }, + } + + const scope = mockScope() + scope.$root._references.keys = ['foo'] + scope.project.rootFolder = rootFolder + + cy.mount( + + + + + + ) + + // Put the cursor on a blank line and type + cy.get('.cm-line').eq(16).click().as('line') + cy.get('@line').type('\\include{e', { delay: 100 }) + cy.findAllByRole('option').contains('example.tex').click() + activeEditorLine().contains('\\include{example') + activeEditorLine().type('{}}{Enter}') + + activeEditorLine().type('\\include{s', { delay: 100 }) + cy.findAllByRole('option').contains('sometext.txt').click() + activeEditorLine().contains('\\include{sometext.txt') + activeEditorLine().type('{}}{Enter}') + + activeEditorLine().type('\\inclu', { delay: 100 }) + cy.contains('\\include{}').click() + cy.contains('example.tex').click() + activeEditorLine().contains('\\include{example}') + activeEditorLine().type('{rightArrow}{Enter}') + + activeEditorLine().type('\\inclu', { delay: 100 }) + cy.findAllByRole('option').contains('\\include{}').click() + cy.findAllByRole('option').contains('sometext.txt').click() + activeEditorLine().contains('\\include{sometext.txt}') + activeEditorLine().type('{rightArrow}{Enter}') + + activeEditorLine().click().as('line') + activeEditorLine().type('\\input{e', { delay: 100 }) + cy.findAllByRole('option').contains('example.tex').click() + activeEditorLine().contains('\\input{example') + activeEditorLine().type('{}}{Enter}') + + activeEditorLine().click().as('line') + activeEditorLine().type('\\input{s', { delay: 100 }) + cy.findAllByRole('option').contains('sometext.txt').click() + activeEditorLine().contains('\\input{sometext.txt') + activeEditorLine().type('{}}{Enter}') + + activeEditorLine().type('\\inpu', { delay: 100 }) + cy.findAllByRole('option').contains('\\input{}').click() + cy.findAllByRole('option').contains('example.tex').click() + activeEditorLine().contains('\\input{example}') + activeEditorLine().type('{rightArrow}{Enter}') + + activeEditorLine().type('\\inpu', { delay: 100 }) + cy.findAllByRole('option').contains('\\input{}').click() + cy.findAllByRole('option').contains('sometext.txt').click() + activeEditorLine().contains('\\input{sometext.txt}') + }) +}) diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-cursor.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-cursor.spec.tsx new file mode 100644 index 0000000000..a144e9ff81 --- /dev/null +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-cursor.spec.tsx @@ -0,0 +1,117 @@ +import { FC } from 'react' +import { EditorProviders } from '../../../helpers/editor-providers' +import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor' +import { mockScope } from '../helpers/mock-scope' + +const Container: FC = ({ children }) => ( +
{children}
+) + +describe('Cursor and active line highlight', function () { + const content = `line 1 + +${'long line '.repeat(200)}` + + function assertIsFullLineHeight($item: JQuery) { + cy.get('@line').then($line => { + expect(Math.round($item.outerHeight()!)).to.equal( + Math.round($line.outerHeight()!) + ) + }) + } + + beforeEach(function () { + window.metaAttributesCache.set('ol-preventCompileOnLoad', true) + cy.interceptEvents() + cy.interceptSpelling() + + const scope = mockScope(content) + + cy.mount( + + + + + + ) + }) + + it('has cursor', function () { + // put the cursor on a blank line to type in + cy.get('.cm-line').eq(1).click().as('line') + + cy.get('.cm-cursor').as('cursor').should('exist') + + cy.get('.cm-cursor').then(assertIsFullLineHeight) + }) + + it('has cursor on empty line whose height is the same as the line', function () { + // Put the cursor on a blank line + cy.get('.cm-line').eq(1).click().as('line') + + cy.get('.cm-cursor').as('cursor').should('exist') + + cy.get('@cursor').then(assertIsFullLineHeight) + }) + + it('has cursor on non-empty line whose height is the same as the line', function () { + // Put the cursor on a blank line + cy.get('.cm-line').eq(1).click().as('line') + cy.get('@line').type('wombat') + + cy.get('.cm-cursor').as('cursor').should('exist') + + cy.get('@cursor').then(assertIsFullLineHeight) + }) + + it('puts cursor in the correct place inside brackets', function () { + // Put the cursor on a blank line + cy.get('.cm-line').eq(1).click().as('line') + cy.get('@line').type('[{Enter}') + + // Get the line inside the bracket + cy.get('.cm-line').eq(2).as('line') + + // Check that the middle of the cursor is within the line boundaries + cy.get('.cm-cursor').then($cursor => { + cy.get('@line').then($line => { + const cursorCentreY = $cursor.offset()!.top + $cursor.outerHeight()! / 2 + const lineTop = $line.offset()!.top + const lineBottom = lineTop + $line.outerHeight()! + expect(cursorCentreY).to.be.within(lineTop, lineBottom) + }) + }) + }) + + it('has active line highlight line decoration of same height as line when there is no selection and line does not wrap', function () { + // Put the cursor on a blank line + cy.get('.cm-line').eq(1).click().as('line') + + cy.get('.cm-content .cm-activeLine').as('highlight').should('exist') + cy.get('.ol-cm-activeLineLayer .cm-activeLine').should('not.exist') + + cy.get('@highlight').then(assertIsFullLineHeight) + }) + + it('has active line highlight layer decoration of same height as non-wrapped line when there is no selection and line wraps', function () { + // Put the cursor on a blank line + cy.get('.cm-line').eq(2).click().as('line') + + cy.get('.ol-cm-activeLineLayer .cm-activeLine') + .as('highlight') + .should('exist') + cy.get('.cm-content .cm-activeLine').should('not.exist') + + cy.get('.cm-line').eq(1).as('line') + + cy.get('@highlight').then(assertIsFullLineHeight) + }) + + it('has no active line highlight when there is a selection', function () { + // Put the cursor on a blank line + cy.get('.cm-line').eq(1).click().as('line') + cy.get('@line').type('{ctrl}A') + + cy.get('.cm-activeLine').should('not.exist') + }) +}) diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-fundamentals.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-fundamentals.spec.tsx new file mode 100644 index 0000000000..7c683cc678 --- /dev/null +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-fundamentals.spec.tsx @@ -0,0 +1,78 @@ +import { FC } from 'react' +import { EditorProviders } from '../../../helpers/editor-providers' +import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor' +import { mockScope } from '../helpers/mock-scope' + +const Container: FC = ({ children }) => ( +
{children}
+) + +describe(' fundamentals', function () { + const content = ` +test +` + beforeEach(function () { + window.metaAttributesCache.set('ol-preventCompileOnLoad', true) + cy.interceptEvents() + cy.interceptSpelling() + + const scope = mockScope(content) + + cy.mount( + + + + + + ) + + cy.get('.cm-line').eq(0).click().as('first-line') + cy.get('.cm-line').eq(1).click().as('line') + cy.get('.cm-line').eq(2).click().as('empty-line') + }) + + it('deletes with backspace', function () { + cy.get('@line').type('{backspace}').should('have.text', 'tes') + }) + + it('moves with arrow keys', function () { + cy.get('@line') + .type('{leftArrow}1') + .should('have.text', 'tes1t') + .type('{rightArrow}2') + .should('have.text', 'tes1t2') + .type('{downArrow}3') + .type('{upArrow}{upArrow}4') + cy.get('@empty-line').should('have.text', '3') + cy.get('@first-line').should('have.text', '4') + }) + + it('deletes with delete', function () { + cy.get('@line').type('{leftArrow}{del}').should('have.text', 'tes') + }) + + it('types characters', function () { + cy.get('@empty-line') + .type('hello codemirror!') + .should('have.text', 'hello codemirror!') + }) + + it('replaces selections', function () { + cy.get('@line') + .type('{shift}{leftArrow}{leftArrow}{leftArrow}') + .type('abby cat') + .should('have.text', 'tabby cat') + }) + + it('inserts LaTeX commands', function () { + cy.get('@empty-line') + .type('\\cmd[opt]{{}arg}') + .should('have.text', '\\cmd[opt]{arg}') + }) + + it('allows line-breaks', function () { + cy.get('.cm-content').find('.cm-line').should('have.length', 3) + cy.get('@empty-line').type('{enter}{enter}') + cy.get('.cm-content').find('.cm-line').should('have.length', 5) + }) +}) diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-shortcuts.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-shortcuts.spec.tsx new file mode 100644 index 0000000000..698016e88f --- /dev/null +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-shortcuts.spec.tsx @@ -0,0 +1,321 @@ +import { mockScope } from '../helpers/mock-scope' +import { EditorProviders } from '../../../helpers/editor-providers' +import CodeMirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor' +import { metaKey } from '../helpers/meta-key' +import { FC } from 'react' +import { activeEditorLine } from '../helpers/active-editor-line' + +const Container: FC = ({ children }) => ( +
{children}
+) + +const CHARACTERS = + 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ\\0123456789' + +describe('keyboard shortcuts', { scrollBehavior: false }, function () { + beforeEach(function () { + window.metaAttributesCache.set('ol-preventCompileOnLoad', true) + cy.interceptEvents() + cy.interceptSpelling() + + const scope = mockScope() + + cy.mount( + + + + + + ) + + cy.get('.cm-line').eq(16).click().as('line') + cy.get('.cm-editor').as('editor') + }) + + afterEach(function () { + window.metaAttributesCache = new Map() + }) + + it('comment line with {meta+shift+/}', function () { + cy.get('@line') + .type('text') + .type(`{${metaKey}+shift+/}`) + .should('have.text', '% text') + + cy.get('@line').type(`{${metaKey}+shift+/}`).should('have.text', 'text') + }) + + it('comment line with {meta+/}', function () { + cy.get('@line') + .type('text') + .type(`{${metaKey}+/}`) + .should('have.text', '% text') + + cy.get('@line').type(`{${metaKey}+/}`).should('have.text', 'text') + }) + + it('comment line with {meta+ß}', function () { + cy.get('@line') + .type('text') + .type(`{${metaKey}+ß}`) + .should('have.text', '% text') + + cy.get('@line').type(`{${metaKey}+ß}`).should('have.text', 'text') + }) + + it('comment line with {ctrl+#}', function () { + cy.get('@line').type('text') + cy.get('@editor').trigger('keydown', { key: '#', ctrlKey: true }) + cy.get('@line').should('have.text', '% text') + + cy.get('@editor').trigger('keydown', { key: '#', ctrlKey: true }) + cy.get('@line').should('have.text', 'text') + }) + + it('undo line with {meta+z}', function () { + cy.get('@line').type('text').type(`{${metaKey}+z}`).should('have.text', '') + }) + + it('redo line with {meta+shift+z}', function () { + cy.get('@line') + .type('text') + .type(`{${metaKey}+z}`) // undo + .type(`{${metaKey}+shift+z}`) // redo + .should('have.text', 'text') + }) + + it('redo line with {meta+y}', function () { + cy.get('@line') + .type('text') + .type(`{${metaKey}+z}`) // undo + .type(`{${metaKey}+y}`) // redo + .should('have.text', 'text') + }) + + it('delete line with {meta+d}', function () { + cy.get('.cm-line').then($lines => { + const linesCount = $lines.length + cy.get('@line').type(`{${metaKey}+d}`) + cy.get('.cm-line').should('have.length', linesCount - 1) + }) + }) + + it('indent line with {tab}', function () { + cy.get('@line') + .trigger('keydown', { key: 'Tab' }) + .should('have.text', ' ') + }) + + it('unindent line with {shift+tab}', function () { + cy.get('@line') + .trigger('keydown', { key: 'Tab' }) // indent + .trigger('keydown', { key: 'Tab', shiftKey: true }) // unindent + .should('have.text', '') + }) + + it('uppercase selection with {ctrl+u}', function () { + cy.get('@line') + .type('a') + .type('{shift+leftArrow}') // select text + .type('{ctrl+u}') + .should('have.text', 'A') + }) + + it('lowercase selection with {ctrl+shift+u}', function () { + if (navigator.platform.startsWith('Linux')) { + // Skip test as {ctrl+shift+u} is bound elsewhere in some Linux systems + // eslint-disable-next-line mocha/no-skipped-tests + this.skip() + } + + cy.get('@line') + .type('A') + .type('{shift+leftArrow}') // select text + .type('{ctrl+shift+u}') // TODO: ctrl+shift+u is a system shortcut so this fails in CI + .should('have.text', 'a') + }) + + it('wrap selection with "\\textbf{}" by using {meta+b}', function () { + cy.get('@line') + .type('a') + .type('{shift+leftArrow}') // select text + .type(`{${metaKey}+b}`) + .should('have.text', '\\textbf{a}') + }) + + it('wrap selection with "\\textit{}" by using {meta+i}', function () { + cy.get('@line') + .type('a') + .type('{shift+leftArrow}') // select text + .type(`{${metaKey}+i}`) + .should('have.text', '\\textit{a}') + }) +}) + +describe('emacs keybindings', { scrollBehavior: false }, function () { + beforeEach(function () { + window.metaAttributesCache.set('ol-preventCompileOnLoad', true) + cy.interceptEvents() + cy.interceptSpelling() + + // Make a short doc that will fit entirely into the dom tree, so that + // index() corresponds to line number - 1 + const shortDoc = ` +\\documentclass{article} +\\begin{document} +contentLine1 +contentLine2 +contentLine3 +\\end{document} +` + + const scope = mockScope(shortDoc) + scope.settings.mode = 'emacs' + + cy.mount( + + + + + + ) + cy.get('.cm-line').eq(1).scrollIntoView().click().as('line') + cy.get('.cm-editor').as('editor') + }) + + afterEach(function () { + window.metaAttributesCache = new Map() + }) + + it('emulates search behaviour', function () { + activeEditorLine().index().should('equal', 1) + + // Search should be closed + cy.findByRole('search').should('have.length', 0) + + // Invoke C-s + cy.get('@line').type('{ctrl}s') + + // Search should now be open + cy.findByRole('search').should('have.length', 1) + cy.findByRole('textbox', { name: 'Find' }).as('search-input') + + // Write a search query + cy.get('@search-input').should('have.focus').type('contentLine') + cy.contains(`1 of 3`) + // Should assert that activeEditorLine.index() === 21, but activeEditorLine + // only works if editor is focused, not the search box. + + // Repeated C-s should go to next match + cy.get('@search-input').type('{ctrl}s') + cy.contains(`2 of 3`) + // Should assert that activeEditorLine.index() === 22, but activeEditorLine + // only works if editor is focused, not the search box. + + // C-g should close the search + cy.get('@search-input').type('{ctrl}g') + cy.findByRole('search').should('have.length', 0) + + // Cursor should be back to where the search originated from + activeEditorLine().index().should('equal', 1) + + // Invoke C-r + cy.get('@line').type('{ctrl}r') + + // Search should now be open at first match + cy.findByRole('search').should('have.length', 1) + cy.contains(`1 of 3`) + + // Repeated C-r should go to previous match + cy.get('@search-input').type('{ctrl}r') + cy.contains(`3 of 3`) + + // Close search panel to clear global variable + cy.get('@search-input').type('{ctrl}g') + cy.findByRole('search').should('have.length', 0) + }) + + it('toggle comments with M-;', function () { + cy.get('@line') + .should('have.text', '\\documentclass{article}') + .type('{alt};') + .should('have.text', '% \\documentclass{article}') + }) + + it('should jump between start and end with M-S-, and M-S-.', function () { + activeEditorLine().index().should('equal', 1) + activeEditorLine().type('{alt}{shift},') + activeEditorLine().index().should('equal', 0) + activeEditorLine().type('{alt}{shift}.') + activeEditorLine().index().should('equal', 7) + }) + + it('can enter characters', function () { + cy.get('.cm-line') + .eq(0) + .scrollIntoView() + .click() + .type(CHARACTERS) + .should('have.text', CHARACTERS) + }) +}) + +describe('vim keybindings', { scrollBehavior: false }, function () { + beforeEach(function () { + window.metaAttributesCache.set('ol-preventCompileOnLoad', true) + cy.interceptEvents() + cy.interceptSpelling() + + // Make a short doc that will fit entirely into the dom tree, so that + // index() corresponds to line number - 1 + const shortDoc = ` +\\documentclass{article} +\\begin{document} +contentLine1 +contentLine2 +contentLine3 +\\end{document} +` + + const scope = mockScope(shortDoc) + scope.settings.mode = 'vim' + + cy.mount( + + + + + + ) + cy.get('.cm-line').eq(1).scrollIntoView().click().as('line') + cy.get('.cm-editor').as('editor') + }) + + afterEach(function () { + window.metaAttributesCache = new Map() + }) + + it('can enter characters', function () { + cy.get('.cm-line') + .eq(0) + .scrollIntoView() + .click() + .type(`i${CHARACTERS}{esc}`) + .should('have.text', CHARACTERS) + }) + + it('can move around in normal mode', function () { + // Move cursor up + cy.get('@line').type('k') + activeEditorLine().index().should('equal', 0) + + // Move cursor down + cy.get('@line').type('j') + activeEditorLine().index().should('equal', 2) + + // Move the cursor left, insert 1, move it right, insert a 2 + cy.get('@line') + .type('hi1{esc}la2{esc}') + .should('have.text', '\\documentclass{article1}2') + }) +}) diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-spellchecker.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-spellchecker.spec.tsx new file mode 100644 index 0000000000..f59fb55099 --- /dev/null +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-spellchecker.spec.tsx @@ -0,0 +1,95 @@ +import { mockScope } from '../helpers/mock-scope' +import { EditorProviders } from '../../../helpers/editor-providers' +import CodeMirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor' +import { FC } from 'react' + +const Container: FC = ({ children }) => ( +
{children}
+) + +describe('Spellchecker', function () { + const content = ` +\\documentclass{} + +\\title{} +\\author{} + +\\begin{document} +\\maketitle + +\\begin{abstract} +\\end{abstract} + +\\section{} + +\\end{document}` + + beforeEach(function () { + window.metaAttributesCache.set('ol-preventCompileOnLoad', true) + cy.interceptEvents() + + const scope = mockScope(content) + + cy.mount( + + + + + + ) + + cy.get('.cm-line').eq(13).click().as('line') + }) + + afterEach(function () { + window.metaAttributesCache = new Map() + }) + + it('makes initial spellcheck request', function () { + cy.intercept('POST', '/spelling/check').as('spellCheckRequest') + cy.get('@line').type('wombat') + cy.wait('@spellCheckRequest') + }) + + it('makes only one spellcheck request for multiple typed characters', function () { + let spellCheckRequestCount = 0 + cy.intercept('POST', '/spelling/check', req => { + ++spellCheckRequestCount + if (spellCheckRequestCount > 1) { + throw new Error('No more than one request was expected') + } + req.reply({ + misspellings: [], + }) + }).as('spellCheckRequest') + + cy.get('@line').type('wombat') + cy.wait('@spellCheckRequest') + }) + + it('shows red underline for misspelled word', function () { + cy.intercept('POST', '/spelling/check', { + misspellings: [ + { + index: 0, + suggestions: [ + 'noncombat', + 'wombat', + 'nutmeat', + 'nitwit', + 'steamboat', + 'entombed', + 'tombed', + ], + }, + ], + }).as('spellCheckRequest') + + cy.get('@line').type('notawombat') + cy.wait('@spellCheckRequest') + cy.get('@line') + .get('.ol-cm-spelling-error') + .should('exist') + .contains('notawombat') + }) +}) diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-list.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-list.spec.tsx new file mode 100644 index 0000000000..d3b8b4c50b --- /dev/null +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-list.spec.tsx @@ -0,0 +1,283 @@ +import { FC } from 'react' +import { EditorProviders } from '../../../helpers/editor-providers' +import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor' +import { mockScope } from '../helpers/mock-scope' + +const isMac = /Mac/.test(window.navigator?.platform) + +const Container: FC = ({ children }) => ( +
{children}
+) + +const mountEditor = (content: string) => { + const scope = mockScope(content) + scope.editor.showVisual = true + + cy.mount( + + + + + + ) +} + +describe(' lists in Rich Text mode', function () { + beforeEach(function () { + window.metaAttributesCache.set('ol-preventCompileOnLoad', true) + cy.interceptEvents() + cy.interceptSpelling() + }) + + it('creates a nested list inside an unindented list', function () { + const content = [ + '\\begin{itemize}', + '\\item Test', + '\\item Test', + '\\end{itemize}', + ].join('\n') + mountEditor(content) + + // create a nested list + cy.get('.cm-line') + .eq(2) + .type(isMac ? '{cmd}]' : '{ctrl}]') + + cy.get('.cm-content').should( + 'have.text', + [ + '\\begin{itemize}', + ' Test', + '\\begin{itemize}', + ' Test', + '\\end{itemize}', + '\\end{itemize}', + ].join('') + ) + }) + + it('creates a nested list inside an indented list', function () { + const content = [ + '\\begin{itemize}', + '\\item Test', + '\\item Test', + '\\end{itemize}', + ].join('\n') + mountEditor(content) + + // create a nested list + cy.get('.cm-line') + .eq(2) + .type(isMac ? '{cmd}]' : '{ctrl}]') + + cy.get('.cm-content').should( + 'have.text', + [ + '\\begin{itemize}', + ' Test', + '\\begin{itemize}', + ' Test', + '\\end{itemize}', + '\\end{itemize}', + ].join('') + ) + }) + + it('creates a nested list on Tab at the start of an item', function () { + const content = [ + '\\begin{itemize}', + '\\item Test', + '\\item Test', + '\\end{itemize}', + ].join('\n') + mountEditor(content) + + // move to the start of the item and press Tab + cy.get('.cm-line') + .eq(2) + .click() + .type('{leftArrow}'.repeat(4)) + .trigger('keydown', { + key: 'Tab', + }) + + cy.get('.cm-content').should( + 'have.text', + [ + '\\begin{itemize}', + ' Test', + '\\begin{itemize}', + ' Test', + '\\end{itemize}', + '\\end{itemize}', + ].join('') + ) + }) + + it('does not creates a nested list on Tab when not at the start of an item', function () { + const content = [ + '\\begin{itemize}', + '\\item Test', + '\\item Test', + '\\end{itemize}', + ].join('\n') + mountEditor(content) + + // focus a line (at the end of a list item) and press Tab + cy.get('.cm-line').eq(2).click().trigger('keydown', { + key: 'Tab', + }) + + cy.get('.cm-content').should( + 'have.text', + [ + // + '\\begin{itemize}', + ' Test', + ' Test ', + '\\end{itemize}', + ].join('') + ) + }) + + it('removes a nested list on Shift-Tab', function () { + const content = [ + '\\begin{itemize}', + '\\item Test', + '\\item Test', + '\\end{itemize}', + ].join('\n') + mountEditor(content) + + // move to the start of the list item and press Tab + cy.get('.cm-line') + .eq(2) + .click() + .type('{leftArrow}'.repeat(4)) + .trigger('keydown', { + key: 'Tab', + }) + + cy.get('.cm-content').should( + 'have.text', + [ + '\\begin{itemize}', + ' Test', + '\\begin{itemize}', + ' Test', + '\\end{itemize}', + '\\end{itemize}', + ].join('') + ) + + // focus the indented line and press Shift-Tab + cy.get('.cm-line').eq(4).trigger('keydown', { + key: 'Tab', + shiftKey: true, + }) + + cy.get('.cm-content').should( + 'have.text', + [ + // + '\\begin{itemize}', + ' Test', + ' Test', + '\\end{itemize}', + ].join('') + ) + }) + + it('does not remove a top-level nested list on Shift-Tab', function () { + const content = [ + '\\begin{itemize}', + '\\item Test', + '\\item Test', + '\\end{itemize}', + ].join('\n') + mountEditor(content) + + // focus a list item and press Shift-Tab + cy.get('.cm-line').eq(2).trigger('keydown', { + key: 'Tab', + shiftKey: true, + }) + + cy.get('.cm-content').should( + 'have.text', + [ + // + '\\begin{itemize}', + ' Test', + ' Test', + '\\end{itemize}', + ].join('') + ) + }) + + it('handles up arrow at the start of a list item', function () { + const content = [ + '\\begin{itemize}', + '\\item One', + '\\item Two', + '\\end{itemize}', + ].join('\n') + mountEditor(content) + + cy.get('.cm-line') + .eq(2) + .click() + .type('{leftArrow}'.repeat(3)) // to the start of the item + .type('{upArrow}{Shift}{rightArrow}{rightArrow}{rightArrow}') // up and extend to the end of the item + + cy.window().should(win => { + expect(win.getSelection()?.toString()).to.equal('One') + }) + }) + + it('handles up arrow at the start of an indented list item', function () { + const content = [ + '\\begin{itemize}', + ' \\item One', + ' \\item Two', + '\\end{itemize}', + ].join('\n') + mountEditor(content) + + cy.get('.cm-line') + .eq(2) + .click() + .type('{leftArrow}'.repeat(3)) // to the start of the item + .type('{upArrow}{Shift}{rightArrow}{rightArrow}{rightArrow}') // up and extend to the end of the item + + cy.window().should(win => { + expect(win.getSelection()?.toString()).to.equal('One') + }) + }) + + it('handles keyboard navigation around a list', function () { + const content = [ + '\\begin{itemize}', + '\\item One', + '\\item Two', + '\\end{itemize}', + ].join('\n') + mountEditor(content) + + cy.get('.cm-line') + .eq(0) + .click('left') + .type( + '{downArrow}'.repeat(4) + // down to the end line + '{rightArrow}'.repeat(3) + // along a few characters + '{upArrow}'.repeat(2) + // up to the first list item + '{rightArrow}'.repeat(4) + // along to the start of the second list item + '{shift}' + // start extending the selection + '{rightArrow}'.repeat(3) // cover the word + ) + + cy.window().should(win => { + expect(win.getSelection()?.toString()).to.equal('Two') + }) + }) +}) diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-toolbar.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-toolbar.spec.tsx new file mode 100644 index 0000000000..afe9525044 --- /dev/null +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual-toolbar.spec.tsx @@ -0,0 +1,183 @@ +import { FC } from 'react' +import { EditorProviders } from '../../../helpers/editor-providers' +import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor' +import { mockScope } from '../helpers/mock-scope' + +const isMac = /Mac/.test(window.navigator?.platform) + +const selectAll = () => { + cy.get('.cm-content').trigger( + 'keydown', + isMac ? { key: 'a', metaKey: true } : { key: 'a', ctrlKey: true } + ) +} + +const clickToolbarButton = (text: string) => { + cy.findByLabelText(text).click() + cy.findByLabelText(text).trigger('mouseout') +} + +const Container: FC = ({ children }) => ( +
{children}
+) + +const mountEditor = (content: string) => { + const scope = mockScope(content) + scope.editor.showVisual = true + + cy.mount( + + + + + + ) +} + +describe(' toolbar in Rich Text mode', function () { + beforeEach(function () { + window.metaAttributesCache.set('ol-preventCompileOnLoad', true) + cy.interceptEvents() + cy.interceptSpelling() + }) + + it('should handle Undo and Redo', function () { + mountEditor('') + cy.get('.cm-line').eq(0).type('hi') + cy.get('.cm-content').should('have.text', 'hi') + clickToolbarButton('Undo') + cy.get('.cm-content').should('have.text', '') + clickToolbarButton('Redo') + cy.get('.cm-content').should('have.text', 'hi') + }) + + it('should handle section level changes', function () { + mountEditor('hi') + cy.get('.cm-content').should('have.text', 'hi') + + clickToolbarButton('Choose section heading level') + cy.findByRole('menu').within(() => { + cy.findByText('Subsection').click() + }) + cy.get('.cm-content').should('have.text', '{hi}') + + clickToolbarButton('Choose section heading level') + cy.findByRole('menu').within(() => { + cy.findByText('Normal text').click() + }) + cy.get('.cm-content').should('have.text', 'hi') + }) + + it('should toggle Bold and Italic', function () { + mountEditor('hi') + cy.get('.cm-content').should('have.text', 'hi') + selectAll() + + // bold + clickToolbarButton('Format Bold') + cy.get('.cm-content').should('have.text', '{hi}') + cy.get('.ol-cm-command-textbf').should('have.length', 1) + clickToolbarButton('Format Bold') + cy.get('.cm-content').should('have.text', 'hi') + cy.get('.ol-cm-command-textbf').should('have.length', 0) + + // italic + clickToolbarButton('Format Italic') + cy.get('.cm-content').should('have.text', '{hi}') + cy.get('.ol-cm-command-textit').should('have.length', 1) + clickToolbarButton('Format Italic') + cy.get('.cm-content').should('have.text', 'hi') + cy.get('.ol-cm-command-textit').should('have.length', 0) + }) + + it('should wrap content with inline math', function () { + mountEditor('2+3=5') + selectAll() + + clickToolbarButton('Insert Inline Math') + cy.get('.cm-content').should('have.text', '\\(2+3=5\\)') + }) + + it('should wrap content with display math', function () { + mountEditor('2+3=5') + selectAll() + + clickToolbarButton('Insert Display Math') + cy.get('.cm-content').should('have.text', '\\[2+3=5\\]') + }) + + it('should wrap content with a link', function () { + mountEditor('test') + selectAll() + + clickToolbarButton('Insert Link') + cy.get('.cm-content').should('have.text', '\\href{}{test}') + + cy.get('.cm-line') + .eq(0) + .type('http://example.com') + .should('have.text', '\\href{http://example.com}{test}') + }) + + it('should insert a figure', function () { + mountEditor('test') + + clickToolbarButton('Insert Figure') + + cy.get('.cm-content').should( + 'have.text', + [ + 'test', + '\\begin{figure}', + ' \\centering', + ' \\includegraphics{}', + ' Caption', + ' 🏷fig:my_label', + '\\end{figure}', + ].join('') + ) + + cy.get('.cm-line') + .eq(3) + .type('test.png') + .should('have.text', ' \\includegraphics{test.png}') + }) + + it('should insert a bullet list', function () { + mountEditor('test') + selectAll() + + clickToolbarButton('Bullet List') + + cy.get('.cm-content').should( + 'have.text', + [ + // + '\\begin{itemize}', + ' test', + '\\end{itemize}', + ].join('') + ) + + cy.get('.cm-line').eq(1).type('ing').should('have.text', ' testing') + }) + + it('should insert a numbered list', function () { + mountEditor('test') + selectAll() + + clickToolbarButton('Numbered List') + + cy.get('.cm-content').should( + 'have.text', + [ + // + '\\begin{enumerate}', + ' test', + '\\end{enumerate}', + ].join('') + ) + + cy.get('.cm-line').eq(1).type('ing').should('have.text', ' testing') + }) +}) diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual.spec.tsx new file mode 100644 index 0000000000..90e791f734 --- /dev/null +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-visual.spec.tsx @@ -0,0 +1,377 @@ +// Needed since eslint gets confused by mocha-each +/* eslint-disable mocha/prefer-arrow-callback */ +import { FC } from 'react' +import { EditorProviders } from '../../../helpers/editor-providers' +import CodemirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor' +import { mockScope } from '../helpers/mock-scope' +import forEach from 'mocha-each' + +const Container: FC = ({ children }) => ( +
{children}
+) + +describe(' in Rich Text mode', function () { + beforeEach(function () { + window.metaAttributesCache.set('ol-preventCompileOnLoad', true) + window.metaAttributesCache.set( + 'ol-mathJax3Path', + 'https://unpkg.com/mathjax@3.2.2/es5/tex-svg-full.js' + ) + cy.interceptEvents() + cy.interceptSpelling() + + // 3 blank lines + const content = '\n'.repeat(3) + + const scope = mockScope(content) + scope.editor.showVisual = true + + cy.mount( + + + + + + ) + + cy.get('.cm-line').eq(0).click().as('first-line') + cy.get('.cm-line').eq(1).as('second-line') + cy.get('.cm-line').eq(2).as('third-line') + cy.get('.cm-line').eq(3).as('fourth-line') + cy.get('.ol-cm-toolbar [aria-label="Format Bold"]').as('toolbar-bold') + }) + + forEach(['LaTeX', 'TeX']).it('renders the %s logo', function (logo) { + cy.get('@first-line').type(`\\${logo}{{}}{Enter}`).should('have.text', logo) + }) + + it('renders \\dots', function () { + cy.get('@first-line') + .type('\\dots{Esc}') + .should('have.text', '\\dots') + .type('{Enter}') + .should('have.text', '…') + }) + + it('creates a new list item on Enter', function () { + cy.get('@first-line').type('\\begin{{}itemize') + + // select the first autocomplete item + cy.findByRole('option').eq(0).click() + + cy.get('@first-line').should('have.text', '\\begin{itemize}') + cy.get('@second-line') + .should('have.text', ' ') + .find('.ol-cm-item') + .should('have.length', 1) + cy.get('@third-line').should('have.text', '\\end{itemize}') + + cy.get('@second-line').type('test{Enter}test') + + cy.get('@first-line').should('have.text', '\\begin{itemize}') + cy.get('@second-line') + .should('have.text', ' test') + .find('.ol-cm-item') + .should('have.length', 1) + cy.get('@third-line') + .should('have.text', ' test') + .find('.ol-cm-item') + .should('have.length', 1) + cy.get('@fourth-line').should('have.text', '\\end{itemize}') + }) + + it('finishes a list on Enter in the last item if empty', function () { + cy.get('@first-line').type('\\begin{{}itemize') + + // select the first autocomplete item + cy.findByRole('option').eq(0).click() + + cy.get('@second-line').type('test{Enter}{Enter}') + + cy.get('.cm-line') + .eq(0) + .should('have.text', ' test') + .find('.ol-cm-item') + .should('have.length', 1) + + cy.get('.cm-line').eq(1).should('have.text', '') + }) + + it('does not finish a list on Enter in an earlier item if empty', function () { + cy.get('@first-line').type('\\begin{{}itemize') + + // select the first autocomplete item + cy.findByRole('option').eq(0).click() + + cy.get('@second-line').type('test{Enter}test{Enter}{upArrow}{Enter}{Enter}') + + const lines = [ + '\\begin{itemize}', + ' test', + ' ', + ' ', + ' test', + ' ', + '\\end{itemize}', + ] + + cy.get('.cm-content').should('have.text', lines.join('')) + }) + + forEach(['textbf', 'textit', 'underline']).it( + 'handles \\%s text', + function (command) { + cy.get('@first-line') + .type(`\\${command}{`) + .should('have.text', `\\${command}{`) + .type('} ') // Should still show braces for empty commands + .should('have.text', '{} ') + .type('{Backspace}{leftArrow}test text') + .should('have.text', '{test text}') + .type('{rightArrow} foo') + .should('have.text', 'test text foo') // no braces + .find(`.ol-cm-command-${command}`) + } + ) + + forEach([ + 'part', + 'chapter', + 'section', + 'subsection', + 'subsubsection', + 'paragraph', + 'subparagraph', + ]).it('handles \\%s sectioning command', function (command) { + cy.get('@first-line') + .type(`\\${command}{`) + .should('have.text', `\\${command}{`) + .type(`}`) + .should('have.text', `\\${command}{}`) + .type(' ') + .should('have.text', `\\${command}{} `) + // Press enter before closing brace + .type('{Backspace}{leftArrow}title{leftArrow}{Enter}') + .should('have.text', 'title') + .find(`.ol-cm-heading.ol-cm-command-${command}`) + .should('exist') + }) + + forEach([ + 'textsc', + 'texttt', + 'sout', + 'emph', + ['verb', '|', '|'], + 'url', + 'caption', + ]).it( + 'handles \\%s text', + function (command, openingBrace = '{', closingBrace = '}') { + cy.get('@first-line') + .type(`\\${command}${openingBrace}`) + .should('have.text', `\\${command}${openingBrace}`) + .type(`${closingBrace}`) + .should('have.text', `\\${command}${openingBrace}${closingBrace}`) + .type(' ') + .should('have.text', `\\${command}${openingBrace}${closingBrace} `) + .type('{Backspace}{leftArrow}test text{rightArrow} ') + .should('have.text', 'test text ') + .find(`.ol-cm-command-${command}`) + .should('exist') + } + ) + + forEach([ + ['ref', '🏷'], + ['label', '🏷'], + ['cite', '📚'], + ['include', '🔗'], + ]).it('handles \\%s commands', function (command, icon) { + cy.get('@first-line') + .type(`\\${command}{} `) + .should('have.text', `\\${command}{} `) + .type('{Backspace}{leftArrow}key') + .should('have.text', `\\${command}{key}`) + .type('{rightArrow}') + .should('have.text', `\\${command}{key}`) + .type(' ') + .should('have.text', `${icon}key `) + }) + + it('handles \\href command', function () { + cy.get('@first-line') + .type('\\href{{}https://overleaf.com} ') + .should('have.text', '\\href{https://overleaf.com} ') + .type('{Backspace}{{}{Del}Overleaf ') + .should('have.text', '\\href{https://overleaf.com}{Overleaf ') + .type('{Backspace}} ') + .should('have.text', 'Overleaf ') + .find('.ol-cm-link-text') + .should('exist') + }) + + it('displays unknown commands unchanged', function () { + cy.get('@first-line') + .type('\\foo[bar]{{}baz} ') + .should('have.text', '\\foo[bar]{baz} ') + }) + + describe('Figure environments', function () { + beforeEach(function () { + cy.get('@first-line').type('\\begin{{}figure').type('{Enter}') // end with cursor in file path + }) + + it('loads figures', function () { + cy.get('@third-line').type('path/to/image') + + cy.get('@third-line') + .should('have.text', ' \\includegraphics{path/to/image}') + .type('{DownArrow}{DownArrow}{DownArrow}{DownArrow}') + .should('not.exist') // Should be removed from dom when line is hidden + + cy.get('img.ol-cm-graphics').should('have.attr', 'src', 'path/to/image') + }) + + it('marks lines as figure environments', function () { + // inside the figure + cy.get('@second-line').should('have.class', 'ol-cm-environment-figure') + // outside the figure + cy.get('.cm-line') + .eq(6) + .should('not.have.class', 'ol-cm-environment-figure') + }) + + it('marks environment has centered when it has \\centering command', function () { + // inside the figure + cy.get('@third-line').should('have.class', 'ol-cm-environment-centered') + // outside the figure + cy.get('.cm-line') + .eq(6) + .should('not.have.class', 'ol-cm-environment-centered') + // the line containing \centering + cy.get('@second-line') + .should('have.text', ' \\centering') + .should('have.class', 'ol-cm-environment-centered') + .type('{Backspace}') + .should('have.text', ' \\centerin') + .should('not.have.class', 'ol-cm-environment-centered') + }) + }) + describe('Toolbar', function () { + describe('Formatting buttons highlighting', function () { + it('handles empty selections inside of bold', function () { + cy.get('@first-line').type('\\textbf{{}test}{LeftArrow}') // \textbf{test|} + cy.get('@toolbar-bold').should('have.class', 'active') + cy.get('@first-line').type('{LeftArrow}') // \textbf{tes|t} + cy.get('@toolbar-bold').should('have.class', 'active') + cy.get('@first-line').type('{LeftArrow}'.repeat(3)) // \textbf{|test} + cy.get('@toolbar-bold').should('have.class', 'active') + }) + + it('handles empty selections outside bold', function () { + cy.get('@first-line').type('\\textbf{{}test}') + cy.get('@toolbar-bold').should('not.have.class', 'active') + cy.get('@first-line').type('{LeftArrow}'.repeat(6)) + cy.get('@toolbar-bold').should('not.have.class', 'active') + }) + + it('handles range selections inside bold', function () { + cy.get('@first-line') + .type('\\textbf{{}test}') + .type('{LeftArrow}'.repeat(4)) + .type('{Shift}{RightArrow}{RightArrow}') + cy.get('@toolbar-bold').should('have.class', 'active') + }) + + it('handles range selections spanning bold', function () { + cy.get('@first-line') + .type('\\textbf{{}test} outside') + .type('{LeftArrow}'.repeat(10)) + .type('{Shift}' + '{RightArrow}'.repeat(5)) + cy.get('@toolbar-bold').should('not.have.class', 'active') + }) + + it('does not highlight bold when commands at selection ends are different', function () { + cy.get('@first-line') + .type('\\textbf{{}first} \\textbf{{}second}') + .type('{LeftArrow}'.repeat(12)) + .type('{Shift}' + '{RightArrow}'.repeat(7)) + cy.get('@toolbar-bold').should('not.have.class', 'active') + }) + + it('highlight when ends share common formatting ancestor', function () { + cy.get('@first-line') + .type('\\textbf{{}\\textit{{}first} \\textit{{}second}}') + .type('{LeftArrow}'.repeat(13)) + .type('{Shift}' + '{RightArrow}'.repeat(7)) + cy.get('@toolbar-bold').should('have.class', 'active') + }) + }) + }) + + describe('Beamer frames', function () { + it('hides markup', function () { + cy.get('@first-line').type( + '\\begin{{}frame}{{}Slide\\\\title}{Enter}\\end{{}frame}{Enter}' + ) + cy.get('.ol-cm-divider').should('exist') + cy.get('.ol-cm-frame-title').should('exist') + }) + it('typesets title', function () { + cy.get('@first-line').type( + '\\begin{{}frame}{{}Slide\\\\title}{Enter}\\end{{}frame}{Enter}' + ) + cy.get('.ol-cm-frame-title') + .should('exist') + .should('have.html', 'Slide
title') + }) + + // eslint-disable-next-line mocha/no-skipped-tests + it.skip('typesets math in title', function () { + cy.get('@first-line').type( + '\\begin{{}frame}{{}Slide $\\pi$}{Enter}\\end{{}frame}{Enter}' + ) + + // allow plenty of time for MathJax to load + cy.get('.MathJax', { timeout: 10000 }).should('exist') + }) + + it('typesets subtitle', function () { + cy.get('@first-line').type( + '\\begin{{}frame}{{}Slide title}{{}Slide subtitle}{Enter}\\end{{}frame}{Enter}' + ) + cy.get('.ol-cm-frame-subtitle') + .should('exist') + .should('have.html', 'Slide subtitle') + }) + }) + + it('typesets \\maketitle', function () { + cy.get('@first-line').type( + [ + '\\author{{}Author}', + '\\title{{}Document title\\\\with $\\pi$}', + '\\begin{{}document}', + '\\maketitle', + '\\end{{}document}', + '', + ].join('{Enter}') + ) + + // allow plenty of time for MathJax to load + // TODO: re-enable this assertion when stable + // cy.get('.MathJax', { timeout: 10000 }).should('exist') + + cy.get('.ol-cm-maketitle').should('exist') + cy.get('.ol-cm-title') + .should('exist') + .should('contain.html', 'Document title
with') + cy.get('.ol-cm-author').should('have.text', 'Author') + }) + + // TODO: \input + // TODO: Math + // TODO: Abstract + // TODO: Preamble +}) diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor.spec.tsx new file mode 100644 index 0000000000..96bf225b63 --- /dev/null +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor.spec.tsx @@ -0,0 +1,679 @@ +import CodeMirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor' +import { EditorProviders } from '../../../helpers/editor-providers' +import { FC } from 'react' +import { mockScope } from '../helpers/mock-scope' +import { metaKey } from '../helpers/meta-key' +import { docId } from '../helpers/mock-doc' +import { activeEditorLine } from '../helpers/active-editor-line' + +const Container: FC = ({ children }) => ( +
{children}
+) + +describe('', { scrollBehavior: false }, function () { + beforeEach(function () { + window.metaAttributesCache.set('ol-preventCompileOnLoad', true) + cy.interceptEvents() + cy.interceptSpelling() + }) + + afterEach(function () { + window.metaAttributesCache = new Map() + }) + + it('deletes selected text on Backspace', function () { + const scope = mockScope() + + cy.mount( + + + + + + ) + + // put the cursor on a blank line to type in + cy.get('.cm-line').eq(16).click().as('line') + + cy.get('@line') + .type('this is some text') + .should('have.text', 'this is some text') + .type('{shift}{leftArrow}{leftArrow}{leftArrow}{leftArrow}') + .type('{backspace}') + .should('have.text', 'this is some ') + }) + + it('renders client-side lint annotations in the gutter', function () { + const scope = mockScope() + scope.settings.syntaxValidation = true + + cy.clock() + + cy.mount( + + + + + + ) + + cy.tick(1000) + cy.clock().invoke('restore') + + // TODO: aria role/label for gutter markers? + cy.get('.cm-lint-marker-error').should('have.length', 2) + cy.get('.cm-lint-marker-warning').should('have.length', 0) + }) + + it('renders annotations in the gutter', function () { + const scope = mockScope() + + scope.pdf.logEntryAnnotations = { + [docId]: [ + { + row: 20, + type: 'error', + text: 'Another error', + }, + { + row: 19, + type: 'error', + text: 'An error', + }, + { + row: 20, + type: 'warning', + text: 'A warning on the same line', + }, + { + row: 25, + type: 'warning', + text: 'Another warning', + }, + ], + } + + cy.clock() + + cy.mount( + + + + + + ) + + cy.tick(1000) + cy.clock().invoke('restore') + + // TODO: aria role/label for gutter markers? + cy.get('.cm-lint-marker-error').should('have.length', 2) + cy.get('.cm-lint-marker-warning').should('have.length', 1) + }) + + it('renders code in an editor', function () { + const scope = mockScope() + + cy.mount( + + + + + + ) + + cy.contains('Your introduction goes here!') + }) + + it('does not indent when entering new line off non-empty line', function () { + const scope = mockScope() + + cy.mount( + + + + + + ) + + // put the cursor on a blank line to type in + cy.get('.cm-line').eq(16).click().type('foo{enter}') + + activeEditorLine().should('have.text', '') + }) + + it('indents automatically when using snippet', function () { + const scope = mockScope() + + cy.mount( + + + + + + ) + + // put the cursor on a blank line to type in + cy.get('.cm-line').eq(16).click().as('line') + + cy.get('@line').type('\\begin{{}itemiz') + cy.findAllByRole('listbox').contains('\\begin{itemize}').click() + + activeEditorLine().invoke('text').should('match', /^ {4}/) + }) + + it('keeps indentation when going to a new line', function () { + const scope = mockScope() + + cy.mount( + + + + + + ) + + // put the cursor on a blank line to type in + cy.get('.cm-line').eq(16).click().as('line') + + // Single indentation + cy.get('@line').trigger('keydown', { key: 'Tab' }).type('{enter}') + + activeEditorLine().should('have.text', ' ') + + // Double indentation + activeEditorLine().trigger('keydown', { key: 'Tab' }).type('{enter}') + + activeEditorLine().should('have.text', ' ') + }) + + it('renders cursor highlights', function () { + const scope = mockScope() + + scope.onlineUserCursorHighlights = { + [docId]: [ + { + label: 'Test User', + cursor: { row: 10, column: 5 }, + hue: 150, + }, + { + label: 'Another User', + cursor: { row: 7, column: 2 }, + hue: 50, + }, + { + label: 'Starter User', + cursor: { row: 0, column: 0 }, + hue: 0, + }, + ], + } + + cy.mount( + + + + + + ) + + cy.get('.ol-cm-cursorHighlight').should('have.length', 3) + }) + + it('does not allow typing to the document in read-only mode', function () { + const scope = mockScope() + scope.permissionsLevel = 'readOnly' + + cy.mount( + + + + + + ) + + // Handling the thrown error on failing to type text + cy.on('fail', error => { + if (error.message.includes('it requires a valid typeable element')) { + return + } + + throw error + }) + + cy.get('.cm-line').eq(16).click().as('line') + + cy.get('@line').type('text') + cy.get('@line').should('not.contain.text', 'text') + }) + + it('highlights matching brackets', function () { + const scope = mockScope() + + cy.mount( + + + + + + ) + + // put the cursor on a blank line to type in + cy.get('.cm-line').eq(16).click() + + const pairs = ['()', '[]', '{}'] + + pairs.forEach(pair => { + activeEditorLine().type(pair).as('line') + cy.get('@line').find('.cm-matchingBracket').should('exist') + cy.get('@line').type('{enter}') + }) + }) + + it('folds code', function () { + const scope = mockScope() + + cy.mount( + + + + + + ) + + // select foldable line + cy.get('.cm-line').eq(9).click().as('line') + + const testUnfoldedState = () => { + cy.get('.cm-gutterElement').eq(11).should('have.text', '11') + + cy.get('.cm-gutterElement').eq(12).should('have.text', '12') + } + + const testFoldedState = () => { + cy.get('.cm-gutterElement').eq(11).should('have.text', '13') + + cy.get('.cm-gutterElement').eq(12).should('have.text', '14') + } + + testUnfoldedState() + + // Fold + cy.get('span[title="Fold line"]').eq(1).click() + + testFoldedState() + + // Unfold + cy.get('span[title="Unfold line"]').eq(1).click() + + testUnfoldedState() + }) + + it('save file with `:w` command in vim mode', function () { + window.metaAttributesCache.set('ol-preventCompileOnLoad', false) + cy.interceptCompile() + + const scope = mockScope() + scope.settings.mode = 'vim' + + cy.mount( + + + + + + ) + + // Compile on initial load + cy.waitForCompile() + cy.interceptCompile() + + // put the cursor on a blank line to type in + cy.get('.cm-line').eq(16).click().as('line') + + cy.get('.cm-vim-panel').should('have.length', 0) + + cy.get('@line').type(':') + + cy.get('.cm-vim-panel').should('have.length', 1) + + cy.get('.cm-vim-panel input').type('w').type('{enter}') + + // Compile after save + cy.waitForCompile() + }) + + it('search and replace text', function () { + const scope = mockScope() + + cy.mount( + + + + + + ) + + cy.get('.cm-line') + .eq(16) + .click() + .type( + '{enter}text_to_find{enter}abcde 1{enter}abcde 2{enter}abcde 3{enter}ABCDE 4{enter}' + ) + + // select text `text_to_find` + cy.get('.cm-line').eq(17).dblclick().as('lineToFind') + + // search panel is not displayed + cy.findByRole('search').should('have.length', 0) + + cy.get('@lineToFind').type(`{${metaKey}+f}`) + + // search panel is displayed + cy.findByRole('search').should('have.length', 1) + + cy.findByRole('textbox', { name: 'Find' }).as('search-input') + cy.findByRole('textbox', { name: 'Replace' }).as('replace-input') + + cy.get('@search-input') + // search input should be focused + .should('be.focused') + // search input's value should be set to the selected text + .should('have.value', 'text_to_find') + + cy.get('@search-input').clear().type('abcde') + + cy.findByRole('button', { name: 'next' }).as('next-btn') + cy.findByRole('button', { name: 'previous' }).as('previous-btn') + + // shows the number of matches + cy.contains(`1 of 4`) + + for (let i = 4; i; i--) { + // go to previous occurrence + cy.get('@previous-btn').click() + + // shows the number of matches + cy.contains(`${i} of 4`) + } + + for (let i = 1; i <= 4; i++) { + // shows the number of matches + cy.contains(`${i} of 4`) + + // go to next occurrence + cy.get('@next-btn').click() + } + + // roll round to 1 + cy.contains(`1 of 4`) + + // matches case + cy.contains('Aa').click() + cy.get('@search-input').clear().type('ABCDE') + cy.get('.cm-searchMatch-selected').should('contain.text', 'ABCDE') + cy.get('@search-input').clear() + cy.contains('Aa').click() + + // matches regex + cy.contains('[.*]').click() + cy.get('@search-input').type('\\\\author\\{{}\\w+\\}') + cy.get('.cm-searchMatch-selected').should('contain.text', '\\author{You}') + cy.contains('[.*]').click() + cy.get('@search-input').clear() + cy.get('.cm-searchMatch-selected').should('not.exist') + + // replace + cy.get('@search-input').type('abcde 1') + cy.get('@replace-input').type('test 1') + cy.findByRole('button', { name: 'Replace', exact: true }).click() + cy.get('.cm-line') + .eq(18) + .should('contain.text', 'test 1') + .should('not.contain.text', 'abcde') + + // replace all + cy.get('@search-input').clear().type('abcde') + cy.get('@replace-input').clear().type('test') + cy.findByRole('button', { name: /replace all/i }).click() + cy.get('@search-input').clear() + cy.get('@replace-input').clear() + cy.should('not.contain.text', 'abcde') + + // close the search form, to clear the stored query + cy.findByRole('button', { name: 'Close' }).click() + }) + + it('auto-closes custom brackets', function () { + const scope = mockScope() + + cy.mount( + + + + + + ) + + // put the cursor on a blank line to type in + cy.get('.cm-line').eq(16).click().as('line') + + // { auto-closes + cy.get('@line').type('{{}') // NOTE: {{} = literal { + cy.get('@line').should('have.text', '{}') + cy.get('@line').type('{Backspace}') + cy.get('@line').should('have.text', '') + + // [ auto-closes + cy.get('@line').type('[') + cy.get('@line').should('have.text', '[]') + cy.get('@line').type('{Backspace}') + cy.get('@line').should('have.text', '') + + // $ auto-closes + cy.get('@line').type('$') + cy.get('@line').should('have.text', '$$') + cy.get('@line').type('{rightArrow}{Backspace}{Backspace}') + cy.get('@line').should('have.text', '') + + // $$ auto-closes + cy.get('@line').type('$$') + cy.get('@line').should('have.text', '$$$$') + cy.get('@line').type('{rightArrow}{rightArrow}{Backspace}{Backspace}') + cy.get('@line').should('have.text', '$$') + cy.get('@line').type('{Backspace}{Backspace}') + cy.get('@line').should('have.text', '') + + // \{ doesn't auto-close + cy.get('@line').type('\\{{}') + cy.get('@line').should('have.text', '\\{') + cy.get('@line').type('{Backspace}{Backspace}') + cy.get('@line').should('have.text', '') + + // \[ *does* auto-close + cy.get('@line').type('\\[') + cy.get('@line').should('have.text', '\\[\\]') + cy.get('@line').type( + '{rightArrow}{rightArrow}{Backspace}{Backspace}{Backspace}{Backspace}' + ) + cy.get('@line').should('have.text', '') + + // \( *does* auto-close + cy.get('@line').type('\\(') + cy.get('@line').should('have.text', '\\(\\)') + cy.get('@line').type( + '{rightArrow}{rightArrow}{Backspace}{Backspace}{Backspace}{Backspace}' + ) + cy.get('@line').should('have.text', '') + + // \$ doesn't auto-close + cy.get('@line').type('\\$') + cy.get('@line').should('have.text', '\\$') + cy.get('@line').type('{Backspace}{Backspace}') + cy.get('@line').should('have.text', '') + + // { doesn't auto-close in front of an alphanumeric character + cy.get('@line').type('2{leftArrow}{{}') + cy.get('@line').should('have.text', '{2') + cy.get('@line').type('{rightArrow}{Backspace}{Backspace}') + cy.get('@line').should('have.text', '') + + // [ doesn't auto-close in front of an alphanumeric character + cy.get('@line').type('2{leftArrow}[') + cy.get('@line').should('have.text', '[2') + cy.get('@line').type('{rightArrow}{Backspace}{Backspace}') + cy.get('@line').should('have.text', '') + + // $ doesn't auto-close in front of an alphanumeric character + cy.get('@line').type('2{leftArrow}$') + cy.get('@line').should('have.text', '$2') + cy.get('@line').type('{rightArrow}{Backspace}{Backspace}') + cy.get('@line').should('have.text', '') + + // $$ doesn't auto-close in front of an alphanumeric character + cy.get('@line').type('2{leftArrow}$$') + cy.get('@line').should('have.text', '$$2') + cy.get('@line').type('{rightArrow}{Backspace}{Backspace}{Backspace}') + cy.get('@line').should('have.text', '') + + // { does auto-close in front of a known character + cy.get('@line').type(':{leftArrow}{{}') + cy.get('@line').should('have.text', '{}:') + cy.get('@line').type( + '{rightArrow}{rightArrow}{Backspace}{Backspace}{Backspace}' + ) + cy.get('@line').should('have.text', '') + + // [ does auto-close in front of a known character + cy.get('@line').type(':{leftArrow}[') + cy.get('@line').should('have.text', '[]:') + cy.get('@line').type( + '{rightArrow}{rightArrow}{Backspace}{Backspace}{Backspace}' + ) + cy.get('@line').should('have.text', '') + + // $ does auto-close in front of a known character + cy.get('@line').type(':{leftArrow}$') + cy.get('@line').should('have.text', '$$:') + cy.get('@line').type( + '{rightArrow}{rightArrow}{Backspace}{Backspace}{Backspace}' + ) + cy.get('@line').should('have.text', '') + + // $$ does auto-close in front of a known character + cy.get('@line').type(':{leftArrow}$$') + cy.get('@line').should('have.text', '$$$$:') + cy.get('@line').type( + '{rightArrow}{rightArrow}{rightArrow}{Backspace}{Backspace}{Backspace}{Backspace}{Backspace}' + ) + cy.get('@line').should('have.text', '') + + // $ at the end of an inline "dollar math" node skips the closing $ + cy.get('@line').type('$2+3=5') + cy.get('@line').should('have.text', '$2+3=5$') + cy.get('@line').type('$') + cy.get('@line').should('have.text', '$2+3=5$') + cy.get('@line').type('{Backspace}'.repeat(7)) + cy.get('@line').should('have.text', '') + }) + + it('navigates in the search panel', function () { + const scope = mockScope() + + cy.mount( + + + + + + ) + + // Open the search panel + cy.get('.cm-line').eq(16).click().type(`{${metaKey}+f}`) + + cy.findByRole('search').within(() => { + cy.findByLabelText('Find').as('find-input') + cy.findByLabelText('Replace').as('replace-input') + cy.get('[type="checkbox"][name="caseSensitive"]').as('case-sensitive') + cy.get('[type="checkbox"][name="regexp"]').as('regexp') + cy.get('[type="checkbox"][name="wholeWord"]').as('whole-word') + cy.get('label').contains('Aa').as('case-sensitive-label') + cy.get('label').contains('[.*]').as('regexp-label') + cy.get('label').contains('W').as('whole-word-label') + cy.findByRole('button', { name: 'Replace' }).as('replace') + cy.findByRole('button', { name: 'Replace All' }).as('replace-all') + cy.findByRole('button', { name: 'next' }).as('find-next') + cy.findByRole('button', { name: 'previous' }).as('find-previous') + cy.findByRole('button', { name: 'Close' }).as('close') + + // Tab forwards... + cy.get('@find-input').should('be.focused').tab() + cy.get('@replace-input').should('be.focused').tab() + cy.get('@case-sensitive').should('be.focused').tab() + cy.get('@regexp').should('be.focused').tab() + cy.get('@whole-word').should('be.focused').tab() + cy.get('@find-next').should('be.focused').tab() + cy.get('@find-previous').should('be.focused').tab() + cy.get('@replace').should('be.focused').tab() + cy.get('@replace-all').should('be.focused').tab() + + // ... then backwards + cy.get('@close').should('be.focused').tab({ shift: true }) + cy.get('@replace-all').should('be.focused').tab({ shift: true }) + cy.get('@replace').should('be.focused').tab({ shift: true }) + cy.get('@find-previous').should('be.focused').tab({ shift: true }) + cy.get('@find-next').should('be.focused').tab({ shift: true }) + cy.get('@whole-word').should('be.focused').tab({ shift: true }) + cy.get('@regexp').should('be.focused').tab({ shift: true }) + cy.get('@case-sensitive').should('be.focused').tab({ shift: true }) + cy.get('@replace-input').should('be.focused').tab({ shift: true }) + cy.get('@find-input').should('be.focused') + + for (const option of [ + '@case-sensitive-label', + '@regexp-label', + '@whole-word-label', + ]) { + // Toggle when clicked, then focus the search input + cy.get(option).click().should('have.class', 'checked') + cy.get('@find-input').should('be.focused') + + // Toggle when clicked again, then focus the search input + cy.get(option).click().should('not.have.class', 'checked') + cy.get('@find-input').should('be.focused') + } + }) + }) + + it('restores stored cursor and scroll position', function () { + const scope = mockScope() + + window.localStorage.setItem( + `doc.position.${docId}`, + JSON.stringify({ + cursorPosition: { row: 50, column: 5 }, + firstVisibleLine: 45, + }) + ) + + cy.mount( + + + + + + ) + + activeEditorLine() + .should('have.text', 'contentLine 29') + .should(() => { + const selection = window.getSelection() as Selection + expect(selection.isCollapsed).to.be.true + + const rect = selection.getRangeAt(0).getBoundingClientRect() + expect(Math.round(rect.top)).to.be.gte(100) + expect(Math.round(rect.left)).to.be.gte(90) + }) + }) +}) diff --git a/services/web/test/frontend/features/source-editor/extensions/cursor-position.test.ts b/services/web/test/frontend/features/source-editor/extensions/cursor-position.test.ts new file mode 100644 index 0000000000..66b1ae2a32 --- /dev/null +++ b/services/web/test/frontend/features/source-editor/extensions/cursor-position.test.ts @@ -0,0 +1,113 @@ +import sinon from 'sinon' +import { waitFor } from '@testing-library/react' +import { expect } from 'chai' +import { EditorView } from '@codemirror/view' +import { EditorSelection, EditorState } from '@codemirror/state' +import { + cursorPosition, + restoreCursorPosition, +} from '../../../../../frontend/js/features/source-editor/extensions/cursor-position' + +const doc = ` +\\documentclass{article} +\\title{Your Paper} +\\author{You} +\\begin{document} +\\maketitle +\\begin{abstract} +Your abstract. +\\end{abstract} +\\section{Introduction} +Your introduction goes here! +\\end{document}` + +const mockDoc = () => { + return { + doc_id: 'test-doc', + } +} + +describe('CodeMirror cursor position extension', function () { + afterEach(function () { + sinon.restore() + }) + + it('stores cursor position when the view is destroyed', async function () { + const currentDoc = mockDoc() + + sinon.stub(window.Storage.prototype, 'getItem').callsFake(key => { + switch (key) { + case 'doc.position.test-doc': + return JSON.stringify({ + cursorPosition: { row: 1, column: 1 }, + firstVisibleLine: 5, + }) + default: + return null + } + }) + + const setItem = sinon.spy(window.Storage.prototype, 'setItem') + + const view = new EditorView({ + state: EditorState.create({ + doc, + extensions: [cursorPosition({ currentDoc })], + }), + }) + + view.dispatch({ + selection: EditorSelection.cursor(50), + }) + + view.destroy() + + await waitFor(() => { + expect(setItem).to.have.been.calledWith( + 'doc.position.test-doc', + JSON.stringify({ + cursorPosition: { + row: 3, + column: 6, + }, + firstVisibleLine: 5, + }) + ) + }) + }) + + it('restores cursor position', async function () { + const currentDoc = mockDoc() + + const getItem = sinon + .stub(window.Storage.prototype, 'getItem') + .callsFake(key => { + switch (key) { + case 'doc.position.test-doc': + return JSON.stringify({ + cursorPosition: { row: 3, column: 5 }, + firstVisibleLine: 0, + }) + default: + return null + } + }) + + const view = new EditorView({ + state: EditorState.create({ + doc, + extensions: [cursorPosition({ currentDoc })], + }), + }) + view.dispatch(restoreCursorPosition(view.state.doc, 'test-doc')) + + expect(getItem).to.have.been.calledWith('doc.position.test-doc') + + await waitFor(() => { + const [range] = view.state.selection.ranges + expect(range.head).to.eq(49) + expect(range.anchor).to.eq(49) + expect(range.empty).to.eq(true) + }) + }) +}) diff --git a/services/web/test/frontend/features/source-editor/extensions/line-duplication-command.test.ts b/services/web/test/frontend/features/source-editor/extensions/line-duplication-command.test.ts new file mode 100644 index 0000000000..46314a23f3 --- /dev/null +++ b/services/web/test/frontend/features/source-editor/extensions/line-duplication-command.test.ts @@ -0,0 +1,169 @@ +import { foldEffect, foldState } from '@codemirror/language' +import { EditorSelection, EditorState } from '@codemirror/state' +import { DecorationSet, EditorView } from '@codemirror/view' +import { expect } from 'chai' +import { duplicateSelection } from '../../../../../frontend/js/features/source-editor/commands/ranges' + +type Position = { + from: number + to: number +} + +function folds(foldRanges: DecorationSet) { + const ranges: Position[] = [] + foldRanges.between(Number.MIN_VALUE, Number.MAX_VALUE, (from, to) => { + ranges.push({ from, to }) + }) + return ranges +} + +describe('Line duplication command', function () { + describe('For single selections', function () { + describe('For cursor selection', function () { + it('Cursor selection duplicates line downwards', function () { + const view = new EditorView({ + doc: 'line1\nline2', + selection: EditorSelection.cursor(0), + }) + + duplicateSelection(view) + + expect(view.state.doc.toString()).to.equal('line1\nline1\nline2') + + expect(view.state.selection.ranges.length).to.equal(1) + expect(view.state.selection.ranges[0].eq(EditorSelection.cursor(0))).to + .be.true + }) + + it('Preserves folded ranges', function () { + const view = new EditorView({ + doc: '\\begin{itemize}\n\t\\item test\n\\end{itemize}', + extensions: foldState, + }) + + view.dispatch( + view.state.update({ + selection: EditorSelection.cursor(0), + // Fold to \begin{itemize}...\end{itemize} + effects: [foldEffect.of({ from: 15, to: 28 })], + }) + ) + + duplicateSelection(view) + + expect(folds(view.state.field(foldState))).to.deep.equal([ + { from: 15, to: 28 }, + { from: 57, to: 70 }, + ]) + + expect(view.state.selection.ranges.length).to.equal(1) + expect(view.state.selection.ranges[0].eq(EditorSelection.cursor(0))).to + .be.true + }) + }) + describe('For range selections', function () { + it('Duplicates line with a cursor downwards', function () { + const view = new EditorView({ + doc: 'line1\nline2', + selection: EditorSelection.cursor(0), + }) + + duplicateSelection(view) + + expect(view.state.doc.toString()).to.equal('line1\nline1\nline2') + }) + + it('Duplicates range forwards', function () { + const view = new EditorView({ + doc: 'line1\nline2', + selection: EditorSelection.range(0, 5), + }) + + duplicateSelection(view) + + expect(view.state.doc.toString()).to.equal('line1line1\nline2') + expect(view.state.selection.ranges.length).to.equal(1) + expect(view.state.selection.ranges[0].eq(EditorSelection.range(5, 10))) + .to.be.true + }) + + it('Duplicates range backwards', function () { + const view = new EditorView({ + doc: 'line1\nline2', + selection: EditorSelection.range(5, 0), + }) + + duplicateSelection(view) + + expect(view.state.doc.toString()).to.equal('line1line1\nline2') + expect(view.state.selection.ranges.length).to.equal(1) + expect(view.state.selection.ranges[0].eq(EditorSelection.range(5, 0))) + .to.be.true + }) + }) + }) + + describe('For multiple selections', function () { + it('Preserves folded ranges', function () { + const doc = + '\\begin{itemize}\n\t\\item line1\n\\end{itemize}\n\\begin{itemize}\n\t\\item line2\n\\end{itemize}' + const view = new EditorView({ + doc, + extensions: [foldState, EditorState.allowMultipleSelections.of(true)], + }) + + view.dispatch( + view.state.update({ + selection: EditorSelection.create([ + EditorSelection.cursor(0), + EditorSelection.cursor(43), + ]), + effects: [ + foldEffect.of({ from: 15, to: 29 }), + foldEffect.of({ from: 58, to: 72 }), + ], + }) + ) + + duplicateSelection(view) + + expect(view.state.doc.toString()).to.equal( + '\\begin{itemize}\n\t\\item line1\n\\end{itemize}\n\\begin{itemize}\n\t\\item line1\n\\end{itemize}\n\\begin{itemize}\n\t\\item line2\n\\end{itemize}\n\\begin{itemize}\n\t\\item line2\n\\end{itemize}' + ) + + expect(folds(view.state.field(foldState))).to.deep.equal([ + { from: 15, to: 29 }, + { from: 58, to: 72 }, + { from: 101, to: 115 }, + { from: 144, to: 158 }, + ]) + + expect(view.state.selection.ranges.length).to.equal(2) + expect(view.state.selection.ranges[0].eq(EditorSelection.cursor(0))).to.be + .true + expect(view.state.selection.ranges[1].eq(EditorSelection.cursor(86))).to + .be.true + }) + + it('Duplicates all selections', function () { + const view = new EditorView({ + doc: 'line1\nline2', + extensions: [EditorState.allowMultipleSelections.of(true)], + selection: EditorSelection.create([ + EditorSelection.cursor(1), + EditorSelection.range(7, 9), + ]), + }) + + duplicateSelection(view) + + expect(view.state.doc.toString()).to.equal('line1\nline1\nlinine2') + + expect(view.state.selection.ranges.length).to.equal(2) + expect(view.state.selection.ranges[0].eq(EditorSelection.cursor(1))).to.be + .true + expect(view.state.selection.ranges[1].eq(EditorSelection.range(15, 17))) + .to.be.true + }) + }) +}) diff --git a/services/web/test/frontend/features/source-editor/extensions/line-wrapping-indentation.test.ts b/services/web/test/frontend/features/source-editor/extensions/line-wrapping-indentation.test.ts new file mode 100644 index 0000000000..3db8088d60 --- /dev/null +++ b/services/web/test/frontend/features/source-editor/extensions/line-wrapping-indentation.test.ts @@ -0,0 +1,115 @@ +import { expect } from 'chai' +import { EditorView, DecorationSet } from '@codemirror/view' +import { EditorState } from '@codemirror/state' +import { buildDecorations } from '../../../../../frontend/js/features/source-editor/extensions/line-wrapping-indentation' + +const basicDoc = ` +\\begin{document} +Test +\\end{document} +` + +const docLongLineNoIndentation = ` +\\begin{document} +Test +Hello one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four +\\end{document} +` + +const docLongLineWithIndentation = ` +\\begin{document} +Test + Hello one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four +\\end{document} +` + +const docLongLineWithLotsOfIndentation = ` +\\begin{document} +Test + Hello one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four + + Hello one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four + + Hello one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four + +Hello + + Hello one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four one two three four + +Hello +\\end{document} +` + +describe('line-wrapping-indentation', function () { + describe('buildDecorations', function () { + const _buildView = (doc: string) => { + return new EditorView({ + state: EditorState.create({ + doc, + }), + }) + } + + const _toArray = (decorations: DecorationSet) => { + const result = [] + const cursor = decorations.iter() + while (cursor.value) { + result.push({ from: cursor.from, to: cursor.to, value: cursor.value }) + cursor.next() + } + return result + } + + describe('basic document', function () { + it('should have no decorations', function () { + const view = _buildView(basicDoc) + + const decorations = buildDecorations(view, 24) + expect(decorations).to.exist + expect(decorations.size).to.equal(0) + }) + }) + + describe('document with long lines, no indentation', function () { + it('should have no decorations', function () { + const view = _buildView(docLongLineNoIndentation) + + const decorations = buildDecorations(view, 24) + expect(decorations).to.exist + expect(decorations.size).to.equal(0) + }) + }) + + describe('document with long lines, with indentation', function () { + it('should have a decoration', function () { + const view = _buildView(docLongLineWithIndentation) + + const decorations = buildDecorations(view, 24) + expect(decorations).to.exist + expect(decorations.size).to.equal(1) + + const decorationItem = _toArray(decorations)[0] + expect(decorationItem.from).to.equal(23) + expect(decorationItem.to).to.equal(23) + }) + }) + + describe('document with long lines, with lots of indentation', function () { + it('should have a decoration', function () { + const view = _buildView(docLongLineWithLotsOfIndentation) + + const decorations = buildDecorations(view, 24) + expect(decorations).to.exist + expect(decorations.size).to.equal(4) + + const decorationsArray = _toArray(decorations) + const expectedPositions = [23, 265, 507, 758] + + decorationsArray.forEach((item, index) => { + expect(item.from).to.equal(expectedPositions[index]) + expect(item.to).to.equal(expectedPositions[index]) + }) + }) + }) + }) +}) diff --git a/services/web/test/frontend/features/source-editor/extensions/realtime.test.ts b/services/web/test/frontend/features/source-editor/extensions/realtime.test.ts new file mode 100644 index 0000000000..7e301fd2b0 --- /dev/null +++ b/services/web/test/frontend/features/source-editor/extensions/realtime.test.ts @@ -0,0 +1,66 @@ +import sinon from 'sinon' +import { expect } from 'chai' +import { EditorFacade } from '../../../../../frontend/js/features/source-editor/extensions/realtime' +import { EditorView } from '@codemirror/view' +import { EditorState } from '@codemirror/state' + +describe('CodeMirror EditorFacade', function () { + let state: EditorState, view: EditorView + beforeEach(function () { + state = EditorState.create() + view = new EditorView({ state }) + }) + + it('should allow us to manipulate the CodeMirror document', function () { + const editor = new EditorFacade(view) + const text = 'basic test, nothing more' + + editor.cmInsert(0, text) + + expect(editor.getValue()).to.equal(text) + + editor.cmDelete(0, 'b') + + expect(editor.getValue()).to.equal(text.slice(1)) + }) + + it('should allow us to attach change listeners', function () { + const editor = new EditorFacade(view) + const listenerA = sinon.stub() + const listenerB = sinon.stub() + + editor.on('change', listenerA) + editor.on('change', listenerB) + + expect(listenerA).to.not.have.been.called + expect(listenerB).to.not.have.been.called + + const magicNumber = Math.random() + editor.emit('change', magicNumber) + + expect(listenerA).to.have.been.calledWith(magicNumber) + expect(listenerB).to.have.been.calledWith(magicNumber) + }) + + it('should attach to ShareJs document', function () { + const editor = new EditorFacade(view) + const text = 'something nice' + const shareDoc = { + on: sinon.stub(), + getText: sinon.stub().returns(text), + removeListener: sinon.stub(), + detach_cm6: undefined, + } + + editor.cmInsert(0, text) + + // @ts-ignore + editor.attachShareJs(shareDoc) + + expect(shareDoc.on.callCount).to.equal(2) + expect(shareDoc.on).to.have.been.calledWith('insert') + expect(shareDoc.on).to.have.been.calledWith('delete') + + expect(shareDoc.detach_cm6).to.be.a('function') + }) +}) diff --git a/services/web/test/frontend/features/source-editor/extensions/scroll-position.test.ts b/services/web/test/frontend/features/source-editor/extensions/scroll-position.test.ts new file mode 100644 index 0000000000..07147aee60 --- /dev/null +++ b/services/web/test/frontend/features/source-editor/extensions/scroll-position.test.ts @@ -0,0 +1,119 @@ +import sinon from 'sinon' +import { fireEvent, waitFor } from '@testing-library/react' +import { expect } from 'chai' +import { EditorView } from '@codemirror/view' +import { EditorState } from '@codemirror/state' +import { + restoreScrollPosition, + scrollPosition, +} from '../../../../../frontend/js/features/source-editor/extensions/scroll-position' + +const doc = ` +\\documentclass{article} +\\title{Your Paper} +\\author{You} +\\begin{document} +\\maketitle +\\begin{abstract} +Your abstract. +\\end{abstract} +\\section{Introduction} +Your introduction goes here! +\\end{document}` + +const mockDoc = () => { + return { + doc_id: 'test-doc', + } +} + +describe('CodeMirror scroll position extension', function () { + beforeEach(function () { + sinon.stub(HTMLElement.prototype, 'scrollHeight').returns(800) + sinon.stub(HTMLElement.prototype, 'scrollWidth').returns(500) + sinon.stub(HTMLElement.prototype, 'clientHeight').returns(200) + sinon.stub(HTMLElement.prototype, 'clientWidth').returns(500) + + sinon + .stub(HTMLElement.prototype, 'getBoundingClientRect') + .returns({ top: 100, left: 0, right: 500, bottom: 200 } as DOMRect) + + // Range.getClientRects doesn't exist yet in jsdom + window.Range.prototype.getClientRects = sinon.stub().returns([]) + }) + + afterEach(function () { + sinon.restore() + // @ts-ignore + delete window.Range.prototype.getClientRects + }) + + it('stores scroll position when the view is destroyed', async function () { + const currentDoc = mockDoc() + + sinon.stub(window.Storage.prototype, 'getItem').callsFake(key => { + switch (key) { + case 'doc.position.test-doc': + return JSON.stringify({ + cursorPosition: { row: 2, column: 2 }, + firstVisibleLine: 5, + }) + default: + return null + } + }) + + const view = new EditorView({ + state: EditorState.create({ + doc, + extensions: [scrollPosition({ currentDoc })], + }), + }) + + const setItem = sinon.spy(window.Storage.prototype, 'setItem') + fireEvent.scroll(view.scrollDOM, { target: { scrollTop: 10 } }) + + view.destroy() + + const expected = JSON.stringify({ + cursorPosition: { row: 2, column: 2 }, + firstVisibleLine: 12, + }) + + await waitFor(() => { + expect(setItem).to.have.been.calledWith('doc.position.test-doc', expected) + }) + }) + + it('restores scroll position', async function () { + const currentDoc = mockDoc() + + const getItem = sinon + .stub(window.Storage.prototype, 'getItem') + .callsFake(key => { + switch (key) { + case 'editor.position.test-doc': + return JSON.stringify({ firstVisibleLine: 12 }) + default: + return null + } + }) + + const view = new EditorView({ + state: EditorState.create({ + doc, + extensions: [scrollPosition({ currentDoc })], + }), + }) + view.dispatch(restoreScrollPosition()) + + await waitFor(() => { + expect(getItem).to.have.been.calledWith('doc.position.test-doc') + }) + + // TODO: scrollTop should be a higher value but requires more mocking + // await waitFor(() => { + // expect(view.scrollDOM.scrollTop).to.eq(0) + // }) + }) +}) diff --git a/services/web/test/frontend/features/source-editor/extensions/spelling/cache.test.ts b/services/web/test/frontend/features/source-editor/extensions/spelling/cache.test.ts new file mode 100644 index 0000000000..97ab853944 --- /dev/null +++ b/services/web/test/frontend/features/source-editor/extensions/spelling/cache.test.ts @@ -0,0 +1,61 @@ +import { WordCache } from '../../../../../../frontend/js/features/source-editor/extensions/spelling/cache' +import { expect } from 'chai' +import { Word } from '../../../../../../frontend/js/features/source-editor/extensions/spelling/spellchecker' + +describe('WordCache', function () { + describe('basic operations', function () { + let cache: WordCache, lang: string + beforeEach(function () { + cache = new WordCache() + lang = 'xx' + }) + + it('should store values in cache', function () { + let word = 'foo' + expect(cache.get(lang, word)).to.not.exist + cache.set(lang, word, true) + expect(cache.get(lang, word)).to.equal(true) + + word = 'bar' + expect(cache.get(lang, word)).to.not.exist + cache.set(lang, word, ['a', 'b']) + expect(cache.get(lang, word)).to.deep.equal(['a', 'b']) + }) + + it('should store words in separate languages', function () { + const word = 'foo' + const otherLang = 'zz' + + cache.set(lang, word, 101) + expect(cache.get(lang, word)).to.equal(101) + expect(cache.get(otherLang, word)).to.not.exist + + cache.set(otherLang, word, 202) + expect(cache.get(lang, word)).to.equal(101) + expect(cache.get(otherLang, word)).to.equal(202) + }) + + it('should check words against cache', function () { + cache.set(lang, 'foo', ['a', 'b']) + cache.set(lang, 'bar', true) + cache.set(lang, 'baz', true) + const wordsToCheck = [ + { text: 'foo', from: 0 }, + { text: 'baz', from: 1 }, + { text: 'quux', from: 2 }, + { text: 'foo', from: 3 }, + { text: 'zaz', from: 4 }, + ] as Word[] + const result = cache.checkWords(lang, wordsToCheck) + expect(result).to.have.keys('knownMisspelledWords', 'unknownWords') + expect(result.knownMisspelledWords).to.deep.equal([ + { text: 'foo', suggestions: ['a', 'b'], from: 0 }, + { text: 'foo', suggestions: ['a', 'b'], from: 3 }, + ]) + expect(result.unknownWords).to.deep.equal([ + { text: 'quux', from: 2 }, + { text: 'zaz', from: 4 }, + ]) + }) + }) +}) diff --git a/services/web/test/frontend/features/source-editor/extensions/spelling/line-tracker.test.ts b/services/web/test/frontend/features/source-editor/extensions/spelling/line-tracker.test.ts new file mode 100644 index 0000000000..793e4ea292 --- /dev/null +++ b/services/web/test/frontend/features/source-editor/extensions/spelling/line-tracker.test.ts @@ -0,0 +1,182 @@ +import { LineTracker } from '../../../../../../frontend/js/features/source-editor/extensions/spelling/line-tracker' +// import sinon from 'sinon' +import { expect } from 'chai' +import { EditorView } from '@codemirror/view' +import { EditorState } from '@codemirror/state' +import { Word } from '../../../../../../frontend/js/features/source-editor/extensions/spelling/spellchecker' + +const doc = [ + 'Hello test one two', + 'three four five six', + 'seven eight nine.', +].join('\n') + +describe('LineTracker', function () { + describe('basic operations', function () { + let state: EditorState, + view: EditorView, + lineTracker: LineTracker, + check: (spec: [number, any][]) => void + beforeEach(function () { + state = EditorState.create({ + doc, + extensions: [ + EditorView.updateListener.of(update => { + lineTracker.applyUpdate(update) + }), + ], + }) + view = new EditorView({ state }) + lineTracker = new LineTracker(view.state.doc) + check = spec => { + spec.forEach(([n, expectedValue]) => { + expect(lineTracker.lineHasChanged(n)).to.equal(expectedValue) + }) + } + }) + + it('start with the correct number of lines', function () { + expect(state.doc.lines).to.equal(3) + expect(lineTracker.count()).to.equal(state.doc.lines) + }) + + it('starts with all lines marked as changed', function () { + expect(state.doc.lines).to.equal(3) + check([ + [1, true], + [2, true], + [3, true], + ]) + }) + + it('clears a line', function () { + check([[1, true]]) + lineTracker.clearLine(1) + check([[1, false]]) + }) + + it('clears lines based on a list of words', function () { + lineTracker.clearChangedLinesForWords([ + { lineNumber: 1 }, + { lineNumber: 3 }, + ] as Word[]) + check([ + [1, false], + [2, true], + [3, false], + ]) + }) + + it('should update lines in response to text insertion', function () { + lineTracker.clearChangedLinesForWords([ + { lineNumber: 1 }, + { lineNumber: 2 }, + { lineNumber: 3 }, + ] as Word[]) + check([ + [1, false], + [2, false], + [3, false], + ]) + + let transaction = view.state.update({ + changes: [{ from: 0, insert: 'x' }], + }) + view.dispatch(transaction) + check([ + [1, true], + [2, false], + [3, false], + ]) + + transaction = view.state.update({ + changes: [{ from: view.state.doc.length - 2, insert: 'x' }], + }) + view.dispatch(transaction) + check([ + [1, true], + [2, false], + [3, true], + ]) + }) + + it('should update lines in response to large text insertion', function () { + lineTracker.clearChangedLinesForWords([ + { lineNumber: 1 }, + { lineNumber: 2 }, + { lineNumber: 3 }, + ] as Word[]) + check([ + [1, false], + [2, false], + [3, false], + ]) + + const text = new Array(1000).fill('x').join('\n') + + const transaction = view.state.update({ + changes: [{ from: 0, insert: text }], + }) + view.dispatch(transaction) + expect(lineTracker.count()).to.equal(1002) + const expectations: [number, boolean][] = [] + for (let i = 1; i <= 1000; i++) { + expectations.push([i, true]) + } + expectations.push([1001, false]) + expectations.push([1002, false]) + check(expectations) + }) + + it('should update lines in response to removal of a line', function () { + lineTracker.clearChangedLinesForWords([ + { lineNumber: 1 }, + { lineNumber: 2 }, + { lineNumber: 3 }, + ] as Word[]) + check([ + [1, false], + [2, false], + [3, false], + ]) + + // Overwrite the line plus some part of the second line + const transaction = view.state.update({ + changes: [{ from: 0, to: doc[0].length + 3, insert: 'x' }], + }) + view.dispatch(transaction) + check([ + [1, true], + [2, false], + ]) + }) + + it('should handle multiple changes', function () { + lineTracker.clearChangedLinesForWords([ + { lineNumber: 1 }, + { lineNumber: 2 }, + { lineNumber: 3 }, + ] as Word[]) + check([ + [1, false], + [2, false], + [3, false], + ]) + + const transaction = view.state.update({ + changes: [ + { from: 0, insert: 'x' }, + { from: doc[0].length + 2, insert: 'xxxxx\nxxxxx\nxxxx' }, + ], + }) + view.dispatch(transaction) + check([ + [1, true], + [2, true], + [3, true], + [4, false], + [5, false], + ]) + }) + }) +}) diff --git a/services/web/test/frontend/features/source-editor/extensions/spelling/spellchecker.test.ts b/services/web/test/frontend/features/source-editor/extensions/spelling/spellchecker.test.ts new file mode 100644 index 0000000000..d143baf1d1 --- /dev/null +++ b/services/web/test/frontend/features/source-editor/extensions/spelling/spellchecker.test.ts @@ -0,0 +1,271 @@ +import { + getWordsFromLine, + viewportLinesToCheck, + buildSpellCheckResult, + Word, +} from '../../../../../../frontend/js/features/source-editor/extensions/spelling/spellchecker' +import { LineTracker } from '../../../../../../frontend/js/features/source-editor/extensions/spelling/line-tracker' +import { expect } from 'chai' +import { EditorView } from '@codemirror/view' +import { EditorState, Line } from '@codemirror/state' +import _ from 'lodash' +import { IgnoredWords } from '../../../../../../frontend/js/features/dictionary/ignored-words' +import { LaTeXLanguage } from '../../../../../../frontend/js/features/source-editor/languages/latex/latex-language' +import { LanguageSupport } from '@codemirror/language' + +const latex = new LanguageSupport(LaTeXLanguage) + +const makeView = (text: string) => { + const view = new EditorView({ + state: EditorState.create({ + doc: text, + extensions: [latex], + }), + }) + return view +} + +describe('SpellChecker', function () { + describe('getWordsFromLine', function () { + let lang: string, ignoredWords: IgnoredWords + beforeEach(function () { + /* Note: ignore the word 'test' */ + lang = 'en' + ignoredWords = new Set([]) as unknown as IgnoredWords + }) + + it('should get words from a line', function () { + const view = makeView('Hello test one two') + const line = view.state.doc.line(1) + const words = getWordsFromLine(view, line, ignoredWords, lang) + expect(words).to.deep.equal([ + { text: 'Hello', from: 0, to: 5, lineNumber: 1, lang: 'en' }, + { text: 'test', from: 6, to: 10, lineNumber: 1, lang: 'en' }, + { text: 'one', from: 11, to: 14, lineNumber: 1, lang: 'en' }, + { text: 'two', from: 15, to: 18, lineNumber: 1, lang: 'en' }, + ]) + }) + + it('should ignore words in ignoredWords', function () { + ignoredWords = new Set(['test']) as unknown as IgnoredWords + const view = makeView('Hello test one two') + const line = view.state.doc.line(1) + const words = getWordsFromLine(view, line, ignoredWords, lang) + expect(words).to.deep.equal([ + { text: 'Hello', from: 0, to: 5, lineNumber: 1, lang: 'en' }, + { text: 'one', from: 11, to: 14, lineNumber: 1, lang: 'en' }, + { text: 'two', from: 15, to: 18, lineNumber: 1, lang: 'en' }, + ]) + }) + + it('should get no words from an empty line', function () { + const view = makeView(' ') + const line = view.state.doc.line(1) + const words = getWordsFromLine(view, line, ignoredWords, lang) + expect(words).to.deep.equal([]) + }) + + it('should ignore content of some commands in the text', function () { + const view = makeView('\\usepackage[foo]{ bar } seven eight') + const line = view.state.doc.line(1) + const words = getWordsFromLine(view, line, ignoredWords, lang) + expect(words).to.deep.equal([ + { text: 'seven', from: 24, to: 29, lineNumber: 1, lang: 'en' }, + { text: 'eight', from: 30, to: 35, lineNumber: 1, lang: 'en' }, + ]) + }) + + it('should ignore command names in the text', function () { + const view = makeView('\\foo nine \\bar ten \\baz[]{}') + const line = view.state.doc.line(1) + const words = getWordsFromLine(view, line, ignoredWords, lang) + expect(words).to.deep.equal([ + { text: 'nine', from: 5, to: 9, lineNumber: 1, lang: 'en' }, + { text: 'ten', from: 15, to: 18, lineNumber: 1, lang: 'en' }, + ]) + }) + }) + + describe('viewportLinesToCheck', function () { + const expectLines = (lines: Line[], expectations: any) => { + expect(lines.map(l => l.number)).to.deep.equal(expectations) + } + const expectLineRange = (lines: Line[], from: number, to: number) => { + expect(lines.map(l => l.number)).to.deep.equal(_.range(from, to + 1)) + } + + let view: EditorView + beforeEach(function () { + view = makeView(new Array(1000).fill('aa bb cc dd').join('\n')) + // Test preconditions on these structures + const viewport = view.viewport + expect(view.state.doc.lines).to.equal(1000) + const firstVisibleLine = view.state.doc.lineAt(viewport.from).number + const lastVisibleLine = view.state.doc.lineAt(viewport.to).number + expect(firstVisibleLine).to.equal(1) + expect(lastVisibleLine).to.equal(36) + }) + + describe('when all lines are changed', function () { + let lineTracker: LineTracker + beforeEach(function () { + lineTracker = new LineTracker(view.state.doc) + }) + + it('should check all lines in the viewport when not first check', function () { + const firstCheck = false + const linesToCheck = viewportLinesToCheck(lineTracker, firstCheck, view) + expectLineRange(linesToCheck, 1, 36) + }) + + it('should check more lines than the viewport on first check', function () { + const firstCheck = true + const lineTracker = new LineTracker(view.state.doc) + const linesToCheck = viewportLinesToCheck(lineTracker, firstCheck, view) + expectLineRange(linesToCheck, 1, 108) + }) + }) + + describe('when no lines have changed', function () { + let lineTracker: LineTracker + beforeEach(function () { + lineTracker = new LineTracker(view.state.doc) + lineTracker.clearAllLines() + }) + + it('on first check, should not check any lines', function () { + const firstCheck = true + const linesToCheck = viewportLinesToCheck(lineTracker, firstCheck, view) + expect(linesToCheck).to.deep.equal([]) + }) + + it('on not first check, should not check any lines', function () { + const firstCheck = false + const linesToCheck = viewportLinesToCheck(lineTracker, firstCheck, view) + expectLines(linesToCheck, []) + }) + }) + + describe('when some lines have changed in viewport', function () { + let lineTracker: LineTracker + beforeEach(function () { + lineTracker = new LineTracker(view.state.doc) + lineTracker.clearAllLines() + lineTracker.markLineAsUpdated(3) + lineTracker.markLineAsUpdated(7) + }) + + it('should check correct lines', function () { + const firstCheck = false + const linesToCheck = viewportLinesToCheck(lineTracker, firstCheck, view) + expectLines(linesToCheck, [3, 7]) + }) + }) + + describe('when some lines have changed outside viewport', function () { + let lineTracker: LineTracker + beforeEach(function () { + lineTracker = new LineTracker(view.state.doc) + lineTracker.clearAllLines() + lineTracker.markLineAsUpdated(300) + lineTracker.markLineAsUpdated(307) + }) + + it('should not check lines', function () { + const firstCheck = false + const linesToCheck = viewportLinesToCheck(lineTracker, firstCheck, view) + expectLines(linesToCheck, []) + }) + }) + + describe('when some lines have changed inside and outside viewport', function () { + let lineTracker: LineTracker + beforeEach(function () { + lineTracker = new LineTracker(view.state.doc) + lineTracker.clearAllLines() + lineTracker.markLineAsUpdated(10) + lineTracker.markLineAsUpdated(12) + lineTracker.markLineAsUpdated(300) + lineTracker.markLineAsUpdated(307) + }) + + it('should check only lines in viewport', function () { + const firstCheck = false + const linesToCheck = viewportLinesToCheck(lineTracker, firstCheck, view) + expectLines(linesToCheck, [10, 12]) + }) + }) + }) + + describe('buildSpellCheckResult', function () { + it('should build an empty result', function () { + const knownMisspelledWords: Word[] = [] + const unknownWords: Word[] = [] + const misspellings: { index: number; suggestions: string[] }[] = [] + const result = buildSpellCheckResult( + knownMisspelledWords, + unknownWords, + misspellings + ) + expect(result).to.deep.equal({ + cacheAdditions: [], + misspelledWords: [], + }) + }) + it('should build a realistic result', function () { + const _makeWord = (text: string, suggestions?: string[]) => { + const word = new Word({ + text, + from: 0, + to: 0, + lineNumber: 0, + lang: 'xx', + }) + if (suggestions != null) { + word.suggestions = suggestions + } + return word + } + // We know this word is misspelled + const knownMisspelledWords = [_makeWord('fff', ['food', 'fleece'])] + // These words we didn't know + const unknownWords = [ + _makeWord('aaa'), + _makeWord('bbb'), + _makeWord('ccc'), + _makeWord('ddd'), + ] + // These are the suggestions we got back from the backend + const misspellings = [ + { index: 1, suggestions: ['box', 'bass'] }, + { index: 3, suggestions: ['docs', 'dance'] }, + ] + // Build the result structure + const result = buildSpellCheckResult( + knownMisspelledWords, + unknownWords, + misspellings + ) + expect(result).to.have.keys('cacheAdditions', 'misspelledWords') + // Check cache additions + expect(result.cacheAdditions.map(([k, v]) => [k.text, v])).to.deep.equal([ + // Put these in cache as known misspellings + ['bbb', ['box', 'bass']], + ['ddd', ['docs', 'dance']], + // Put these in cache as known-correct + ['aaa', true], + ['ccc', true], + ]) + // Check misspellings + expect( + result.misspelledWords.map(w => [w.text, w.suggestions]) + ).to.deep.equal([ + // Words in the payload that we now know were misspelled + ['bbb', ['box', 'bass']], + ['ddd', ['docs', 'dance']], + // Word we already knew was misspelled, preserved here + ['fff', ['food', 'fleece']], + ]) + }) + }) +}) diff --git a/services/web/test/frontend/features/source-editor/helpers/active-editor-line.ts b/services/web/test/frontend/features/source-editor/helpers/active-editor-line.ts new file mode 100644 index 0000000000..0ad0abf7be --- /dev/null +++ b/services/web/test/frontend/features/source-editor/helpers/active-editor-line.ts @@ -0,0 +1,25 @@ +export const activeEditorLine = () => { + // wait for the selection to be in the editor content DOM + cy.window().then(win => { + cy.get('.cm-content').should($el => { + const contentNode = $el.get(0) + const range = win.getSelection()?.getRangeAt(0) + expect(range?.intersectsNode(contentNode)).to.be.true + }) + }) + + // find the closest line block ancestor of the selection + return cy.window().then(win => { + const activeNode = win.getSelection()?.focusNode + + if (!activeNode) { + return cy.wrap(null) + } + + // use the parent element if this is a node, e.g. text + const activeElement = + 'closest' in activeNode ? activeNode : activeNode.parentElement + + return cy.wrap(activeElement?.closest('.cm-line')) + }) +} diff --git a/services/web/test/frontend/features/source-editor/helpers/codemirror.ts b/services/web/test/frontend/features/source-editor/helpers/codemirror.ts new file mode 100644 index 0000000000..93e5bf743c --- /dev/null +++ b/services/web/test/frontend/features/source-editor/helpers/codemirror.ts @@ -0,0 +1,249 @@ +/* eslint-disable no-dupe-class-members */ +import { LanguageSupport } from '@codemirror/language' +import { EditorSelection, Line, SelectionRange } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import { Assertion } from 'chai' +import { LaTeXLanguage } from '../../../../../frontend/js/features/source-editor/languages/latex/latex-language' + +export class CodemirrorTestSession { + public view: EditorView + + constructor(content: string[] | string) { + this.view = createView(content) + } + + insert(content: string): void { + this.view.dispatch( + this.view.state.changeByRange(range => { + const changeDescription = [ + { + from: range.from, + to: range.to, + insert: content, + }, + ] + + const changes = this.view.state.changes(changeDescription) + + return { + range: EditorSelection.cursor(range.head).map(changes), + changes, + } + }) + ) + } + + insertAt(position: number, content: string) { + const changes = [{ from: position, insert: content }] + this.view.dispatch({ + changes, + selection: this.view.state.selection.map( + this.view.state.changes(changes), + 1 + ), + }) + } + + insertAtLine(line: number, offset: number, content: string): void + insertAtLine(line: number, content: string): void + insertAtLine( + lineNumber: number, + offsetOrContent: string | number, + content?: string + ) { + const line = this.view.state.doc.line(lineNumber) + if (typeof offsetOrContent === 'string' && typeof content === 'string') { + throw new Error( + 'If a third argument is provided, the second must be an integer' + ) + } + // Insert at end of line + if (typeof offsetOrContent === 'string') { + content = offsetOrContent + offsetOrContent = line.to + } + + if (typeof content !== 'string') { + throw new Error('content must be provided to insertAtLine') + } + + if (offsetOrContent < line.from || offsetOrContent > line.to) { + throw new Error('Offset is outside the range of the line') + } + this.insertAt(line.from + offsetOrContent, content) + } + + delete(position: number, length: number) { + this.view.dispatch({ + changes: [{ from: position - length, to: position }], + }) + } + + applyCommand(command: (view: EditorView) => any) { + return command(this.view) + } + + setCursor(position: number): void + setCursor(line: number, offset: number): void + setCursor(positionOrLine: number, offset?: number) { + if (offset !== undefined) { + const line = this.view.state.doc.line(positionOrLine) + positionOrLine = line.from + offset + } + this.view.dispatch({ + selection: EditorSelection.cursor(positionOrLine), + }) + } + + setSelection(selection: EditorSelection) { + this.view.dispatch({ + selection, + }) + } +} + +const latex = new LanguageSupport(LaTeXLanguage) +function createView(content: string[] | string): EditorView { + if (Array.isArray(content)) { + content = content.join('\n') + } + return new EditorView({ + doc: stripSelectionMarkers(content), + selection: createSelections(content) ?? EditorSelection.cursor(0), + extensions: [latex], + }) +} + +function stripSelectionMarkers(content: string) { + return content.replaceAll(/[<|>]/g, '') +} + +function hasSelectionMarkers(content: string) { + return !!content.match(/[<|>]/g) +} + +function createSelections(content: string, offset = 0) { + const selections = [] + let index = 0 + for (let i = 0; i < content.length; i++) { + if (content[i] === '|') { + selections.push(EditorSelection.cursor(index + offset)) + } + if (content[i] === '<') { + // find end + const startOfRange = index + let foundEnd = false + for (++i; i < content.length; ++i) { + if (content[i] === '|') { + throw new Error( + "Invalid cursor indicator '|' within a range started with '<'" + ) + } + if (content[i] === '<') { + throw new Error( + "Invalid start range indicator '<' inside another range" + ) + } + if (content[i] === '>') { + foundEnd = true + selections.push( + EditorSelection.range(startOfRange + offset, index + offset) + ) + break + } + index++ + } + if (!foundEnd) { + throw new Error("Missing end range indicator '>'") + } + } + index++ + } + if (selections.length) { + return EditorSelection.create(selections) + } + return null +} + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + export namespace Chai { + interface Assertion { + line(lineNumber: number): Assertion + } + } +} + +export function viewHelpers(chai: Chai.ChaiStatic, utils: Chai.ChaiUtils) { + utils.addMethod( + chai.Assertion.prototype, + 'line', + function getLine(this: Chai.Assertion, line: number) { + const object = utils.flag(this, 'object') + new Assertion(object).to.be.instanceOf(CodemirrorTestSession) + const testSession = object as CodemirrorTestSession + const lineInEditor = testSession.view.state.doc.line(line) + utils.flag(this, 'object', lineInEditor.text) + utils.flag(this, 'cmSession', testSession) + utils.flag(this, 'line', lineInEditor) + } + ) + utils.overwriteMethod(chai.Assertion.prototype, 'equal', (_super: any) => { + return function newEqual( + this: Chai.Assertion, + value: string, + requireSelections?: boolean + ) { + const session = utils.flag(this, 'cmSession') as + | CodemirrorTestSession + | undefined + utils.flag(this, 'cmSession', null) + const line = utils.flag(this, 'line') as Line | undefined + utils.flag(this, 'line', null) + + if (!session || !line) { + // eslint-disable-next-line prefer-rest-params + return _super.apply(this, arguments) + } + + const lineContent = stripSelectionMarkers(value) + + if (requireSelections === undefined) { + requireSelections = hasSelectionMarkers(value) + } + + // We can now check selections as well + const selections = createSelections(value, line.from) + const contentAssertion = new Assertion(line.text) + utils.transferFlags(this, contentAssertion) + contentAssertion.to.equal(lineContent) + + if (selections) { + const selectionAssertion = new Assertion( + session.view.state.selection.ranges + ) + utils.transferFlags(this, selectionAssertion, false) + for (const rangeToMatch of selections.ranges) { + selectionAssertion.satisfies( + (ranges: SelectionRange[]) => + ranges.some( + possibleMatch => + possibleMatch.eq(rangeToMatch) || + // Allow reverse selections as well, as we don't syntactically + // distinguish them + EditorSelection.range( + possibleMatch.to, + possibleMatch.from + ).eq(rangeToMatch) + ), + `Selections [${session.view.state.selection.ranges + .map(range => `{ from: ${range.from}, to: ${range.to}}`) + .join(', ')}] did not include selection {from: ${ + rangeToMatch.from + }, to: ${rangeToMatch.to}}` + ) + } + } + } + }) +} diff --git a/services/web/test/frontend/features/source-editor/helpers/meta-key.ts b/services/web/test/frontend/features/source-editor/helpers/meta-key.ts new file mode 100644 index 0000000000..19920f0880 --- /dev/null +++ b/services/web/test/frontend/features/source-editor/helpers/meta-key.ts @@ -0,0 +1,3 @@ +const isMac = /Mac/.test(window.navigator?.platform) + +export const metaKey = isMac ? 'meta' : 'ctrl' diff --git a/services/web/test/frontend/features/source-editor/helpers/mock-doc.ts b/services/web/test/frontend/features/source-editor/helpers/mock-doc.ts new file mode 100644 index 0000000000..df88b98ff9 --- /dev/null +++ b/services/web/test/frontend/features/source-editor/helpers/mock-doc.ts @@ -0,0 +1,81 @@ +import { ShareDoc } from '../../../../../types/share-doc' +import { EventEmitter } from 'events' + +export const docId = 'test-doc' + +export function mockDocContent(content: string) { + return ` +\\documentclass{article} + +\\title{Your Paper} +\\author{You} + +\\begin{document} +\\maketitle + +\\begin{abstract} +Your abstract. +\\end{abstracts} + +\\section{Introduction} + +Your introduction goes here! + +\\section{Results} + +Your results go here! \\cite{foo} + +${content} + +\\end{document}` +} + +const contentLines = Array.from(Array(100), (e, i) => `contentLine ${i}`) +const defaultContent = mockDocContent(contentLines.join('\n')) + +const MAX_DOC_LENGTH = 2 * 1024 * 1024 // window.maxDocLength + +class MockShareDoc extends EventEmitter { + constructor(public text: string) { + super() + } + + getText() { + return this.text + } + + insert() { + // do nothing + } + + del() { + // do nothing + } +} + +export const mockDoc = (content = defaultContent) => { + const mockShareJSDoc: ShareDoc = new MockShareDoc(content) + + return { + doc_id: docId, + getSnapshot: () => { + return content + }, + attachToCM6: (cm6: any) => { + cm6.attachShareJs(mockShareJSDoc, MAX_DOC_LENGTH) + }, + detachFromCM6: () => { + // Do nothing + }, + on: () => { + // Do nothing + }, + off: () => { + // Do nothing + }, + ranges: { + changes: [], + comments: [], + }, + } +} diff --git a/services/web/test/frontend/features/source-editor/helpers/mock-scope.ts b/services/web/test/frontend/features/source-editor/helpers/mock-scope.ts new file mode 100644 index 0000000000..399c4afbfd --- /dev/null +++ b/services/web/test/frontend/features/source-editor/helpers/mock-scope.ts @@ -0,0 +1,44 @@ +import { docId, mockDoc } from './mock-doc' +import { Folder } from '../../../../../types/folder' + +export const mockScope = (content?: string) => { + return { + settings: { + fontSize: 12, + fontFamily: 'monaco', + lineHeight: 'normal', + editorTheme: 'textmate', + overallTheme: '', + mode: 'default', + autoComplete: true, + autoPairDelimiters: true, + trackChanges: true, + syntaxValidation: false, + }, + editor: { + sharejs_doc: mockDoc(content), + open_doc_name: 'test.tex', + open_doc_id: docId, + showVisual: false, + }, + pdf: { + logEntryAnnotations: {}, + }, + project: { + _id: 'test-project', + name: 'Test Project', + spellCheckLanguage: 'en', + rootFolder: [] as Folder[], + }, + onlineUserCursorHighlights: {}, + permissionsLevel: 'owner', + $on: cy.stub(), + $broadcast: cy.stub(), + $emit: cy.stub(), + $root: { + _references: { + keys: ['foo'], + }, + }, + } +} diff --git a/services/web/test/frontend/features/source-editor/languages/latex/latex-folding.test.ts b/services/web/test/frontend/features/source-editor/languages/latex/latex-folding.test.ts new file mode 100644 index 0000000000..8825164797 --- /dev/null +++ b/services/web/test/frontend/features/source-editor/languages/latex/latex-folding.test.ts @@ -0,0 +1,329 @@ +import { expect } from 'chai' +import { EditorState, Text } from '@codemirror/state' +import { LaTeXLanguage } from '../../../../../../frontend/js/features/source-editor/languages/latex/latex-language' +import { + ensureSyntaxTree, + foldNodeProp, + LanguageSupport, +} from '@codemirror/language' +import { EditorView } from '@codemirror/view' +const latex = new LanguageSupport(LaTeXLanguage) + +const makeView = (lines: string[]): EditorView => { + const text = Text.of(lines) + const view = new EditorView({ + state: EditorState.create({ + doc: text, + extensions: [latex], + }), + }) + return view +} + +type Fold = { from: number; to: number } + +const _getFolds = (view: EditorView) => { + const ranges: Fold[] = [] + const tree = ensureSyntaxTree(view.state, view.state.doc.length) + if (!tree) { + throw new Error("Couldn't get Syntax Tree") + } + tree.iterate({ + enter: nodeRef => { + const prop = nodeRef.type.prop(foldNodeProp) + if (prop) { + const hasFold = prop(nodeRef.node, view.state) + if (hasFold) { + ranges.push({ from: hasFold.from, to: hasFold.to }) + } + } + }, + }) + return ranges +} + +describe('CodeMirror LaTeX-folding', function () { + describe('With empty document', function () { + let view: EditorView, content: string[] + beforeEach(function () { + content = [''] + view = makeView(content) + }) + + it('should not produce any folds', function () { + const folds = _getFolds(view) + expect(folds).to.be.empty + }) + }) + + describe('Sectioning command folding', function () { + describe('with no foldable sections', function () { + let view: EditorView, content: string[] + beforeEach(function () { + content = ['hello', 'test'] + view = makeView(content) + }) + + it('should not produce any folds', function () { + const folds = _getFolds(view) + expect(folds).to.be.empty + }) + }) + + describe('with one foldable section', function () { + let view: EditorView, content: string[] + + beforeEach(function () { + content = ['hello', '\\section{one}', 'a', 'b', 'c'] + view = makeView(content) + }) + + it('should produce one fold', function () { + const folds = _getFolds(view) + expect(folds.length).to.equal(1) + }) + + it('should fold from the section line to last line', function () { + const folds = _getFolds(view) + const fold = folds[0] + expect(view.state.doc.lineAt(fold.from).number).to.equal(2) + expect(view.state.doc.lineAt(fold.to).number).to.equal( + view.state.doc.lines + ) + }) + }) + + describe('with two foldable sections', function () { + let view: EditorView, content: string[] + + beforeEach(function () { + content = [ + 'hello', + '\\section{one}', + 'a', + 'b', + '\\section{two}', + 'c', + 'd', + ] + view = makeView(content) + }) + + it('should produce two folds', function () { + const folds = _getFolds(view) + expect(folds.length).to.equal(2) + expect(view.state.doc.lineAt(folds[0].from).number).to.equal(2) + expect(view.state.doc.lineAt(folds[0].to).number).to.equal(4) + expect(view.state.doc.lineAt(folds[1].from).number).to.equal(5) + expect(view.state.doc.lineAt(folds[1].to).number).to.equal( + view.state.doc.lines + ) + }) + }) + + describe('with realistic nesting', function () { + let view: EditorView, content: string[] + + beforeEach(function () { + content = [ + 'hello', + '\\chapter{1}', + ' a', + ' \\section{1.1}', + ' a', + ' \\subsection{1.1.1}', + ' a', + ' \\section{1.2}', + ' a', + ' \\subsection{1.2.1}', + ' a', + '\\chapter{2}', + ' a', + ' \\section{2.1}', + ' a', + ' \\section{2.2}', + ' a', + ] + view = makeView(content) + }) + + it('should produce many folds', function () { + const folds = _getFolds(view) + expect(folds.length).to.equal(8) + + const foldDescriptions = folds.map(fold => { + const fromLine = view.state.doc.lineAt(fold.from).number + const toLine = view.state.doc.lineAt(fold.to).number + return { fromLine, toLine } + }) + + expect(foldDescriptions).to.deep.equal([ + { fromLine: 2, toLine: 11 }, + { fromLine: 4, toLine: 7 }, + { fromLine: 6, toLine: 7 }, + { fromLine: 8, toLine: 11 }, + { fromLine: 10, toLine: 11 }, + { fromLine: 12, toLine: 17 }, + { fromLine: 14, toLine: 15 }, + { fromLine: 16, toLine: 17 }, + ]) + }) + }) + }) + + describe('Environment folding', function () { + describe('with single environment', function () { + let view: EditorView, content: string[] + beforeEach(function () { + content = ['\\begin{foo}', 'content', '\\end{foo}'] + view = makeView(content) + }) + + it('should fold the environment', function () { + const folds = _getFolds(view) + expect(folds.length).to.equal(1) + expect(folds).to.deep.equal([{ from: 11, to: 20 }]) + }) + }) + + describe('with nested environment', function () { + let view: EditorView, content: string[] + beforeEach(function () { + content = [ + '\\begin{foo}', + '\\begin{bar}', + 'content', + '\\end{bar}', + '\\end{foo}', + ] + view = makeView(content) + }) + + it('should fold the environment', function () { + const folds = _getFolds(view) + expect(folds.length).to.equal(2) + expect(folds).to.deep.equal([ + { from: 11, to: 42 }, + { from: 23, to: 32 }, + ]) + }) + }) + }) + + describe('Comment folding', function () { + describe('with a single set of comments', function () { + let view: EditorView, content: string[] + beforeEach(function () { + content = ['Hello', '% {', 'this is folded', '% }', 'End'] + view = makeView(content) + }) + + it('should fold the region marked by comments', function () { + const folds = _getFolds(view) + expect(folds.length).to.equal(1) + expect(folds).to.deep.equal([{ from: 9, to: 27 }]) + }) + }) + + describe('with several sets of comments', function () { + let view: EditorView, content: string[] + beforeEach(function () { + content = [ + 'Hello', + '% {', + 'this is folded', + '% }', + '', + '% {', + 'and this also', + '% }', + 'End', + ] + view = makeView(content) + }) + + it('should fold both regions marked by comments', function () { + const folds = _getFolds(view) + expect(folds.length).to.equal(2) + expect(folds).to.deep.equal([ + { from: 9, to: 27 }, + { from: 33, to: 50 }, + ]) + }) + }) + + describe('with nested sets of comments', function () { + let view: EditorView, content: string[] + beforeEach(function () { + content = [ + 'Hello', + '% {', + 'one', + '% {', + 'two', + '% {', + 'three', + '% }', + 'two', + '% }', + 'one', + '% }', + 'End', + ] + view = makeView(content) + }) + + it('should fold all the regions marked by comments, with nesting', function () { + const folds = _getFolds(view) + expect(folds.length).to.equal(3) + expect(folds).to.deep.equal([ + { from: 9, to: 50 }, + { from: 17, to: 42 }, + { from: 25, to: 34 }, + ]) + }) + }) + + describe('with fold comment spanning entire document', function () { + let view: EditorView, content: string[] + beforeEach(function () { + content = ['% {', 'Hello', '% }'] + view = makeView(content) + }) + + it('should fold', function () { + const folds = _getFolds(view) + expect(folds.length).to.equal(1) + expect(folds).to.deep.equal([{ from: 3, to: 12 }]) + }) + }) + + describe('with fold comment at start of document', function () { + let view: EditorView, content: string[] + beforeEach(function () { + content = ['% {', 'Hello', '% }', 'Test'] + view = makeView(content) + }) + + it('should fold', function () { + const folds = _getFolds(view) + expect(folds.length).to.equal(1) + expect(folds).to.deep.equal([{ from: 3, to: 12 }]) + }) + }) + + describe('with fold comment at end of document', function () { + let view: EditorView, content: string[] + beforeEach(function () { + content = ['Test', '% {', 'Hello', '% }'] + view = makeView(content) + }) + + it('should fold', function () { + const folds = _getFolds(view) + expect(folds.length).to.equal(1) + expect(folds).to.deep.equal([{ from: 8, to: 17 }]) + }) + }) + }) +}) diff --git a/services/web/test/frontend/features/source-editor/languages/latex/latex-linter.test.ts b/services/web/test/frontend/features/source-editor/languages/latex/latex-linter.test.ts new file mode 100644 index 0000000000..c596217942 --- /dev/null +++ b/services/web/test/frontend/features/source-editor/languages/latex/latex-linter.test.ts @@ -0,0 +1,685 @@ +import { assert } from 'chai' +import LintWorker from '../../../../../../frontend/js/features/source-editor/languages/latex/linter/latex-linter.worker.js' +import { errorsToDiagnostics } from '../../../../../../frontend/js/features/source-editor/languages/latex/linter/errors-to-diagnostics' +import { Diagnostic } from '@codemirror/lint' +import { mergeCompatibleOverlappingDiagnostics } from '../../../../../../frontend/js/features/source-editor/languages/latex/linter/merge-overlapping-diagnostics' + +const { Parse } = new LintWorker() + +describe('LatexLinter', function () { + it('should accept a simple environment match without errors', function () { + const { errors } = Parse('\\begin{foo}\n' + '\\end{foo}\n') + assert.equal(errors.length, 0) + }) + + it('should accept an invalid \\it* command', function () { + const { errors } = Parse('\\it*hello\n' + '\\bye\n') + assert.equal(errors.length, 0) + }) + + it('should accept newcomlumntype', function () { + const { errors } = Parse( + 'hello\n' + + '\\newcolumntype{M}[1]{>{\\begin{varwidth}[t]{#1}}l<{\\end{varwidth}}}\n' + + 'bye' + ) + assert.equal(errors.length, 0) + }) + + it('should accept newenvironment', function () { + const { errors } = Parse( + '\\newenvironment{Algorithm}[2][tbh]%\n' + + '{\\begin{myalgo}[#1]\n' + + '\\centering\n' + + '\\part{title}\\begin{minipage}{#2}\n' + + '\\begin{algorithm}[H]}%\n' + + '{\\end{algorithm}\n' + + '\\end{minipage}\n' + + '\\end{myalgo}}' + ) + assert.equal(errors.length, 0) + }) + + it('should accept newenvironment II', function () { + const { errors } = Parse( + '\\newenvironment{claimproof}[1][\\myproofname]{\\begin{proof}[#1]\\renewcommand*{\\qedsymbol}{\\(\\diamondsuit\\)}}{\\end{proof}}' + ) + assert.equal(errors.length, 0) + }) + + it('should accept superscript inside math mode', function () { + const { errors } = Parse('this is $a^b$ test') + assert.equal(errors.length, 0) + }) + + it('should accept subscript inside math mode', function () { + const { errors } = Parse('this is $a_b$ test') + assert.equal(errors.length, 0) + }) + + it('should return an error for superscript outside math mode', function () { + const { errors } = Parse('this is a^b test') + assert.equal(errors.length, 1) + assert.equal(errors[0].text, '^ must be inside math mode') + assert.equal(errors[0].type, 'error') + }) + + it('should return an error subscript outside math mode', function () { + const { errors } = Parse('this is a_b test') + assert.equal(errors.length, 1) + assert.equal(errors[0].text, '_ must be inside math mode') + assert.equal(errors[0].type, 'error') + }) + + it('should accept math mode inside \\hbox outside math mode', function () { + const { errors } = Parse('this is \\hbox{for every $bar$}') + assert.equal(errors.length, 0) + }) + + it('should accept math mode inside \\hbox inside math mode', function () { + const { errors } = Parse('this is $foo = \\hbox{for every $bar$}$ test') + assert.equal(errors.length, 0) + }) + + it('should accept math mode inside \\text inside math mode', function () { + const { errors } = Parse('this is $foo = \\text{for every $bar$}$ test') + assert.equal(errors.length, 0) + }) + + it('should accept verbatim', function () { + const { errors } = Parse( + 'this is text\n' + + '\\begin{verbatim}\n' + + 'this is verbatim\n' + + '\\end{verbatim}\n' + + 'this is more text\n' + ) + assert.equal(errors.length, 0) + }) + + it('should accept verbatim with environment inside', function () { + const { errors } = Parse( + 'this is text\n' + + '\\begin{verbatim}\n' + + 'this is verbatim\n' + + '\\begin{foo}\n' + + 'this is verbatim too\n' + + '\\end{foo}\n' + + '\\end{verbatim}\n' + + 'this is more text\n' + ) + assert.equal(errors.length, 0) + }) + + it('should accept verbatim with \\begin{verbatim} inside', function () { + const { errors } = Parse( + 'this is text\n' + + '\\begin{verbatim}\n' + + 'this is verbatim\n' + + '\\begin{verbatim}\n' + + 'this is verbatim too\n' + + '\\end{verbatim}\n' + + 'this is more text\n' + ) + assert.equal(errors.length, 0) + }) + + it('should accept equation', function () { + const { errors } = Parse( + 'this is text\n' + + '\\begin{equation}\n' + + '\\alpha^2 + b^2 = c^2\n' + + '\\end{equation}\n' + + 'this is more text\n' + ) + assert.equal(errors.length, 0) + }) + + it('should accept $$', function () { + const { errors } = Parse( + 'this is text\n' + + '$$\n' + + '\\alpha^2 + b^2 = c^2\n' + + '$$\n' + + 'this is more text\n' + ) + assert.equal(errors.length, 0) + }) + + it('should accept $', function () { + const { errors } = Parse( + 'this is text $\\alpha^2 + b^2 = c^2$' + ' this is more text\n' + ) + assert.equal(errors.length, 0) + }) + + it('should accept \\[', function () { + const { errors } = Parse( + 'this is text\n' + + '\\[\n' + + '\\alpha^2 + b^2 = c^2\n' + + '\\]\n' + + 'this is more text\n' + ) + assert.equal(errors.length, 0) + }) + + it('should accept \\(', function () { + const { errors } = Parse( + 'this is text \\(\\alpha^2 + b^2 = c^2\\)' + ' this is more text\n' + ) + assert.equal(errors.length, 0) + }) + + it('should accept \\begin{foo}', function () { + const { errors } = Parse( + 'this is text\n' + + '\\begin{foo}\n' + + 'this is foo\n' + + '\\end{foo}\n' + + 'this is more text\n' + ) + assert.equal(errors.length, 0) + }) + + it('should accept \\begin{foo_bar}', function () { + const { errors } = Parse( + 'this is text\n' + + '\\begin{foo_bar}\n' + + 'this is foo bar\n' + + '\\end{foo_bar}\n' + + 'this is more text\n' + ) + assert.equal(errors.length, 0) + }) + + it('should accept \\begin{foo} \\begin{bar}', function () { + const { errors } = Parse( + 'this is text\n' + + '\\begin{foo}\n' + + '\\begin{bar}\n' + + '\\begin{baz}\n' + + 'this is foo bar baz\n' + + '\\end{baz}\n' + + '\\end{bar}\n' + + '\\end{foo}\n' + + 'this is more text\n' + ) + assert.equal(errors.length, 0) + }) + + it('should accept \\verb|...|', function () { + const { errors } = Parse('this is text \\verb|hello| and more\n') + assert.equal(errors.length, 0) + }) + + it('should accept \\verb|...| with special chars', function () { + const { errors } = Parse('this is text \\verb|{}()^_@$xhello| and more\n') + assert.equal(errors.length, 0) + }) + + it('should accept \\url|...|', function () { + const { errors } = Parse( + 'this is text \\url|http://www.sharelatex.com/| and more\n' + ) + assert.equal(errors.length, 0) + }) + + it('should accept \\url{...}', function () { + const { errors } = Parse( + 'this is text \\url{http://www.sharelatex.com/} and more\n' + ) + assert.equal(errors.length, 0) + }) + + it('should accept \\url{...} with % chars', function () { + const { errors } = Parse( + 'this is text \\url{http://www.sharelatex.com/hello%20world} and more\n' + ) + assert.equal(errors.length, 0) + }) + + it('should accept \\left( and \\right)', function () { + const { errors } = Parse('math $\\left( x + y \\right) = y + x$ and more\n') + assert.equal(errors.length, 0) + }) + + it('should accept \\left( and \\right.', function () { + const { errors } = Parse('math $\\left( x + y \\right. = y + x$ and more\n') + assert.equal(errors.length, 0) + }) + + it('should accept \\left. and \\right)', function () { + const { errors } = Parse('math $\\left. x + y \\right) = y + x$ and more\n') + assert.equal(errors.length, 0) + }) + + it('should accept complex math nesting', function () { + const { errors } = Parse( + 'math $\\left( {x + {y + z} + x} \\right\\} = \\left[y + x\\right.$ and more\n' + ) + assert.equal(errors.length, 0) + }) + + it('should accept math toggling $a$$b$', function () { + const { errors } = Parse('math $a$$b$ and more\n') + assert.equal(errors.length, 0) + }) + + it('should accept math toggling $$display$$$inline$', function () { + const { errors } = Parse('math $$display$$$inline$ and more\n') + assert.equal(errors.length, 0) + }) + + it('should accept math definition commands', function () { + const { errors } = Parse( + '\\let\\originalleft\\left\n' + + '\\let\\originalright\\right\n' + + '\\renewcommand{\\left}{\\mathopen{}\\mathclose\\bgroup\\originalleft}\n' + + '\\renewcommand{\\right}{\\aftergroup\\egroup\\originalright}\n' + ) + assert.equal(errors.length, 0) + }) + + it('should accept math reflectbox commands', function () { + const { errors } = Parse('$\\reflectbox{$alpha$}$\n') + assert.equal(errors.length, 0) + }) + + it('should accept math scalebox commands', function () { + const { errors } = Parse('$\\scalebox{2}{$alpha$}$\n') + assert.equal(errors.length, 0) + }) + + it('should accept math rotatebox commands', function () { + const { errors } = Parse('$\\rotatebox{60}{$alpha$}$\n') + assert.equal(errors.length, 0) + }) + + it('should accept math resizebox commands', function () { + const { errors } = Parse('$\\resizebox{2}{3}{$alpha$}$\n') + assert.equal(errors.length, 0) + }) + + it('should accept all math box commands', function () { + const { errors } = Parse( + '\\[ \\left(\n' + + '\\shiftright{2ex}{\\raisebox{-2ex}{\\scalebox{2}{$\\ast$}}}\n' + + '\\reflectbox{$ddots$}\n' + + '\\right). \\]\n' + ) + assert.equal(errors.length, 0) + }) + + it('should accept math tag commands', function () { + const { errors } = Parse('$\\tag{$alpha$}$\n') + assert.equal(errors.length, 0) + }) + + it('should accept math \\def commands', function () { + const { errors } = Parse( + '\\def\\peb[#1]{{\\left\\lfloor #1\\right\\rfloor}}' + ) + assert.equal(errors.length, 0) + }) + + it('should accept math \\def commands II', function () { + const { errors } = Parse('\\def\\foo#1{\\gamma^#1}') + assert.equal(errors.length, 0) + }) + + it('should accept DeclareMathOperator', function () { + const { errors } = Parse('\\DeclareMathOperator{\\var}{\\Delta^2\\!}') + assert.equal(errors.length, 0) + }) + + it('should accept DeclarePairedDelimiter', function () { + const { errors } = Parse( + '\\DeclarePairedDelimiter{\\spro}{\\left(}{\\right)^{\\ast}}' + ) + assert.equal(errors.length, 0) + }) + + it('should accept nested user-defined math commands', function () { + const { errors } = Parse( + '$\\foo{$\\alpha \\bar{x^y}{\\cite{hello}}$}{\\gamma}{$\\beta\\baz{\\alpha}$}{\\cite{foo}}$' + ) + assert.equal(errors.length, 0) + }) + + it('should accept nested user-defined math commands II', function () { + const { errors } = Parse( + '\\foo{$\\alpha \\bar{x^y}{\\cite{hello}}$}{\\gamma}{$\\beta\\baz{\\alpha}$}{\\cite{foo}}' + ) + assert.equal(errors.length, 0) + }) + + it('should accept newenvironment with multiple parameters', function () { + const { errors } = Parse( + '\\newenvironment{case}[1][\\textsc{Case}]\n' + + '{\\begin{trivlist}\\item[\\hskip \\labelsep {\\textsc{#1}}]}{\\end{trivlist}}' + ) + assert.equal(errors.length, 0) + }) + + it('should accept newenvironment with no parameters', function () { + const { errors } = Parse( + '\\newenvironment{case}{\\begin{trivlist}\\item[\\hskip \\labelsep {\\textsc{#1}}]}{\\end{trivlist}}' + ) + assert.equal(errors.length, 0) + }) + + it('should accept tikzfeynman', function () { + const { errors } = Parse( + '\\begin{equation*}\n' + + '\\feynmandiagram[layered layout, medium, horizontal=a to b] {\n' + + ' a [particle=\\(H\\)] -- [scalar] b [dot] -- [photon] f1 [particle=\\(W^{\\pm}\\)],\n' + + ' b -- [boson, edge label=\\(W^{\\mp}\\)] c [dot],\n' + + ' c -- [fermion] f2 [particle=\\(f\\)],\n' + + " c -- [anti fermion] f3 [particle=\\(\\bar{f}'\\)],\n" + + ' };this is a change\n' + + '\\end{equation*}' + ) + assert.equal(errors.length, 0) + }) + + it('should return errors from malformed \\end', function () { + const { errors } = Parse( + 'this is text\n' + + '\\begin{foo}\n' + + '\\begin{bar}\n' + + 'this is foo bar baz\n' + + '\\end{bar\n' + + '\\end{foo}\n' + + 'this is more text\n' + ) + assert.equal(errors.length, 4) + assert.equal(errors[0].text, 'unclosed \\begin{bar} found at \\end{foo}') + assert.equal(errors[1].text, 'invalid environment command \\end{bar') + assert.equal(errors[2].text, 'unclosed open group { found at \\end{foo}') + assert.equal(errors[3].text, 'unexpected \\end{foo} after \\begin{bar}') + }) + + it('should accept \\newcommand*', function () { + const { errors } = Parse('\\newcommand*{\\foo}{\\bar}') + assert.equal(errors.length, 0) + }) + + it('should accept incomplete \\newcommand*', function () { + const { errors } = Parse('\\newcommand*{\\beq' + '}') + assert.equal(errors.length, 0) + }) + + // %novalidate + // %begin novalidate + // %end novalidate + // \begin{foo} + // \begin{new_theorem} + // \begin{foo invalid environment command + // \newcommand{\foo}{\bar} + // \newcommand[1]{\foo}{\bar #1} + // \renewcommand... + // \def + // \DeclareRobustCommand + // \newcolumntype + // \newenvironment + // \renewenvironment + // \verb|....| + // \url|...| + // \url{...} + // \left( \right) + // \left. \right. + // $...$ + // $$....$$ + // $...$$...$ + // $a^b$ vs a^b + // $$a^b$$ vs a^b + // Matrix for envs for {} left/right \[ \] \( \) $ $$ begin end + // begin equation + // align(*) + // equation(*) + // ] + // array(*) + // eqnarray(*) + // split + // aligned + // cases + // pmatrix + // gathered + // matrix + // alignedat + // smallmatrix + // subarray + // vmatrix + // shortintertext + + it('should return math mode contexts', function () { + const { contexts } = Parse( + '\\begin{document}\n' + + '$$\n' + + '\\begin{array}\n' + + '\\left( \\foo{bar} \\right] & 2\n' + + '\\end{array}\n' + + '$$\n' + + '\\end{document}' + ) + assert.equal(contexts.length, 1) + assert.equal(contexts[0].type, 'math') + assert.equal(contexts[0].range.start.row, 1) + assert.equal(contexts[0].range.start.column, 0) + assert.equal(contexts[0].range.end.row, 5) + assert.equal(contexts[0].range.end.column, 2) + }) + + it('should remove error when cursor is inside incomplete command', function () { + const { errors } = Parse('\\begin{}') + const diagnostics = errorsToDiagnostics(errors, 7, 9) + assert.equal(errors.length, 1) + assert.equal(diagnostics.length, 0) + }) + + it('should show an error when cursor is outside incomplete command', function () { + const { errors } = Parse('\\begin{}') + const diagnostics = errorsToDiagnostics(errors, 6, 9) + assert.equal(errors.length, 1) + assert.equal(diagnostics.length, 1) + assert.equal(diagnostics[0].from, 0) + assert.equal(diagnostics[0].to, 6) + }) + + it('should adjust an error range when the cursor is inside that range', function () { + const { errors } = Parse('\\begin{}') + const diagnostics = errorsToDiagnostics(errors, 4, 7) + assert.equal(errors.length, 1) + assert.equal(errors[0].startPos, 0) + assert.equal(errors[0].endPos, 7) + assert.equal(diagnostics.length, 1) + assert.equal(diagnostics[0].from, 0) + assert.equal(diagnostics[0].to, 4) + }) + + it('should reject an error when part of the error range is outside of the document boundaries', function () { + const { errors } = Parse('\\begin{}') + const diagnostics = errorsToDiagnostics(errors, 8, 6) + assert.equal(errors.length, 1) + assert.equal(diagnostics.length, 0) + }) + + it('should merge two overlapping identical diagnostics', function () { + const diagnostics: Diagnostic[] = [ + { + from: 0, + to: 2, + message: 'Message 1', + severity: 'error', + }, + { + from: 1, + to: 3, + message: 'Message 1', + severity: 'error', + }, + ] + const mergedDiagnostics = mergeCompatibleOverlappingDiagnostics(diagnostics) + assert.deepEqual(mergedDiagnostics, [ + { + from: 0, + to: 3, + message: 'Message 1', + severity: 'error', + }, + ]) + }) + + it('should merge two touching identical diagnostics', function () { + const diagnostics: Diagnostic[] = [ + { + from: 0, + to: 2, + message: 'Message 1', + severity: 'error', + }, + { + from: 2, + to: 3, + message: 'Message 1', + severity: 'error', + }, + ] + const mergedDiagnostics = mergeCompatibleOverlappingDiagnostics(diagnostics) + assert.deepEqual(mergedDiagnostics, [ + { + from: 0, + to: 3, + message: 'Message 1', + severity: 'error', + }, + ]) + }) + + it('should not merge two overlapping diagnostics with different messages', function () { + const diagnostics: Diagnostic[] = [ + { + from: 0, + to: 2, + message: 'Message 1', + severity: 'error', + }, + { + from: 1, + to: 3, + message: 'Message 2', + severity: 'error', + }, + ] + const mergedDiagnostics = mergeCompatibleOverlappingDiagnostics(diagnostics) + assert.deepEqual(diagnostics, mergedDiagnostics) + }) + + it('should not merge two overlapping diagnostics with different severities', function () { + const diagnostics: Diagnostic[] = [ + { + from: 0, + to: 2, + message: 'Message 1', + severity: 'error', + }, + { + from: 1, + to: 3, + message: 'Message 1', + severity: 'warning', + }, + ] + const mergedDiagnostics = mergeCompatibleOverlappingDiagnostics(diagnostics) + assert.deepEqual(diagnostics, mergedDiagnostics) + }) + + it('should merge three overlapping identical diagnostics', function () { + const diagnostics: Diagnostic[] = [ + { + from: 0, + to: 2, + message: 'Message 1', + severity: 'error', + }, + { + from: 1, + to: 4, + message: 'Message 1', + severity: 'error', + }, + { + from: 3, + to: 5, + message: 'Message 1', + severity: 'error', + }, + ] + const mergedDiagnostics = mergeCompatibleOverlappingDiagnostics(diagnostics) + assert.deepEqual(mergedDiagnostics, [ + { + from: 0, + to: 5, + message: 'Message 1', + severity: 'error', + }, + ]) + }) + + it('should merge two separate sets of overlapping identical diagnostics', function () { + const diagnostics: Diagnostic[] = [ + { + from: 0, + to: 2, + message: 'Message 1', + severity: 'error', + }, + { + from: 2, + to: 3, + message: 'Message 1', + severity: 'error', + }, + { + from: 2, + to: 5, + message: 'Message 2', + severity: 'error', + }, + { + from: 4, + to: 6, + message: 'Message 3', + severity: 'error', + }, + { + from: 5, + to: 7, + message: 'Message 3', + severity: 'error', + }, + ] + const mergedDiagnostics = mergeCompatibleOverlappingDiagnostics(diagnostics) + assert.deepEqual(mergedDiagnostics, [ + { + from: 0, + to: 3, + message: 'Message 1', + severity: 'error', + }, + { + from: 2, + to: 5, + message: 'Message 2', + severity: 'error', + }, + { + from: 4, + to: 7, + message: 'Message 3', + severity: 'error', + }, + ]) + }) +}) diff --git a/services/web/test/frontend/features/source-editor/languages/latex/latex-outline.test.ts b/services/web/test/frontend/features/source-editor/languages/latex/latex-outline.test.ts new file mode 100644 index 0000000000..870f189bce --- /dev/null +++ b/services/web/test/frontend/features/source-editor/languages/latex/latex-outline.test.ts @@ -0,0 +1,514 @@ +import { LanguageSupport } from '@codemirror/language' +import { EditorState, Text } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import { expect } from 'chai' +import { documentOutline } from '../../../../../../frontend/js/features/source-editor/languages/latex/document-outline' +import { + FlatOutline, + getNestingLevel, +} from '../../../../../../frontend/js/features/source-editor/utils/tree-query' +import { LaTeXLanguage } from '../../../../../../frontend/js/features/source-editor/languages/latex/latex-language' +import { + Book, + Chapter, + Paragraph, + Part, + Section, + SubParagraph, + SubSection, + SubSubSection, +} from '../../../../../../frontend/js/features/source-editor/lezer-latex/latex.terms.mjs' + +const latex = new LanguageSupport(LaTeXLanguage, documentOutline.extension) + +const makeView = (lines: string[]): EditorView => { + const text = Text.of(lines) + const view = new EditorView({ + state: EditorState.create({ + doc: text, + extensions: [latex], + }), + }) + return view +} + +const BOOK_LEVEL = getNestingLevel(Book) +const PART_LEVEL = getNestingLevel(Part) +const CHAPTER_LEVEL = getNestingLevel(Chapter) +const SECTION_LEVEL = getNestingLevel(Section) +const SUB_SECTION_LEVEL = getNestingLevel(SubSection) +const SUB_SUB_SECTION_LEVEL = getNestingLevel(SubSubSection) +const PARAGRAPH_LEVEL = getNestingLevel(Paragraph) +const SUB_PARAGRAPH_LEVEL = getNestingLevel(SubParagraph) +const FRAME_LEVEL = getNestingLevel('frame') + +const insertText = (view: EditorView, position: number, text: string) => { + view.dispatch({ + changes: [{ from: position, insert: text }], + }) +} + +const deleteText = (view: EditorView, position: number, length: number) => { + view.dispatch({ + changes: [{ from: position - length, to: position }], + }) +} + +const getOutline = (view: EditorView): FlatOutline | null => { + return view.state.field(documentOutline)?.items || null +} + +describe('CodeMirror LaTeX-FileOutline', function () { + describe('with no update', function () { + describe('an empty document', function () { + let view: EditorView, content: string[] + beforeEach(function () { + content = [''] + view = makeView(content) + }) + + it('should have empty outline', function () { + const outline = getOutline(view) + expect(outline).to.be.empty + }) + }) + + describe('a document with nested sections', function () { + let view: EditorView, content: string[] + beforeEach(function () { + content = [ + 'line 1', + '\\section{sec title}', + 'content', + '\\subsection{subsec title}', + ] + view = makeView(content) + }) + + it('should have outline with different levels', function () { + const outline = getOutline(view) + expect(outline).to.be.deep.equal([ + { + from: 7, + to: 26, + level: SECTION_LEVEL, + title: 'sec title', + line: 2, + }, + { + from: 35, + to: 60, + level: SUB_SECTION_LEVEL, + title: 'subsec title', + line: 4, + }, + ]) + }) + }) + + describe('a document with sibling sections', function () { + let view: EditorView, content: string[] + beforeEach(function () { + content = [ + 'line 1', + '\\section{sec title 1}', + 'content', + '\\section{sec title 2}', + ] + view = makeView(content) + }) + + it('should have outline with same levels for siblings', function () { + const outline = getOutline(view) + expect(outline).to.be.deep.equal([ + { + from: 7, + to: 28, + level: SECTION_LEVEL, + title: 'sec title 1', + line: 2, + }, + { + from: 37, + to: 58, + level: SECTION_LEVEL, + title: 'sec title 2', + line: 4, + }, + ]) + }) + }) + }) + + describe('with change to title', function () { + let view: EditorView, content: string[] + beforeEach(function () { + content = ['\\section{title }'] + view = makeView(content) + const initialOutline = getOutline(view) + expect(initialOutline).to.deep.equal([ + { + from: 0, + to: 16, + title: 'title ', + line: 1, + level: SECTION_LEVEL, + }, + ]) + }) + + describe('for appending to title', function () { + beforeEach(function () { + insertText(view, 15, '1') + }) + + it('should update title in outline', function () { + const updatedOutline = getOutline(view) + expect(updatedOutline).to.deep.equal([ + { + from: 0, + to: 17, + title: 'title 1', + line: 1, + level: SECTION_LEVEL, + }, + ]) + }) + }) + + describe('for removing from title', function () { + beforeEach(function () { + deleteText(view, 15, 1) + }) + + it('should update title in outline', function () { + const updatedOutline = getOutline(view) + expect(updatedOutline).to.deep.equal([ + { + from: 0, + to: 15, + title: 'title', + line: 1, + level: SECTION_LEVEL, + }, + ]) + }) + }) + }) + + describe('for moving section', function () { + let view: EditorView, content: string[] + beforeEach(function () { + content = ['\\section{title}', '\\subsection{subtitle}'] + view = makeView(content) + const initialOutline = getOutline(view) + expect(initialOutline).to.deep.equal([ + { + from: 0, + to: 15, + title: 'title', + line: 1, + level: SECTION_LEVEL, + }, + { + from: 16, + to: 37, + title: 'subtitle', + line: 2, + level: SUB_SECTION_LEVEL, + }, + ]) + insertText(view, 15, '\n') + }) + + it('should update position for moved section', function () { + const updatedOutline = getOutline(view) + expect(updatedOutline).to.deep.equal([ + { + from: 0, + to: 15, + title: 'title', + line: 1, + level: SECTION_LEVEL, + }, + { + from: 17, + to: 38, + title: 'subtitle', + line: 3, + level: SUB_SECTION_LEVEL, + }, + ]) + }) + }) + + describe('for removing a section', function () { + let view: EditorView, content: string[] + beforeEach(function () { + content = ['\\section{title}'] + view = makeView(content) + const initialOutline = getOutline(view) + expect(initialOutline).to.deep.equal([ + { + from: 0, + to: 15, + title: 'title', + line: 1, + level: SECTION_LEVEL, + }, + ]) + deleteText(view, 4, 1) + }) + + it('should remove the section from the outline', function () { + const updatedOutline = getOutline(view) + expect(updatedOutline).to.be.empty + }) + }) + + describe('for changing parent section', function () { + let view: EditorView, content: string[] + beforeEach(function () { + content = [ + '\\section{section}', + '%\\subsection{subsection}', // initially commented out + '\\subsubsection{subsubsection}', + ] + view = makeView(content) + const initialOutline = getOutline(view) + expect(initialOutline).to.deep.equal([ + { + from: 0, + to: 17, + title: 'section', + line: 1, + level: SECTION_LEVEL, + }, + { + from: 43, + to: 72, + title: 'subsubsection', + line: 3, + level: SUB_SUB_SECTION_LEVEL, + }, + ]) + // Remove the % + deleteText(view, 19, 1) + }) + + it('should be nested properly', function () { + const updatedOutline = getOutline(view) + expect(updatedOutline).to.deep.equal([ + { + from: 0, + to: 17, + title: 'section', + line: 1, + level: SECTION_LEVEL, + }, + { + from: 18, + to: 41, + title: 'subsection', + line: 2, + level: SUB_SECTION_LEVEL, + }, + { + from: 42, + to: 71, + title: 'subsubsection', + line: 3, + level: SUB_SUB_SECTION_LEVEL, + }, + ]) + }) + }) + + describe('for a sectioning command inside a newcommand or renewcommand', function () { + let view: EditorView, content: string[] + beforeEach(function () { + content = [ + '\\section{section}', + '\\newcommand{\\test}{\\section{should not display}}', + '\\renewcommand{\\test}{\\section{should still not display}}', + ] + view = makeView(content) + }) + it('should not include them in the outline', function () { + const outline = getOutline(view) + expect(outline?.length).to.equal(1) + expect(outline).to.deep.equal([ + { + from: 0, + to: 17, + title: 'section', + line: 1, + level: SECTION_LEVEL, + }, + ]) + }) + }) + + describe('for all section types', function () { + let view: EditorView, content: string[] + beforeEach(function () { + content = [ + '\\book{book}', + '\\part{part}', + '\\chapter{chapter}', + '\\section{section}', + '\\subsection{subsection}', + '\\subsubsection{subsubsection}', + '\\paragraph{paragraph}', + '\\subparagraph{subparagraph}', + ] + view = makeView(content) + }) + + it('should include them in the file outline', function () { + const outline = getOutline(view) + expect(outline).to.deep.equal([ + { + from: 0, + to: 11, + title: 'book', + line: 1, + level: BOOK_LEVEL, + }, + { + from: 12, + to: 23, + title: 'part', + line: 2, + level: PART_LEVEL, + }, + { + from: 24, + to: 41, + title: 'chapter', + line: 3, + level: CHAPTER_LEVEL, + }, + { + from: 42, + to: 59, + title: 'section', + line: 4, + level: SECTION_LEVEL, + }, + { + from: 60, + to: 83, + title: 'subsection', + line: 5, + level: SUB_SECTION_LEVEL, + }, + { + from: 84, + to: 113, + title: 'subsubsection', + line: 6, + level: SUB_SUB_SECTION_LEVEL, + }, + { + from: 114, + to: 135, + title: 'paragraph', + line: 7, + level: PARAGRAPH_LEVEL, + }, + { + from: 136, + to: 163, + title: 'subparagraph', + line: 8, + level: SUB_PARAGRAPH_LEVEL, + }, + ]) + }) + }) + + describe('sectioning commands with optional arguments', function () { + let view: EditorView, content: string[] + beforeEach(function () { + content = ['\\section[short title]{section}'] + view = makeView(content) + }) + + it('should use the optional argument as title', function () { + const outline = getOutline(view) + expect(outline).to.deep.equal([ + { + from: 0, + to: 30, + title: 'short title', + line: 1, + level: SECTION_LEVEL, + }, + ]) + }) + }) + + describe('for ill-formed \\def command', function () { + let view: EditorView, content: string[] + beforeEach(function () { + content = ['\\def\\x{', '\\section{test}', '\\subsection{test2}'] + view = makeView(content) + }) + + it('still shows an outline', function () { + const outline = getOutline(view) + expect(outline).to.deep.equal([ + { + from: 8, + to: 22, + title: 'test', + line: 2, + level: SECTION_LEVEL, + }, + { + from: 23, + to: 41, + title: 'test2', + line: 3, + level: SUB_SECTION_LEVEL, + }, + ]) + }) + }) + + describe('for beamer frames', function () { + describe('with titles', function () { + let view: EditorView, content: string[] + beforeEach(function () { + content = ['\\begin{frame}{frame title}{}', '\\end{frame}'] + view = makeView(content) + }) + + it('should show up in the file outline', function () { + const outline = getOutline(view) + expect(outline).to.deep.equal([ + { + from: 0, + to: 28, + title: 'frame title', + line: 1, + level: FRAME_LEVEL, + }, + ]) + }) + }) + describe('without titles', function () { + let view: EditorView, content: string[] + beforeEach(function () { + content = ['\\begin{frame}', '\\end{frame}'] + view = makeView(content) + }) + + it('should not show up in the file outline', function () { + const outline = getOutline(view) + expect(outline).to.be.empty + }) + }) + }) +}) diff --git a/services/web/types/current-doc.ts b/services/web/types/current-doc.ts index f99b8b69c1..3482a58d68 100644 --- a/services/web/types/current-doc.ts +++ b/services/web/types/current-doc.ts @@ -1,6 +1,6 @@ import EventEmitter from '../frontend/js/utils/EventEmitter' import { ShareDoc } from './share-doc' -import { EditorFacade } from '../modules/source-editor/frontend/js/extensions/realtime' +import { EditorFacade } from '../frontend/js/features/source-editor/extensions/realtime' import { AnyOperation, Change, diff --git a/services/web/types/project-settings.ts b/services/web/types/project-settings.ts index 04e84be034..e8e10faa45 100644 --- a/services/web/types/project-settings.ts +++ b/services/web/types/project-settings.ts @@ -1,4 +1,4 @@ -import { OverallTheme } from '../modules/source-editor/frontend/js/extensions/theme' +import { OverallTheme } from '../frontend/js/features/source-editor/extensions/theme' export type AllowedImageName = { imageDesc: string diff --git a/services/web/types/subscription/dashboard/subscription.ts b/services/web/types/subscription/dashboard/subscription.ts index f10557bb4e..ee85e8a6a6 100644 --- a/services/web/types/subscription/dashboard/subscription.ts +++ b/services/web/types/subscription/dashboard/subscription.ts @@ -1,7 +1,7 @@ import { CurrencyCode } from '../../../frontend/js/features/subscription/data/currency' import { Nullable } from '../../utils' import { Plan } from '../plan' -import { User } from '../../../types/user' +import { User } from '../../user' type SubscriptionState = 'active' | 'canceled' | 'expired' diff --git a/services/web/webpack-plugins/lezer-grammar-compiler.js b/services/web/webpack-plugins/lezer-grammar-compiler.js index f32da10199..efadc5e542 100644 --- a/services/web/webpack-plugins/lezer-grammar-compiler.js +++ b/services/web/webpack-plugins/lezer-grammar-compiler.js @@ -1,9 +1,6 @@ const fs = require('fs') const path = require('path') -const modulePath = path.resolve( - __dirname, - '../modules/source-editor/scripts/lezer-latex/generate.js' -) +const modulePath = path.resolve(__dirname, '../scripts/lezer-latex/generate.js') try { fs.accessSync(modulePath, fs.constants.W_OK)