scroll-position.ts 4.9 KB

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