Browse Source

Move source editor out of module (#12457)

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

* Move files

* Update paths

* Remove source-editor module and checks for its existence

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

GitOrigin-RevId: 89b7cc2b409db01ad103198ccbd1b126ab56349b
Tim Down 3 years ago
parent
commit
7f37ba737c
100 changed files with 11388 additions and 37 deletions
  1. 2 2
      services/web/.eslintignore
  2. 3 3
      services/web/.gitignore
  3. 2 2
      services/web/.prettierignore
  4. 3 6
      services/web/app/views/project/editor/editor-pane.pug
  5. 0 1
      services/web/app/views/project/editor/meta.pug
  6. 4 0
      services/web/app/views/source-editor/source-editor.pug
  7. 1 1
      services/web/cypress/support/webpack.cypress.ts
  8. 1 1
      services/web/frontend/js/features/editor-left-menu/components/settings/settings-font-family.tsx
  9. 1 1
      services/web/frontend/js/features/editor-left-menu/components/settings/settings-line-height.tsx
  10. 1 1
      services/web/frontend/js/features/editor-left-menu/components/settings/settings-overall-theme.tsx
  11. 1 1
      services/web/frontend/js/features/editor-left-menu/utils/api.ts
  12. 27 0
      services/web/frontend/js/features/source-editor/commands/cursor.ts
  13. 47 0
      services/web/frontend/js/features/source-editor/commands/indent.ts
  14. 744 0
      services/web/frontend/js/features/source-editor/commands/ranges.ts
  15. 120 0
      services/web/frontend/js/features/source-editor/commands/select.ts
  16. 96 0
      services/web/frontend/js/features/source-editor/components/codemirror-editor.tsx
  17. 156 0
      services/web/frontend/js/features/source-editor/components/codemirror-outline.tsx
  18. 422 0
      services/web/frontend/js/features/source-editor/components/codemirror-search-form.tsx
  19. 17 0
      services/web/frontend/js/features/source-editor/components/codemirror-search.tsx
  20. 121 0
      services/web/frontend/js/features/source-editor/components/codemirror-toolbar.tsx
  21. 30 0
      services/web/frontend/js/features/source-editor/components/codemirror-view.tsx
  22. 13 18
      services/web/frontend/js/features/source-editor/components/editor-switch.tsx
  23. 25 0
      services/web/frontend/js/features/source-editor/components/source-editor.tsx
  24. 62 0
      services/web/frontend/js/features/source-editor/components/toolbar/overflow.tsx
  25. 115 0
      services/web/frontend/js/features/source-editor/components/toolbar/section-heading-dropdown.tsx
  26. 86 0
      services/web/frontend/js/features/source-editor/components/toolbar/toolbar-button.tsx
  27. 189 0
      services/web/frontend/js/features/source-editor/components/toolbar/toolbar-items.tsx
  28. 6 0
      services/web/frontend/js/features/source-editor/controllers/source-editor-controller.ts
  29. 139 0
      services/web/frontend/js/features/source-editor/extensions/annotations.ts
  30. 151 0
      services/web/frontend/js/features/source-editor/extensions/auto-complete.ts
  31. 31 0
      services/web/frontend/js/features/source-editor/extensions/auto-pair.ts
  32. 134 0
      services/web/frontend/js/features/source-editor/extensions/bracket-matching.ts
  33. 50 0
      services/web/frontend/js/features/source-editor/extensions/browser.ts
  34. 30 0
      services/web/frontend/js/features/source-editor/extensions/bundle.ts
  35. 482 0
      services/web/frontend/js/features/source-editor/extensions/changes/change-manager.ts
  36. 226 0
      services/web/frontend/js/features/source-editor/extensions/changes/comments.ts
  37. 49 0
      services/web/frontend/js/features/source-editor/extensions/class-highlighter.ts
  38. 206 0
      services/web/frontend/js/features/source-editor/extensions/close-prefixed-brackets.ts
  39. 207 0
      services/web/frontend/js/features/source-editor/extensions/cursor-highlights.ts
  40. 161 0
      services/web/frontend/js/features/source-editor/extensions/cursor-position.ts
  41. 101 0
      services/web/frontend/js/features/source-editor/extensions/draw-selection.ts
  42. 13 0
      services/web/frontend/js/features/source-editor/extensions/editable.ts
  43. 76 0
      services/web/frontend/js/features/source-editor/extensions/empty-line-filler.ts
  44. 11 0
      services/web/frontend/js/features/source-editor/extensions/exception-logger.ts
  45. 35 0
      services/web/frontend/js/features/source-editor/extensions/filter-characters.ts
  46. 19 0
      services/web/frontend/js/features/source-editor/extensions/folding-keymap.ts
  47. 33 0
      services/web/frontend/js/features/source-editor/extensions/font-load.ts
  48. 61 0
      services/web/frontend/js/features/source-editor/extensions/go-to-line.ts
  49. 116 0
      services/web/frontend/js/features/source-editor/extensions/highlight-active-line.ts
  50. 128 0
      services/web/frontend/js/features/source-editor/extensions/highlight-selection-matches.ts
  51. 8 0
      services/web/frontend/js/features/source-editor/extensions/indentation-markers.ts
  52. 138 0
      services/web/frontend/js/features/source-editor/extensions/index.ts
  53. 96 0
      services/web/frontend/js/features/source-editor/extensions/inline-background.ts
  54. 186 0
      services/web/frontend/js/features/source-editor/extensions/keybindings.ts
  55. 92 0
      services/web/frontend/js/features/source-editor/extensions/language.ts
  56. 85 0
      services/web/frontend/js/features/source-editor/extensions/line-numbers.ts
  57. 144 0
      services/web/frontend/js/features/source-editor/extensions/line-wrapping-indentation.ts
  58. 13 0
      services/web/frontend/js/features/source-editor/extensions/phrases.ts
  59. 222 0
      services/web/frontend/js/features/source-editor/extensions/realtime.ts
  60. 35 0
      services/web/frontend/js/features/source-editor/extensions/scroll-one-line.ts
  61. 157 0
      services/web/frontend/js/features/source-editor/extensions/scroll-position.ts
  62. 255 0
      services/web/frontend/js/features/source-editor/extensions/search.ts
  63. 174 0
      services/web/frontend/js/features/source-editor/extensions/shortcuts.ts
  64. 35 0
      services/web/frontend/js/features/source-editor/extensions/spelling/backend.ts
  65. 105 0
      services/web/frontend/js/features/source-editor/extensions/spelling/cache.ts
  66. 241 0
      services/web/frontend/js/features/source-editor/extensions/spelling/context-menu.ts
  67. 1 0
      services/web/frontend/js/features/source-editor/extensions/spelling/helpers.ts
  68. 25 0
      services/web/frontend/js/features/source-editor/extensions/spelling/ignored-words.ts
  69. 140 0
      services/web/frontend/js/features/source-editor/extensions/spelling/index.ts
  70. 127 0
      services/web/frontend/js/features/source-editor/extensions/spelling/line-tracker.ts
  71. 100 0
      services/web/frontend/js/features/source-editor/extensions/spelling/misspelled-words.ts
  72. 387 0
      services/web/frontend/js/features/source-editor/extensions/spelling/spellchecker.ts
  73. 39 0
      services/web/frontend/js/features/source-editor/extensions/symbol-palette.ts
  74. 270 0
      services/web/frontend/js/features/source-editor/extensions/theme.ts
  75. 42 0
      services/web/frontend/js/features/source-editor/extensions/third-party-extensions.ts
  76. 106 0
      services/web/frontend/js/features/source-editor/extensions/toolbar/commands.ts
  77. 14 0
      services/web/frontend/js/features/source-editor/extensions/toolbar/comments.ts
  78. 316 0
      services/web/frontend/js/features/source-editor/extensions/toolbar/lists.ts
  79. 139 0
      services/web/frontend/js/features/source-editor/extensions/toolbar/sections.ts
  80. 7 0
      services/web/frontend/js/features/source-editor/extensions/toolbar/snippets.ts
  81. 221 0
      services/web/frontend/js/features/source-editor/extensions/toolbar/toolbar-panel.ts
  82. 8 0
      services/web/frontend/js/features/source-editor/extensions/toolbar/utils/analytics.ts
  83. 355 0
      services/web/frontend/js/features/source-editor/extensions/track-changes.ts
  84. 165 0
      services/web/frontend/js/features/source-editor/extensions/vertical-overflow.ts
  85. 927 0
      services/web/frontend/js/features/source-editor/extensions/visual/atomic-decorations.ts
  86. 70 0
      services/web/frontend/js/features/source-editor/extensions/visual/list-item-marker.ts
  87. 180 0
      services/web/frontend/js/features/source-editor/extensions/visual/mark-decorations.ts
  88. 75 0
      services/web/frontend/js/features/source-editor/extensions/visual/selection.ts
  89. 35 0
      services/web/frontend/js/features/source-editor/extensions/visual/skip-preamble-cursor.ts
  90. 10 0
      services/web/frontend/js/features/source-editor/extensions/visual/utils/select-node.ts
  91. 80 0
      services/web/frontend/js/features/source-editor/extensions/visual/utils/typeset-content.ts
  92. 141 0
      services/web/frontend/js/features/source-editor/extensions/visual/visual-keymap.ts
  93. 339 0
      services/web/frontend/js/features/source-editor/extensions/visual/visual-theme.ts
  94. 49 0
      services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/begin.ts
  95. 24 0
      services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/brace.ts
  96. 160 0
      services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/character.ts
  97. 17 0
      services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/divider.ts
  98. 27 0
      services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/end-document.ts
  99. 13 0
      services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/end.ts
  100. 39 0
      services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/environment-line.ts

+ 2 - 2
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

+ 3 - 3
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

+ 2 - 2
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

+ 3 - 6
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

+ 0 - 1
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)

+ 4 - 0
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"
+)

+ 1 - 1
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

+ 1 - 1
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'
 

+ 1 - 1
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'
 

+ 1 - 1
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()

+ 1 - 1
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,

+ 27 - 0
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
+  }

+ 47 - 0
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
+}

+ 744 - 0
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{<test}>
+  // 3. \textbf<{test}>
+  // 4. <\textbf{test}>
+  // 4. \textbf<>{test}
+  const tree = syntaxTree(state)
+  if (tree.length < range.to) {
+    return range
+  }
+
+  const nodeLeft = tree.resolveInner(range.from, 1)
+  const nodeRight = tree.resolveInner(range.to, -1)
+  const parentLeft = getWrappingAncestor(nodeLeft, command, state)
+  const parentRight = getWrappingAncestor(nodeRight, command, state)
+
+  const parent = getParentNode(nodeLeft, state)
+  if (parent?.type.is('UnknownCommand') && spansWholeArgument(parent, range)) {
+    return bubbleUpRange(
+      command,
+      ancestorOfNodeWithType(nodeLeft, 'UnknownCommand'),
+      range,
+      state
+    )
+  }
+
+  if (!parentLeft) {
+    // We're not trying to unbold, so don't bother adjusting range
+    return bubbleUpRange(
+      command,
+      ancestorOfNodeWithType(nodeLeft, 'UnknownCommand'),
+      range,
+      state
+    )
+  }
+  if (nodeLeft.type.is('CtrlSeq') && range.from === range.to) {
+    const command = nodeLeft.parent?.parent
+    if (!command) {
+      return range
+    }
+    return EditorSelection.cursor(command.from)
+  }
+
+  let { from, to } = range
+  if (nodeLeft.type.is('CtrlSeq')) {
+    from = nodeLeft.to + 1
+  }
+  if (nodeLeft.type.is('OpenBrace')) {
+    from = nodeLeft.to
+  }
+  // We know that parentLeft is the UnknownCommand, so now we check if we're
+  // to the right of the closing brace. (parent is TextArgument, grandparent is
+  // UnknownCommand)
+  if (parentLeft === parentRight && nodeRight.type.is('CloseBrace')) {
+    to = nodeRight.from
+  }
+  return bubbleUpRange(command, parentLeft, moveRange(range, from, to), state)
+}
+
+function spansWholeArgument(
+  commandNode: SyntaxNode | null,
+  range: SelectionRange
+): boolean {
+  const argument = commandNode?.getChild('TextArgument')?.getChild('LongArg')
+  const res = Boolean(
+    argument && argument.from === range.from && argument.to === range.to
+  )
+  return res
+}
+
+function bubbleUpRange(
+  command: string,
+  node: SyntaxNode | null,
+  range: SelectionRange,
+  state: EditorState
+) {
+  let currentRange = range
+  for (
+    let ancestorCommand: SyntaxNode | null = ancestorOfNodeWithType(
+      node,
+      'UnknownCommand'
+    );
+    spansWholeArgument(ancestorCommand, currentRange);
+    ancestorCommand = ancestorOfNodeWithType(
+      ancestorCommand.parent,
+      'UnknownCommand'
+    )
+  ) {
+    if (!ancestorCommand) {
+      break
+    }
+    currentRange = moveRange(
+      currentRange,
+      ancestorCommand.from,
+      ancestorCommand.to
+    )
+    if (isUnknownCommandWithName(ancestorCommand, command, state)) {
+      const argumentNode = ancestorCommand
+        .getChild('TextArgument')
+        ?.getChild('LongArg')
+      if (!argumentNode) {
+        return range
+      }
+      return moveRange(range, argumentNode.from, argumentNode.to)
+    }
+  }
+
+  return range
+}
+
+export function toggleRanges(command: string) {
+  /* There are a number of situations we need to handle in this function.
+   * In the following examples, the selection range is marked within <>
+
+   * 1. If the parent node at start and end of selection is different -> do
+   *    nothing & show error. Case 8 is an exception to this.
+   *      -> For good and bad, this disallows \textbf{this <is} weird
+   *         \textit{to> do}
+   * 2. If selection contains a BlankLine (i.e. two newlines in a row) -> do
+   *    nothing & show error
+   *      -> \textbf doesn't allow paragraph breaks).
+   * 3. If the selection is not within a \textbf -> wrap it in a \textbf
+   * 4. If selection is at the beginning of a \textbf -> shrink the \textbf
+   *    command
+   *      -> e.g. \textbf{<this is> a test} → <this is>\textbf{ a test}
+   * 5. Similarly for selection at end of \textbf command
+   * 6. If selection is in the middle of a \textbf command -> split the command
+   *    into two
+   *      -> e.g. \textbf{this <is a> test} → \textbf{this }<is a>\textbf{ test}
+   * 7. If the selection is a whole \textbf command → remove the wrapping
+   *    command.
+   * 8. If the selection spans two \textbf's with the same parent then join the
+   *    two
+   *      -> e.g. \textbf{this <is} a \textbf{te>st} → \textbf{this <is a te>st}
+   * 9. If the selection spans the end of a \textbf, into the parent of the
+   *    command, then extend the \textbf.
+   *      -> e.g. \textbf{this <is} a test> → \textbf{this <is a test>}
+   * 10. Similarly for selections spanning the beginning of the selection
+   */
+  return (view: EditorView): boolean => {
+    if (view.state.readOnly) {
+      return false
+    }
+    view.dispatch(
+      view.state.changeByRange(initialRange => {
+        const range = adjustRangeIfNeeded(command, initialRange, view.state)
+        const content = view.state.sliceDoc(range.from, range.to)
+
+        const ancestorAtStartOfRange = getParentNode(
+          range.from,
+          view.state,
+          range.from === 0 ? 1 : -1
+        )
+        const ancestorAtEndOfRange = range.empty
+          ? ancestorAtStartOfRange
+          : getParentNode(
+              range.to,
+              view.state,
+              range.to === view.state.doc.length ? -1 : 1
+            )
+
+        if (ancestorAtStartOfRange !== ancestorAtEndOfRange) {
+          // But handle the exception of case 8
+          const ancestorAtStartIsWrappingCommand =
+            ancestorAtStartOfRange &&
+            isUnknownCommandWithName(
+              ancestorAtStartOfRange,
+              command,
+              view.state
+            )
+          const ancestorAtEndIsWrappingCommand =
+            ancestorAtEndOfRange &&
+            isUnknownCommandWithName(ancestorAtEndOfRange, command, view.state)
+          if (
+            ancestorAtStartIsWrappingCommand &&
+            ancestorAtEndIsWrappingCommand &&
+            ancestorAtStartOfRange?.parent?.parent &&
+            ancestorAtEndOfRange?.parent?.parent
+          ) {
+            // Test for case 8
+            const nextAncestorAtStartOfRange =
+              ancestorAtStartOfRange.parent.parent
+            const nextAncestorAtEndOfRange = ancestorAtEndOfRange.parent.parent
+
+            if (nextAncestorAtStartOfRange === nextAncestorAtEndOfRange) {
+              // Join the two ranges
+              const textBetweenRanges = view.state.sliceDoc(
+                ancestorAtStartOfRange.to,
+                ancestorAtEndOfRange.from
+              )
+              const ancestorStartArgumentNode =
+                ancestorAtStartOfRange.lastChild?.getChild('LongArg')
+              const ancestorEndArgumentNode =
+                ancestorAtEndOfRange.lastChild?.getChild('LongArg')
+              if (!ancestorStartArgumentNode || !ancestorEndArgumentNode) {
+                throw new Error("Can't find argument node")
+              }
+              const actualContent = view.state.sliceDoc(
+                ancestorAtStartOfRange.from,
+                ancestorAtEndOfRange.to
+              )
+              const firstCommandArgument = view.state.sliceDoc(
+                ancestorStartArgumentNode.from,
+                ancestorStartArgumentNode.to
+              )
+              const secondCommandArgument = view.state.sliceDoc(
+                ancestorEndArgumentNode.from,
+                ancestorEndArgumentNode.to
+              )
+              validateReplacement(
+                `${command}{${firstCommandArgument}}${textBetweenRanges}${command}{${secondCommandArgument}}`,
+                actualContent
+              )
+              const changes = view.state.changes([
+                {
+                  from: ancestorAtStartOfRange.from,
+                  to: ancestorAtEndOfRange.to,
+                  insert: `${command}{${firstCommandArgument}${textBetweenRanges}${secondCommandArgument}}`,
+                },
+              ])
+              return {
+                changes,
+                range: moveRange(
+                  range,
+                  range.from,
+                  range.to - command.length - 1 - 1
+                ),
+              }
+            }
+          }
+
+          if (
+            ancestorAtEndIsWrappingCommand &&
+            ancestorAtEndOfRange.parent?.parent === ancestorAtStartOfRange
+          ) {
+            // Extend to the left. Case 10
+            const contentUpToCommand = view.state.sliceDoc(
+              range.from,
+              ancestorAtEndOfRange.from
+            )
+            const ancestorEndArgumentNode =
+              ancestorAtEndOfRange.lastChild?.getChild('LongArg')
+            if (!ancestorEndArgumentNode) {
+              throw new Error("Can't find argument node")
+            }
+            const commandContent = view.state.sliceDoc(
+              ancestorEndArgumentNode.from,
+              ancestorEndArgumentNode.to
+            )
+            const actualContent = view.state.sliceDoc(
+              range.from,
+              ancestorAtEndOfRange.to
+            )
+            validateReplacement(
+              `${contentUpToCommand}${command}{${commandContent}}`,
+              actualContent
+            )
+            const changes = view.state.changes([
+              {
+                from: range.from,
+                to: ancestorAtEndOfRange.to,
+                insert: `${command}{${contentUpToCommand}${commandContent}}`,
+              },
+            ])
+            return {
+              changes,
+              range: moveRange(
+                range,
+                range.from + command.length + 1,
+                range.to
+              ),
+            }
+          }
+
+          if (
+            ancestorAtStartIsWrappingCommand &&
+            ancestorAtStartOfRange.parent?.parent === ancestorAtEndOfRange
+          ) {
+            // Extend to the right. Case 9
+            const contentAfterCommand = view.state.sliceDoc(
+              ancestorAtStartOfRange.to,
+              range.to
+            )
+            const ancestorStartArgumentNode =
+              ancestorAtStartOfRange.lastChild?.getChild('LongArg')
+            if (!ancestorStartArgumentNode) {
+              throw new Error("Can't find argument node")
+            }
+            const commandContent = view.state.sliceDoc(
+              ancestorStartArgumentNode.from,
+              ancestorStartArgumentNode.to
+            )
+            const actualContent = view.state.sliceDoc(
+              ancestorAtStartOfRange.from,
+              range.to
+            )
+            validateReplacement(
+              `${command}{${commandContent}}${contentAfterCommand}`,
+              actualContent
+            )
+            const changes = view.state.changes([
+              {
+                from: ancestorAtStartOfRange.from,
+                to: range.to,
+                insert: `${command}{${commandContent}${contentAfterCommand}}`,
+              },
+            ])
+            return {
+              changes,
+              range: moveRange(range, range.from, range.to - 1),
+            }
+          }
+          // Bail out in case 1
+          // TODO: signal error to the user
+          return { range }
+        }
+
+        const ancestor = ancestorAtStartOfRange
+
+        // Bail out in case 2
+        if (content.includes('\n\n')) {
+          // TODO: signal error to the user
+          return { range }
+        }
+
+        const isCursorBeforeAncestor =
+          range.empty &&
+          ancestor &&
+          range.from === ancestor.from &&
+          isUnknownCommandWithName(ancestor, command, view.state)
+
+        // If we can't find an ancestor node, or if the parent is not an exsting
+        // \textbf, then we just wrap it in a range. Case 3.
+        if (
+          isCursorBeforeAncestor ||
+          !ancestor ||
+          !isUnknownCommandWithName(ancestor, command, view.state)
+        ) {
+          return wrapRangeInCommand(view.state, range, command)
+        }
+
+        const argumentNode = ancestor.lastChild?.getChild('LongArg')
+        if (!argumentNode) {
+          throw new Error("Can't find argument node")
+        }
+
+        // We should trim from the beginning. Case 4
+        if (range.from === argumentNode.from && range.to !== argumentNode.to) {
+          const textAfterSelection = view.state.sliceDoc(
+            range.to,
+            argumentNode.to
+          )
+          const wholeCommand = view.state.sliceDoc(ancestor.from, ancestor.to)
+          validateReplacement(
+            `${command}{${content}${textAfterSelection}}`,
+            wholeCommand
+          )
+          const changes = view.state.changes([
+            {
+              from: ancestor.from,
+              to: ancestor.to,
+              insert: `${content}${command}{${textAfterSelection}}`,
+            },
+          ])
+          return {
+            range: moveRange(
+              range,
+              range.from - command.length - 1,
+              range.to - command.length - 1
+            ),
+            changes,
+          }
+        }
+
+        // We should trim from the end. Case 5
+        if (range.to === argumentNode.to && range.from !== argumentNode.from) {
+          const textBeforeSelection = view.state.sliceDoc(
+            ancestor.from,
+            range.from
+          )
+          const wholeCommand = view.state.sliceDoc(ancestor.from, ancestor.to)
+          validateReplacement(`${textBeforeSelection}${content}}`, wholeCommand)
+          const changes = view.state.changes([
+            {
+              from: ancestor.from,
+              to: ancestor.to,
+              insert: `${textBeforeSelection}}${content}`,
+            },
+          ])
+          // We should shift selection forward by the } we insert
+          return {
+            range: moveRange(range, range.from + 1, range.to + 1),
+            changes,
+          }
+        }
+
+        // We should split the command in two. Case 6
+        if (range.from !== argumentNode.from && range.to !== argumentNode.to) {
+          const textBeforeSelection = view.state.sliceDoc(
+            ancestor.from,
+            range.from
+          )
+          const textAfterSelection = view.state.sliceDoc(range.to, ancestor.to)
+          const wholeCommand = view.state.sliceDoc(ancestor.from, ancestor.to)
+          validateReplacement(
+            `${textBeforeSelection}${content}${textAfterSelection}`,
+            wholeCommand
+          )
+          const changes = view.state.changes([
+            {
+              from: ancestor.from,
+              to: ancestor.to,
+              insert: `${textBeforeSelection}}${content}${command}{${textAfterSelection}`,
+            },
+          ])
+
+          return {
+            range: moveRange(range, range.from + 1, range.to + 1),
+            changes,
+          }
+        }
+
+        // Remove the wrapping command. Case 7
+        if (spansWholeArgument(ancestor, range)) {
+          const argumentContent = view.state.sliceDoc(
+            argumentNode.from,
+            argumentNode.to
+          )
+          const wholeCommand = view.state.sliceDoc(ancestor.from, ancestor.to)
+          validateReplacement(`${command}{${content}}`, wholeCommand)
+          const changes = view.state.changes([
+            { from: ancestor.from, to: ancestor.to, insert: argumentContent },
+          ])
+
+          return {
+            range: moveRange(
+              range,
+              range.from - command.length - 1,
+              range.to - command.length - 1
+            ),
+            changes,
+          }
+        }
+
+        // Shouldn't happen, but default to just wrapping the content
+        return wrapRangeInCommand(view.state, range, command)
+      })
+    )
+    return true
+  }
+}

