vertical-overflow.ts 5.9 KB

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