cursor-position.ts 4.5 KB

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