+ 120 - 0
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)

+ 96 - 0
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<EditorView | null>(null)
+  if (viewRef.current === null) {
+    const timer = dispatchTimer()
+
+    const view = new EditorView({
+      state,
+      dispatch: tr => {
+        timer.start(tr)
+        view.update([tr])
+        if (isMounted.current) {
+          setState(view.state)
+        }
+        timer.end(tr, view)
+      },
+    })
+    viewRef.current = view
+  }
+
+  return (
+    <CodeMirrorStateContext.Provider value={state}>
+      <CodeMirrorViewContext.Provider value={viewRef.current}>
+        <CodemirrorOutline />
+        <CodeMirrorView />
+        <CodeMirrorSearch />
+        <CodeMirrorToolbar />
+        {sourceEditorComponents.map(
+          ({ import: { default: Component }, path }) => (
+            <Component key={path} />
+          )
+        )}
+      </CodeMirrorViewContext.Provider>
+    </CodeMirrorStateContext.Provider>
+  )
+}
+
+export default memo(CodeMirrorEditor)
+
+const CodeMirrorStateContext = createContext<EditorState | undefined>(undefined)
+
+export const useCodeMirrorStateContext = (): EditorState => {
+  const context = useContext(CodeMirrorStateContext)
+
+  if (!context) {
+    throw new Error(
+      'useCodeMirrorStateContext is only available inside CodeMirrorEditor'
+    )
+  }
+
+  return context
+}
+
+const CodeMirrorViewContext = createContext<EditorView | undefined>(undefined)
+
+export const useCodeMirrorViewContext = (): EditorView => {
+  const context = useContext(CodeMirrorViewContext)
+
+  if (!context) {
+    throw new Error(
+      'useCodeMirrorViewContext is only available inside CodeMirrorEditor'
+    )
+  }
+
+  return context
+}

+ 156 - 0
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<string>('editor.open_doc_name')
+  const goToLineEmitter = useScopeEventEmitter('editor:gotoLine', true)
+  const outlineToggledEmitter = useScopeEventEmitter('outline-toggled')
+  const [currentlyHighlightedLine, setCurrentlyHighlightedLine] =
+    useState<number>(-1)
+  const isTexFile = useMemo(() => isValidTeXFile(docName), [docName])
+  const [ignoreNextCursorUpdate, setIgnoreNextCursorUpdate] =
+    useState<boolean>(false)
+  const [ignoreNextScroll, setIgnoreNextScroll] = useState<boolean>(false)
+  const [binaryFileOpened, setBinaryFileOpened] = useState<boolean>(false)
+
+  useEventListener(
+    'file-view:file-opened',
+    useCallback(_ => {
+      setBinaryFileOpened(true)
+    }, [])
+  )
+
+  useEventListener(
+    'scroll:editor:update',
+    useCallback(
+      evt => {
+        if (ignoreNextScroll) {
+          setIgnoreNextScroll(false)
+          return
+        }
+        setCurrentlyHighlightedLine(evt.detail + 1)
+      },
+      [ignoreNextScroll]
+    )
+  )
+
+  useEventListener(
+    'cursor:editor:update',
+    useCallback(
+      evt => {
+        if (ignoreNextCursorUpdate) {
+          setIgnoreNextCursorUpdate(false)
+          return
+        }
+        setCurrentlyHighlightedLine(evt.detail.row + 1)
+      },
+      [ignoreNextCursorUpdate]
+    )
+  )
+
+  useEventListener(
+    'doc:after-opened',
+    useCallback(evt => {
+      if (evt.detail) {
+        setIgnoreNextCursorUpdate(true)
+      }
+      setBinaryFileOpened(false)
+      setIgnoreNextScroll(true)
+    }, [])
+  )
+
+  const outlineStatus = useMemo(
+    () =>
+      debouncedState.field(documentOutline, false)?.status ||
+      ProjectionStatus.Pending,
+    [debouncedState]
+  )
+
+  const flatOutline = useMemo(() => {
+    const outlineResult = debouncedState.field(documentOutline, false)
+    if (outlineResult?.status !== ProjectionStatus.Pending) {
+      // We have a (potentially partial) outline.
+      return outlineResult?.items!.map(element => {
+        // Remove {from, to} to not trip up deep comparison
+        const { level, title, line } = element
+        return { level, title, line }
+      })
+    }
+    return undefined
+  }, [debouncedState])
+
+  const outline = useDeepCompareMemo(() => {
+    return flatOutline ? nestOutline(flatOutline) : []
+  }, [flatOutline])
+
+  const jumpToLine = useCallback(
+    (lineNumber, syncToPdf) => {
+      setIgnoreNextScroll(true)
+      goToLineEmitter(lineNumber, 0, syncToPdf)
+      eventTracking.sendMB('outline-jump-to-line')
+    },
+    [goToLineEmitter]
+  )
+
+  const onToggle = useCallback(
+    isOpen => {
+      outlineToggledEmitter(isOpen)
+    },
+    [outlineToggledEmitter]
+  )
+
+  const highlightedLine = useMemo(
+    () => closestSectionLineNumber(flatOutline, currentlyHighlightedLine),
+    [flatOutline, currentlyHighlightedLine]
+  )
+
+  const outlineDomElement = document.querySelector('.outline-container')
+  if (!outlineDomElement) {
+    return null
+  }
+
+  return createPortal(
+    <OutlinePane
+      outline={outline}
+      onToggle={onToggle}
+      eventTracking={eventTracking}
+      isTexFile={isTexFile && !binaryFileOpened}
+      jumpToLine={jumpToLine}
+      highlightedLine={highlightedLine}
+      show
+      isPartial={outlineStatus === ProjectionStatus.Partial}
+    />,
+    outlineDomElement
+  )
+})

+ 422 - 0
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<string>('settings.mode')
+  const emacsKeybindingsActive = keybindings === 'emacs'
+  const [activeSearchOption, setActiveSearchOption] =
+    useState<ActiveSearchOption>(null)
+
+  // Generate random ID for option buttons. This is necessary because the label
+  // for each checkbox is separated from it in the DOM so that the buttons can
+  // be outside the natural tab order
+  const idSuffix = useMemo(() => Math.random().toString(16).slice(2), [])
+  const caseSensitiveId = 'caseSensitive' + idSuffix
+  const regexpId = 'regexp' + idSuffix
+  const wholeWordId = 'wholeWord' + idSuffix
+
+  const { t } = useTranslation()
+
+  const [position, setPosition] = useState<{
+    current: number
+    total: number
+  } | null>(null)
+
+  const formRef = useRef<HTMLFormElement | null>(null)
+  const inputRef = useRef<HTMLInputElement | null>(null)
+  const replaceRef = useRef<HTMLInputElement | null>(null)
+
+  const handleInputRef = useCallback(node => {
+    inputRef.current = node
+
+    // focus the search input when the panel opens
+    if (node) {
+      node.select()
+      node.focus()
+    }
+  }, [])
+
+  const handleReplaceRef = useCallback(node => {
+    replaceRef.current = node
+  }, [])
+
+  const handleSubmit = useCallback(event => {
+    event.preventDefault()
+  }, [])
+
+  useEffect(() => {
+    const { from, to } = state.selection.main
+
+    const query = getSearchQuery(state)
+
+    if (query.valid) {
+      const cursor = query.getCursor(state.doc) as SearchCursor
+
+      let total = 0
+      let current = 0
+
+      while (!cursor.next().done) {
+        total++
+
+        if (total >= MAX_MATCH_COUNT) {
+          break
+        }
+
+        const item = cursor.value
+
+        if (current === 0 && item.from === from && item.to === to) {
+          current = total
+        }
+      }
+
+      setPosition({ current, total })
+    } else {
+      setPosition(null)
+    }
+  }, [state])
+
+  const handleChange = useCallback(() => {
+    if (formRef.current) {
+      const data = Object.fromEntries(new FormData(formRef.current))
+
+      const query = new SearchQuery({
+        search: data.search as string,
+        replace: data.replace as string,
+        caseSensitive: data.caseSensitive === 'on',
+        regexp: data.regexp === 'on',
+        literal: true,
+        wholeWord: data.wholeWord === 'on',
+      })
+
+      view.dispatch({ effects: setSearchQuery.of(query) })
+    }
+  }, [view])
+
+  const handleFormKeyDown = useCallback(
+    event => {
+      if (runScopeHandlers(view, event, 'search-panel')) {
+        event.preventDefault()
+      }
+    },
+    [view]
+  )
+
+  // Returns true if the event was handled, false otherwise
+  const handleEmacsNavigation = useCallback(
+    event => {
+      const emacsCtrlSeq =
+        emacsKeybindingsActive &&
+        event.ctrlKey &&
+        !event.altKey &&
+        !event.shiftKey
+
+      if (!emacsCtrlSeq) {
+        return false
+      }
+
+      switch (event.key) {
+        case 's': {
+          event.stopPropagation()
+          event.preventDefault()
+          findNext(view)
+          return true
+        }
+        case 'r': {
+          event.stopPropagation()
+          event.preventDefault()
+          findPrevious(view)
+          return true
+        }
+        case 'g': {
+          event.stopPropagation()
+          event.preventDefault()
+          closeSearchPanel(view)
+          document.dispatchEvent(new CustomEvent('cm:emacs-close-search-panel'))
+          return true
+        }
+        default: {
+          return false
+        }
+      }
+    },
+    [view, emacsKeybindingsActive]
+  )
+
+  const handleSearchKeyDown = useCallback(
+    event => {
+      switch (event.key) {
+        case 'Enter':
+          event.preventDefault()
+          if (event.shiftKey) {
+            findPrevious(view)
+          } else {
+            findNext(view)
+          }
+          break
+      }
+      handleEmacsNavigation(event)
+    },
+    [view, handleEmacsNavigation]
+  )
+
+  const handleReplaceKeyDown = useCallback(
+    event => {
+      switch (event.key) {
+        case 'Enter':
+          event.preventDefault()
+          replaceNext(view)
+          break
+
+        case 'Tab': {
+          if (event.shiftKey) {
+            event.preventDefault()
+            inputRef.current?.focus()
+          }
+        }
+      }
+      handleEmacsNavigation(event)
+    },
+    [view, handleEmacsNavigation]
+  )
+
+  const focusSearchBox = useCallback(() => {
+    inputRef.current?.focus()
+  }, [])
+
+  const query = useMemo(() => {
+    return getSearchQuery(state)
+  }, [state])
+
+  return (
+    // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
+    <form
+      ref={formRef}
+      onSubmit={handleSubmit}
+      onKeyDown={handleFormKeyDown}
+      className="ol-cm-search-form"
+      role="search"
+    >
+      <div className="ol-cm-search-controls">
+        <InputGroup bsSize="small" className="ol-cm-search-input-group">
+          <FormControl
+            type="text"
+            name="search"
+            // IMPORTANT: CodeMirror uses this attribute to focus the input
+            // when the panel opens and when the panel is refocused
+            main-field="true"
+            placeholder={t('search_search_for')}
+            autoComplete="off"
+            value={query.search || ''}
+            onChange={handleChange}
+            onKeyDown={handleSearchKeyDown}
+            className="ol-cm-search-form-input"
+            bsSize="small"
+            inputRef={handleInputRef}
+            aria-label={t('search_command_find')}
+          />
+
+          <InputGroup.Button>
+            <Tooltip
+              id="search-match-case"
+              description={t('search_match_case')}
+            >
+              <label
+                className={classnames(
+                  'btn btn-sm btn-default ol-cm-search-input-button',
+                  {
+                    checked: query.caseSensitive,
+                    focused: activeSearchOption === 'caseSensitive',
+                  }
+                )}
+                htmlFor={caseSensitiveId}
+                aria-label={t('search_match_case')}
+              >
+                Aa
+              </label>
+            </Tooltip>
+          </InputGroup.Button>
+
+          <InputGroup.Button>
+            <Tooltip id="search-regexp" description={t('search_regexp')}>
+              <label
+                className={classnames(
+                  'btn btn-sm btn-default ol-cm-search-input-button',
+                  {
+                    checked: query.regexp,
+                    focused: activeSearchOption === 'regexp',
+                  }
+                )}
+                htmlFor={regexpId}
+                aria-label={t('search_regexp')}
+              >
+                [.*]
+              </label>
+            </Tooltip>
+          </InputGroup.Button>
+
+          <InputGroup.Button>
+            <Tooltip
+              id="search-whole-word"
+              description={t('search_whole_word')}
+            >
+              <label
+                className={classnames(
+                  'btn btn-sm btn-default ol-cm-search-input-button',
+                  {
+                    checked: query.wholeWord,
+                    focused: activeSearchOption === 'wholeWord',
+                  }
+                )}
+                htmlFor={wholeWordId}
+                aria-label={t('search_whole_word')}
+              >
+                W
+              </label>
+            </Tooltip>
+          </InputGroup.Button>
+        </InputGroup>
+
+        <InputGroup
+          bsSize="small"
+          className="ol-cm-search-input-group ol-cm-search-replace-input"
+        >
+          <FormControl
+            type="text"
+            name="replace"
+            placeholder={t('search_replace_with')}
+            autoComplete="off"
+            value={query.replace || ''}
+            onChange={handleChange}
+            onKeyDown={handleReplaceKeyDown}
+            className="ol-cm-search-form-input"
+            bsSize="small"
+            inputRef={handleReplaceRef}
+            aria-label={t('search_command_replace')}
+          />
+        </InputGroup>
+
+        <div className="ol-cm-search-hidden-inputs">
+          <input
+            id={caseSensitiveId}
+            name="caseSensitive"
+            type="checkbox"
+            checked={query.caseSensitive}
+            onChange={handleChange}
+            onClick={focusSearchBox}
+            onFocus={() => setActiveSearchOption('caseSensitive')}
+            onBlur={() => setActiveSearchOption(null)}
+          />
+
+          <input
+            id={regexpId}
+            name="regexp"
+            type="checkbox"
+            checked={query.regexp}
+            onChange={handleChange}
+            onClick={focusSearchBox}
+            onFocus={() => setActiveSearchOption('regexp')}
+            onBlur={() => setActiveSearchOption(null)}
+          />
+
+          <input
+            id={wholeWordId}
+            name="wholeWord"
+            type="checkbox"
+            checked={query.wholeWord}
+            onChange={handleChange}
+            onClick={focusSearchBox}
+            onFocus={() => setActiveSearchOption('wholeWord')}
+            onBlur={() => setActiveSearchOption(null)}
+          />
+        </div>
+
+        <div className="ol-cm-search-form-group ol-cm-search-next-previous">
+          <ButtonGroup className="ol-cm-search-form-button-group">
+            <Button type="button" bsSize="small" onClick={() => findNext(view)}>
+              <Icon
+                type="chevron-down"
+                fw
+                accessibilityLabel={t('search_next')}
+              />
+            </Button>
+
+            <Button
+              type="button"
+              bsSize="small"
+              onClick={() => findPrevious(view)}
+            >
+              <Icon
+                type="chevron-up"
+                fw
+                accessibilityLabel={t('search_previous')}
+              />
+            </Button>
+          </ButtonGroup>
+
+          {position !== null && (
+            <div className="ol-cm-search-form-position">
+              {position.total === MAX_MATCH_COUNT
+                ? `${position.current} ${t('of')} ${MAX_MATCH_COUNT}+`
+                : `${position.current} ${t('of')} ${position.total}`}
+            </div>
+          )}
+        </div>
+
+        <div className="ol-cm-search-form-group ol-cm-search-replace-buttons">
+          <Button
+            type="button"
+            bsSize="small"
+            onClick={() => replaceNext(view)}
+          >
+            {t('search_replace')}
+          </Button>
+
+          <Button type="button" bsSize="small" onClick={() => replaceAll(view)}>
+            {t('search_replace_all')}
+          </Button>
+        </div>
+      </div>
+
+      <div className="ol-cm-search-form-close">
+        <button
+          className="close"
+          onClick={() => closeSearchPanel(view)}
+          type="button"
+          aria-label={t('close')}
+        >
+          <span aria-hidden="true">&times;</span>
+        </button>
+      </div>
+    </form>
+  )
+}
+
+export default CodeMirrorSearchForm

+ 17 - 0
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(<CodeMirrorSearchForm />, dom)
+}
+
+export default CodeMirrorSearch

+ 121 - 0
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(<Toolbar />, panel.dom)
+}
+
+const Toolbar = memo(function Toolbar() {
+  const state = useCodeMirrorStateContext()
+
+  const [overflowed, setOverflowed] = useState(false)
+  const [collapsed, setCollapsed] = useState(false)
+
+  const overflowBeforeRef = useRef<HTMLDivElement>(null)
+  const overflowedItemsRef = useRef<Set<string>>(new Set())
+
+  const {
+    open: overflowOpen,
+    onToggle: setOverflowOpen,
+    ref: overflowRef,
+  } = useDropdown()
+
+  const buildOverflow = useCallback(
+    (element: Element) => {
+      setOverflowOpen(false)
+      setOverflowed(false)
+
+      if (overflowBeforeRef.current) {
+        overflowedItemsRef.current = new Set()
+
+        const buttonGroups = [
+          ...element.querySelectorAll<HTMLDivElement>('[data-overflow]'),
+        ].reverse()
+
+        // restore all the overflowed items
+        for (const buttonGroup of buttonGroups) {
+          buttonGroup.classList.remove('overflow-hidden')
+        }
+
+        // find all the available items
+        for (const buttonGroup of buttonGroups) {
+          if (element.scrollWidth <= element.clientWidth) {
+            break
+          }
+          // add this item to the overflow
+          overflowedItemsRef.current.add(buttonGroup.dataset.overflow!)
+          buttonGroup.classList.add('overflow-hidden')
+        }
+
+        setOverflowed(overflowedItemsRef.current.size > 0)
+      }
+    },
+    [setOverflowOpen]
+  )
+
+  // build when the container resizes
+  const resizeRef = useResizeObserver(buildOverflow)
+
+  const toggleToolbar = useCallback(() => {
+    setCollapsed(value => !value)
+  }, [])
+
+  if (collapsed) {
+    return null
+  }
+
+  return (
+    <div className="ol-cm-toolbar" ref={resizeRef}>
+      <ToolbarItems state={state} />
+      <div className="ol-cm-toolbar-button-group" ref={overflowBeforeRef}>
+        <ToolbarOverflow
+          overflowed={overflowed}
+          target={overflowBeforeRef.current ?? undefined}
+          overflowOpen={overflowOpen}
+          setOverflowOpen={setOverflowOpen}
+          overflowRef={overflowRef}
+        >
+          <ToolbarItems state={state} overflowed={overflowedItemsRef.current} />
+        </ToolbarOverflow>
+      </div>
+      <div className="ol-cm-toolbar-button-group ol-cm-toolbar-end">
+        <ToolbarButton
+          id="toolbar-toggle-search"
+          label="Toggle Search"
+          command={commands.toggleSearch}
+          active={searchPanelOpen(state)}
+          icon="search"
+        />
+      </div>
+      <div className="ol-cm-toolbar-button-group hidden">
+        <ToolbarButton
+          id="toolbar-expand-less"
+          label="Hide Toolbar"
+          command={toggleToolbar}
+          icon="caret-up"
+          hidden // enable this once there's a way to show the toolbar again
+        />
+      </div>
+    </div>
+  )
+})

+ 30 - 0
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 <div ref={containerRef} style={{ height: '100%' }} />
+}
+
+export default memo(CodeMirrorView)

+ 13 - 18
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() {
       <fieldset className="toggle-switch">
         <legend className="sr-only">Editor mode.</legend>
 
-        {hasNewSourceEditor && (
-          <>
-            <input
-              type="radio"
-              name="editor"
-              value="cm6"
-              id="editor-switch-cm6"
-              className="toggle-switch-input"
-              checked={!richTextOrVisual && !!newSourceEditor}
-              onChange={handleChange}
-            />
-            <label htmlFor="editor-switch-cm6" className="toggle-switch-label">
-              <span>Source</span>
-            </label>
-          </>
-        )}
+        <input
+          type="radio"
+          name="editor"
+          value="cm6"
+          id="editor-switch-cm6"
+          className="toggle-switch-input"
+          checked={!richTextOrVisual && !!newSourceEditor}
+          onChange={handleChange}
+        />
+        <label htmlFor="editor-switch-cm6" className="toggle-switch-label">
+          <span>Source</span>
+        </label>
 
         {showLegacySourceEditor ? (
           <>
@@ -126,7 +121,7 @@ function EditorSwitch() {
               onChange={handleChange}
             />
             <label htmlFor="editor-switch-ace" className="toggle-switch-label">
-              <span>{hasNewSourceEditor ? 'Source (legacy)' : 'Source'}</span>
+              <span>Source (legacy)</span>
             </label>
           </>
         ) : null}

+ 25 - 0
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 (
+    <Suspense
+      fallback={
+        <div className="pdf-loading-spinner-container">
+          <LoadingSpinner delay={500} />
+        </div>
+      }
+    >
+      <CodeMirrorEditor />
+    </Suspense>
+  )
+}
+
+export default withErrorBoundary(memo(SourceEditor), ErrorBoundaryFallback)

+ 62 - 0
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<Popover>
+}> = memo(function ToolbarOverflow({
+  overflowed,
+  target,
+  overflowOpen,
+  setOverflowOpen,
+  overflowRef,
+  children,
+}) {
+  const className = classnames(
+    'ol-cm-toolbar-button',
+    'ol-cm-toolbar-overflow-toggle',
+    {
+      'ol-cm-toolbar-overflow-toggle-visible': overflowed,
+    }
+  )
+
+  return (
+    <>
+      <Button
+        type="button"
+        id="toolbar-more"
+        className={className}
+        aria-label="More"
+        bsStyle={null}
+        onMouseDown={event => {
+          event.preventDefault()
+          event.stopPropagation()
+        }}
+        onClick={() => {
+          setOverflowOpen(!overflowOpen)
+        }}
+      >
+        <Icon type="ellipsis-h" fw />
+      </Button>
+
+      <Overlay
+        show={overflowOpen}
+        target={target}
+        placement="bottom"
+        container={document.querySelector('.cm-editor')}
+        containerPadding={0}
+        animation
+        onHide={() => setOverflowOpen(false)}
+      >
+        <Popover id="popover-toolbar-overflow" ref={overflowRef}>
+          <div className="ol-cm-toolbar-overflow">{children}</div>
+        </Popover>
+      </Overlay>
+    </>
+  )
+})

