vertical-overflow.ts 6.7 KB

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