cursor-position.ts 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
  1. import {
  2. EditorSelection,
  3. EditorState,
  4. SelectionRange,
  5. Text,
  6. TransactionSpec,
  7. } from '@codemirror/state'
  8. import { EditorView, ViewPlugin } from '@codemirror/view'
  9. import { findValidPosition } from '../utils/position'
  10. import customLocalStorage from '../../../infrastructure/local-storage'
  11. import { debugConsole } from '@/utils/debugging'
  12. const buildStorageKey = (docId: string) => `doc.position.${docId}`
  13. /**
  14. * A custom extension that:
  15. * a) stores the cursor position in localStorage when the view is destroyed or the window is closed.
  16. * b) dispatches the cursor position when it changes, for use with “show position in PDF”.
  17. */
  18. export const cursorPosition = ({
  19. currentDoc: { doc_id: docId },
  20. }: {
  21. currentDoc: { doc_id: string }
  22. }) => {
  23. return [
  24. // store cursor position
  25. ViewPlugin.define(view => {
  26. const unloadListener = () => {
  27. storeCursorPosition(view, docId)
  28. }
  29. window.addEventListener('unload', unloadListener)
  30. return {
  31. destroy: () => {
  32. window.removeEventListener('unload', unloadListener)
  33. unloadListener()
  34. },
  35. }
  36. }),
  37. // Asynchronously dispatch cursor position when the selection changes and
  38. // provide a little debouncing. Using requestAnimationFrame postpones it
  39. // until the next CM6 DOM update.
  40. ViewPlugin.define(view => {
  41. let animationFrameRequest: number | null = null
  42. return {
  43. update(update) {
  44. if (update.selectionSet || update.docChanged) {
  45. if (animationFrameRequest) {
  46. window.cancelAnimationFrame(animationFrameRequest)
  47. }
  48. animationFrameRequest = window.requestAnimationFrame(() => {
  49. animationFrameRequest = null
  50. dispatchCursorPosition(update.state)
  51. })
  52. }
  53. },
  54. }
  55. }),
  56. ]
  57. }
  58. // convert the selection head to a row and column
  59. const buildCursorPosition = (state: EditorState) => {
  60. const pos = state.selection.main.head
  61. const line = state.doc.lineAt(pos)
  62. const row = line.number - 1 // 0-indexed
  63. const column = pos - line.from
  64. return { row, column }
  65. }
  66. // dispatch the current cursor position for use with synctex
  67. const dispatchCursorPosition = (state: EditorState) => {
  68. const cursorPosition = buildCursorPosition(state)
  69. window.dispatchEvent(
  70. new CustomEvent('cursor:editor:update', { detail: cursorPosition })
  71. )
  72. }
  73. // store the cursor position for restoring on load
  74. const storeCursorPosition = (view: EditorView, docId: string) => {
  75. const key = buildStorageKey(docId)
  76. const data = customLocalStorage.getItem(key)
  77. const cursorPosition = buildCursorPosition(view.state)
  78. customLocalStorage.setItem(key, { ...data, cursorPosition })
  79. }
  80. // restore the stored cursor position on load
  81. export const restoreCursorPosition = (
  82. doc: Text,
  83. docId: string
  84. ): TransactionSpec => {
  85. try {
  86. const key = buildStorageKey(docId)
  87. const data = customLocalStorage.getItem(key)
  88. const { row = 0, column = 0 } = data?.cursorPosition || {}
  89. // restore the cursor to its original position, or the end of the document if past the end
  90. const { lines } = doc
  91. const lineNumber = row < lines ? row + 1 : lines
  92. const line = doc.line(lineNumber)
  93. const offset = line.from + column
  94. const pos = Math.min(offset || 0, doc.length)
  95. return {
  96. selection: EditorSelection.cursor(pos),
  97. }
  98. } catch (error) {
  99. // ignore invalid cursor position
  100. debugConsole.debug('invalid cursor position', error)
  101. return {}
  102. }
  103. }
  104. const dispatchSelectionAndScroll = (
  105. view: EditorView,
  106. selection: SelectionRange
  107. ) => {
  108. window.setTimeout(() => {
  109. view.dispatch({
  110. selection,
  111. effects: EditorView.scrollIntoView(selection, { y: 'center' }),
  112. })
  113. view.focus()
  114. })
  115. }
  116. export const setCursorLineAndScroll = (
  117. view: EditorView,
  118. lineNumber: number,
  119. columnNumber = 0
  120. ) => {
  121. // TODO: map the position through any changes since the previous compile?
  122. let selectionRange
  123. try {
  124. const pos = findValidPosition(view.state.doc, lineNumber, columnNumber)
  125. selectionRange = EditorSelection.cursor(pos)
  126. } catch (error) {
  127. // ignore invalid cursor position
  128. debugConsole.debug('invalid cursor position', error)
  129. }
  130. if (selectionRange) {
  131. dispatchSelectionAndScroll(view, selectionRange)
  132. }
  133. }
  134. export const setCursorPositionAndScroll = (view: EditorView, pos: number) => {
  135. let selectionRange
  136. try {
  137. pos = Math.min(pos, view.state.doc.length)
  138. selectionRange = EditorSelection.cursor(pos)
  139. } catch (error) {
  140. // ignore invalid cursor position
  141. debugConsole.debug('invalid cursor position', error)
  142. }
  143. if (selectionRange) {
  144. dispatchSelectionAndScroll(view, selectionRange)
  145. }
  146. }