+ 115 - 0
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<HTMLButtonElement | null>(null)
+
+  const currentLevel = findCurrentSectionHeadingLevel(state)
+  const currentLabel = currentLevel
+    ? levels.get(currentLevel.level) ?? currentLevel.level
+    : '---'
+
+  return (
+    <>
+      <button
+        ref={toggleButtonRef}
+        type="button"
+        id="section-heading-menu-button"
+        aria-haspopup="true"
+        aria-controls="section-heading-menu"
+        aria-label={t('toolbar_choose_section_heading_level')}
+        className="ol-cm-toolbar-menu-toggle"
+        onMouseDown={event => event.preventDefault()}
+        onClick={() => setOverflowOpen(!overflowOpen)}
+      >
+        <span>{currentLabel}</span>
+        <Icon type="caret-down" fw />
+      </button>
+
+      <Overlay
+        show={overflowOpen}
+        onHide={() => setOverflowOpen(false)}
+        animation={false}
+        container={document.querySelector('.cm-editor')}
+        containerPadding={0}
+        placement="bottom"
+        rootClose
+        target={toggleButtonRef.current ?? undefined}
+      >
+        <Popover
+          id="popover-toolbar-section-heading"
+          className="ol-cm-toolbar-menu-popover"
+        >
+          <div
+            className="ol-cm-toolbar-menu"
+            id="section-heading-menu"
+            role="menu"
+            aria-labelledby="section-heading-menu-button"
+          >
+            {levelsEntries.map(([level, label]) => (
+              <button
+                type="button"
+                role="menuitem"
+                key={level}
+                onClick={() => {
+                  emitCommandEvent(view, 'section-level-change')
+                  setSectionHeadingLevel(view, level)
+                  view.focus()
+                  setOverflowOpen(false)
+                }}
+                className={classnames(
+                  'ol-cm-toolbar-menu-item',
+                  `section-level-${level}`,
+                  {
+                    'ol-cm-toolbar-menu-item-active':
+                      level === currentLevel?.level,
+                  }
+                )}
+              >
+                {label}
+              </button>
+            ))}
+          </div>
+        </Popover>
+      </Overlay>
+    </>
+  )
+}

+ 86 - 0
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 = (
+    <Button
+      className={classnames('ol-cm-toolbar-button', className, { hidden })}
+      aria-label={label}
+      onMouseDown={handleMouseDown}
+      onClick={handleClick}
+      bsStyle={null}
+      active={active}
+      disabled={disabled}
+      type="button"
+    >
+      {textIcon ? icon : <Icon type={icon} fw accessibilityLabel={label} />}
+    </Button>
+  )
+
+  if (!label) {
+    return button
+  }
+
+  const description = (
+    <>
+      <div>{label}</div>
+      {shortcut && <div>{shortcut}</div>}
+    </>
+  )
+
+  return (
+    <Tooltip
+      id={id}
+      description={description}
+      overlayProps={{ placement: 'bottom' }}
+    >
+      {button}
+    </Tooltip>
+  )
+})

+ 189 - 0
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<string>
+}> = memo(function ToolbarItems({ state, overflowed }) {
+  const { t } = useTranslation()
+  const { toggleSymbolPalette, showSymbolPalette } = useEditorContext()
+  const isActive = withinFormattingCommand(state)
+  const listDepth = minimumListDepthForSelection(state)
+  const addCommentEmitter = useScopeEventEmitter('comment:start_adding')
+  const { setReviewPanelOpen } = useLayoutContext()
+  const addComment = useCallback(
+    (view: EditorView) => {
+      const range = view.state.selection.main
+      if (range.empty) {
+        const line = view.state.doc.lineAt(range.head)
+        view.dispatch({
+          selection: EditorSelection.range(line.from, line.to),
+        })
+      }
+      setReviewPanelOpen(true)
+      addCommentEmitter()
+    },
+    [addCommentEmitter, setReviewPanelOpen]
+  )
+
+  const showGroup = (group: string) => !overflowed || overflowed.has(group)
+
+  return (
+    <>
+      {showGroup('group-history') && (
+        <div className="ol-cm-toolbar-button-group">
+          <ToolbarButton
+            id="toolbar-undo"
+            label={t('toolbar_undo')}
+            command={undo}
+            icon="undo"
+            shortcut={isMac ? '⌘Z' : 'Ctrl+Z'}
+          />
+          <ToolbarButton
+            id="toolbar-redo"
+            label={t('toolbar_redo')}
+            command={redo}
+            icon="repeat"
+            shortcut={isMac ? '⇧⌘Z' : 'Ctrl+Y'}
+          />
+        </div>
+      )}
+      {showGroup('group-section') && (
+        <div
+          className="ol-cm-toolbar-button-group"
+          data-overflow="group-section"
+        >
+          <SectionHeadingDropdown />
+        </div>
+      )}
+      {showGroup('group-format') && (
+        <div className="ol-cm-toolbar-button-group">
+          <ToolbarButton
+            id="toolbar-format-bold"
+            label={t('toolbar_format_bold')}
+            command={commands.toggleBold}
+            active={isActive('\\textbf')}
+            icon="bold"
+            shortcut={isMac ? '⌘B' : 'Ctrl+B'}
+          />
+          <ToolbarButton
+            id="toolbar-format-italic"
+            label={t('toolbar_format_italic')}
+            command={commands.toggleItalic}
+            active={isActive('\\textit')}
+            icon="italic"
+            shortcut={isMac ? '⌘I' : 'Ctrl+I'}
+          />
+        </div>
+      )}
+      {showGroup('group-math') && (
+        <div className="ol-cm-toolbar-button-group" data-overflow="group-math">
+          <ToolbarButton
+            id="toolbar-inline-math"
+            label={t('toolbar_insert_inline_math')}
+            command={commands.wrapInInlineMath}
+            icon="π"
+            textIcon
+            className="ol-cm-toolbar-button-math"
+          />
+          <ToolbarButton
+            id="toolbar-display-math"
+            label={t('toolbar_insert_display_math')}
+            command={commands.wrapInDisplayMath}
+            icon="Σ"
+            textIcon
+            className="ol-cm-toolbar-button-math"
+          />
+          <ToolbarButton
+            id="toolbar-toggle-symbol-palette"
+            label={t('toolbar_toggle_symbol_palette')}
+            active={showSymbolPalette}
+            command={toggleSymbolPalette}
+            icon="Ω"
+            textIcon
+            className="ol-cm-toolbar-button-math"
+          />
+        </div>
+      )}
+      {showGroup('group-misc') && (
+        <div className="ol-cm-toolbar-button-group" data-overflow="group-misc">
+          <ToolbarButton
+            id="toolbar-href"
+            label={t('toolbar_insert_link')}
+            command={commands.wrapInHref}
+            icon="link"
+          />
+          <ToolbarButton
+            id="toolbar-add-comment"
+            label={t('toolbar_add_comment')}
+            command={addComment}
+            disabled={!canAddComment(state)}
+            icon="comment"
+            hidden // enable this if an alternative to the floating "Add Comment" button is needed
+          />
+          <ToolbarButton
+            id="toolbar-figure"
+            label={t('toolbar_insert_figure')}
+            command={commands.insertFigure}
+            icon="picture-o"
+          />
+          <ToolbarButton
+            id="toolbar-table"
+            label={t('toolbar_insert_table')}
+            command={commands.insertTable}
+            icon="table"
+            hidden
+          />
+        </div>
+      )}
+      {showGroup('group-list') && (
+        <div className="ol-cm-toolbar-button-group" data-overflow="group-list">
+          <ToolbarButton
+            id="toolbar-bullet-list"
+            label={t('toolbar_bullet_list')}
+            command={commands.toggleBulletList}
+            icon="list-ul"
+          />
+          <ToolbarButton
+            id="toolbar-numbered-list"
+            label={t('toolbar_numbered_list')}
+            command={commands.toggleNumberedList}
+            icon="list-ol"
+          />
+          <ToolbarButton
+            id="toolbar-format-indent-decrease"
+            label={t('toolbar_decrease_indent')}
+            command={commands.indentDecrease}
+            icon="outdent"
+            shortcut={isMac ? '⌘[' : 'Ctrl+['}
+            disabled={listDepth < 2}
+          />
+          <ToolbarButton
+            id="toolbar-format-indent-increase"
+            label={t('toolbar_increase_indent')}
+            command={commands.indentIncrease}
+            icon="indent"
+            shortcut={isMac ? '⌘]' : 'Ctrl+]'}
+            disabled={listDepth < 1}
+          />
+        </div>
+      )}
+    </>
+  )
+})

+ 6 - 0
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), []))

+ 139 - 0
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<Diagnostic[]>()
+
+export const compileDiagnosticsState = StateField.define<
+  RangeSet<DiagnosticRangeValue>
+>({
+  create() {
+    return RangeSet.empty
+  },
+  update(value, transaction) {
+    for (const effect of transaction.effects) {
+      if (effect.is(setCompileDiagnosticsEffect)) {
+        return RangeSet.of(
+          effect.value.map(diagnostic =>
+            new DiagnosticRangeValue(diagnostic).range(
+              diagnostic.from,
+              diagnostic.to
+            )
+          ),
+          true
+        )
+      }
+    }
+
+    if (transaction.docChanged) {
+      value = value.map(transaction.changes)
+    }
+
+    return value
+  },
+})
+
+export const setAnnotations = (doc: Text, annotations: Annotation[]) => {
+  const diagnostics: Diagnostic[] = []
+
+  for (const annotation of annotations) {
+    // ignore "whole document" (row: -1) annotations
+    if (annotation.row !== -1) {
+      try {
+        diagnostics.push(convertAnnotationToDiagnostic(doc, annotation))
+      } catch (error) {
+        // ignore invalid annotations
+        console.debug('invalid annotation position', error)
+      }
+    }
+  }
+
+  return {
+    effects: setCompileDiagnosticsEffect.of(diagnostics),
+  }
+}
+
+export const showCompileLogDiagnostics = (show: boolean) => {
+  return {
+    effects: [
+      // reconfigure the compile log lint source
+      compileLintSourceConf.reconfigure(show ? compileLogLintSource() : []),
+    ],
+  }
+}
+
+const convertAnnotationToDiagnostic = (
+  doc: Text,
+  annotation: Annotation
+): Diagnostic => {
+  if (annotation.row < 0) {
+    throw new Error(`Invalid annotation row ${annotation.row}`)
+  }
+
+  const line = doc.line(annotation.row + 1)
+
+  return {
+    from: line.from,
+    to: line.to, // NOTE: highlight whole line as synctex doesn't output column number
+    severity: annotation.type,
+    message: annotation.text,
+    // source: annotation.source, // NOTE: the source is displayed in the tooltip
+  }
+}

+ 151 - 0
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',
+  },
+})

+ 31 - 0
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)),
+  ]
+}

+ 134 - 0
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<Decoration>[] = []
+
+      if (matchedAdjacent(match)) {
+        // combine an adjacent pair of matching markers into a single decoration
+        decorations.push(
+          matchingMark.range(
+            Math.min(match.start.from, match.end.from),
+            Math.max(match.start.to, match.end.to)
+          )
+        )
+      } else {
+        // default match rendering (defaultRenderMatch in @codemirror/matchbrackets)
+        const mark = match.matched ? matchingMark : nonmatchingMark
+        decorations.push(mark.range(match.start.from, match.start.to))
+        if (match.end) {
+          decorations.push(mark.range(match.end.from, match.end.to))
+        }
+      }
+
+      return decorations
+    },
+  })
+}
+
+interface AdjacentMatchResult extends MatchResult {
+  end: {
+    from: number
+    to: number
+  }
+}
+
+const matchedAdjacent = (match: MatchResult): match is AdjacentMatchResult =>
+  Boolean(
+    match.matched &&
+      match.end &&
+      (match.start.to === match.end.from || match.end.to === match.start.from)
+  )
+
+export const bracketSelection = (): Extension[] => [
+  EditorView.domEventHandlers({
+    dblclick: (evt, view) => {
+      const pos = view.posAtCoords({
+        x: evt.pageX,
+        y: evt.pageY,
+      })
+      if (!pos) return false
+
+      const search = (direction: Direction, position: number) => {
+        const match = matchBrackets(view.state, position, direction, {
+          // Only look at data in the syntax tree, don't scan the text
+          maxScanDistance: 0,
+        })
+        if (match?.matched && match.end) {
+          const newRange = EditorSelection.range(
+            Math.min(match.start.from, match.end.from),
+            Math.max(match.end.to, match.start.to)
+          )
+          return newRange
+        }
+        return false
+      }
+
+      const dispatchSelection = (range: SelectionRange) => {
+        view.dispatch({
+          selection: range,
+        })
+        return true
+      }
+      // 1. Look forwards, from the character *behind* the cursor
+      const forwardsExcludingBrackets = search(FORWARDS, pos - 1)
+      if (forwardsExcludingBrackets) {
+        return dispatchSelection(
+          EditorSelection.range(
+            forwardsExcludingBrackets.from + 1,
+            forwardsExcludingBrackets.to - 1
+          )
+        )
+      }
+
+      // 2. Look forwards, from the character *in front of* the cursor
+      const forwardsIncludingBrackets = search(FORWARDS, pos)
+      if (forwardsIncludingBrackets) {
+        return dispatchSelection(forwardsIncludingBrackets)
+      }
+
+      // 3. Look backwards, from the character *behind* the cursor
+      const backwardsIncludingBrackets = search(BACKWARDS, pos)
+      if (backwardsIncludingBrackets) {
+        return dispatchSelection(backwardsIncludingBrackets)
+      }
+
+      // 4. Look backwards, from the character *in front of* the cursor
+      const backwardsExcludingBrackets = search(BACKWARDS, pos + 1)
+      if (backwardsExcludingBrackets) {
+        return dispatchSelection(
+          EditorSelection.range(
+            backwardsExcludingBrackets.from + 1,
+            backwardsExcludingBrackets.to - 1
+          )
+        )
+      }
+
+      return false
+    },
+  }),
+  EditorView.baseTheme({
+    '.cm-matchingBracket': {
+      pointerEvents: 'none',
+    },
+  }),
+]

+ 50 - 0
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',
+}

+ 30 - 0
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,
+}

+ 482 - 0
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<string, any>) => {
+    const contentRect = view.contentDOM.getBoundingClientRect()
+
+    const { doc } = view.state
+
+    const items = Object.values(entries || {})
+
+    const allVisible = items.length <= CULL_AFTER
+    let visibilityChanged = false
+
+    const docLength = doc.length
+
+    const editorPaddingTop = editorVerticalTopPadding(view)
+
+    for (const entry of items) {
+      // TODO: clamp to max row and column, account for folding?
+      const coords = fullHeightCoordsAtPos(
+        view,
+        Math.min(entry.offset, docLength) // avoid exception for comments at end of document when deleting text
+      )
+
+      if (coords) {
+        const y = coords.top - contentRect.top - editorPaddingTop
+        const height = coords.bottom - coords.top
+
+        entry.screenPos = { y, height, editorPaddingTop }
+      }
+
+      if (allVisible) {
+        if (!entry.visible) {
+          visibilityChanged = true
+        }
+        entry.visible = true
+      }
+    }
+
+    if (!allVisible) {
+      const { from, to } = view.viewport
+
+      for (const entry of items) {
+        const previouslyVisible = entry.visible
+
+        entry.visible = entry.offset >= from && entry.offset <= to
+
+        if (previouslyVisible !== entry.visible) {
+          visibilityChanged = true
+        }
+      }
+    }
+
+    return visibilityChanged
+  }
+
+  /**
+   * Add a comment (thread) to the ShareJS doc when it's created
+   */
+  const addComment = (offset: number, length: number, threadId: string) => {
+    currentDoc.submitOp({
+      c: view.state.doc.sliceString(offset, offset + length),
+      p: offset,
+      t: threadId,
+    })
+  }
+
+  /**
+   * Remove a comment (thread) from the range tracker when it's deleted
+   */
+  const removeComment = (commentId: string) => {
+    currentDoc.ranges.removeCommentId(commentId)
+  }
+
+  /**
+   * Remove tracked changes from the range tracker when they're accepted
+   */
+  const acceptChanges = (changeIds: string[]) => {
+    currentDoc.ranges.removeChangeIds(changeIds)
+  }
+
+  /**
+   * Remove tracked changes from the range tracker when they're rejected,
+   * and restore the original content
+   */
+  const rejectChanges = (changeIds: string[]) => {
+    const changes: any[] = currentDoc.ranges.getChanges(changeIds)
+
+    if (changes.length === 0) {
+      return {}
+    }
+
+    // When doing bulk rejections, adjacent changes might interact with each other.
+    // Consider an insertion with an adjacent deletion (which is a common use-case, replacing words):
+    //
+    //     "foo bar baz" -> "foo quux baz"
+    //
+    // The change above will be modeled with two ops, with the insertion going first:
+    //
+    //     foo quux baz
+    //         |--| -> insertion of "quux", op 1, at position 4
+    //             | -> deletion of "bar", op 2, pushed forward by "quux" to position 8
+    //
+    // When rejecting these changes at once, if the insertion is rejected first, we get unexpected
+    // results. What happens is:
+    //
+    //     1) Rejecting the insertion deletes the added word "quux", i.e., it removes 4 chars
+    //        starting from position 4;
+    //
+    //           "foo quux baz" -> "foo  baz"
+    //                |--| -> 4 characters to be removed
+    //
+    //     2) Rejecting the deletion adds the deleted word "bar" at position 8 (i.e. it will act as if
+    //        the word "quuux" was still present).
+    //
+    //            "foo  baz" -> "foo  bazbar"
+    //                     | -> deletion of "bar" is reverted by reinserting "bar" at position 8
+    //
+    // While the intended result would be "foo bar baz", what we get is:
+    //
+    //      "foo  bazbar" (note "bar" readded at position 8)
+    //
+    // The issue happens because of step 1. To revert the insertion of "quux", 4 characters are deleted
+    // from position 4. This includes the position where the deletion exists; when that position is
+    // cleared, the RangesTracker considers that the deletion is gone and stops tracking/updating it.
+    // As we still hold a reference to it, the code tries to revert it by readding the deleted text, but
+    // does so at the outdated position (position 8, which was valid when "quux" was present).
+    //
+    // To avoid this kind of problem, we need to make sure that reverting operations doesn't affect
+    // subsequent operations that come after. Reverse sorting the operations based on position will
+    // achieve it; in the case above, it makes sure that the the deletion is reverted first:
+    //
+    //     1) Rejecting the deletion adds the deleted word "bar" at position 8
+    //
+    //            "foo quux baz" -> "foo quuxbar baz"
+    //                                       | -> deletion of "bar" is reverted by
+    //                                            reinserting "bar" at position 8
+    //
+    //     2) Rejecting the insertion deletes the added word "quux", i.e., it removes 4 chars
+    //        starting from position 4 and achieves the expected result:
+    //
+    //           "foo quuxbar baz" -> "foo bar baz"
+    //                |--| -> 4 characters to be removed
+
+    changes.sort((a, b) => b.op.p - a.op.p)
+
+    const changesToDispatch = changes.map(change => {
+      const { op } = change
+
+      const opType = 'i' in op ? 'i' : 'c' in op ? 'c' : 'd'
+
+      switch (opType) {
+        case 'd': {
+          return {
+            from: op.p,
+            to: op.p,
+            insert: op.d,
+          }
+        }
+
+        case 'i': {
+          const from = op.p
+          const content = op.i
+          const to = from + content.length
+
+          const text = view.state.doc.sliceString(from, to)
+
+          if (text !== content) {
+            throw new Error(
+              `Op to be removed (${JSON.stringify(
+                change.op
+              )}) does not match editor text '${text}'`
+            )
+          }
+
+          return { from, to, insert: '' }
+        }
+
+        default: {
+          throw new Error(`unknown change: ${JSON.stringify(change)}`)
+        }
+      }
+    })
+
+    return {
+      changes: changesToDispatch,
+      annotations: [trackChangesAnnotation.of('reject')],
+    }
+  }
+
+  /**
+   * If the current selection is empty, select the whole line.
+   *
+   * Used when adding a comment with no selected range, e.g. with a keyboard shortcut.
+   */
+  const selectCurrentLine = () => {
+    if (view.state.selection.main.empty) {
+      const line = view.state.doc.lineAt(view.state.selection.main.from)
+
+      view.dispatch({
+        selection: {
+          anchor: line.from,
+          head: line.to === view.state.doc.length ? line.to : line.to + 1,
+        },
+      })
+    }
+  }
+
+  /**
+   * Collapse the current selection to a single point (after inserting a comment)
+   */
+  const collapseSelection = () => {
+    view.dispatch({
+      selection: EditorSelection.cursor(view.state.selection.main.head),
+    })
+  }
+
+  /**
+   * Listen for events dispatched from the (Angular) review panel.
+   *
+   * These are combined into a single listener, avoiding the need to add and remove event listeners individually.
+   */
+  const reviewPanelEventListener = (event: Event) => {
+    const { type, payload } = (
+      event as CustomEvent<{ type: string; payload: any }>
+    ).detail
+
+    switch (type) {
+      // receive review panel scroll events
+      case 'scroll': {
+        view.scrollDOM.scrollBy(0, payload)
+        break
+      }
+
+      case 'overview-closed': {
+        window.setTimeout(() => {
+          dispatchScrollEvent()
+        }, 0)
+        break
+      }
+
+      case 'recalculate-screen-positions': {
+        const changed = recalculateScreenPositions(payload)
+        if (changed) {
+          dispatchEditorEvent('track-changes:visibility_changed')
+        }
+        break
+      }
+
+      case 'changes:accept': {
+        acceptChanges(payload)
+        view.dispatch(buildChangeMarkers())
+        broadcastChange()
+        dispatchFocusChangedEvent(view.state)
+        break
+      }
+
+      case 'changes:reject': {
+        view.dispatch(rejectChanges(payload))
+        dispatchFocusChangedEvent(view.state)
+        break
+      }
+
+      case 'comment:select_line': {
+        selectCurrentLine()
+        break
+      }
+
+      case 'comment:add': {
+        addComment(payload.offset, payload.length, payload.threadId)
+        collapseSelection()
+        break
+      }
+
+      case 'comment:remove': {
+        removeComment(payload)
+        view.dispatch(buildChangeMarkers())
+        broadcastChange()
+        break
+      }
+
+      case 'comment:resolve_threads':
+      case 'comment:unresolve_thread': {
+        view.dispatch(buildChangeMarkers())
+        broadcastChange()
+        break
+      }
+
+      case 'loaded_threads': {
+        view.dispatch(buildChangeMarkers())
+        broadcastChange()
+        window.setTimeout(() => {
+          dispatchFocusChangedEvent(view.state)
+        }, 0)
+        break
+      }
+
+      case 'sizes': {
+        const { overflowTop, height } = payload
+        const padding = view.documentPadding
+        const contentHeight =
+          view.contentDOM.clientHeight - padding.top - padding.bottom
+        const paddingNeeded = height - contentHeight
+
+        if (overflowTop !== padding.top || paddingNeeded !== padding.bottom) {
+          view.dispatch(
+            setVerticalOverflow({
+              top: overflowTop,
+              bottom: paddingNeeded,
+            })
+          )
+        }
+
+        break
+      }
+    }
+  }
+
+  /**
+   *
+   */
+  const broadcastChange = () => {
+    dispatchEditorEvent('track-changes:changed')
+  }
+
+  /**
+   * When the editor content, focus, size, viewport or selection changes,
+   * tell the review panel to update.
+   *
+   * @param state object
+   */
+  const dispatchFocusChangedEvent = debounce((state: EditorState) => {
+    // TODO: multiple selections?
+    const { from, to, empty } = state.selection.main
+
+    dispatchEditorEvent('focus:changed', { from, to, empty })
+  }, 50)
+
+  /**
+   * When the editor is scrolled, tell the review panel so it can scroll in sync.
+   */
+  const dispatchScrollEvent = () => {
+    window.dispatchEvent(
+      new CustomEvent('editor:scroll', {
+        detail: {
+          height: view.scrollDOM.scrollHeight,
+          scrollTop: view.scrollDOM.scrollTop,
+          paddingTop: editorVerticalTopPadding(view),
+        },
+      })
+    )
+  }
+
+  /**
+   * Add event listeners to the ShareJS doc so that change markers are rebuilt when the tracked changes are updated.
+   *
+   * Also add event listeners to the editor scroll DOM and window.
+   */
+  const addListeners = () => {
+    // NOTE: the namespace "cm6" is needed so the listeners can be removed individually
+    currentDoc.on('ranges:dirty.cm6', () => {
+      // TODO: use currentDoc.ranges.getDirtyState and only update those which have changed?
+      window.setTimeout(() => {
+        view.dispatch(buildChangeMarkers())
+        broadcastChange()
+      }, 0)
+    })
+
+    // called on joinDoc
+    currentDoc.on('ranges:clear.cm6', () => {
+      window.setTimeout(() => {
+        view.dispatch(clearChangeMarkers())
+        broadcastChange()
+      }, 0)
+    })
+
+    // called on joinDoc
+    currentDoc.on('ranges:redraw.cm6', () => {
+      window.setTimeout(() => {
+        view.dispatch(buildChangeMarkers())
+        broadcastChange()
+      }, 0)
+    })
+
+    // sync review panel scroll with editor scroll
+    view.scrollDOM.addEventListener('scroll', dispatchScrollEvent)
+
+    // listen for events from the review panel controller
+    window.addEventListener('review-panel:event', reviewPanelEventListener)
+  }
+
+  /**
+   * Remove event listeners
+   */
+  const removeListeners = () => {
+    currentDoc.off('ranges:clear.cm6')
+    currentDoc.off('ranges:dirty.cm6')
+    currentDoc.off('ranges:redraw.cm6')
+
+    view.scrollDOM.removeEventListener('scroll', dispatchScrollEvent)
+
+    window.removeEventListener('review-panel:event', reviewPanelEventListener)
+  }
+
+  let lastUpdatedTopPaddingAt = 0
+  const PADDING_GEOMETRY_CHANGE_INTERVAL = 50
+
+  return {
+    initialize() {
+      addListeners()
+      dispatchEditorEvent('track-changes:changed')
+    },
+    handleUpdate(update: ViewUpdate) {
+      if (updateSetsVerticalOverflow(update)) {
+        lastUpdatedTopPaddingAt = Date.now()
+      }
+      if (
+        update.docChanged ||
+        update.focusChanged ||
+        update.viewportChanged ||
+        update.selectionSet ||
+        update.geometryChanged
+      ) {
+        // Ignore a change to the editor geometry that occurs immediately after
+        // an update to the vertical padding because otherwise it triggers
+        // another update to the padding and so on ad infinitum. This is not an
+        // ideal way to handle this but I couldn't see another way.
+        if (
+          update.geometryChanged &&
+          Date.now() - lastUpdatedTopPaddingAt <
+            PADDING_GEOMETRY_CHANGE_INTERVAL
+        ) {
+          return
+        }
+        dispatchFocusChangedEvent(update.state)
+      }
+    },
+    destroy() {
+      removeListeners()
+    },
+  }
+}

