vertical-overflow.ts 6.0 KB

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