cursor-position.ts 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
  1. import {
  2. EditorSelection,
  3. EditorState,
  4. Text,
  5. TransactionSpec,
  6. } from '@codemirror/state'
  7. import { EditorView, ViewPlugin } from '@codemirror/view'
  8. import { findValidPosition } from '../utils/position'
  9. import customLocalStorage from '../../../infrastructure/local-storage'
  10. import { debugConsole } from '@/utils/debugging'
  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(() => {
  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. debugConsole.debug('invalid cursor position', error)
  100. return {}
  101. }
  102. }
  103. const createClampedSelection = (max: number, from: number, to?: number) => {
  104. if (to === undefined) {
  105. return EditorSelection.cursor(Math.min(from, max))
  106. }
  107. return EditorSelection.range(Math.min(from, max), Math.min(to, max))
  108. }
  109. const dispatchSelectionAndScroll = (
  110. view: EditorView,
  111. from: number,
  112. to?: number
  113. ) => {
  114. window.setTimeout(() => {
  115. const selection = createClampedSelection(view.state.doc.length, from, to)
  116. view.dispatch({
  117. selection,
  118. effects: EditorView.scrollIntoView(selection, { y: 'center' }),
  119. })
  120. view.focus()
  121. })
  122. }
  123. export const setCursorLineAndScroll = (
  124. view: EditorView,
  125. lineNumber: number,
  126. columnNumber?: number,
  127. selectText?: string
  128. ) => {
  129. // TODO: map the position through any changes since the previous compile?
  130. const { doc } = view.state
  131. const from = findValidPosition(doc, lineNumber, columnNumber)
  132. if (selectText) {
  133. if (columnNumber === undefined) {
  134. // somewhere on this line
  135. const line = doc.lineAt(from)
  136. const index = line.text.indexOf(selectText)
  137. if (index > -1 && index === line.text.lastIndexOf(selectText)) {
  138. const from = line.from + index
  139. const to = from + selectText.length
  140. dispatchSelectionAndScroll(view, from, to)
  141. return
  142. }
  143. } else {
  144. // at this exact position
  145. const to = from + selectText.length
  146. if (doc.sliceString(from, to) === selectText) {
  147. dispatchSelectionAndScroll(view, from, to)
  148. return
  149. }
  150. }
  151. }
  152. dispatchSelectionAndScroll(view, from)
  153. }
  154. export const setCursorPositionAndScroll = (view: EditorView, pos: number) => {
  155. dispatchSelectionAndScroll(view, pos)
  156. }