+ 226 - 0
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<CommentOperation>
+  }[]
+}
+
+/**
+ * Find tracked comments within the range of the current transaction's changes
+ */
+export const findCommentsInCut = (
+  currentDoc: CurrentDoc,
+  transaction: Transaction
+) => {
+  const items: StoredComment[] = []
+
+  transaction.changes.iterChanges((fromA, toA) => {
+    const comments = currentDoc.ranges.comments
+      .filter(
+        comment =>
+          fromA <= comment.op.p && comment.op.p + comment.op.c.length <= toA
+      )
+      .map(comment => ({
+        offset: comment.op.p - fromA,
+        text: comment.op.c,
+        comment,
+      }))
+
+    if (comments.length) {
+      items.push({
+        text: transaction.startState.sliceDoc(fromA, toA),
+        comments,
+      })
+    }
+  })
+
+  return items
+}
+
+/**
+ * Find stored comments matching the text of the current transaction's changes
+ */
+export const findCommentsInPaste = (
+  storedComments: StoredComment[],
+  transaction: Transaction
+) => {
+  const ops: ChangeOperation[] = []
+
+  transaction.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
+    const insertedText = inserted.toString()
+
+    // note: only using the first match
+    const matchedComment = storedComments.find(
+      item => item.text === insertedText
+    )
+
+    if (matchedComment) {
+      for (const { offset, text, comment } of matchedComment.comments) {
+        // Resubmitting an existing comment op (by thread id) will move it
+        ops.push({
+          c: text,
+          p: fromB + offset,
+          t: comment.id,
+        })
+      }
+    }
+  })
+
+  return ops
+}
+
+class CommentRangeValue extends RangeValue {
+  constructor(
+    public content: string,
+    public comment: Change<CommentOperation>
+  ) {
+    super()
+  }
+}
+
+/**
+ * Find tracked comments with no content with the ranges of a transaction's changes
+ */
+export const findDetachedCommentsInChanges = (
+  currentDoc: CurrentDoc,
+  transaction: Transaction
+) => {
+  const items: Range<CommentRangeValue>[] = []
+
+  transaction.changes.iterChanges((fromA, toA) => {
+    for (const comment of currentDoc.ranges.comments) {
+      const content = comment.op.c
+
+      // TODO: handle comments that were never attached
+      if (!content.length) {
+        continue
+      }
+
+      const from = comment.op.p
+      const to = from + content.length
+
+      if (fromA <= from && to <= toA) {
+        items.push(new CommentRangeValue(content, comment).range(from, to))
+      }
+    }
+  })
+
+  return RangeSet.of(items, true)
+}
+
+/**
+ * Submit operations to the ShareJS doc
+ * (used when restoring comments on paste)
+ */
+const submitOps = (
+  currentDoc: CurrentDoc,
+  ops: AnyOperation[],
+  transaction: Transaction
+) => {
+  for (const op of ops) {
+    currentDoc.submitOp(op)
+  }
+
+  // Check that comments still match text. Will throw error if not.
+  currentDoc.ranges.validate(transaction.state.doc.toString())
+}
+
+/**
+ * Wait for the ShareJS doc to fire an event, then submit the operations.
+ */
+const submitOpsAfterEvent = (
+  currentDoc: CurrentDoc,
+  eventName: string,
+  ops: AnyOperation[],
+  transaction: Transaction
+) => {
+  // We have to wait until the change has been processed by the range
+  // tracker, since if we move the ops into place beforehand, they will be
+  // moved again when the changes are processed by the range tracker. This
+  // ranges:dirty event is fired after the doc has applied the changes to
+  // the range tracker.
+  // TODO: could put this in an update listener instead, if the ShareJS doc has been updated by then?
+  currentDoc.on(eventName, () => {
+    currentDoc.off(eventName)
+    submitOps(currentDoc, ops, transaction)
+  })
+}
+
+/**
+ * Look through the comments stored on cut, and restore those in text that matches the pasted text.
+ */
+export const restoreCommentsOnPaste = (
+  currentDoc: CurrentDoc,
+  transaction: Transaction,
+  storedComments: StoredComment[]
+) => {
+  if (storedComments.length) {
+    const ops = findCommentsInPaste(storedComments, transaction)
+
+    if (ops.length) {
+      submitOpsAfterEvent(
+        currentDoc,
+        'ranges:dirty.paste-cm6',
+        ops,
+        transaction
+      )
+    }
+  }
+}
+
+/**
+ * When undoing a change, find comments from the original content and restore them.
+ */
+export const restoreDetachedComments = (
+  currentDoc: CurrentDoc,
+  transaction: Transaction,
+  storedComments: RangeSet<any>
+) => {
+  const ops: ChangeOperation[] = []
+
+  const cursor = storedComments.iter()
+
+  while (cursor.value) {
+    const { id } = cursor.value.comment
+
+    const comment = currentDoc.ranges.comments.find(item => item.id === id)
+
+    // check that the comment still exists and is detached
+    if (comment && comment.op.c === '') {
+      const content = transaction.state.doc.sliceString(
+        cursor.from,
+        cursor.from + cursor.value.content.length
+      )
+
+      if (cursor.value.content === content) {
+        ops.push({
+          c: cursor.value.content,
+          p: cursor.from,
+          t: id,
+        })
+      }
+    }
+
+    cursor.next()
+  }
+
+  // FIXME: timing issue with rapid undos
+  if (ops.length) {
+    window.setTimeout(() => {
+      submitOps(currentDoc, ops, transaction)
+    }, 0)
+  }
+
+  // submitOpsAfterEvent('ranges:dirty.undo-cm6', ops, transaction)
+}

+ 49 - 0
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' },
+])

+ 206 - 0
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<string, string | false>
+    }>('closePrefixedBrackets', sel.head)
+
+    // no config for this language, don't handle
+    if (!config?.brackets) {
+      return false
+    }
+
+    const prevCharacter = prevChar(view.state.doc, sel.from)
+    const input = `${prevCharacter}${insert}`
+    const close = config.brackets[input] ?? config.brackets[insert]
+
+    // not specified, don't handle
+    if (close === undefined) {
+      return false
+    }
+
+    // prevent auto-close, just insert the character
+    if (close === false) {
+      return insertInput(view, insert)
+    }
+
+    const nextCharacter = nextChar(doc, sel.from)
+
+    if (insert === '$') {
+      // avoid duplicating a math-closing dollar sign
+      if (moveOverClosingMathDollar(view, sel)) {
+        return true
+      }
+
+      // avoid creating an odd number of dollar signs
+      const count = countSurroundingCharacters(doc, sel.from, insert)
+      if (count % 2 !== 0) {
+        return insertInput(view, insert)
+      }
+    }
+
+    // This is the default set of "before" characters from the closeBrackets extension,
+    // plus $ (so $$ works as expected)
+    if (!sel.empty || !nextCharacter || /[\s)\]}:;>$]/.test(nextCharacter)) {
+      // auto-close
+      return insertBracket(view, insert, close)
+    }
+
+    return false
+  })
+}
+
+const moveOverClosingMathDollar = (
+  view: EditorView,
+  sel: SelectionRange
+): boolean => {
+  if (!sel.empty) {
+    return false
+  }
+
+  // inside dollar math
+  const outerNode = ancestorNodeOfType(view.state, sel.from, 'DollarMath')
+  if (!outerNode) {
+    return false
+  }
+
+  // not display math
+  const innerNode = outerNode.getChild('InlineMath')
+  if (!innerNode) {
+    return false
+  }
+
+  // the cursor is at the end of the InlineMath node
+  if (sel.from !== innerNode.to) {
+    return false
+  }
+
+  // there's already some math content
+  const content = view.state.doc.sliceString(innerNode.from, innerNode.to)
+  if (content.length === 0) {
+    return false
+  }
+
+  // move the cursor outside the DollarMath node
+  view.dispatch({
+    selection: EditorSelection.cursor(outerNode.to),
+  })
+  return true
+}
+
+const countSurroundingCharacters = (doc: Text, pos: number, insert: string) => {
+  let count = 0
+
+  // count backwards
+  let to = pos
+  do {
+    const char = doc.sliceString(to - 1, to)
+    if (char !== insert) {
+      break
+    }
+    count++
+    to--
+  } while (to > 1)
+
+  // count forwards
+  let from = pos
+  do {
+    const char = doc.sliceString(from, from + 1)
+    if (char !== insert) {
+      break
+    }
+    count++
+    from++
+  } while (from < doc.length)
+
+  return count
+}

+ 207 - 0
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<RangeSet<HighlightRangeValue>>({
+  create() {
+    return RangeSet.empty
+  },
+  update(value, tr) {
+    for (const effect of tr.effects) {
+      if (effect.is(setCursorHighlightsEffect)) {
+        const highlightRanges = []
+
+        for (const highlight of effect.value) {
+          // NOTE: other highlight types could be handled here
+          if ('cursor' in highlight) {
+            try {
+              const { row, column } = highlight.cursor
+              const pos = findValidPosition(tr.state.doc, row + 1, column)
+              highlightRanges.push(
+                new HighlightRangeValue(highlight).range(pos)
+              )
+            } catch (error) {
+              // ignore invalid highlights
+              console.debug('invalid highlight position', error)
+            }
+          }
+        }
+
+        return RangeSet.of(highlightRanges, true)
+      }
+    }
+
+    if (tr.docChanged) {
+      value = value.map(tr.changes)
+    }
+
+    return value
+  },
+})
+
+const cursorTooltip = (view: EditorView, pos: number): Tooltip | null => {
+  const highlights: Highlight[] = []
+
+  view.state
+    .field(cursorHighlightsState)
+    .between(pos, pos, (from, to, value) => {
+      highlights.push(value.highlight)
+    })
+
+  if (highlights.length === 0) {
+    return null
+  }
+
+  return {
+    pos,
+    end: pos,
+    above: true,
+    create: () => {
+      const dom = document.createElement('div')
+      dom.classList.add('ol-cm-cursorTooltip')
+
+      for (const highlight of highlights) {
+        const label = document.createElement('div')
+        label.classList.add('ol-cm-cursorHighlightLabel')
+        label.style.setProperty('--hue', highlight.hue)
+        label.textContent = highlight.label
+        dom.appendChild(label)
+      }
+
+      return { dom }
+    },
+  }
+}
+
+const setCursorHighlightsEffect = StateEffect.define<Highlight[]>()
+
+export const setCursorHighlights = (
+  cursorHighlights: Highlight[] = []
+): TransactionSpec => {
+  return {
+    effects: setCursorHighlightsEffect.of(cursorHighlights),
+  }
+}
+
+class CursorMarker extends RectangleMarker {
+  constructor(
+    private highlight: Highlight,
+    className: string,
+    left: number,
+    top: number,
+    width: number | null,
+    height: number
+  ) {
+    super(className, left, top, width, height)
+  }
+
+  draw(): HTMLDivElement {
+    const element = super.draw()
+    element.style.setProperty('--hue', this.highlight.hue)
+    return element
+  }
+}
+
+// draw the collaborator cursors in a separate layer, so they don't affect word wrapping
+const cursorHighlightsLayer = layer({
+  above: true,
+  class: 'ol-cm-cursorHighlightsLayer',
+  update: (update, layer) => {
+    return (
+      update.docChanged ||
+      update.selectionSet ||
+      update.transactions.some(tr =>
+        tr.effects.some(effect => effect.is(setCursorHighlightsEffect))
+      )
+    )
+  },
+  markers(view) {
+    const markers: CursorMarker[] = []
+    const highlightRanges = view.state.field(cursorHighlightsState)
+    const base = getBase(view)
+    const { from, to } = view.viewport
+    highlightRanges.between(from, to, (from, to, { highlight }) => {
+      const pos = fullHeightCoordsAtPos(view, from)
+      if (pos) {
+        markers.push(
+          new CursorMarker(
+            highlight,
+            'ol-cm-cursorHighlight',
+            pos.left - base.left,
+            pos.top - base.top,
+            null,
+            pos.bottom - pos.top
+          )
+        )
+      }
+    })
+    return markers
+  },
+})

+ 161 - 0
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)
+  }
+}

+ 101 - 0
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',
+})

+ 13 - 0
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))],
+  }
+}

+ 76 - 0
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',
+      },
+    }),
+  ]
+}

+ 11 - 0
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' },
+    })
+  })
+}

+ 35 - 0
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
+  })
+}

+ 19 - 0
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,
+    },
+  ])
+}

+ 33 - 0
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<readonly FontFace[]>()
+export const hasFontLoadedEffect = updateHasEffect(fontLoadEffect)
+
+const plugin = ViewPlugin.define(view => {
+  function listener(this: FontFaceSet, event: FontFaceSetLoadEvent) {
+    view.dispatch({ effects: fontLoadEffect.of(event.fontfaces) })
+  }
+
+  const fontLoadSupport = 'fonts' in document
+  if (fontLoadSupport) {
+    // TypeScript doesn't appear to know the correct type for the listener
+    document.fonts.addEventListener('loadingdone', listener as EventListener)
+  }
+
+  return {
+    destroy() {
+      if (fontLoadSupport) {
+        document.fonts.removeEventListener(
+          'loadingdone',
+          listener as EventListener
+        )
+      }
+    },
+  }
+})
+
+export function fontLoad() {
+  return plugin
+}

+ 61 - 0
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',
+        },
+      },
+    }),
+  ]
+}

+ 116 - 0
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
+  },
+})

+ 128 - 0
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
+}

+ 8 - 0
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 }),
+  ])

+ 138 - 0
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<string, any>): Extension[] => [
+  lineNumbers(),
+  highlightSpecialChars(),
+  history({ newGroupDelay: 250 }),
+  foldGutter({
+    openText: '▾',
+    closedText: '▸',
+  }),
+  drawSelection(),
+  EditorState.allowMultipleSelections.of(true),
+  EditorView.lineWrapping,
+  indentOnInput(),
+  lineWrappingIndentation(options.visual.visual),
+  indentationMarkers(options.visual.visual),
+  bracketMatching(),
+  bracketSelection(),
+  rectangularSelection(),
+  crosshairCursor(),
+  dropCursor(),
+  tooltips({
+    parent: document.body,
+  }),
+  keymap.of([
+    ...defaultKeymap.filter(
+      // We only filter on keys, so if the keybinding doesn't have a key,
+      // allow it
+      item => !item.key || !ignoredDefaultKeybindings.has(item.key)
+    ),
+    ...historyKeymap,
+    ...lintKeymap,
+  ]),
+  foldingKeymap(),
+  goToLinePanel(),
+  filterCharacters(),
+
+  // `autoComplete` needs to be before `keybindings` so that arrow key handling
+  // in the autocomplete pop-up takes precedence over Vim/Emacs key bindings
+  autoComplete(options.settings),
+
+  // `keybindings` needs to be before `language` so that Vim/Emacs bindings take
+  // precedence over language-specific keyboard shortcuts
+  keybindings(),
+
+  annotations(), // NOTE: must be before `language`
+  language(options.currentDoc, options.metadata, options.settings),
+  theme(options.theme),
+  realtime(options.currentDoc, options.handleError),
+  cursorPosition(options.currentDoc),
+  scrollPosition(options.currentDoc),
+  cursorHighlights(),
+  autoPair(options.settings),
+  editable(),
+  search(),
+  phrases(options.phrases),
+  parserWatcher(),
+  spelling(options.spelling),
+  shortcuts(),
+  symbolPalette(),
+  emptyLineFiller(), // NOTE: must be before `trackChanges`
+  trackChanges(options.currentDoc, options.changeManager),
+  visual(options.currentDoc, options.visual),
+  verticalOverflow(),
+  highlightActiveLine(options.visual.visual),
+  highlightActiveLineGutter(),
+  scrollOneLine(),
+  fontLoad(),
+  inlineBackground(options.visual.visual),
+  exceptionLogger(),
+  moduleExtensions.map(extension => extension()),
+  thirdPartyExtensions(),
+]

+ 96 - 0
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<boolean>()
+
+function firstVisibleNonSpacePos(view: EditorView) {
+  for (const range of view.visibleRanges) {
+    const match = /\S/.exec(view.state.sliceDoc(range.from, range.to))
+    if (match) {
+      return range.from + match.index
+    }
+  }
+  return null
+}
+
+function measureHalfLeading(view: EditorView) {
+  const pos = firstVisibleNonSpacePos(view)
+  if (pos === null) {
+    return 0
+  }
+
+  const coords = view.coordsAtPos(pos)
+  if (!coords) {
+    return 0
+  }
+  const inlineBoxHeight = coords.bottom - coords.top
+
+  // Rounding prevents gaps appearing in some situations
+  return round((view.defaultLineHeight - inlineBoxHeight) / 2, 2)
+}
+
+function createTheme(halfLeading: number) {
+  return EditorView.theme({
+    '.cm-content': {
+      '--half-leading': halfLeading + 'px',
+    },
+  })
+}
+
+const plugin = ViewPlugin.define(
+  view => {
+    let halfLeading = 0
+
+    const measureRequest = {
+      read: () => {
+        return measureHalfLeading(view)
+      },
+
+      write: (newHalfLeading: number) => {
+        if (newHalfLeading !== halfLeading) {
+          halfLeading = newHalfLeading
+          window.setTimeout(() =>
+            view.dispatch({
+              effects: themeConf.reconfigure(createTheme(newHalfLeading)),
+              annotations: changeHalfLeadingAnnotation.of(true),
+            })
+          )
+        }
+      },
+    }
+
+    return {
+      update(update) {
+        // Ignore any update triggered by this plugin
+        if (
+          update.transactions.some(tr =>
+            tr.annotation(changeHalfLeadingAnnotation)
+          )
+        ) {
+          return
+        }
+        if (
+          hasFontLoadedEffect(update) ||
+          (update.geometryChanged && !update.docChanged) ||
+          update.transactions.some(tr => tr.annotation(themeOptionsChange)) ||
+          hasLanguageLoadedEffect(update)
+        ) {
+          view.requestMeasure(measureRequest)
+        }
+      },
+    }
+  },
+  {
+    provide: () => [themeConf.of(createTheme(0))],
+  }
+)
+
+export const inlineBackground = (visual: boolean) => {
+  return sourceOnly(visual, plugin)
+}

