import { Extension, Facet, StateEffect, StateField, TransactionSpec, } from '@codemirror/state' import { Decoration, EditorView, ViewPlugin, ViewUpdate, WidgetType, } from '@codemirror/view' /** * A custom extension which stores values for padding needed * a) at the top and bottom of the editor, to match the height of the review panel, and * b) at the bottom of the editor content, so the last line of the document can be scrolled to the top of the editor. */ export function verticalOverflow(): Extension { return [ overflowPaddingState, minimumBottomPaddingState, bottomPadding, topPadding, contentAttributes, topPaddingDecoration, bottomPaddingPlugin, topPaddingPlugin, ] } type VerticalPadding = { top: number; bottom: number } const setOverflowPaddingEffect = StateEffect.define() // Store extra padding needed at the top and bottom of the editor to match the height of the review panel. // The padding needs to allow enough space for tracked changes/comments at the top and/or bottom of the review panel. const overflowPaddingState = StateField.define({ create() { return { top: 0, bottom: 0 } }, update(value, tr) { for (const effect of tr.effects) { if (effect.is(setOverflowPaddingEffect)) { const { top, bottom } = effect.value // only update the state when the values actually change if (top !== value.top || bottom !== value.bottom) { value = { top, bottom } } } } return value }, }) const setMinimumBottomPaddingEffect = StateEffect.define() // Store extra padding needed at the bottom of the editor content. // The content must have a space at the bottom equivalent to the // height of the editor content minus one line, so that the last // line in the document can be scrolled to the top of the editor. const minimumBottomPaddingState = StateField.define({ create() { return 0 }, update(value, tr) { for (const effect of tr.effects) { if (effect.is(setMinimumBottomPaddingEffect)) { value = effect.value } } return value }, }) // Set scrollTop to counteract changes to the top padding. // This view plugin is needed because the overflowPaddingState StateField doesn't have access to the view. const topPaddingPlugin = ViewPlugin.define(view => { let previousTop = 0 return { update: update => { const { top } = update.state.field(overflowPaddingState) if (top !== previousTop) { const diff = top - previousTop if (diff < 0) { // padding is decreasing, scroll now view.scrollDOM.scrollTop += diff } else { // padding is increasing, scroll after it has been applied view.requestMeasure({ key: 'vertical-overflow-scroll-top', read() { // do nothing }, write(measure, view) { view.scrollDOM.scrollTop += diff }, }) } previousTop = top } }, } }) /** * When the editor geometry changes, recalculate the amount of padding needed at * the end of the doc: (the scrollDOM height - 1 line height). * Adapted from the CodeMirror 6 scrollPastEnd extension, licensed under the MIT * license: * https://github.com/codemirror/view/blob/main/src/scrollpastend.ts */ const bottomPaddingPlugin = ViewPlugin.define(view => { let previousHeight = 0 const measure = { key: 'vertical-overflow-bottom-padding', read(view: EditorView) { return view.scrollDOM.clientHeight - view.defaultLineHeight }, write(height: number, view: EditorView) { if (height !== previousHeight) { // dispatch must be wrapped in a timeout to avoid clashing with the current update window.setTimeout(() => view.dispatch({ effects: setMinimumBottomPaddingEffect.of(height), }) ) previousHeight = height } }, } view.requestMeasure(measure) return { update: update => { if (update.geometryChanged) { update.view.requestMeasure(measure) } }, } }) const topPaddingFacet = Facet.define({ combine(values) { return Math.max(0, ...values) }, }) const topPadding = topPaddingFacet.from(overflowPaddingState, state => { return state.top }) const bottomPaddingFacet = Facet.define({ combine(values) { return Math.max(0, ...values) }, }) const bottomPadding = bottomPaddingFacet.computeN( [overflowPaddingState, minimumBottomPaddingState], state => { return [ state.field(minimumBottomPaddingState), state.field(overflowPaddingState).bottom, ] } ) // Set a style attribute on the contentDOM containing the calculated bottom padding. // This value will be concatenated with style values from any other extensions. const contentAttributes = EditorView.contentAttributes.compute( [bottomPaddingFacet], state => { const bottom = state.facet(bottomPaddingFacet) const style = `padding-bottom: ${bottom}px;` return { style } } ) class TopPaddingWidget extends WidgetType { constructor(private readonly height: number) { super() this.height = height } toDOM(view: EditorView): HTMLElement { const element = document.createElement('div') element.style.height = this.height + 'px' return element } get estimatedHeight() { return this.height } eq(widget: TopPaddingWidget) { return this.height === widget.height } updateDOM(element: HTMLElement, view: EditorView): boolean { element.style.height = this.height + 'px' view.requestMeasure() return true } } const topPaddingDecoration = EditorView.decorations.compute( [topPaddingFacet], state => { const top = state.facet(topPaddingFacet) return Decoration.set([ Decoration.widget({ widget: new TopPaddingWidget(top), block: true, }).range(0), ]) } ) export function setVerticalOverflow(padding: VerticalPadding): TransactionSpec { return { effects: [setOverflowPaddingEffect.of(padding)], } } export function updateSetsVerticalOverflow(update: ViewUpdate): boolean { return update.transactions.some(tr => { return tr.effects.some(effect => effect.is(setOverflowPaddingEffect)) }) } export function updateChangesTopPadding(update: ViewUpdate): boolean { return ( update.state.field(overflowPaddingState).top !== update.startState.field(overflowPaddingState).top ) } export function editorVerticalTopPadding(view: EditorView): number { return view.state.field(overflowPaddingState, false)?.top ?? 0 } export function editorOverflowPadding(view: EditorView) { return view.state.field(overflowPaddingState, false) }