2023-04-13 04:21:25 -04:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2023-06-08 04:35:51 -04:00
|
|
|
/**
|
|
|
|
* 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.
|
|
|
|
*/
|
2023-04-13 04:21:25 -04:00
|
|
|
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 {}
|
|
|
|
}
|
|
|
|
}
|