scroll-position.ts 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
  1. import { BlockInfo, EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view'
  2. import { throttle } from 'lodash'
  3. import customLocalStorage from '../../../infrastructure/local-storage'
  4. import {
  5. EditorSelection,
  6. StateEffect,
  7. Text,
  8. TransactionSpec,
  9. } from '@codemirror/state'
  10. import { sourceOnly, toggleVisualEffect } from './visual/visual'
  11. import { debugConsole } from '@/utils/debugging'
  12. const buildStorageKey = (docId: string) => `doc.position.${docId}`
  13. type LineInfo = {
  14. first: BlockInfo
  15. middle: BlockInfo
  16. }
  17. /**
  18. * A custom extension that:
  19. * a) stores the scroll position (first visible line number) in localStorage when the view is destroyed,
  20. * or the window is closed, or when switching between Source and Rich Text, and
  21. * b) dispatches the scroll position (middle visible line) when it changes, for use in the outline.
  22. */
  23. export const scrollPosition = (
  24. {
  25. currentDoc: { doc_id: docId },
  26. }: {
  27. currentDoc: { doc_id: string }
  28. },
  29. { visual }: { visual: boolean }
  30. ) => {
  31. // store lineInfo for use on unload, when the DOM has already been unmounted
  32. let lineInfo: LineInfo
  33. const scrollHandler = throttle(
  34. (event, view) => {
  35. // exclude a scroll event with no target, which happens when switching docs
  36. if (event.target === view.scrollDOM) {
  37. lineInfo = calculateLineInfo(view)
  38. dispatchScrollPosition(lineInfo, view)
  39. }
  40. },
  41. // long enough to capture intent, but short enough that the selected heading in the outline appears current
  42. 120,
  43. { trailing: true }
  44. )
  45. return [
  46. // store/dispatch scroll position
  47. ViewPlugin.define(
  48. view => {
  49. const unloadListener = () => {
  50. if (lineInfo) {
  51. storeScrollPosition(lineInfo, view, docId)
  52. }
  53. }
  54. window.addEventListener('unload', unloadListener)
  55. return {
  56. update: (update: ViewUpdate) => {
  57. for (const tr of update.transactions) {
  58. for (const effect of tr.effects) {
  59. if (effect.is(toggleVisualEffect)) {
  60. // store the scroll position when switching between source and rich text
  61. if (lineInfo) {
  62. storeScrollPosition(lineInfo, view, docId)
  63. }
  64. } else if (effect.is(restoreScrollPositionEffect)) {
  65. // restore the scroll position
  66. window.setTimeout(() => {
  67. view.dispatch(scrollStoredLineToTop(tr.state.doc, docId))
  68. window.dispatchEvent(
  69. new Event('editor:scroll-position-restored')
  70. )
  71. })
  72. }
  73. }
  74. }
  75. },
  76. destroy: () => {
  77. scrollHandler.cancel()
  78. window.removeEventListener('unload', unloadListener)
  79. unloadListener()
  80. },
  81. }
  82. },
  83. {
  84. eventHandlers: {
  85. scroll: scrollHandler,
  86. },
  87. }
  88. ),
  89. // restore the scroll position when switching to source mode
  90. sourceOnly(
  91. visual,
  92. EditorView.updateListener.of(update => {
  93. for (const tr of update.transactions) {
  94. for (const effect of tr.effects) {
  95. if (effect.is(toggleVisualEffect)) {
  96. if (!effect.value) {
  97. // switching to the source editor
  98. window.setTimeout(() => {
  99. update.view.dispatch(restoreScrollPosition())
  100. update.view.focus()
  101. })
  102. }
  103. }
  104. }
  105. }
  106. })
  107. ),
  108. ]
  109. }
  110. const restoreScrollPositionEffect = StateEffect.define()
  111. export const restoreScrollPosition = () => {
  112. return {
  113. effects: restoreScrollPositionEffect.of(null),
  114. }
  115. }
  116. const calculateLineInfo = (view: EditorView) => {
  117. // the top of the scrollDOM element relative to the top of the document
  118. const { top, height } = view.scrollDOM.getBoundingClientRect()
  119. const distanceFromDocumentTop = top - view.documentTop
  120. return {
  121. first: view.lineBlockAtHeight(distanceFromDocumentTop),
  122. // top plus half the height of the scrollDOM element
  123. middle: view.lineBlockAtHeight(distanceFromDocumentTop + height / 2),
  124. }
  125. }
  126. // dispatch the middle visible line number (for the outline)
  127. const dispatchScrollPosition = (lineInfo: LineInfo, view: EditorView) => {
  128. const middleVisibleLine = view.state.doc.lineAt(lineInfo.middle.from).number
  129. window.dispatchEvent(
  130. new CustomEvent('scroll:editor:update', {
  131. detail: middleVisibleLine,
  132. })
  133. )
  134. }
  135. // store the scroll position (first visible line number, for restoring on load)
  136. const storeScrollPosition = (
  137. lineInfo: LineInfo,
  138. view: EditorView,
  139. docId: string
  140. ) => {
  141. const key = buildStorageKey(docId)
  142. const data = customLocalStorage.getItem(key)
  143. const pos = Math.min(lineInfo.first.from, view.state.doc.length)
  144. const firstVisibleLine = view.state.doc.lineAt(pos).number
  145. customLocalStorage.setItem(key, { ...data, firstVisibleLine })
  146. }
  147. // restore the scroll position using the stored first visible line number
  148. const scrollStoredLineToTop = (doc: Text, docId: string): TransactionSpec => {
  149. try {
  150. const key = buildStorageKey(docId)
  151. const data = customLocalStorage.getItem(key)
  152. // restore the scroll position to its original position, or the last line of the document
  153. const firstVisibleLine = Math.min(data?.firstVisibleLine ?? 1, doc.lines)
  154. const line = doc.line(firstVisibleLine)
  155. const selectionRange = EditorSelection.cursor(line.from)
  156. return {
  157. effects: EditorView.scrollIntoView(selectionRange, {
  158. y: 'start',
  159. yMargin: 0,
  160. }),
  161. }
  162. } catch (e) {
  163. // ignore invalid line number
  164. debugConsole.error(e)
  165. return {}
  166. }
  167. }