+ 186 - 0
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('<C-c>', 'insert')
+
+  Vim.defineAction(
+    'insertModeCtrlC',
+    (cm: CodeMirror, actionArgs: object, state: any) => {
+      if (hasNonEmptySelection(cm)) {
+        navigator.clipboard.writeText(cm.getSelection())
+        cm.setSelection(cm.getCursor(), cm.getCursor())
+      } else {
+        Vim.exitInsertMode(cm)
+      }
+    }
+  )
+
+  // Overwrite the moveByCharacters command with a decoration-aware version
+  Vim.defineMotion(
+    'moveByCharacters',
+    function (
+      cm: CodeMirror,
+      head: { line: number; ch: number },
+      motionArgs: Record<string, unknown>
+    ) {
+      const { cm6: view } = cm
+      const repeat = Math.min(Number(motionArgs.repeat), view.state.doc.length)
+      const forward = Boolean(motionArgs.forward)
+      // head.line is 0-indexed
+      const startLine = view.state.doc.line(head.line + 1)
+      let cursor = EditorSelection.cursor(startLine.from + head.ch)
+      for (let i = 0; i < repeat; ++i) {
+        cursor = view.moveByChar(cursor, forward)
+      }
+      const finishLine = view.state.doc.lineAt(cursor.head)
+      return new CodeMirror.Pos(
+        finishLine.number - 1,
+        cursor.head - finishLine.from
+      )
+    }
+  )
+
+  Vim.mapCommand('<C-c>', 'action', 'insertModeCtrlC', undefined, {
+    context: 'insert',
+  })
+
+  // Code folding commands
+  Vim.defineAction('toggleFold', function (cm: CodeMirror) {
+    toggleFold(cm.cm6)
+  })
+  Vim.mapCommand('za', 'action', 'toggleFold')
+
+  Vim.defineAction('foldCode', function (cm: CodeMirror) {
+    foldCode(cm.cm6)
+  })
+  Vim.mapCommand('zc', 'action', 'foldCode')
+
+  Vim.defineAction('unfoldCode', function (cm: CodeMirror) {
+    unfoldCode(cm.cm6)
+  })
+  Vim.mapCommand('zo', 'action', 'unfoldCode')
+
+  // Make the Vim 'write' command start a compile
+  CodeMirror.commands.save = () => {
+    window.dispatchEvent(new Event('pdf:recompile'))
+  }
+  customisedVim = true
+}
+
+// Used to ensure that only one listener is active
+let emacsSearchCloseListener: (() => void) | undefined
+
+let customisedEmacs = false
+const customiseEmacsOnce = () => {
+  if (customisedEmacs) {
+    return
+  }
+  customisedEmacs = true
+
+  const jumpToLastMark = (handler: EmacsHandler) => {
+    const mark = handler.popEmacsMark()
+    if (!mark || !mark.length) {
+      return
+    }
+    let selection = null
+    if (mark.length >= 2) {
+      selection = EditorSelection.range(mark[0], mark[1])
+    } else {
+      selection = EditorSelection.cursor(mark[0])
+    }
+    handler.view.dispatch({ selection, scrollIntoView: true })
+  }
+
+  EmacsHandler.addCommands({
+    openSearch(handler: EmacsHandler) {
+      const mark = handler.view.state.selection.main
+      handler.pushEmacsMark([mark.anchor, mark.head])
+      openSearchPanel(handler.view)
+      if (emacsSearchCloseListener) {
+        document.removeEventListener(
+          'cm:emacs-close-search-panel',
+          emacsSearchCloseListener
+        )
+      }
+      emacsSearchCloseListener = () => {
+        jumpToLastMark(handler)
+      }
+      document.addEventListener(
+        'cm:emacs-close-search-panel',
+        emacsSearchCloseListener
+      )
+    },
+  })
+  EmacsHandler.bindKey('C-s', 'openSearch')
+  EmacsHandler.bindKey('C-r', 'openSearch')
+}
+
+const options = [
+  {
+    name: 'default',
+    load: async () => {
+      // TODO: load default keybindings?
+      return []
+    },
+  },
+  {
+    name: 'vim',
+    load: () =>
+      import('@replit/codemirror-vim').then(m => {
+        customiseVimOnce(m.Vim, m.CodeMirror)
+        return m.vim()
+      }),
+  },
+  {
+    name: 'emacs',
+    load: () =>
+      import('@replit/codemirror-emacs').then(m => {
+        customiseEmacsOnce()
+        return m.emacs()
+      }),
+  },
+]
+
+const keybindingsConf = new Compartment()
+
+export const keybindings = () => {
+  return keybindingsConf.of(Prec.highest([]))
+}
+
+export const setKeybindings = async (
+  selectedKeybindings = 'default'
+): Promise<TransactionSpec> => {
+  const selectedOption = options.find(
+    option => option.name === selectedKeybindings
+  )
+
+  if (!selectedOption) {
+    throw new Error(`No key bindings found with name ${selectedKeybindings}`)
+  }
+
+  const support = await selectedOption.load()
+
+  return {
+    // NOTE: use Prec.highest as this keybinding must be above the default keymap(s)
+    effects: keybindingsConf.reconfigure(Prec.highest(support)),
+  }
+}

+ 92 - 0
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<Metadata | undefined>({
+  create: () => undefined,
+  update: (value, transaction) => {
+    for (const effect of transaction.effects) {
+      if (effect.is(setMetadataEffect)) {
+        return effect.value
+      }
+    }
+    return value
+  },
+})
+
+export const language = (
+  currentDoc: CurrentDoc,
+  metadata: Metadata,
+  { syntaxValidation }: Options
+) => {
+  const languageDescription = LanguageDescription.matchFilename(
+    languages,
+    currentDoc.docName
+  )
+
+  if (!languageDescription) {
+    return []
+  }
+
+  return [
+    // Default to four-space indentation, which prevents a shift in line
+    // indentation markers when LaTeX loads
+    languageConf.of(indentUnit.of('    ')),
+    metadataState,
+    ViewPlugin.define(view => {
+      // load the language asynchronously
+      languageDescription.load().then(support => {
+        view.dispatch({
+          effects: [
+            languageConf.reconfigure(support),
+            languageLoadedEffect.of(null),
+          ],
+        })
+        // Wait until the previous effects have been processed
+        view.dispatch({
+          effects: [
+            setMetadataEffect.of(metadata),
+            setSyntaxValidationEffect.of(syntaxValidation),
+          ],
+        })
+      })
+
+      return {}
+    }),
+    metadataState,
+  ]
+}
+
+export const setMetadataEffect = StateEffect.define<Metadata>()
+
+export const setMetadata = (values: Metadata): TransactionSpec => {
+  return {
+    effects: setMetadataEffect.of(values),
+  }
+}
+
+export const setSyntaxValidationEffect = StateEffect.define<boolean>()
+
+export const setSyntaxValidation = (value: boolean): TransactionSpec => {
+  return {
+    effects: setSyntaxValidationEffect.of(value),
+  }
+}

+ 85 - 0
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
+      },
+    },
+  })
+}

+ 144 - 0
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<number>()
+
+export const lineWrappingIndentation = (visual: boolean) => {
+  // this extension should only be active in the source editor
+  return sourceOnly(visual, [
+    // store the current maxIndent, based on the clientWidth
+    StateField.define<number>({
+      create() {
+        return 0
+      },
+      update(value, tr) {
+        for (const effect of tr.effects) {
+          if (effect.is(setMaxIndentEffect)) {
+            value = effect.value
+          }
+        }
+
+        return value
+      },
+      provide(field) {
+        return [
+          // calculate the max indent when the geometry changes
+          ViewPlugin.define(view => {
+            const measure = {
+              key: 'line-wrapping-indentation-max-indent',
+              read(view: EditorView) {
+                return (
+                  (view.contentDOM.clientWidth / view.defaultCharacterWidth) *
+                  MAX_INDENT_FRACTION
+                )
+              },
+              write(value: number, view: EditorView) {
+                if (view.state.field(field) !== value) {
+                  window.setTimeout(() => {
+                    view.dispatch({
+                      effects: setMaxIndentEffect.of(value),
+                    })
+                  })
+                }
+              },
+            }
+
+            return {
+              update(update: ViewUpdate) {
+                if (update.geometryChanged) {
+                  view.requestMeasure(measure)
+                }
+              },
+            }
+          }),
+
+          // rebuild the decorations when needed
+          ViewPlugin.define<{ decorations: DecorationSet }>(
+            view => {
+              let previousMaxIndent = 0
+
+              const value = {
+                decorations: buildDecorations(view, view.state.field(field)),
+
+                update(update: ViewUpdate) {
+                  const maxIndent = view.state.field(field)
+
+                  if (
+                    maxIndent !== previousMaxIndent ||
+                    update.geometryChanged ||
+                    update.viewportChanged
+                  ) {
+                    value.decorations = buildDecorations(view, maxIndent)
+                  }
+
+                  previousMaxIndent = maxIndent
+                },
+              }
+
+              return value
+            },
+            {
+              decorations: value => value.decorations,
+            }
+          ),
+        ]
+      },
+    }),
+  ])
+}
+
+export const buildDecorations = (view: EditorView, maxIndent: number) => {
+  const { state } = view
+  const { doc, tabSize } = state
+
+  const decorations: Range<Decoration>[] = []
+
+  let from = 0
+
+  for (const line of doc.iterLines()) {
+    // const indent = line.match(/^(\s*)/)[1].length
+    const indent = calculateIndent(line, tabSize, maxIndent)
+
+    if (indent) {
+      decorations.push(lineIndentDecoration(indent).range(from))
+    }
+
+    from += line.length + 1
+  }
+
+  return Decoration.set(decorations)
+}
+
+const lineIndentDecoration = (indent: number) =>
+  Decoration.line({
+    attributes: {
+      // style: `text-indent: ${indent}ch hanging`, // "hanging" would be ideal, when browsers support it
+      style: `text-indent: -${indent}ch; padding-left: calc(${indent}ch + 4px)`, // add 4px to account for existing padding-left
+    },
+  })
+
+// calculate the character width of whitespace at the start of a line
+const calculateIndent = (line: string, tabSize: number, maxIndent: number) => {
+  let indent = 0
+
+  for (const char of line) {
+    if (char === ' ') {
+      indent++
+    } else if (char === '\t') {
+      indent += tabSize - (indent % tabSize)
+    } else {
+      break
+    }
+  }
+
+  return Math.min(indent, maxIndent)
+}

+ 13 - 0
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<string, string>) => {
+  return phrasesConf.of(EditorState.phrases.of(phrases))
+}
+
+export const setPhrases = (value: Record<string, string>): TransactionSpec => {
+  return {
+    effects: phrasesConf.reconfigure(EditorState.phrases.of(value)),
+  }
+}

+ 222 - 0
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'
+  }
+}

+ 35 - 0
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,
+    },
+  ])
+}

+ 157 - 0
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 {}
+  }
+}

+ 255 - 0
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<boolean>()
+
+const ignoredSearchKeybindings = new Set([
+  // This keybinding causes issues with entering @ on certain keyboard layouts
+  // https://github.com/overleaf/internal/issues/12119
+  'Alt-g',
+])
+
+const selectNextMatch = (query: SearchQuery, state: EditorState) => {
+  if (!query.valid) {
+    return false
+  }
+
+  let cursor = query.getCursor(state.doc, state.selection.main.from)
+
+  let result = cursor.next()
+
+  if (result.done) {
+    cursor = query.getCursor(state.doc)
+    result = cursor.next()
+  }
+
+  return result.done ? null : result.value
+}
+
+let searchQuery: SearchQuery | null
+
+export const search = () => {
+  let open = false
+
+  return [
+    // keymap for search
+    keymap.of(
+      searchKeymap.filter(
+        item => !item.key || !ignoredSearchKeybindings.has(item.key)
+      )
+    ),
+
+    // highlight text which matches the current selection
+    highlightSelectionMatches(),
+
+    // a wrapper round `search`, which creates a custom panel element and passes it to React by dispatching an event
+    searchExtension({
+      literal: true,
+      createPanel: () => {
+        const dom = document.createElement('div')
+        dom.className = 'ol-cm-search'
+
+        return {
+          dom,
+          mount() {
+            open = true
+
+            // focus the search input when the panel is already open
+            const searchInput =
+              dom.querySelector<HTMLInputElement>('[main-field]')
+            if (searchInput) {
+              searchInput.focus()
+              searchInput.select()
+            }
+          },
+          destroy() {
+            window.setTimeout(() => {
+              open = false // in a timeout, so the view plugin below can run its destroy method first
+            }, 0)
+          },
+        }
+      },
+    }),
+
+    // restore a stored search and re-open the search panel
+    ViewPlugin.define(view => {
+      if (searchQuery) {
+        const _searchQuery = searchQuery
+        window.setTimeout(() => {
+          openSearchPanel(view)
+          view.dispatch({
+            effects: setSearchQuery.of(_searchQuery),
+            annotations: restoreSearchQueryAnnotation.of(true),
+          })
+        }, 0)
+      }
+
+      return {
+        destroy() {
+          // persist the current search query if the panel is open
+          searchQuery = open ? getSearchQuery(view.state) : null
+        },
+      }
+    }),
+
+    // select a match while searching
+    EditorView.updateListener.of(update => {
+      for (const tr of update.transactions) {
+        // avoid changing the selection and viewport when switching between files
+        if (tr.annotation(restoreSearchQueryAnnotation)) {
+          continue
+        }
+
+        for (const effect of tr.effects) {
+          if (effect.is(setSearchQuery)) {
+            const query = effect.value
+            if (!query) return
+
+            // The rest of this messes up searching in Vim, which is handled by
+            // the Vim extension, so bail out here in Vim mode. Happily, the
+            // Vim extension sticks an extra property on the query value that
+            // can be checked
+            if ('forVim' in query) return
+
+            const next = selectNextMatch(query, tr.state)
+
+            if (next) {
+              // select a match if possible
+              const spec: TransactionSpec = {
+                selection: { anchor: next.from, head: next.to },
+                userEvent: 'select.search',
+              }
+
+              // scroll into view if not opening the panel
+              if (searchPanelOpen(tr.startState)) {
+                spec.effects = EditorView.scrollIntoView(next.from, {
+                  y: 'center',
+                })
+              }
+
+              update.view.dispatch(spec)
+            } else {
+              // clear the selection if the query became invalid
+              const prevQuery = getSearchQuery(tr.startState)
+
+              if (prevQuery.valid) {
+                const { from } = tr.startState.selection.main
+
+                update.view.dispatch({
+                  selection: { anchor: from },
+                })
+              }
+            }
+          }
+        }
+      }
+    }),
+
+    // search form theme
+    EditorView.theme({
+      '.ol-cm-search-form': {
+        padding: '10px',
+        display: 'flex',
+        gap: '10px',
+        background: 'var(--ol-blue-gray-1)',
+        '--ol-cm-search-form-focus-shadow':
+          'inset 0 1px 1px rgb(0 0 0 / 8%), 0 0 8px rgb(102 175 233 / 60%)',
+      },
+      '.ol-cm-search-controls': {
+        display: 'grid',
+        gridTemplateColumns: 'auto auto',
+        gridTemplateRows: 'auto auto',
+        gap: '10px',
+      },
+      '.ol-cm-search-form-row': {
+        display: 'flex',
+        gap: '10px',
+        justifyContent: 'space-between',
+      },
+      '.ol-cm-search-form-group': {
+        display: 'flex',
+        gap: '10px',
+        alignItems: 'center',
+      },
+      '.ol-cm-search-input-group': {
+        border: '1px solid var(--input-border)',
+        borderRadius: '20px',
+        background: 'white',
+        width: '100%',
+        maxWidth: '25em',
+        '& input[type="text"]': {
+          background: 'none',
+          boxShadow: 'none',
+        },
+        '& input[type="text"]:focus': {
+          outline: 'none',
+          boxShadow: 'none',
+        },
+        '& .btn.btn': {
+          background: 'var(--ol-blue-gray-0)',
+          color: 'var(--ol-blue-gray-3)',
+          borderRadius: '50%',
+          height: '2em',
+          display: 'inline-flex',
+          alignItems: 'center',
+          justifyContent: 'center',
+          width: '2em',
+          marginRight: '3px',
+          '&.checked': {
+            color: '#fff',
+            backgroundColor: 'var(--ol-blue)',
+          },
+          '&:active': {
+            boxShadow: 'none',
+          },
+        },
+        '&:focus-within': {
+          borderColor: 'var(--input-border-focus)',
+          boxShadow: 'var(--ol-cm-search-form-focus-shadow)',
+        },
+      },
+      '.input-group .ol-cm-search-form-input': {
+        border: 'none',
+      },
+      '.ol-cm-search-input-button': {
+        background: '#fff',
+        color: 'inherit',
+        border: 'none',
+      },
+      '.ol-cm-search-input-button.focused': {
+        borderColor: 'var(--input-border-focus)',
+        boxShadow: 'var(--ol-cm-search-form-focus-shadow)',
+      },
+      '.ol-cm-search-form-button-group': {
+        flexShrink: 0,
+      },
+      '.ol-cm-search-form-position': {
+        flexShrink: 0,
+        color: 'var(--ol-blue-gray-4)',
+      },
+      '.ol-cm-search-hidden-inputs': {
+        position: 'absolute',
+        left: '-10000px',
+      },
+      '.ol-cm-search-form-close': {
+        flex: 1,
+      },
+      '.ol-cm-search-replace-input': {
+        order: 3,
+      },
+      '.ol-cm-search-replace-buttons': {
+        order: 4,
+      },
+    }),
+  ]
+}

+ 174 - 0
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))
+}

+ 35 - 0
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,
+  })
+}

+ 105 - 0
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<string, WordCacheValue>
+
+  constructor() {
+    this._cache = new LRU({ max: CACHE_MAX })
+  }
+
+  set(lang: string, wordText: string, value: WordCacheValue) {
+    const key = cacheKey(lang, wordText)
+    this._cache.set(key, value)
+  }
+
+  get(lang: string, wordText: string) {
+    const key = cacheKey(lang, wordText)
+    return this._cache.get(key)
+  }
+
+  remove(lang: string, wordText: string) {
+    const key = cacheKey(lang, wordText)
+    this._cache.delete(key)
+  }
+
+  /*
+   * Given a language and a list of words,
+   * check the cache and sort the words into two categories:
+   *   - words we know to be misspelled
+   *   - words that are presently unknown to us
+   */
+  checkWords(
+    lang: string,
+    wordsToCheck: Word[]
+  ): {
+    knownMisspelledWords: Word[]
+    unknownWords: Word[]
+  } {
+    const knownMisspelledWords = []
+    const unknownWords = []
+    const seen: Record<string, WordCacheValue | undefined> = {}
+    for (const word of wordsToCheck) {
+      const wordText = word.text
+      if (seen[wordText] == null) {
+        seen[wordText] = this.get(lang, wordText)
+      }
+      const cached = seen[wordText]
+      if (cached == null) {
+        // Word is not known
+        unknownWords.push(word)
+      } else if (cached === true) {
+        // Word is known to be correct
+      } else {
+        // Word is known to be misspelled
+        word.suggestions = cached
+        knownMisspelledWords.push(word)
+      }
+    }
+    return {
+      knownMisspelledWords,
+      unknownWords,
+    }
+  }
+}
+
+export const addWordToCache = StateEffect.define<{
+  lang: string
+  wordText: string
+  value: string[] | boolean
+}>()
+
+export const removeWordFromCache = StateEffect.define<{
+  lang: string
+  wordText: string
+}>()
+
+// Share a single instance of WordCache between all instances of the CM6 source editor. This means that cached words are
+// retained when switching away from CM6 and then back to it.
+const wordCache = new WordCache()
+
+export const cacheField = StateField.define<WordCache>({
+  create() {
+    return wordCache
+  },
+  update(cache, transaction) {
+    for (const effect of transaction.effects) {
+      if (effect.is(addWordToCache)) {
+        const { lang, wordText, value } = effect.value
+        cache.set(lang, wordText, value)
+      } else if (effect.is(removeWordFromCache)) {
+        const { lang, wordText } = effect.value
+        cache.remove(lang, wordText)
+      }
+    }
+    return cache
+  },
+})

+ 241 - 0
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<RangeValue & { spec: { word: Word } }>
+
+/*
+ * The time until which a click event will be ignored, so it doesn't immediately close the spelling menu.
+ * Safari emits an additional "click" event when event.preventDefault() is called in the "contextmenu" event listener.
+ */
+let openingUntil = 0
+
+/*
+ * Hide the spelling menu on click
+ */
+const handleClickEvent = (event: MouseEvent, view: EditorView) => {
+  if (Date.now() < openingUntil) {
+    return
+  }
+
+  if (view.state.field(spellingMenuField, false)) {
+    view.dispatch({
+      effects: hideSpellingMenu.of(null),
+    })
+  }
+}
+
+/*
+ * Detect when the user right-clicks on a misspelled word,
+ * and show a menu of suggestions
+ */
+const handleContextMenuEvent = (event: MouseEvent, view: EditorView) => {
+  const position = view.posAtCoords(
+    {
+      x: event.pageX,
+      y: event.pageY,
+    },
+    false
+  )
+
+  const marks = view.state.field(misspelledWordsField)
+
+  let targetMark: Mark | null = null
+  marks.between(view.viewport.from, view.viewport.to, (from, to, value) => {
+    if (position >= from && position <= to) {
+      targetMark = { from, to, value }
+      return false
+    }
+  })
+  if (!targetMark) {
+    return
+  }
+
+  const { from, to, value } = targetMark as Mark
+
+  const targetWord = value.spec.word
+  if (!targetWord) {
+    console.debug(
+      '>> spelling no word associated with decorated range, stopping'
+    )
+    return
+  }
+
+  event.preventDefault()
+
+  openingUntil = Date.now() + 100
+
+  view.dispatch({
+    selection: EditorSelection.range(from, to),
+    effects: showSpellingMenu.of({
+      mark: targetMark,
+      word: targetWord,
+    }),
+  })
+}
+
+/*
+ * Spelling menu "tooltip" field.
+ * Manages the menu of suggestions shown on right-click
+ */
+export const spellingMenuField = StateField.define<Tooltip | null>({
+  create() {
+    return null
+  },
+  update(menu, transaction) {
+    for (const effect of transaction.effects) {
+      if (effect.is(hideSpellingMenu)) {
+        return null
+      } else if (effect.is(showSpellingMenu)) {
+        const { mark, word } = effect.value
+        // Build a "Tooltip" showing the suggestions
+        return {
+          pos: mark.from,
+          above: false,
+          strictSide: false,
+          create: view => {
+            return createSpellingSuggestionList(mark, word, view)
+          },
+        }
+      }
+    }
+    return menu
+  },
+  provide: field => {
+    return [
+      showTooltip.from(field),
+      EditorView.domEventHandlers({
+        contextmenu: handleContextMenuEvent,
+        click: handleClickEvent,
+      }),
+    ]
+  },
+})
+
+const showSpellingMenu = StateEffect.define<{ mark: Mark; word: Word }>()
+
+const hideSpellingMenu = StateEffect.define()
+
+/*
+ * Creates the suggestion menu dom, to be displayed in the
+ * spelling menu "tooltip"
+ * */
+const createSpellingSuggestionList = (
+  mark: Mark,
+  word: Word,
+  view: EditorView
+) => {
+  // Wrapper div.
+  // Note, CM6 doesn't like showing complex elements
+  // 'inside' its Tooltip element, so we style this
+  // wrapper div to be basically invisible, and allow
+  // the dropdown list to hang off of it, giving the illusion that
+  // the list _is_ the tooltip.
+  // See the theme in spelling/index for styling that makes this work.
+  const dom = document.createElement('div')
+  dom.classList.add('ol-cm-spelling-context-menu-tooltip')
+
+  // List
+  const list = document.createElement('ul')
+  list.classList.add('dropdown-menu', 'dropdown-menu-unpositioned')
+
+  // List items, with links inside
+  if (Array.isArray(word.suggestions)) {
+    for (const suggestion of word.suggestions.slice(0, ITEMS_TO_SHOW)) {
+      const li = makeLinkItem(suggestion, event => {
+        const text = (event.target as HTMLElement).innerText
+        handleCorrectWord(mark, word, text, view)
+        event.preventDefault()
+      })
+      list.appendChild(li)
+    }
+  }
+
+  // Divider
+  const divider = document.createElement('li')
+  divider.classList.add('divider')
+  list.append(divider)
+
+  // Add to Dictionary
+  const addToDictionary = makeLinkItem(
+    'Add to Dictionary',
+    async function (event: Event) {
+      await handleLearnWord(word, view)
+      event.preventDefault()
+    }
+  )
+  list.append(addToDictionary)
+
+  dom.appendChild(list)
+  return { dom }
+}
+
+const makeLinkItem = (suggestion: string, handler: EventListener) => {
+  const li = document.createElement('li')
+  const button = document.createElement('button')
+  button.classList.add('btn-link', 'text-left', 'dropdown-menu-button')
+  button.onclick = handler
+  button.textContent = suggestion
+  li.appendChild(button)
+  return li
+}
+
+/*
+ * Learn a word, adding it to the local cache
+ * and sending it to the spelling backend
+ */
+const handleLearnWord = async function (word: Word, view: EditorView) {
+  try {
+    await learnWordRequest(word)
+    view.dispatch({
+      effects: [addIgnoredWord.of(word), hideSpellingMenu.of(null)],
+    })
+  } catch (err) {
+    console.error(err)
+  }
+}
+
+/*
+ * Correct a word, removing the marked range
+ * and replacing it with the chosen text
+ */
+const handleCorrectWord = (
+  mark: Mark,
+  word: Word,
+  text: string,
+  view: EditorView
+) => {
+  const existingText = view.state.doc.sliceString(mark.from, mark.to)
+  // Defend against erroneous replacement, if the word at this
+  // position is not actually what we think it is
+  if (existingText !== word.text) {
+    console.debug(
+      '>> spelling word-to-correct does not match, stopping',
+      mark,
+      existingText,
+      word
+    )
+    return
+  }
+  view.dispatch({
+    changes: [
+      {
+        from: mark.from,
+        to: mark.to,
+        insert: text,
+      },
+    ],
+    effects: [hideSpellingMenu.of(null)],
+  })
+}

