scroll-position.ts 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  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 { toggleVisualEffect } from './visual/visual'
  11. const buildStorageKey = (docId: string) => `doc.position.${docId}`
  12. type LineInfo = {
  13. first: BlockInfo
  14. middle: BlockInfo
  15. }
  16. export const scrollPosition = ({
  17. currentDoc: { doc_id: docId },
  18. }: {
  19. currentDoc: { doc_id: string }
  20. }) => {
  21. // store lineInfo for use on unload, when the DOM has already been unmounted
  22. let lineInfo: LineInfo
  23. const scrollHandler = throttle(
  24. (event, view) => {
  25. // exclude a scroll event with no target, which happens when switching docs
  26. if (event.target === view.scrollDOM) {
  27. lineInfo = calculateLineInfo(view)
  28. dispatchScrollPosition(lineInfo, view)
  29. }
  30. },
  31. // long enough to capture intent, but short enough that the selected heading in the outline appears current
  32. 120,
  33. { trailing: true }
  34. )
  35. return [
  36. // store/dispatch scroll position
  37. ViewPlugin.define(
  38. view => {
  39. const unloadListener = () => {
  40. if (lineInfo) {
  41. storeScrollPosition(lineInfo, view, docId)
  42. }
  43. }
  44. window.addEventListener('unload', unloadListener)
  45. return {
  46. update: (update: ViewUpdate) => {
  47. for (const tr of update.transactions) {
  48. for (const effect of tr.effects) {
  49. if (effect.is(toggleVisualEffect)) {
  50. // store the scroll position when switching between source and rich text
  51. if (lineInfo) {
  52. storeScrollPosition(lineInfo, view, docId)
  53. }
  54. } else if (effect.is(restoreScrollPositionEffect)) {
  55. // restore the scroll position
  56. window.setTimeout(() => {
  57. view.dispatch(scrollStoredLineToTop(tr.state.doc, docId))
  58. window.dispatchEvent(
  59. new Event('editor:scroll-position-restored')
  60. )
  61. })
  62. }
  63. }
  64. }
  65. },
  66. destroy: () => {
  67. scrollHandler.cancel()
  68. window.removeEventListener('unload', unloadListener)
  69. unloadListener()
  70. },
  71. }
  72. },
  73. {
  74. eventHandlers: {
  75. scroll: scrollHandler,
  76. },
  77. }
  78. ),
  79. ]
  80. }
  81. const restoreScrollPositionEffect = StateEffect.define()
  82. export const restoreScrollPosition = () => {
  83. return {
  84. effects: restoreScrollPositionEffect.of(null),
  85. }
  86. }
  87. const calculateLineInfo = (view: EditorView) => {
  88. // the top of the scrollDOM element relative to the top of the document
  89. const { top, height } = view.scrollDOM.getBoundingClientRect()
  90. const distanceFromDocumentTop = top - view.documentTop
  91. return {
  92. first: view.lineBlockAtHeight(distanceFromDocumentTop),
  93. // top plus half the height of the scrollDOM element
  94. middle: view.lineBlockAtHeight(distanceFromDocumentTop + height / 2),
  95. }
  96. }
  97. // dispatch the middle visible line number (for the outline)
  98. const dispatchScrollPosition = (lineInfo: LineInfo, view: EditorView) => {
  99. const middleVisibleLine = view.state.doc.lineAt(lineInfo.middle.from).number
  100. window.dispatchEvent(
  101. new CustomEvent('scroll:editor:update', {
  102. detail: middleVisibleLine,
  103. })
  104. )
  105. }
  106. // store the scroll position (first visible line number, for restoring on load)
  107. const storeScrollPosition = (
  108. lineInfo: LineInfo,
  109. view: EditorView,
  110. docId: string
  111. ) => {
  112. const key = buildStorageKey(docId)
  113. const data = customLocalStorage.getItem(key)
  114. const firstVisibleLine = view.state.doc.lineAt(lineInfo.first.from).number
  115. customLocalStorage.setItem(key, { ...data, firstVisibleLine })
  116. }
  117. // restore the scroll position using the stored first visible line number
  118. const scrollStoredLineToTop = (doc: Text, docId: string): TransactionSpec => {
  119. try {
  120. const key = buildStorageKey(docId)
  121. const data = customLocalStorage.getItem(key)
  122. // restore the scroll position to its original position, or the last line of the document
  123. const firstVisibleLine = Math.min(data?.firstVisibleLine ?? 1, doc.lines)
  124. const line = doc.line(firstVisibleLine)
  125. const selectionRange = EditorSelection.cursor(line.from)
  126. return {
  127. effects: EditorView.scrollIntoView(selectionRange, {
  128. y: 'start',
  129. yMargin: 0,
  130. }),
  131. }
  132. } catch (e) {
  133. // ignore invalid line number
  134. console.error(e)
  135. return {}
  136. }
  137. }