visual.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  1. import {
  2. Compartment,
  3. EditorState,
  4. Extension,
  5. StateEffect,
  6. StateField,
  7. TransactionSpec,
  8. } from '@codemirror/state'
  9. import { visualHighlightStyle, visualTheme } from './visual-theme'
  10. import { atomicDecorations } from './atomic-decorations'
  11. import { markDecorations } from './mark-decorations'
  12. import importOverleafModules from '../../../../../macros/import-overleaf-module.macro'
  13. import { EditorView, ViewPlugin } from '@codemirror/view'
  14. import { visualKeymap } from './visual-keymap'
  15. import { mousedown, mouseDownEffect } from './selection'
  16. import { forceParsing, syntaxTree } from '@codemirror/language'
  17. import { hasLanguageLoadedEffect } from '../language'
  18. import { restoreScrollPosition } from '../scroll-position'
  19. import { listItemMarker } from './list-item-marker'
  20. import { pasteHtml } from './paste-html'
  21. import { commandTooltip } from '../command-tooltip'
  22. import { tableGeneratorTheme } from './table-generator'
  23. import { debugConsole } from '@/utils/debugging'
  24. import { PreviewPath } from '../../../../../../types/preview-path'
  25. import { getFileExtension } from '../../utils/file'
  26. type Options = {
  27. visual: boolean
  28. previewByPath: (path: string) => PreviewPath | null
  29. }
  30. // Module-provided visual editors registered via the
  31. // `sourceEditorVisualExtensions` hook. Module exposes `getExtensions(ext)`,
  32. // returning the visual-mode extensions for that file extension. A module match takes precedence
  33. // over the LaTeX fallback
  34. const moduleVisualExtensionProviders: Array<{
  35. import: { getExtensions: (ext: string) => Extension }
  36. }> = importOverleafModules('sourceEditorVisualExtensions')
  37. const visualConf = new Compartment()
  38. export const toggleVisualEffect = StateEffect.define<boolean>()
  39. const visualState = StateField.define<boolean>({
  40. create() {
  41. return false
  42. },
  43. update(value, tr) {
  44. for (const effect of tr.effects) {
  45. if (effect.is(toggleVisualEffect)) {
  46. return effect.value
  47. }
  48. }
  49. return value
  50. },
  51. })
  52. const configureVisualExtensions = (options: Options) =>
  53. options.visual ? sharedVisualExtensions() : []
  54. export const visual = (docName: string, options: Options): Extension => {
  55. const extensions =
  56. visualModuleExtensions(docName) ?? latexVisualExtensions(options)
  57. return [
  58. visualState.init(() => options.visual),
  59. visualConf.of(configureVisualExtensions(options)),
  60. visualOnly(options.visual, extensions),
  61. ]
  62. }
  63. export const isVisual = (view: EditorView) => {
  64. return view.state.field(visualState, false) || false
  65. }
  66. export const setVisual = (options: Options): TransactionSpec => {
  67. return {
  68. effects: [
  69. toggleVisualEffect.of(options.visual),
  70. visualConf.reconfigure(configureVisualExtensions(options)),
  71. ],
  72. }
  73. }
  74. // Loads `extension` only while the editor is in a particular mode, reacting to
  75. // mode switches via `toggleVisualEffect`. `activeWhenVisual` selects which mode:
  76. // `true` for visual-only, `false` for source-only.
  77. const modeOnly = (
  78. activeWhenVisual: boolean,
  79. visual: boolean,
  80. extension: Extension
  81. ) => {
  82. const conf = new Compartment()
  83. const configure = (visual: boolean) =>
  84. visual === activeWhenVisual ? extension : []
  85. return [
  86. conf.of(configure(visual)),
  87. // Respond to switching editor modes
  88. EditorState.transactionExtender.of(tr => {
  89. for (const effect of tr.effects) {
  90. if (effect.is(toggleVisualEffect)) {
  91. return {
  92. effects: conf.reconfigure(configure(effect.value)),
  93. }
  94. }
  95. }
  96. return null
  97. }),
  98. ]
  99. }
  100. export const visualOnly = (visual: boolean, extension: Extension) =>
  101. modeOnly(true, visual, extension)
  102. export const sourceOnly = (visual: boolean, extension: Extension) =>
  103. modeOnly(false, visual, extension)
  104. const parsedAttributesConf = new Compartment()
  105. /**
  106. * A view plugin which shows the editor content, makes it focusable,
  107. * and restores the scroll position, once the initial decorations have been applied.
  108. */
  109. export const showContentWhenParsed = [
  110. parsedAttributesConf.of([EditorView.editable.of(false)]),
  111. ViewPlugin.define(view => {
  112. const showContent = () => {
  113. view.dispatch(
  114. {
  115. effects: parsedAttributesConf.reconfigure([
  116. EditorView.editorAttributes.of({
  117. class: 'ol-cm-parsed',
  118. }),
  119. EditorView.editable.of(true),
  120. ]),
  121. },
  122. restoreScrollPosition()
  123. )
  124. view.focus()
  125. }
  126. // already parsed
  127. if (syntaxTree(view.state).length === view.state.doc.length) {
  128. window.setTimeout(showContent)
  129. return {}
  130. }
  131. // as a fallback, make sure the content is visible after 5s
  132. const fallbackTimer = window.setTimeout(showContent, 5000)
  133. let languageLoaded = false
  134. return {
  135. update(update) {
  136. // wait for the language to load before telling the parser to run
  137. if (!languageLoaded && hasLanguageLoadedEffect(update)) {
  138. languageLoaded = true
  139. // in a timeout, as this is already in a dispatch cycle
  140. window.setTimeout(() => {
  141. // run asynchronously
  142. new Promise(() => {
  143. // tell the parser to run until the end of the document
  144. forceParsing(view, view.state.doc.length, Infinity)
  145. // clear the fallback timeout
  146. window.clearTimeout(fallbackTimer)
  147. // show the content, in a timeout so the decorations can build first
  148. window.setTimeout(showContent)
  149. }).catch(debugConsole.error)
  150. })
  151. }
  152. },
  153. }
  154. }),
  155. ]
  156. /**
  157. * A transaction extender which scrolls mouse clicks into view, in case decorations have moved the cursor out of view.
  158. */
  159. const scrollJumpAdjuster = EditorState.transactionExtender.of(tr => {
  160. // Attach a "scrollIntoView" effect on all mouse selections to adjust for
  161. // any jumps that may occur when hiding/showing decorations.
  162. if (!tr.scrollIntoView) {
  163. for (const effect of tr.effects) {
  164. if (effect.is(mouseDownEffect) && effect.value === false) {
  165. return {
  166. effects: EditorView.scrollIntoView(tr.newSelection.main.head),
  167. }
  168. }
  169. }
  170. }
  171. return {}
  172. })
  173. const sharedVisualExtensions = () => [
  174. visualTheme,
  175. visualHighlightStyle,
  176. mousedown,
  177. scrollJumpAdjuster,
  178. showContentWhenParsed,
  179. EditorView.contentAttributes.of({ 'aria-label': 'Visual Editor editing' }),
  180. ]
  181. const latexVisualExtensions = (options: Options): Extension => [
  182. listItemMarker,
  183. atomicDecorations(options),
  184. markDecorations, // NOTE: must be after atomicDecorations, so that mark decorations wrap inline widgets
  185. visualKeymap,
  186. commandTooltip,
  187. pasteHtml,
  188. tableGeneratorTheme,
  189. ]
  190. // Returns the visual-mode extensions provided by a module for the active document
  191. const visualModuleExtensions = (docName: string): Extension | null => {
  192. const fileExt = getFileExtension(docName)
  193. if (!fileExt) {
  194. return null
  195. }
  196. for (const provider of moduleVisualExtensionProviders) {
  197. const result = provider.import.getExtensions(fileExt)
  198. const extensions = Array.isArray(result) ? result : [result]
  199. if (extensions.length > 0) {
  200. return extensions
  201. }
  202. }
  203. return null
  204. }