+ 1 - 0
services/web/frontend/js/features/source-editor/extensions/spelling/helpers.ts

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

+ 25 - 0
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<IgnoredWords>({
+  create() {
+    return ignoredWords
+  },
+  update(ignoredWords, transaction) {
+    for (const effect of transaction.effects) {
+      if (effect.is(addIgnoredWord)) {
+        const newWord = effect.value
+        ignoredWords.add(newWord.text)
+      }
+    }
+    return ignoredWords
+  },
+})
+
+export const addIgnoredWord = StateEffect.define<{
+  text: string
+}>()
+
+export const updateAfterAddingIgnoredWord = StateEffect.define<string>()
+
+export const resetSpellChecker = StateEffect.define()

+ 140 - 0
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<string | undefined>()
+
+type Options = { spellCheckLanguage?: string }
+
+/*
+ * Create the spelling extensions array, based on options passed.
+ */
+export const spelling = ({ spellCheckLanguage }: Options) => {
+  return [
+    EditorView.baseTheme({
+      '.ol-cm-spelling-error': {
+        textDecorationColor: 'red',
+        textDecorationLine: 'underline',
+        textDecorationStyle: 'dotted',
+        textDecorationThickness: '2px',
+        textDecorationSkipInk: 'none',
+        textUnderlineOffset: '0.2em',
+      },
+      '.cm-tooltip.ol-cm-spelling-context-menu-tooltip': {
+        borderWidth: '0',
+      },
+    }),
+    spellCheckLanguageConf.of(spellCheckLanguageFacet.of(spellCheckLanguage)),
+    spellCheckField,
+    misspelledWordsField,
+    ignoredWordsField,
+    cacheField,
+    spellingMenuField,
+  ]
+}
+
+const spellCheckField = StateField.define<SpellChecker | null>({
+  create(state) {
+    const [spellCheckLanguage] = state.facet(spellCheckLanguageFacet)
+    return spellCheckLanguage ? new SpellChecker(spellCheckLanguage) : null
+  },
+  update(value, tr) {
+    for (const effect of tr.effects) {
+      if (effect.is(setSpellCheckLanguageEffect)) {
+        value?.destroy()
+        return effect.value ? new SpellChecker(effect.value) : null
+      }
+    }
+    return value
+  },
+  provide(field) {
+    return [
+      ViewPlugin.define(view => {
+        return {
+          destroy: () => {
+            view.state.field(field)?.destroy()
+          },
+        }
+      }),
+      EditorView.domEventHandlers({
+        focus: (event, view) => {
+          if (view.state.facet(EditorView.editable)) {
+            view.state.field(field)?.spellCheckAsap(view)
+          }
+        },
+      }),
+      EditorView.updateListener.of(update => {
+        if (update.state.facet(EditorView.editable)) {
+          update.state.field(field)?.handleUpdate(update)
+        }
+      }),
+    ]
+  },
+})
+
+const setSpellCheckLanguageEffect = StateEffect.define<string | undefined>()
+
+export const setSpelling = ({
+  spellCheckLanguage,
+}: Options): TransactionSpec => {
+  return {
+    effects: [
+      resetMisspelledWords.of(null),
+      spellCheckLanguageConf.reconfigure(
+        spellCheckLanguageFacet.of(spellCheckLanguage)
+      ),
+      setSpellCheckLanguageEffect.of(spellCheckLanguage),
+    ],
+  }
+}
+
+export const addLearnedWord = (
+  spellCheckLanguage: string,
+  word: string
+): TransactionSpec => {
+  return {
+    effects: [
+      addWordToCache.of({
+        lang: spellCheckLanguage,
+        wordText: word,
+        value: true,
+      }),
+      updateAfterAddingIgnoredWord.of(word),
+    ],
+  }
+}
+
+export const removeLearnedWord = (
+  spellCheckLanguage: string,
+  word: string
+): TransactionSpec => {
+  return {
+    effects: [
+      removeWordFromCache.of({
+        lang: spellCheckLanguage,
+        wordText: word,
+      }),
+    ],
+  }
+}
+
+export const resetLearnedWords = (): TransactionSpec => {
+  return {
+    effects: [resetSpellChecker.of(null)],
+  }
+}

+ 127 - 0
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,
+          })
+        }
+      }
+    }
+  }
+}

+ 100 - 0
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<Word[]>()
+
+export const resetMisspelledWords = StateEffect.define()
+
+const createMark = (word: Word) => {
+  const mark = Decoration.mark({
+    class: 'ol-cm-spelling-error',
+    word,
+  })
+  return mark.range(word.from, word.to)
+}
+
+/*
+ * State for misspelled words, the results of a
+ * spellcheck request. Misspelled words are marked
+ * with a red wavy underline.
+ */
+export const misspelledWordsField = StateField.define<DecorationSet>({
+  create() {
+    return Decoration.none
+  },
+  update(marks, transaction) {
+    marks = marks.map(transaction.changes)
+    marks = removeMarksUnderEdit(marks, transaction)
+    for (const effect of transaction.effects) {
+      if (effect.is(addMisspelledWords)) {
+        // We're setting a new list of misspelled words
+        const misspelledWords = effect.value
+        marks = mergeMarks(marks, misspelledWords)
+      } else if (effect.is(updateAfterAddingIgnoredWord)) {
+        // Remove a misspelled word, all instances that match text
+        const word = effect.value
+        marks = removeAllMarksMatchingWordText(marks, word)
+      } else if (effect.is(resetMisspelledWords)) {
+        marks = Decoration.none
+      }
+    }
+    return marks
+  },
+  provide: field => {
+    return EditorView.decorations.from(field)
+  },
+})
+
+/*
+ * Remove any marks whos text has just been edited
+ */
+const removeMarksUnderEdit = (
+  marks: DecorationSet,
+  transaction: Transaction
+) => {
+  transaction.changes.iterChanges((fromA, toA) => {
+    marks = marks.update({
+      // Filter out marks that overlap the change span
+      filter: (from, to, mark) => {
+        const changeStartWithinMark = from <= fromA && to >= fromA
+        const changeEndWithinMark = from <= toA && to >= toA
+        const markHasBeenEdited = changeStartWithinMark || changeEndWithinMark
+        return !markHasBeenEdited
+      },
+    })
+  })
+  return marks
+}
+
+/*
+ * Given the set of marks, and a list of new misspelled-words,
+ * merge these together into a new set of marks
+ */
+const mergeMarks = (marks: DecorationSet, words: Word[]) => {
+  const affectedLines = new Set(words.map(w => w.lineNumber))
+  marks = marks
+    .update({
+      filter: (from, to, mark) => {
+        return !affectedLines.has(mark.spec.word.lineNumber)
+      },
+    })
+    .update({
+      add: _.sortBy(words, ['from']).map(w => createMark(w)),
+      sort: true,
+    })
+  return marks
+}
+
+/*
+ * Remove existing marks matching the text of a supplied word
+ */
+const removeAllMarksMatchingWordText = (marks: DecorationSet, word: string) => {
+  return marks.update({
+    filter: (from, to, mark) => {
+      return mark.spec.word.text !== word
+    },
+  })
+}

+ 387 - 0
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<NormalTextSpan> = getNormalTextSpansFromLine(
+    view,
+    line
+  )
+  const words: Word[] = []
+  let regexResult
+  normalTextSpans.forEach(span => {
+    WORD_REGEX.lastIndex = 0 // reset global stateful regexp for this usage
+    while ((regexResult = WORD_REGEX.exec(span.text))) {
+      let word = regexResult[0]
+      if (word.startsWith("'")) {
+        word = word.slice(1)
+      }
+      if (word.endsWith("'")) {
+        word = word.slice(0, -1)
+      }
+      if (!ignoredWords.has(word)) {
+        words.push(
+          new Word({
+            text: word,
+            from: span.from + regexResult.index,
+            to: span.from + regexResult.index + word.length,
+            lineNumber,
+            lang,
+          })
+        )
+      }
+    }
+  })
+  return words
+}

+ 39 - 0
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)
+      },
+    }
+  })
+}

+ 270 - 0
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<boolean>()
+
+export type FontFamily = 'monaco' | 'lucida'
+export type LineHeight = 'compact' | 'normal' | 'wide'
+export type OverallTheme = '' | 'light-'
+
+type Options = {
+  fontSize: number
+  fontFamily: FontFamily
+  lineHeight: LineHeight
+  overallTheme: OverallTheme
+}
+
+export const theme = (options: Options) => [
+  baseTheme,
+  staticTheme,
+  syntaxHighlighting(classHighlighter),
+  optionsThemeConf.of(createThemeFromOptions(options)),
+  selectedThemeConf.of([]),
+]
+
+export const setOptionsTheme = (options: Options): TransactionSpec => {
+  return {
+    effects: optionsThemeConf.reconfigure(createThemeFromOptions(options)),
+    annotations: themeOptionsChange.of(true),
+  }
+}
+
+export const setEditorTheme = async (
+  editorTheme: string
+): Promise<TransactionSpec> => {
+  const theme = await loadSelectedTheme(editorTheme)
+
+  return {
+    effects: selectedThemeConf.reconfigure(theme),
+  }
+}
+
+const svgUrl = (content: string) =>
+  `url('data:image/svg+xml,${encodeURIComponent(
+    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">${content}</svg>`
+  )}')`
+
+export const lineHeights: Record<LineHeight, number> = {
+  compact: 1.33,
+  normal: 1.6,
+  wide: 2,
+}
+
+const fontFamilies: Record<FontFamily, string[]> = {
+  monaco: ['Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'monospace'],
+  lucida: ['Lucida Console', 'Source Code Pro', 'monospace'],
+}
+
+const createThemeFromOptions = ({
+  fontSize = 12,
+  fontFamily = 'monaco',
+  lineHeight = 'normal',
+  overallTheme = '',
+}: Options) => {
+  // theme styles that depend on settings
+  return [
+    EditorView.editorAttributes.of({
+      class: overallTheme === '' ? 'overall-theme-dark' : 'overall-theme-light',
+    }),
+    EditorView.theme({
+      '&.cm-editor': {
+        // set variables
+        '--font-size': `${fontSize}px`,
+        '--source-font-family': fontFamilies[fontFamily]?.join(', '),
+        '--line-height': lineHeights[lineHeight],
+      },
+      '.cm-content': {
+        fontSize: 'var(--font-size)',
+        fontFamily: 'var(--source-font-family)',
+        lineHeight: 'var(--line-height)',
+      },
+      '.cm-cursor-primary': {
+        fontSize: 'var(--font-size)',
+        fontFamily: 'var(--source-font-family)',
+        lineHeight: 'var(--line-height)',
+      },
+      '.cm-gutters': {
+        fontSize: 'var(--font-size)',
+        lineHeight: 'var(--line-height)',
+      },
+      '.cm-tooltip': {
+        // set variables for tooltips, which are outside the editor
+        '--font-size': `${fontSize}px`,
+        '--source-font-family': fontFamilies[fontFamily]?.join(', '),
+        '--line-height': lineHeights[lineHeight],
+        // NOTE: fontFamily is not set here, as most tooltips use the UI font
+        fontSize: 'var(--font-size)',
+      },
+      '.cm-panel': {
+        fontSize: 'var(--font-size)',
+      },
+      '.cm-foldGutter .cm-gutterElement > span': {
+        height: 'calc(var(--font-size) * var(--line-height))',
+      },
+      '.cm-lineNumbers': {
+        fontFamily: 'var(--source-font-family)',
+      },
+    }),
+  ]
+}
+
+// base styles that can have &dark and &light variants
+const baseTheme = EditorView.baseTheme({
+  // use a background color for lint error ranges
+  '.cm-lintRange-error': {
+    padding: 'var(--half-leading, 0) 0',
+    background: 'rgba(255, 0, 0, 0.2)',
+    // avoid highlighting nested error ranges
+    '& .cm-lintRange-error': {
+      background: 'none',
+    },
+  },
+  '.cm-widgetBuffer': {
+    height: '1.3em',
+  },
+  '.cm-snippetFieldPosition': {
+    display: 'inline-block',
+    height: '1.3em',
+  },
+  // style the gutter fold button on hover
+  '&dark .cm-foldGutter .cm-gutterElement > span:hover': {
+    boxShadow: '0 1px 1px rgba(255, 255, 255, 0.2)',
+    backgroundColor: 'rgba(255, 255, 255, 0.1)',
+  },
+  '&light .cm-foldGutter .cm-gutterElement > span:hover': {
+    borderColor: 'rgba(0, 0, 0, 0.3)',
+    boxShadow: '0 1px 1px rgba(255, 255, 255, 0.7)',
+    backgroundColor: 'rgba(255, 255, 255, 0.2)',
+  },
+})
+
+// theme styles that don't depend on settings
+// TODO: move some/all of these into baseTheme?
+const staticTheme = EditorView.theme({
+  // make the editor fill the available height
+  '&': {
+    height: '100%',
+    textRendering: 'optimizeSpeed',
+  },
+  // remove the outline from the focused editor
+  '&.cm-editor.cm-focused:not(:focus-visible)': {
+    outline: 'none',
+  },
+  // override default styles for the search panel
+  '.cm-panel.cm-search label': {
+    display: 'inline-flex',
+    alignItems: 'center',
+    fontWeight: 'normal',
+  },
+  '.cm-selectionLayer': {
+    zIndex: -10,
+  },
+  // remove the right-hand border from the gutter
+  // ensure the gutter doesn't shrink
+  '.cm-gutters': {
+    borderRight: 'none',
+    flexShrink: 0,
+  },
+  // style the gutter fold button
+  // TODO: add a class to this element for easier theming
+  '.cm-foldGutter .cm-gutterElement > span': {
+    border: '1px solid transparent',
+    borderRadius: '3px',
+    display: 'inline-flex',
+    flexDirection: 'column',
+    justifyContent: 'center',
+    color: 'rgba(109, 109, 109, 0.7)',
+  },
+  // reduce the padding around line numbers
+  '.cm-lineNumbers .cm-gutterElement': {
+    padding: '0',
+    userSelect: 'none',
+  },
+  // make cursor visible with reduced opacity when the editor is not focused
+  '&:not(.cm-focused) .cm-cursor': {
+    display: 'block',
+    opacity: 0.2,
+  },
+  // make the cursor wider, and use the themed color
+  '.cm-cursor, .cm-dropCursor': {
+    borderWidth: '2px',
+    marginLeft: '-1px', // half the border width
+    borderLeftColor: 'inherit',
+  },
+  // set the default "selection match" style
+  '.cm-selectionMatch, .cm-searchMatch': {
+    backgroundColor: 'transparent',
+    outlineOffset: '-1px',
+  },
+  // make sure selectionMatch inside searchMatch doesn't have a background colour
+  '.cm-searchMatch .cm-selectionMatch': {
+    backgroundColor: 'transparent !important',
+  },
+  // Match the height of search matches to selection matches
+  '.cm-searchMatch': {
+    paddingTop: 'var(--half-leading)',
+    paddingBottom: 'var(--half-leading)',
+  },
+  // remove border from hover tooltips (e.g. cursor highlights)
+  '.cm-tooltip-hover': {
+    border: 'none',
+  },
+  // use the same style as Ace for snippet fields
+  '.cm-snippetField': {
+    background: 'rgba(194, 193, 208, 0.09)',
+    border: '1px dotted rgba(211, 208, 235, 0.62)',
+  },
+  // style the fold placeholder
+  '.cm-foldPlaceholder': {
+    boxSizing: 'border-box',
+    display: 'inline-block',
+    height: '11px',
+    width: '1.8em',
+    marginTop: '-2px',
+    verticalAlign: 'middle',
+    backgroundImage:
+      'url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABEAAAAJCAYAAADU6McMAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAJpJREFUeNpi/P//PwOlgAXGYGRklAVSokD8GmjwY1wasKljQpYACtpCFeADcHVQfQyMQAwzwAZI3wJKvCLkfKBaMSClBlR7BOQikCFGQEErIH0VqkabiGCAqwUadAzZJRxQr/0gwiXIal8zQQPnNVTgJ1TdawL0T5gBIP1MUJNhBv2HKoQHHjqNrA4WO4zY0glyNKLT2KIfIMAAQsdgGiXvgnYAAAAASUVORK5CYII="),url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAA3CAYAAADNNiA5AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAACJJREFUeNpi+P//fxgTAwPDBxDxD078RSX+YeEyDFMCIMAAI3INmXiwf2YAAAAASUVORK5CYII=")',
+    backgroundRepeat: 'no-repeat, repeat-x',
+    backgroundPosition: 'center center, top left',
+    color: 'transparent',
+    border: '1px solid black',
+    borderRadius: '2px',
+  },
+  // align the lint icons with the line numbers
+  '.cm-gutter-lint .cm-gutterElement': {
+    padding: '0.3em',
+  },
+  // reset the default style for the lint gutter error marker, which uses :before
+  '.cm-lint-marker-error:before': {
+    content: 'normal',
+  },
+  // set a new icon for the lint gutter error marker
+  '.cm-lint-marker-error': {
+    content: svgUrl(
+      `<circle cx="20" cy="20" r="15" fill="#f87" stroke="#f43" stroke-width="6"/>`
+    ),
+  },
+  // set a new icon for the lint gutter warning marker
+  '.cm-lint-marker-warning': {
+    content: svgUrl(
+      `<path fill="#FCC483" stroke="#DE8014" stroke-width="6" stroke-linejoin="round" d="M20 6L37 35L3 35Z"/>`
+    ),
+  },
+})
+
+const loadSelectedTheme = async (editorTheme: string) => {
+  const { theme, highlightStyle, dark } = await import(
+    /* webpackChunkName: "cm6-theme" */ `../themes/cm6/${editorTheme}.json`
+  )
+
+  return [
+    EditorView.theme(theme, { dark }),
+    highlightStyle
+      ? EditorView.theme(highlightStyle, { dark })
+      : syntaxHighlighting(defaultHighlightStyle, { fallback: true }), // use the default highlight style if none is provided
+  ]
+}

+ 42 - 0
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
+}

+ 106 - 0
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
+}

+ 14 - 0
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
+}

+ 316 - 0
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')

+ 139 - 0
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,
+        }
+      }
+    })
+  )
+}

+ 7 - 0
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),
+}

+ 221 - 0
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<boolean>()
+const toolbarState = StateField.define<boolean>({
+  create: () => true,
+  update: (value, tr) => {
+    if (tr.state.readOnly) {
+      return false
+    }
+    for (const effect of tr.effects) {
+      if (effect.is(toggleToolbarEffect)) {
+        value = effect.value
+      }
+    }
+    return value
+  },
+  provide: f => showPanel.from(f, on => (on ? createToolbarPanel : null)),
+})
+
+export function createToolbarPanel() {
+  const dom = document.createElement('div')
+  dom.classList.add('ol-cm-toolbar-portal')
+  return { dom, top: true }
+}
+
+export const toolbarPanel = () => [
+  toolbarState,
+  EditorView.theme({
+    '.ol-cm-toolbar': {
+      backgroundColor: 'var(--editor-toolbar-bg)',
+      color: 'var(--toolbar-btn-color)',
+      flex: 1,
+      display: 'flex',
+      overflowX: 'hidden',
+    },
+    '&.overall-theme-dark .ol-cm-toolbar': {
+      '& img': {
+        filter: 'invert(1)',
+      },
+    },
+    '.ol-cm-toolbar-overflow': {
+      display: 'flex',
+      flexWrap: 'wrap',
+    },
+    '#popover-toolbar-overflow': {
+      padding: 0,
+      borderColor: 'rgba(125, 125, 125, 0.2)',
+      backgroundColor: 'var(--editor-toolbar-bg)',
+      color: 'var(--toolbar-btn-color)',
+      '& .popover-content': {
+        padding: 0,
+      },
+      '& .arrow': {
+        borderBottomColor: 'rgba(125, 125, 125, 0.2)',
+        '&:after': {
+          borderBottomColor: 'var(--editor-toolbar-bg)',
+        },
+      },
+    },
+    '.ol-cm-toolbar-button-group': {
+      display: 'flex',
+      alignItems: 'center',
+      whiteSpace: 'nowrap',
+      flexWrap: 'nowrap',
+      padding: '0 4px',
+      margin: '4px 0',
+      lineHeight: '1',
+      '&:not(:first-of-type)': {
+        borderLeft: '1px solid rgba(125, 125, 125, 0.3)',
+        '&.ol-cm-toolbar-end': {
+          borderLeft: 'none',
+        },
+        '&.overflow-hidden': {
+          borderLeft: 'none',
+        },
+      },
+      '&.overflow-hidden': {
+        width: 0,
+        padding: 0,
+      },
+    },
+    '.ol-cm-toolbar-button': {
+      display: 'inline-flex',
+      alignItems: 'center',
+      justifyContent: 'center',
+      padding: '0',
+      margin: '0 1px',
+      backgroundColor: 'transparent',
+      border: 'none',
+      borderRadius: '1px',
+      lineHeight: '1',
+      width: '24px',
+      height: '24px',
+      overflow: 'hidden',
+      '&:hover, &:focus, &:active, &.active': {
+        backgroundColor: 'rgba(125, 125, 125, 0.1)',
+        color: 'inherit',
+        boxShadow: 'none',
+        '&[disabled]': {
+          opacity: '0.2',
+        },
+      },
+      '&.active, &:active': {
+        backgroundColor: 'rgba(125, 125, 125, 0.2)',
+      },
+      '&[disabled]': {
+        opacity: '0.2',
+      },
+      '.overflow-hidden &': {
+        display: 'none',
+      },
+      '&.ol-cm-toolbar-button-math': {
+        fontFamily: '"Noto Serif", serif',
+        fontSize: '16px',
+        fontWeight: 700,
+      },
+    },
+    '&.overall-theme-dark .ol-cm-toolbar-button': {
+      opacity: 0.8,
+      '&:hover, &:focus, &:active, &.active': {
+        backgroundColor: 'rgba(125, 125, 125, 0.2)',
+      },
+      '&.active, &:active': {
+        backgroundColor: 'rgba(125, 125, 125, 0.4)',
+      },
+      '&[disabled]': {
+        opacity: 0.2,
+      },
+    },
+    '.ol-cm-toolbar-end': {
+      flex: 1,
+      justifyContent: 'flex-end',
+    },
+    '.ol-cm-toolbar-overflow-toggle': {
+      display: 'none',
+      '&.ol-cm-toolbar-overflow-toggle-visible': {
+        display: 'flex',
+      },
+    },
+    '.ol-cm-toolbar-menu-toggle': {
+      background: 'transparent',
+      boxShadow: 'none !important',
+      border: 'none',
+      whiteSpace: 'nowrap',
+      color: 'inherit',
+      borderRadius: '0',
+      opacity: 0.8,
+      width: '120px',
+      fontSize: '13px',
+      fontFamily: 'Lato',
+      fontWeight: '700',
+      display: 'flex',
+      alignItems: 'center',
+      justifyContent: 'space-between',
+      padding: '5px 6px',
+      '&:hover, &:focus, &.active': {
+        backgroundColor: 'rgba(125, 125, 125, 0.1)',
+        opacity: '1',
+        color: 'inherit',
+      },
+      '& .caret': {
+        marginTop: '0',
+      },
+    },
+    '.ol-cm-toolbar-menu-popover': {
+      border: 'none',
+      borderRadius: '0',
+      borderBottomLeftRadius: '4px',
+      borderBottomRightRadius: '4px',
+      boxShadow: '0 2px 5px rgb(0 0 0 / 20%)',
+      backgroundColor: 'var(--editor-toolbar-bg)',
+      color: 'var(--toolbar-btn-color)',
+      padding: '0',
+      '&.bottom': {
+        marginTop: '1px',
+      },
+      '&.top': {
+        marginBottom: '1px',
+      },
+      '& .arrow': {
+        display: 'none',
+      },
+      '& .popover-content': {
+        padding: '0',
+      },
+      '& .ol-cm-toolbar-menu': {
+        width: '120px',
+        display: 'flex',
+        flexDirection: 'column',
+        boxSizing: 'border-box',
+        fontSize: '14px',
+      },
+      '& .ol-cm-toolbar-menu-item': {
+        border: 'none',
+        background: 'none',
+        padding: '4px 12px',
+        height: '40px',
+        display: 'flex',
+        alignItems: 'center',
+        fontWeight: 'bold',
+        '&.ol-cm-toolbar-menu-item-active': {
+          backgroundColor: 'rgba(125, 125, 125, 0.1)',
+        },
+        '&:hover': {
+          backgroundColor: 'rgba(125, 125, 125, 0.2)',
+          color: 'inherit',
+        },
+        '&.section-level-section': {
+          fontSize: '1.44em',
+        },
+        '&.section-level-subsection': {
+          fontSize: '1.2em',
+        },
+        '&.section-level-body': {
+          fontWeight: 'normal',
+        },
+      },
+    },
+  }),
+]

