vertical-overflow.ts 5.3 KB

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