vertical-overflow.ts 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. import {
  2. Extension,
  3. StateEffect,
  4. StateField,
  5. TransactionSpec,
  6. } from '@codemirror/state'
  7. import { EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view'
  8. export function verticalOverflow(): Extension {
  9. return [
  10. overflowPaddingState,
  11. minimumBottomPaddingState,
  12. contentAttributes,
  13. bottomPaddingPlugin,
  14. topPaddingPlugin,
  15. ]
  16. }
  17. type VerticalPadding = { top: number; bottom: number }
  18. const setOverflowPaddingEffect = StateEffect.define<VerticalPadding>()
  19. // Store extra padding needed at the top and bottom of the editor to match the height of the review panel.
  20. // The padding needs to allow enough space for tracked changes/comments at the top and/or bottom of the review panel.
  21. const overflowPaddingState = StateField.define<VerticalPadding>({
  22. create() {
  23. return { top: 0, bottom: 0 }
  24. },
  25. update(value, tr) {
  26. for (const effect of tr.effects) {
  27. if (effect.is(setOverflowPaddingEffect)) {
  28. const { top, bottom } = effect.value
  29. // only update the state when the values actually change
  30. if (top !== value.top || bottom !== value.bottom) {
  31. value = { top, bottom }
  32. }
  33. }
  34. }
  35. return value
  36. },
  37. })
  38. const setMinimumBottomPaddingEffect = StateEffect.define<number>()
  39. // Store extra padding needed at the bottom of the editor content.
  40. // The content must have a space at the bottom equivalent to the
  41. // height of the editor content minus one line, so that the last
  42. // line in the document can be scrolled to the top of the editor.
  43. const minimumBottomPaddingState = StateField.define<number>({
  44. create() {
  45. return 0
  46. },
  47. update(value, tr) {
  48. for (const effect of tr.effects) {
  49. if (effect.is(setMinimumBottomPaddingEffect)) {
  50. value = effect.value
  51. }
  52. }
  53. return value
  54. },
  55. })
  56. // Set scrollTop to counteract changes to the top padding.
  57. // This view plugin is needed because the overflowPaddingState StateField doesn't have access to the view.
  58. const topPaddingPlugin = ViewPlugin.define(view => {
  59. let previousTop = 0
  60. return {
  61. update: update => {
  62. const { top } = update.state.field(overflowPaddingState)
  63. if (top !== previousTop) {
  64. const diff = top - previousTop
  65. if (diff < 0) {
  66. // padding is decreasing, scroll now
  67. view.scrollDOM.scrollTop += diff
  68. } else {
  69. // padding is increasing, scroll after it has been applied
  70. view.requestMeasure({
  71. key: 'vertical-overflow-scroll-top',
  72. read() {
  73. // do nothing
  74. },
  75. write(measure, view) {
  76. view.scrollDOM.scrollTop += diff
  77. },
  78. })
  79. }
  80. previousTop = top
  81. }
  82. },
  83. }
  84. })
  85. /**
  86. * When the editor geometry changes, recalculate the amount of padding needed at
  87. * the end of the doc: (the scrollDOM height - 1 line height).
  88. * Adapted from the CodeMirror 6 scrollPastEnd extension, licensed under the MIT
  89. * license:
  90. * https://github.com/codemirror/view/blob/main/src/scrollpastend.ts
  91. */
  92. const bottomPaddingPlugin = ViewPlugin.define(view => {
  93. let previousHeight = 0
  94. const measure = {
  95. key: 'vertical-overflow-bottom-padding',
  96. read(view: EditorView) {
  97. return view.scrollDOM.clientHeight - view.defaultLineHeight
  98. },
  99. write(height: number, view: EditorView) {
  100. if (height !== previousHeight) {
  101. // dispatch must be wrapped in a timeout to avoid clashing with the current update
  102. window.setTimeout(() =>
  103. view.dispatch({
  104. effects: setMinimumBottomPaddingEffect.of(height),
  105. })
  106. )
  107. previousHeight = height
  108. }
  109. },
  110. }
  111. view.requestMeasure(measure)
  112. return {
  113. update: update => {
  114. if (update.geometryChanged) {
  115. update.view.requestMeasure(measure)
  116. }
  117. },
  118. }
  119. })
  120. // Set a style attribute on the contentDOM containing the calculated top and bottom padding.
  121. // This value will be concatenated with style values from any other extensions.
  122. const contentAttributes = EditorView.contentAttributes.compute(
  123. [overflowPaddingState, minimumBottomPaddingState],
  124. state => {
  125. const overflowPadding = state.field(overflowPaddingState)
  126. const minimumBottomPadding = state.field(minimumBottomPaddingState)
  127. const bottomPadding = Math.max(minimumBottomPadding, overflowPadding.bottom)
  128. return {
  129. style: `padding-top: ${overflowPadding.top}px; padding-bottom: ${bottomPadding}px;`,
  130. }
  131. }
  132. )
  133. export function setVerticalOverflow(padding: VerticalPadding): TransactionSpec {
  134. return {
  135. effects: [setOverflowPaddingEffect.of(padding)],
  136. }
  137. }
  138. export function updateSetsVerticalOverflow(update: ViewUpdate): boolean {
  139. return update.transactions.some(tr => {
  140. return tr.effects.some(effect => effect.is(setOverflowPaddingEffect))
  141. })
  142. }
  143. export function editorVerticalTopPadding(view: EditorView): number {
  144. return view.state.field(overflowPaddingState).top
  145. }