+ 8 - 0
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 })
+}

+ 355 - 0
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<RangeSet<any>>({
+  map: (value, mapping) => {
+    return value
+      .update({
+        filter: (from, to) => {
+          return from <= mapping.length && to <= mapping.length
+        },
+      })
+      .map(mapping)
+  },
+})
+
+type Options = {
+  currentDoc: CurrentDoc
+  loadingThreads: boolean
+}
+
+export const trackChanges = (
+  { currentDoc, loadingThreads }: Options,
+  changeManager: ChangeManager
+) => {
+  // A state field that stored any comments found within the ranges of a "cut" transaction,
+  // to be restored when pasting matching text.
+  const cutCommentsState = StateField.define<StoredComment[]>({
+    create: () => {
+      return []
+    },
+    update: (value, transaction) => {
+      if (transaction.annotation(Transaction.remote)) {
+        return value
+      }
+
+      if (!transaction.docChanged) {
+        return value
+      }
+
+      if (transaction.isUserEvent('delete.cut')) {
+        return findCommentsInCut(currentDoc, transaction)
+      }
+
+      if (transaction.isUserEvent('input.paste')) {
+        restoreCommentsOnPaste(currentDoc, transaction, value)
+        return []
+      }
+
+      return value
+    },
+  })
+
+  return [
+    // attach any comments detached by the transaction as an inverted effect, to be applied on undo
+    invertedEffects.of(transaction => {
+      if (
+        transaction.docChanged &&
+        !transaction.annotation(Transaction.remote)
+      ) {
+        const detachedComments = findDetachedCommentsInChanges(
+          currentDoc,
+          transaction
+        )
+        if (detachedComments.size) {
+          return [restoreDetachedCommentsEffect.of(detachedComments)]
+        }
+      }
+      return []
+    }),
+
+    // restore any detached comments on undo
+    EditorState.transactionExtender.of(transaction => {
+      for (const effect of transaction.effects) {
+        if (effect.is(restoreDetachedCommentsEffect)) {
+          // send the comments to the ShareJS doc
+          restoreDetachedComments(currentDoc, transaction, effect.value)
+
+          // return a transaction spec to rebuild the change markers
+          return buildChangeMarkers()
+        }
+      }
+      return null
+    }),
+
+    cutCommentsState,
+
+    // initialize/destroy the change manager, and handle any updates
+    ViewPlugin.define(() => {
+      changeManager.initialize()
+
+      return {
+        update: update => {
+          changeManager.handleUpdate(update)
+        },
+        destroy: () => {
+          changeManager.destroy()
+        },
+      }
+    }),
+
+    // draw change decorations
+    ViewPlugin.define<
+      PluginValue & {
+        decorations: DecorationSet
+      }
+    >(
+      () => {
+        return {
+          decorations: loadingThreads
+            ? Decoration.none
+            : buildChangeDecorations(currentDoc),
+          update(update) {
+            for (const transaction of update.transactions) {
+              this.decorations = this.decorations.map(transaction.changes)
+
+              for (const effect of transaction.effects) {
+                if (effect.is(clearChangesEffect)) {
+                  this.decorations = Decoration.none
+                } else if (effect.is(buildChangesEffect)) {
+                  this.decorations = buildChangeDecorations(currentDoc)
+                }
+              }
+            }
+          },
+        }
+      },
+      {
+        decorations: value => value.decorations,
+      }
+    ),
+
+    // styles for change decorations
+    trackChangesTheme,
+  ]
+}
+
+export const clearChangeMarkers = () => {
+  return {
+    effects: clearChangesEffect.of(null),
+  }
+}
+
+export const buildChangeMarkers = () => {
+  return {
+    effects: buildChangesEffect.of(null),
+  }
+}
+
+const buildChangeDecorations = (currentDoc: CurrentDoc) => {
+  const changes = [...currentDoc.ranges.changes, ...currentDoc.ranges.comments]
+
+  const decorations = []
+
+  for (const change of changes) {
+    try {
+      decorations.push(...createChangeRange(change, currentDoc))
+    } catch (error) {
+      // ignore invalid changes
+      console.debug('invalid change position', error)
+    }
+  }
+
+  return Decoration.set(decorations, true)
+}
+
+class ChangeDeletedWidget extends WidgetType {
+  constructor(public change: Change<DeleteOperation>) {
+    super()
+  }
+
+  toDOM() {
+    const widget = document.createElement('span')
+    widget.classList.add('ol-cm-change')
+    widget.classList.add('ol-cm-change-d')
+
+    return widget
+  }
+
+  eq() {
+    return true
+  }
+}
+
+class ChangeCalloutWidget extends WidgetType {
+  constructor(public change: Change, public opType: string) {
+    super()
+  }
+
+  toDOM() {
+    const widget = document.createElement('span')
+    widget.className = 'ol-cm-change-callout'
+    widget.classList.add(`ol-cm-change-callout-${this.opType}`)
+
+    const inner = document.createElement('span')
+    inner.classList.add('ol-cm-change-callout-inner')
+    widget.appendChild(inner)
+
+    return widget
+  }
+
+  eq(widget: ChangeCalloutWidget) {
+    return widget.opType === this.opType
+  }
+
+  updateDOM(element: HTMLElement) {
+    element.className = 'ol-cm-change-callout'
+    element.classList.add(`ol-cm-change-callout-${this.opType}`)
+    return true
+  }
+}
+
+// const isInsertOperation = (op: Operation): op is InsertOperation => 'i' in op
+const isChangeOperation = (op: Operation): op is ChangeOperation =>
+  'c' in op && 't' in op
+const isCommentOperation = (op: Operation): op is CommentOperation =>
+  'c' in op && !('t' in op)
+const isDeleteOperation = (op: Operation): op is DeleteOperation => 'd' in op
+
+const createChangeRange = (change: Change, currentDoc: CurrentDoc) => {
+  const { id, metadata, op } = change
+
+  const from = op.p
+  // TODO: find valid positions?
+
+  if (isDeleteOperation(op)) {
+    const opType = 'd'
+
+    const changeWidget = Decoration.widget({
+      widget: new ChangeDeletedWidget(change as Change<DeleteOperation>),
+      opType,
+      id,
+      metadata,
+    })
+
+    const calloutWidget = Decoration.widget({
+      widget: new ChangeCalloutWidget(change, opType),
+      opType,
+      id,
+      metadata,
+    })
+
+    return [calloutWidget.range(from, from), changeWidget.range(from, from)]
+  }
+
+  if (isChangeOperation(op) && currentDoc.ranges.resolvedThreadIds[op.t]) {
+    return []
+  }
+
+  const isChangeOrCommentOperation =
+    isChangeOperation(op) || isCommentOperation(op)
+  const opType = isChangeOrCommentOperation ? 'c' : 'i'
+  const changedText = isChangeOrCommentOperation ? op.c : op.i
+  const to = from + changedText.length
+
+  // Mark decorations must not be empty
+  if (from === to) {
+    return []
+  }
+
+  const changeMark = Decoration.mark({
+    tagName: 'span',
+    class: `ol-cm-change ol-cm-change-${opType}`,
+    opType,
+    id,
+    metadata,
+  })
+
+  const calloutWidget = Decoration.widget({
+    widget: new ChangeCalloutWidget(change, opType),
+    opType,
+    id,
+    metadata,
+  })
+
+  return [calloutWidget.range(from, from), changeMark.range(from, to)]
+}
+
+const trackChangesTheme = EditorView.baseTheme({
+  '.cm-line': {
+    overflowX: 'hidden', // needed so the callout elements don't overflow (requires line wrapping to be on)
+  },
+  '&light .ol-cm-change-i': {
+    backgroundColor: '#2c8e304d',
+  },
+  '&dark .ol-cm-change-i': {
+    backgroundColor: 'rgba(37, 107, 41, 0.15)',
+  },
+  '&light .ol-cm-change-c': {
+    backgroundColor: '#f3b1114d',
+  },
+  '&dark .ol-cm-change-c': {
+    backgroundColor: 'rgba(194, 93, 11, 0.15)',
+  },
+  '.ol-cm-change': {
+    padding: 'var(--half-leading, 0) 0',
+  },
+  '.ol-cm-change-d': {
+    borderLeft: '2px dotted #c5060b',
+    marginLeft: '-1px',
+  },
+  '.ol-cm-change-callout': {
+    position: 'relative',
+    pointerEvents: 'none',
+    padding: 'var(--half-leading, 0) 0',
+  },
+  '.ol-cm-change-callout-inner': {
+    display: 'inline-block',
+    position: 'absolute',
+    left: 0,
+    bottom: 0,
+    width: '10000px',
+    borderBottom: '1px dashed black',
+  },
+  '.ol-cm-change-callout-i .ol-cm-change-callout-inner': {
+    borderColor: '#2c8e30',
+  },
+  '.ol-cm-change-callout-c .ol-cm-change-callout-inner': {
+    borderColor: '#f3b111',
+  },
+  '.ol-cm-change-callout-d .ol-cm-change-callout-inner': {
+    borderColor: '#c5060b',
+  },
+})

+ 165 - 0
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<VerticalPadding>()
+
+// Store extra padding needed at the top and bottom of the editor to match the height of the review panel.
+// The padding needs to allow enough space for tracked changes/comments at the top and/or bottom of the review panel.
+const overflowPaddingState = StateField.define<VerticalPadding>({
+  create() {
+    return { top: 0, bottom: 0 }
+  },
+  update(value, tr) {
+    for (const effect of tr.effects) {
+      if (effect.is(setOverflowPaddingEffect)) {
+        const { top, bottom } = effect.value
+        // only update the state when the values actually change
+        if (top !== value.top || bottom !== value.bottom) {
+          value = { top, bottom }
+        }
+      }
+    }
+    return value
+  },
+})
+
+const setMinimumBottomPaddingEffect = StateEffect.define<number>()
+
+// Store extra padding needed at the bottom of the editor content.
+// The content must have a space at the bottom equivalent to the
+// height of the editor content minus one line, so that the last
+// line in the document can be scrolled to the top of the editor.
+const minimumBottomPaddingState = StateField.define<number>({
+  create() {
+    return 0
+  },
+  update(value, tr) {
+    for (const effect of tr.effects) {
+      if (effect.is(setMinimumBottomPaddingEffect)) {
+        value = effect.value
+      }
+    }
+    return value
+  },
+})
+
+// Set scrollTop to counteract changes to the top padding.
+// This view plugin is needed because the overflowPaddingState StateField doesn't have access to the view.
+const topPaddingPlugin = ViewPlugin.define(view => {
+  let previousTop = 0
+
+  return {
+    update: update => {
+      const { top } = update.state.field(overflowPaddingState)
+      if (top !== previousTop) {
+        const diff = top - previousTop
+
+        if (diff < 0) {
+          // padding is decreasing, scroll now
+          view.scrollDOM.scrollTop += diff
+        } else {
+          // padding is increasing, scroll after it has been applied
+          view.requestMeasure({
+            key: 'vertical-overflow-scroll-top',
+            read() {
+              // do nothing
+            },
+            write(measure, view) {
+              view.scrollDOM.scrollTop += diff
+            },
+          })
+        }
+
+        previousTop = top
+      }
+    },
+  }
+})
+
+/**
+ * When the editor geometry changes, recalculate the amount of padding needed at
+ * the end of the doc: (the scrollDOM height - 1 line height).
+ * Adapted from the CodeMirror 6 scrollPastEnd extension, licensed under the MIT
+ * license:
+ * https://github.com/codemirror/view/blob/main/src/scrollpastend.ts
+ */
+const bottomPaddingPlugin = ViewPlugin.define(view => {
+  let previousHeight = 0
+
+  const measure = {
+    key: 'vertical-overflow-bottom-padding',
+    read(view: EditorView) {
+      return view.scrollDOM.clientHeight - view.defaultLineHeight
+    },
+    write(height: number, view: EditorView) {
+      if (height !== previousHeight) {
+        // dispatch must be wrapped in a timeout to avoid clashing with the current update
+        window.setTimeout(() =>
+          view.dispatch({
+            effects: setMinimumBottomPaddingEffect.of(height),
+          })
+        )
+        previousHeight = height
+      }
+    },
+  }
+
+  view.requestMeasure(measure)
+
+  return {
+    update: update => {
+      if (update.geometryChanged) {
+        update.view.requestMeasure(measure)
+      }
+    },
+  }
+})
+
+// Set a style attribute on the contentDOM containing the calculated top and bottom padding.
+// This value will be concatenated with style values from any other extensions.
+const contentAttributes = EditorView.contentAttributes.compute(
+  [overflowPaddingState, minimumBottomPaddingState],
+  state => {
+    const overflowPadding = state.field(overflowPaddingState)
+    const minimumBottomPadding = state.field(minimumBottomPaddingState)
+
+    const bottomPadding = Math.max(minimumBottomPadding, overflowPadding.bottom)
+
+    return {
+      style: `padding-top: ${overflowPadding.top}px; padding-bottom: ${bottomPadding}px;`,
+    }
+  }
+)
+
+export function setVerticalOverflow(padding: VerticalPadding): TransactionSpec {
+  return {
+    effects: [setOverflowPaddingEffect.of(padding)],
+  }
+}
+
+export function updateSetsVerticalOverflow(update: ViewUpdate): boolean {
+  return update.transactions.some(tr => {
+    return tr.effects.some(effect => effect.is(setOverflowPaddingEffect))
+  })
+}
+
+export function editorVerticalTopPadding(view: EditorView): number {
+  return view.state.field(overflowPaddingState).top
+}

