scroll-position.ts 4.9 KB

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