import { BlockInfo, EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view' import { throttle } from 'lodash' import customLocalStorage from '../../../infrastructure/local-storage' import { EditorSelection, StateEffect, Text, TransactionSpec, } from '@codemirror/state' import { toggleVisualEffect } from './visual/visual' const buildStorageKey = (docId: string) => `doc.position.${docId}` type LineInfo = { first: BlockInfo middle: BlockInfo } /** * A custom extension that: * a) stores the scroll position (first visible line number) in localStorage when the view is destroyed, * or the window is closed, or when switching between Source and Rich Text, and * b) dispatches the scroll position (middle visible line) when it changes, for use in the outline. */ export const scrollPosition = ({ currentDoc: { doc_id: docId }, }: { currentDoc: { doc_id: string } }) => { // store lineInfo for use on unload, when the DOM has already been unmounted let lineInfo: LineInfo const scrollHandler = throttle( (event, view) => { // exclude a scroll event with no target, which happens when switching docs if (event.target === view.scrollDOM) { lineInfo = calculateLineInfo(view) dispatchScrollPosition(lineInfo, view) } }, // long enough to capture intent, but short enough that the selected heading in the outline appears current 120, { trailing: true } ) return [ // store/dispatch scroll position ViewPlugin.define( view => { const unloadListener = () => { if (lineInfo) { storeScrollPosition(lineInfo, view, docId) } } window.addEventListener('unload', unloadListener) return { update: (update: ViewUpdate) => { for (const tr of update.transactions) { for (const effect of tr.effects) { if (effect.is(toggleVisualEffect)) { // store the scroll position when switching between source and rich text if (lineInfo) { storeScrollPosition(lineInfo, view, docId) } } else if (effect.is(restoreScrollPositionEffect)) { // restore the scroll position window.setTimeout(() => { view.dispatch(scrollStoredLineToTop(tr.state.doc, docId)) window.dispatchEvent( new Event('editor:scroll-position-restored') ) }) } } } }, destroy: () => { scrollHandler.cancel() window.removeEventListener('unload', unloadListener) unloadListener() }, } }, { eventHandlers: { scroll: scrollHandler, }, } ), ] } const restoreScrollPositionEffect = StateEffect.define() export const restoreScrollPosition = () => { return { effects: restoreScrollPositionEffect.of(null), } } const calculateLineInfo = (view: EditorView) => { // the top of the scrollDOM element relative to the top of the document const { top, height } = view.scrollDOM.getBoundingClientRect() const distanceFromDocumentTop = top - view.documentTop return { first: view.lineBlockAtHeight(distanceFromDocumentTop), // top plus half the height of the scrollDOM element middle: view.lineBlockAtHeight(distanceFromDocumentTop + height / 2), } } // dispatch the middle visible line number (for the outline) const dispatchScrollPosition = (lineInfo: LineInfo, view: EditorView) => { const middleVisibleLine = view.state.doc.lineAt(lineInfo.middle.from).number window.dispatchEvent( new CustomEvent('scroll:editor:update', { detail: middleVisibleLine, }) ) } // store the scroll position (first visible line number, for restoring on load) const storeScrollPosition = ( lineInfo: LineInfo, view: EditorView, docId: string ) => { const key = buildStorageKey(docId) const data = customLocalStorage.getItem(key) const firstVisibleLine = view.state.doc.lineAt(lineInfo.first.from).number customLocalStorage.setItem(key, { ...data, firstVisibleLine }) } // restore the scroll position using the stored first visible line number const scrollStoredLineToTop = (doc: Text, docId: string): TransactionSpec => { try { const key = buildStorageKey(docId) const data = customLocalStorage.getItem(key) // restore the scroll position to its original position, or the last line of the document const firstVisibleLine = Math.min(data?.firstVisibleLine ?? 1, doc.lines) const line = doc.line(firstVisibleLine) const selectionRange = EditorSelection.cursor(line.from) return { effects: EditorView.scrollIntoView(selectionRange, { y: 'start', yMargin: 0, }), } } catch (e) { // ignore invalid line number console.error(e) return {} } }