+ 927 - 0
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<Decoration>[] {
+  if (!argumentNode) {
+    return []
+  }
+  const openBrace = argumentNode.getChild('OpenBrace')
+  const closeBrace = argumentNode.getChild('CloseBrace')
+
+  if (openBrace && closeBrace) {
+    if (
+      // Make sure that decoration ranges are non-empty
+      openBrace.to > start &&
+      (decorateEmptyArguments || argumentNode.to - argumentNode.from > 2)
+    ) {
+      return [
+        Decoration.replace({
+          widget: startWidget,
+        }).range(start, openBrace.to),
+
+        Decoration.replace({ widget: endWidget }).range(
+          closeBrace.from,
+          closeBrace.to
+        ),
+      ]
+    }
+  }
+  return []
+}
+
+const hasClosingBrace = (node: SyntaxNode) =>
+  node.getChild('EnvNameGroup')?.getChild('CloseBrace')
+
+/**
+ * Atomic decorations replace a range of content with an uneditable widget.
+ * Decorations that span multiple lines must be contained in a StateField, not a ViewPlugin.
+ */
+export const atomicDecorations = (options: Options) => {
+  const getPreviewByPath = (path: string) =>
+    options.fileTreeManager.getPreviewByPath(path)
+
+  const createDecorations = (state: EditorState, tree: Tree): DecorationSet => {
+    const decorations: Range<Decoration>[] = []
+
+    const listEnvironmentStack: ListEnvironmentName[] = []
+    let currentListEnvironment: ListEnvironmentName | undefined
+
+    const ordinalStack: number[] = []
+    let currentOrdinal = 0
+
+    let listDepth = 0
+
+    const preamble: {
+      from: number
+      to: number
+      title?: {
+        node: SyntaxNode
+        content: string
+      }
+      author?: {
+        node: SyntaxNode
+        content: string
+      }
+    } = { from: 0, to: 0 }
+
+    // find the positions of the title and author in the preamble
+    tree.iterate({
+      enter(nodeRef) {
+        if (nodeRef.type.is('DocumentEnvironment')) {
+          // Attempt to include \begin{document} in the preamble
+          preamble.to = nodeRef.node.getChild('Content')?.from ?? nodeRef.from
+          return false
+        } else if (nodeRef.type.is('Title')) {
+          const node = nodeRef.node.getChild('TextArgument')
+          if (node) {
+            const content = state.sliceDoc(node.from, node.to)
+            preamble.title = { node, content }
+          }
+        } else if (nodeRef.type.is('Author')) {
+          const node = nodeRef.node.getChild('TextArgument')
+          if (node) {
+            const content = state.sliceDoc(node.from, node.to)
+            preamble.author = { node, content }
+          }
+        }
+      },
+    })
+    if (preamble.to > 0) {
+      // hide the preamble. We use selectionIntersects directly, so that it also
+      // expands in readOnly mode.
+      const endLine = state.doc.lineAt(preamble.to).number
+      for (let lineNumber = 1; lineNumber <= endLine; ++lineNumber) {
+        const line = state.doc.line(lineNumber)
+        const classes = ['ol-cm-preamble-line']
+        if (lineNumber === 1) {
+          classes.push('ol-cm-environment-first-line')
+        }
+        if (lineNumber === endLine) {
+          classes.push('ol-cm-environment-last-line')
+        }
+        decorations.push(
+          Decoration.line({
+            class: classes.join(' '),
+          }).range(line.from)
+        )
+      }
+
+      const isExpanded = selectionIntersects(state.selection, preamble)
+      if (!isExpanded) {
+        decorations.push(
+          Decoration.replace({
+            widget: new PreambleWidget(preamble.to, isExpanded),
+            block: true,
+          }).range(0, preamble.to)
+        )
+      } else {
+        decorations.push(
+          Decoration.widget({
+            widget: new PreambleWidget(preamble.to, isExpanded),
+            block: true,
+            side: -1,
+          }).range(0)
+        )
+      }
+    }
+
+    const startListEnvironment = (envName: ListEnvironmentName) => {
+      if (currentListEnvironment) {
+        listEnvironmentStack.push(currentListEnvironment)
+        ordinalStack.push(currentOrdinal)
+      }
+      currentListEnvironment = envName
+      currentOrdinal = 0
+    }
+
+    const endListEnvironment = () => {
+      currentListEnvironment = listEnvironmentStack.pop()
+      currentOrdinal = ordinalStack.pop() ?? 0
+    }
+
+    tree.iterate({
+      enter(nodeRef) {
+        if (nodeRef.type.is('$Environment')) {
+          if (shouldDecorate(state, nodeRef)) {
+            const envName = getEnvironmentName(nodeRef.node, state)
+            const hideInEnvironmentTypes = ['figure', 'table']
+            if (envName && hideInEnvironmentTypes.includes(envName)) {
+              const beginNode = nodeRef.node.getChild('BeginEnv')
+              const endNode = nodeRef.node.getChild('EndEnv')
+              if (
+                beginNode &&
+                endNode &&
+                hasClosingBrace(beginNode) &&
+                hasClosingBrace(endNode)
+              ) {
+                const beginLine = state.doc.lineAt(beginNode.from)
+                const endLine = state.doc.lineAt(endNode.from)
+
+                const begin = {
+                  from: beginLine.from,
+                  to: extendForwardsOverEmptyLines(state.doc, beginLine),
+                }
+                const end = {
+                  from: extendBackwardsOverEmptyLines(state.doc, endLine),
+                  to: endLine.to,
+                }
+
+                if (shouldDecorate(state, { from: begin.from, to: end.to })) {
+                  decorations.push(
+                    Decoration.replace({
+                      widget: new EnvironmentLineWidget(envName, 'begin'),
+                      block: true,
+                    }).range(begin.from, begin.to),
+                    Decoration.replace({
+                      widget: new EnvironmentLineWidget(envName, 'end'),
+                      block: true,
+                    }).range(end.from, end.to)
+                  )
+
+                  const centeringNode = centeringNodeForEnvironment(nodeRef)
+
+                  if (centeringNode) {
+                    const line = state.doc.lineAt(centeringNode.from)
+                    const from = extendBackwardsOverEmptyLines(state.doc, line)
+                    const to = extendForwardsOverEmptyLines(state.doc, line)
+
+                    decorations.push(
+                      Decoration.replace({
+                        block: true,
+                      }).range(from, to)
+                    )
+                  }
+                }
+              }
+            } else if (nodeRef.type.is('ListEnvironment')) {
+              const beginNode = nodeRef.node.getChild('BeginEnv')
+              const endNode = nodeRef.node.getChild('EndEnv')
+
+              if (
+                beginNode &&
+                endNode &&
+                hasClosingBrace(beginNode) &&
+                hasClosingBrace(endNode)
+              ) {
+                const beginLine = state.doc.lineAt(beginNode.from)
+                const endLine = state.doc.lineAt(endNode.from)
+
+                const begin = {
+                  from: beginLine.from,
+                  to: extendForwardsOverEmptyLines(state.doc, beginLine),
+                }
+                const end = {
+                  from: extendBackwardsOverEmptyLines(state.doc, endLine),
+                  to: endLine.to,
+                }
+
+                if (
+                  !selectionIntersects(state.selection, begin) &&
+                  !selectionIntersects(state.selection, end)
+                ) {
+                  decorations.push(
+                    Decoration.replace({
+                      block: true,
+                    }).range(begin.from, begin.to),
+                    Decoration.replace({
+                      block: true,
+                    }).range(end.from, end.to)
+                  )
+                }
+              }
+            }
+          }
+        } else if (nodeRef.type.is('BeginEnv')) {
+          // the beginning of an environment, with an environment name argument
+          const envName = getEnvironmentName(nodeRef.node, state)
+
+          if (envName) {
+            switch (envName) {
+              case 'itemize':
+              case 'enumerate':
+                startListEnvironment(envName)
+                listDepth++
+                break
+
+              case 'abstract':
+                if (shouldDecorate(state, nodeRef)) {
+                  decorations.push(
+                    Decoration.replace({
+                      widget: new BeginWidget(envName),
+                      block: true,
+                    }).range(nodeRef.from, nodeRef.to)
+                  )
+                }
+                break
+              case 'frame':
+                if (shouldDecorate(state, nodeRef)) {
+                  const parent = nodeRef.node.parent
+                  if (parent?.type.is('Environment')) {
+                    const args = getEnvironmentArguments(parent)
+                    if (!args) {
+                      break
+                    }
+
+                    if (args.length > 0) {
+                      const title = args[0]
+                      if (!title) {
+                        break
+                      }
+                      let to = title.to
+                      const titleTextNode = title.getChild('LongArg')
+                      if (!titleTextNode) {
+                        break
+                      }
+                      const frame: Frame = {
+                        title: {
+                          node: title,
+                          content: state.sliceDoc(
+                            titleTextNode.from,
+                            titleTextNode.to
+                          ),
+                        },
+                      }
+                      if (args.length > 1) {
+                        // We have a subtitle too
+                        const subtitle = args[1]
+                        if (subtitle) {
+                          const subtitleTextNode = subtitle.getChild('LongArg')
+                          if (subtitleTextNode) {
+                            to = subtitle.to
+                            frame.subtitle = {
+                              node: subtitle,
+                              content: state.sliceDoc(
+                                subtitleTextNode.from,
+                                subtitleTextNode.to
+                              ),
+                            }
+                          }
+                        }
+                      }
+                      decorations.push(
+                        Decoration.replace({
+                          widget: new FrameWidget(frame),
+                          block: true,
+                        }).range(nodeRef.from, to)
+                      )
+                    }
+                  }
+                }
+                break
+              default:
+                // do nothing
+                break
+            }
+          }
+        } else if (nodeRef.type.is('EndEnv')) {
+          // the end of an environment, with an environment name argument
+          const envName = getEnvironmentName(nodeRef.node, state)
+
+          if (envName) {
+            switch (envName) {
+              case 'itemize':
+              case 'enumerate':
+                if (currentListEnvironment === envName) {
+                  endListEnvironment()
+                }
+                listDepth--
+                break
+
+              case 'abstract':
+                if (shouldDecorate(state, nodeRef)) {
+                  decorations.push(
+                    Decoration.replace({
+                      widget: new EndWidget(),
+                      block: true,
+                    }).range(nodeRef.from, nodeRef.to)
+                  )
+                }
+                break
+              case 'document':
+                if (shouldDecorate(state, nodeRef)) {
+                  decorations.push(
+                    Decoration.replace({
+                      widget: new EndDocumentWidget(),
+                      block: true,
+                    }).range(nodeRef.from, nodeRef.to)
+                  )
+                }
+                break
+              case 'frame':
+                if (shouldDecorate(state, nodeRef)) {
+                  decorations.push(
+                    Decoration.replace({
+                      widget: new DividerWidget(),
+                      block: true,
+                    }).range(nodeRef.from, nodeRef.to)
+                  )
+                }
+                break
+              default:
+                // do nothing
+                break
+            }
+          }
+        } else if (nodeRef.type.is('$SectioningCommand')) {
+          const ancestorNode = ancestorNodeOfType(
+            state,
+            nodeRef.to,
+            'SectioningCommand'
+          )
+          if (ancestorNode) {
+            const shouldShowBraces = !shouldDecorate(state, ancestorNode)
+            // a section (or subsection, etc) command
+            const argumentNode = ancestorNode.getChild('SectioningArgument')
+            if (argumentNode) {
+              const braces = argumentNode.getChildren('$Brace')
+              if (braces.length !== 2) {
+                return false
+              }
+              const titleNode = argumentNode.getChild('LongArg')
+              if (!titleNode) {
+                return false
+              }
+              const title = state.sliceDoc(titleNode.from, titleNode.to)
+              if (!title.trim()) {
+                return false
+              }
+
+              decorations.push(
+                Decoration.replace({
+                  widget: new BraceWidget(shouldShowBraces ? '}' : ''),
+                }).range(braces[1].from, braces[1].to)
+              )
+
+              decorations.push(
+                Decoration.replace({
+                  widget: new BraceWidget(shouldShowBraces ? '{' : ''),
+                }).range(nodeRef.from, titleNode.from)
+              )
+              return false
+            }
+          }
+        } else if (nodeRef.type.is('VerbCommand')) {
+          if (shouldDecorate(state, nodeRef)) {
+            // \verb content (text only)
+            const contentNode = nodeRef.node.getChild('VerbContent')
+
+            if (contentNode) {
+              if (contentNode.to - contentNode.from > 2) {
+                decorations.push(
+                  Decoration.replace({}).range(
+                    nodeRef.from,
+                    contentNode.from + 1
+                  )
+                )
+
+                decorations.push(
+                  Decoration.replace({}).range(nodeRef.to - 1, nodeRef.to)
+                )
+              }
+            }
+          }
+
+          return false // no markup in verbatim content
+        } else if (nodeRef.type.is('Cite')) {
+          // \cite command with a bibkey argument
+          if (shouldDecorate(state, nodeRef)) {
+            const argumentNode = nodeRef.node
+              .getChild('BibKeyArgument')
+              ?.getChild('ShortTextArgument')
+
+            decorations.push(
+              ...decorateArgumentBraces(
+                new IconBraceWidget('📚'),
+                argumentNode,
+                nodeRef.from
+              )
+            )
+          }
+
+          return false // no markup in cite content
+        } else if (nodeRef.type.is('Ref')) {
+          // \ref command with a ref label argument
+          if (shouldDecorate(state, nodeRef)) {
+            const argumentNode = nodeRef.node
+              .getChild('RefArgument')
+              ?.getChild('ShortTextArgument')
+
+            decorations.push(
+              ...decorateArgumentBraces(
+                new IconBraceWidget('🏷'),
+                argumentNode,
+                nodeRef.from
+              )
+            )
+          }
+
+          return false // no markup in ref content
+        } else if (nodeRef.type.is('Label')) {
+          // \label definition
+          if (shouldDecorate(state, nodeRef)) {
+            const argumentNode = nodeRef.node
+              .getChild('LabelArgument')
+              ?.getChild('ShortTextArgument')
+
+            decorations.push(
+              ...decorateArgumentBraces(
+                new IconBraceWidget('🏷'),
+                argumentNode,
+                nodeRef.from
+              )
+            )
+          }
+
+          return false // no markup in label content
+        } else if (nodeRef.type.is('Include')) {
+          // \include (a file path)
+          if (shouldDecorate(state, nodeRef)) {
+            const argumentNode = nodeRef.node
+              .getChild('IncludeArgument')
+              ?.getChild('FilePathArgument')
+            decorations.push(
+              ...decorateArgumentBraces(
+                new IconBraceWidget('🔗'),
+                argumentNode,
+                nodeRef.from
+              )
+            )
+          }
+
+          return false // no markup in include content
+        } else if (nodeRef.type.is('Input')) {
+          // \input (a file path)
+          // TODO: Ensure this works with BareFilePathArgument
+          if (shouldDecorate(state, nodeRef)) {
+            const contentNode = nodeRef.node.getChild('InputArgument')
+
+            if (contentNode) {
+              if (contentNode.to - contentNode.from > 2) {
+                decorations.push(
+                  Decoration.replace({
+                    widget: new IconBraceWidget('🔗'),
+                  }).range(nodeRef.from, contentNode.from + 1)
+                )
+
+                decorations.push(
+                  Decoration.replace({
+                    widget: new BraceWidget(),
+                  }).range(nodeRef.to - 1, nodeRef.to)
+                )
+              }
+            }
+          }
+
+          return false // no markup in input content
+        } else if (nodeRef.type.is('Math')) {
+          // math equations
+
+          const ancestorNode =
+            ancestorNodeOfType(state, nodeRef.from, '$MathContainer') ||
+            ancestorNodeOfType(state, nodeRef.from, 'EquationEnvironment') ||
+            // NOTE: EquationArrayEnvironment can be nested inside EquationEnvironment
+            ancestorNodeOfType(state, nodeRef.from, 'EquationArrayEnvironment')
+
+          if (
+            ancestorNode &&
+            (ancestorNode.type.is('$Environment')
+              ? shouldDecorateFromLineEdges(state, ancestorNode)
+              : shouldDecorate(state, ancestorNode))
+          ) {
+            // the content of the Math element, without braces
+            const innerContent = state.doc
+              .sliceString(nodeRef.from, nodeRef.to)
+              .trim()
+
+            // only replace when there's content inside the braces
+            if (innerContent.length) {
+              let content = innerContent
+              let displayMode = false
+
+              if (ancestorNode.type.is('$Environment')) {
+                const environmentName = getEnvironmentName(ancestorNode, state)
+                if (environmentName) {
+                  // use the outer content of environments that MathJax supports
+                  // https://docs.mathjax.org/en/latest/input/tex/macros/index.html#environments
+                  if (
+                    environmentName !== 'math' &&
+                    environmentName !== 'displaymath'
+                  ) {
+                    content = state.doc
+                      .sliceString(ancestorNode.from, ancestorNode.to)
+                      .trim()
+                  }
+
+                  if (environmentName !== 'math') {
+                    displayMode = true
+                  }
+                }
+              } else {
+                if (
+                  ancestorNode.type.is('BracketMath') ||
+                  Boolean(ancestorNode.getChild('DisplayMath'))
+                ) {
+                  displayMode = true
+                }
+              }
+
+              decorations.push(
+                Decoration.replace({
+                  widget: new MathWidget(content, displayMode),
+                  block: displayMode,
+                }).range(ancestorNode.from, ancestorNode.to)
+              )
+              return false
+            }
+          }
+        } else if (nodeRef.type.is('HrefCommand')) {
+          // a hyperlink with URL and content arguments
+          if (shouldDecorate(state, nodeRef)) {
+            const urlArgument = nodeRef.node.getChild('UrlArgument')
+            const textArgument = nodeRef.node.getChild('ShortTextArgument')
+
+            if (urlArgument) {
+              decorations.push(
+                ...decorateArgumentBraces(
+                  new BraceWidget(),
+                  textArgument,
+                  nodeRef.from
+                )
+              )
+            }
+          }
+        } else if (nodeRef.type.is('Caption')) {
+          if (shouldDecorate(state, nodeRef)) {
+            // a caption
+            const argumentNode = nodeRef.node.getChild('TextArgument')
+            decorations.push(
+              ...decorateArgumentBraces(
+                new BraceWidget(),
+                argumentNode,
+                nodeRef.from
+              )
+            )
+          }
+        } else if (nodeRef.type.is('IncludeGraphics')) {
+          // \includegraphics with a file path argument
+          if (shouldDecorate(state, nodeRef)) {
+            const filePathArgument = nodeRef.node
+              .getChild('IncludeGraphicsArgument')
+              ?.getChild('FilePathArgument')
+              ?.getChild('LiteralArgContent')
+
+            if (filePathArgument) {
+              const filePath = state.doc.sliceString(
+                filePathArgument.from,
+                filePathArgument.to
+              )
+
+              if (filePath) {
+                const environmentNode = ancestorNodeOfType(
+                  state,
+                  nodeRef.from,
+                  'FigureEnvironment'
+                )
+                const centered = Boolean(
+                  environmentNode &&
+                    centeringNodeForEnvironment(environmentNode)
+                )
+
+                const line = state.doc.lineAt(nodeRef.from)
+
+                const lineContainsOnlyNode =
+                  line.text.trim().length === nodeRef.to - nodeRef.from
+
+                if (lineContainsOnlyNode) {
+                  decorations.push(
+                    Decoration.replace({
+                      widget: new GraphicsWidget(
+                        filePath,
+                        getPreviewByPath,
+                        centered
+                      ),
+                      block: true,
+                    }).range(line.from, line.to)
+                  )
+                } else {
+                  decorations.push(
+                    Decoration.replace({
+                      widget: new InlineGraphicsWidget(
+                        filePath,
+                        getPreviewByPath,
+                        centered
+                      ),
+                    }).range(nodeRef.from, nodeRef.to)
+                  )
+                }
+              }
+
+              return false
+            }
+          }
+        } else if (nodeRef.type.is('Item')) {
+          // only decorate \item inside a list
+          if (currentListEnvironment) {
+            currentOrdinal++
+            const line = state.doc.lineAt(nodeRef.from)
+            const onlySpaceBeforeNode = /^\s*$/.test(
+              state.sliceDoc(line.from, nodeRef.from)
+            )
+            const from = onlySpaceBeforeNode ? line.from : nodeRef.from
+            decorations.push(
+              Decoration.replace({
+                widget: new ItemWidget(
+                  currentListEnvironment || 'document',
+                  currentOrdinal,
+                  listDepth
+                ),
+              }).range(from, nodeRef.to)
+            )
+            return false
+          }
+        } else if (nodeRef.type.is('UnknownCommand')) {
+          // a command that's not defined separately by the grammar
+          const commandNode = nodeRef.node
+          const commandNameNode = commandNode.getChild('$CtrlSeq')
+          const textArgumentNode = commandNode.getChild('TextArgument')
+
+          if (commandNameNode) {
+            const commandName = state.doc
+              .sliceString(commandNameNode.from, commandNameNode.to)
+              .trim()
+
+            if (commandName.length > 0) {
+              if (
+                // markup that can be toggled using toolbar buttons/keyboard shortcuts
+                ['\\textbf', '\\textit', '\\underline'].includes(commandName)
+              ) {
+                const argumentText = textArgumentNode?.getChild('LongArg')
+                const shouldShowBraces =
+                  !shouldDecorate(state, nodeRef) ||
+                  argumentText?.from === argumentText?.to
+                decorations.push(
+                  ...decorateArgumentBraces(
+                    new BraceWidget(shouldShowBraces ? '{' : ''),
+                    textArgumentNode,
+                    nodeRef.from,
+                    true,
+                    new BraceWidget(shouldShowBraces ? '}' : '')
+                  )
+                )
+              } else if (
+                // markup that can't be toggled using toolbar buttons/keyboard shortcuts
+                ['\\textsc', '\\texttt', '\\sout', '\\emph'].includes(
+                  commandName
+                )
+              ) {
+                if (shouldDecorate(state, nodeRef)) {
+                  decorations.push(
+                    ...decorateArgumentBraces(
+                      new BraceWidget(),
+                      textArgumentNode,
+                      nodeRef.from
+                    )
+                  )
+                }
+              } else if (commandName === '\\url') {
+                if (shouldDecorate(state, nodeRef)) {
+                  // command name and opening brace
+                  decorations.push(
+                    ...decorateArgumentBraces(
+                      new BraceWidget(),
+                      textArgumentNode,
+                      nodeRef.from
+                    )
+                  )
+                  return false
+                }
+              } else if (commandName === '\\LaTeX') {
+                if (shouldDecorate(state, nodeRef)) {
+                  decorations.push(
+                    Decoration.replace({
+                      widget: new LaTeXWidget(),
+                    }).range(nodeRef.from, nodeRef.to)
+                  )
+                  return false
+                }
+              } else if (commandName === '\\TeX') {
+                if (shouldDecorate(state, nodeRef)) {
+                  decorations.push(
+                    Decoration.replace({
+                      widget: new TeXWidget(),
+                    }).range(nodeRef.from, nodeRef.to)
+                  )
+                  return false
+                }
+              } else if (commandName === '\\maketitle') {
+                if (shouldDecorate(state, nodeRef)) {
+                  const line = state.doc.lineAt(nodeRef.from)
+                  const from = extendBackwardsOverEmptyLines(state.doc, line)
+                  const to = extendForwardsOverEmptyLines(state.doc, line)
+
+                  if (shouldDecorate(state, { from, to })) {
+                    decorations.push(
+                      Decoration.replace({
+                        widget: new MakeTitleWidget(preamble),
+                        block: true,
+                      }).range(from, to)
+                    )
+                  }
+
+                  return false
+                }
+              } else if (hasCharacterSubstitution(commandName)) {
+                if (shouldDecorate(state, nodeRef)) {
+                  const replacement = createCharacterCommand(commandName)
+                  if (replacement) {
+                    decorations.push(
+                      Decoration.replace({
+                        widget: replacement,
+                      }).range(nodeRef.from, nodeRef.to)
+                    )
+                    return false
+                  }
+                }
+              }
+            }
+          }
+        }
+      },
+    })
+
+    return Decoration.set(decorations, true)
+  }
+
+  let previousTree: Tree
+
+  return [
+    StateField.define<{
+      mousedown: boolean
+      decorations: DecorationSet
+    }>({
+      create(state) {
+        previousTree = syntaxTree(state)
+
+        return {
+          mousedown: false,
+          decorations: createDecorations(state, previousTree),
+        }
+      },
+      update(value, tr) {
+        for (const effect of tr.effects) {
+          // store the "mousedown" value when it changes
+          if (effect.is(mouseDownEffect)) {
+            value = {
+              mousedown: effect.value,
+              decorations: value.decorations, // unchanged
+            }
+          }
+        }
+
+        const tree = syntaxTree(tr.state)
+        if (
+          tree.type === previousTree.type &&
+          tree.length < tr.state.doc.length
+        ) {
+          // still parsing
+          value = {
+            mousedown: value.mousedown, // unchanged
+            decorations: value.decorations.map(tr.changes),
+          }
+        } else if (
+          // only update the decorations when the mouse is not making a selection
+          !value.mousedown &&
+          (tree !== previousTree || tr.selection || hasMouseDownEffect(tr))
+        ) {
+          // tree changed
+          previousTree = tree
+          // TODO: update the existing decorations for the changed range(s)?
+          value = {
+            mousedown: value.mousedown, // unchanged
+            decorations: createDecorations(tr.state, tree),
+          }
+        }
+
+        return value
+      },
+      provide(field) {
+        return [
+          EditorView.decorations.from(field, field => field.decorations),
+          EditorView.atomicRanges.from(field, value => () => value.decorations),
+        ]
+      },
+    }),
+  ]
+}

+ 70 - 0
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
+  }
+}

+ 180 - 0
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<Decoration>[] = []
+
+      for (const { from, to } of view.visibleRanges) {
+        tree?.iterate({
+          from,
+          to,
+          enter(nodeRef) {
+            if (
+              nodeRef.type.is('KnownCommand') ||
+              nodeRef.type.is('UnknownCommand')
+            ) {
+              // decorate commands with a class, for optional styling
+              const ctrlSeq =
+                nodeRef.node.getChild('$CtrlSeq') ??
+                nodeRef.node.firstChild?.getChild('$CtrlSeq')
+
+              if (ctrlSeq) {
+                const text = state.doc.sliceString(ctrlSeq.from + 1, ctrlSeq.to)
+
+                // a special case for "label" as the whole command needs a space afterwards
+                if (text === 'label') {
+                  // decorate the whole command
+                  const from = nodeRef.from
+                  const to = nodeRef.to
+                  if (to > from) {
+                    decorations.push(
+                      Decoration.mark({
+                        class: `ol-cm-${text}`,
+                        inclusive: true,
+                      }).range(from, to)
+                    )
+                  }
+                } else {
+                  // decorate the command content
+                  const from = ctrlSeq.to + 1
+                  const to = nodeRef.to - 1
+                  if (to > from) {
+                    decorations.push(
+                      Decoration.mark({
+                        class: `ol-cm-command-${text}`,
+                        inclusive: true,
+                      }).range(from, to)
+                    )
+                  }
+                }
+              }
+            } else if (nodeRef.type.is('SectioningCommand')) {
+              // decorate section headings with a class, for styling
+              const ctrlSeq = nodeRef.node.getChild('$CtrlSeq')
+              if (ctrlSeq) {
+                const text = state.doc.sliceString(ctrlSeq.from + 1, ctrlSeq.to)
+
+                decorations.push(
+                  Decoration.mark({
+                    class: `ol-cm-heading ol-cm-command-${text}`,
+                  }).range(nodeRef.from, nodeRef.to)
+                )
+              }
+            } else if (nodeRef.type.is('Caption')) {
+              // decorate caption lines with a class, for styling
+              const argument = nodeRef.node.getChild('TextArgument')
+
+              if (argument) {
+                const lines = {
+                  start: state.doc.lineAt(nodeRef.from),
+                  end: state.doc.lineAt(nodeRef.to),
+                }
+
+                for (
+                  let lineNumber = lines.start.number;
+                  lineNumber <= lines.end.number;
+                  lineNumber++
+                ) {
+                  const line = state.doc.line(lineNumber)
+                  decorations.push(
+                    Decoration.line({
+                      class: 'ol-cm-caption-line',
+                    }).range(line.from)
+                  )
+                }
+              }
+            } else if (nodeRef.type.is('$Environment')) {
+              const environmentName = getEnvironmentName(nodeRef.node, state)
+
+              switch (environmentName) {
+                case 'abstract':
+                case 'figure':
+                case 'table':
+                  {
+                    const centered = Boolean(
+                      centeringNodeForEnvironment(nodeRef)
+                    )
+
+                    const lines = {
+                      start: state.doc.lineAt(nodeRef.from),
+                      end: state.doc.lineAt(nodeRef.to),
+                    }
+
+                    for (
+                      let lineNumber = lines.start.number;
+                      lineNumber <= lines.end.number;
+                      lineNumber++
+                    ) {
+                      const line = state.doc.line(lineNumber)
+
+                      const classNames = [
+                        `ol-cm-environment-${environmentName}`,
+                        'ol-cm-environment-line',
+                      ]
+
+                      if (centered) {
+                        classNames.push('ol-cm-environment-centered')
+                      }
+
+                      decorations.push(
+                        Decoration.line({
+                          class: classNames.join(' '),
+                        }).range(line.from)
+                      )
+                    }
+                  }
+                  break
+              }
+            }
+          },
+        })
+      }
+
+      return Decoration.set(decorations, true)
+    }
+
+    let previousTree = syntaxTree(view.state)
+
+    return {
+      decorations: createDecorations(view.state, previousTree),
+      update(update: ViewUpdate) {
+        const tree = syntaxTree(update.state)
+
+        // still parsing
+        if (
+          tree.type === previousTree.type &&
+          tree.length < update.view.viewport.to
+        ) {
+          this.decorations = this.decorations.map(update.changes)
+        } else if (tree !== previousTree || update.viewportChanged) {
+          // parsed or resized
+          previousTree = tree
+          // TODO: update the existing decorations for the changed range(s)?
+          this.decorations = createDecorations(update.state, tree)
+        }
+      },
+    }
+  },
+  {
+    decorations(value) {
+      return value.decorations
+    },
+  }
+)

+ 75 - 0
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<boolean>()
+export const hasMouseDownEffect = hasEffect(mouseDownEffect)
+export const updateHasMouseDownEffect = updateHasEffect(mouseDownEffect)
+
+export const mouseDownListener = EditorView.domEventHandlers({
+  mousedown: (event, view) => {
+    // not wrapped in a timeout, so update listeners know that the mouse is down before they process the selection
+    view.dispatch({
+      effects: mouseDownEffect.of(true),
+    })
+  },
+  mouseup: (event, view) => {
+    // wrap in a timeout, so update listeners receive this effect after the new selection has finished being handled
+    window.setTimeout(() => {
+      view.dispatch({
+        effects: mouseDownEffect.of(false),
+      })
+    })
+  },
+})

+ 35 - 0
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)
+        }
+      }
+    },
+  }
+})

+ 10 - 0
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,
+  })
+}

+ 80 - 0
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
+}

+ 141 - 0
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'),
+    },
+  ])
+)

+ 339 - 0
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',
+  },
+})

+ 49 - 0
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'
+  }
+}

+ 24 - 0
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
+  }
+}

+ 160 - 0
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)
+}

+ 17 - 0
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
+  }
+}

+ 27 - 0
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
+  }
+}

+ 13 - 0
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
+  }
+}

+ 39 - 0
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'
+  }
+}

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