Remember the file's scroll position as long as it stays open

By default returns the user to the same scroll position on a document where they left off (except if changed to code editing before switching back, or vice versa). Only saved in memory could be added to future session saves for #998.
This commit is contained in:
Mitch Capper 2022-03-16 15:53:27 -07:00
parent 03afc1ec29
commit b6754741f9
6 changed files with 528 additions and 11 deletions

View File

@ -82,6 +82,7 @@ interface IDocumentState
index: number index: number
}, },
cursor: any, cursor: any,
firstViewportVisibleItem: any,
wordCount: { wordCount: {
paragraph: number, paragraph: number,
word: number, word: number,

View File

@ -81,6 +81,7 @@ class ContentState {
this._selectedImage = null this._selectedImage = null
this.dropAnchor = null this.dropAnchor = null
this.prevCursor = null this.prevCursor = null
this.firstViewportVisibleItem = null
this.historyTimer = null this.historyTimer = null
this.history = new History(this) this.history = new History(this)
this.turndownConfig = Object.assign({}, DEFAULT_TURNDOWN_CONFIG, { bulletListMarker }) this.turndownConfig = Object.assign({}, DEFAULT_TURNDOWN_CONFIG, { bulletListMarker })
@ -345,6 +346,43 @@ class ContentState {
return this.cursor return this.cursor
} }
getBlockKeyByIndex (index) {
let arr = index.split('.')
const travel = blocks => {
let pos = arr.shift()
let num = parseInt(pos)
if (num !== pos || Number.isInteger(num) === false) { return null }
pos = num
if (blocks.length <= pos) { return null }
let block = blocks[pos]
if (arr.length === 0) { return block.key } else { return travel(block.children) }
}
return travel(this.blocks)
}
getBlockIndex (key) {
if (!key) return null
let result = null
const travel = blocks => {
for (let index = 0; index < blocks.length; index++) {
const block = blocks[index]
if (block.key === key) {
result = `${index}`
return true
}
if (block.children.length) {
if (travel(block.children)) {
result = `${index}.${result}`
return true
}
}
}
return false
}
travel(this.blocks)
return result
}
getBlock (key) { getBlock (key) {
if (!key) return null if (!key) return null
let result = null let result = null

View File

@ -111,6 +111,7 @@ import '@/assets/themes/codemirror/one-dark.css'
// import 'view-image/lib/imgViewer.css' // import 'view-image/lib/imgViewer.css'
import CloseIcon from '@/assets/icons/close.svg' import CloseIcon from '@/assets/icons/close.svg'
// Minus 67 here is the Y offset of the container itself, before we didn't include that in the scroll calculations, as we do now this keeps the actual Y offset the same
const STANDAR_Y = 320 const STANDAR_Y = 320
export default { export default {
@ -501,8 +502,20 @@ export default {
}, },
currentFile: function (value, oldValue) { currentFile: function (value, oldValue) {
if (this.sourceCode === false && oldValue && oldValue !== value) { // Cannot use the changed event above as it happens after we have switched to the new file
let firstViewportVisibleItem = this.getFirstElementInViewport()
if (firstViewportVisibleItem) { oldValue.firstViewportVisibleItem = 'M' + this.editor.contentState.getBlockIndex(firstViewportVisibleItem.id) } else { oldValue.firstViewportVisibleItem = 'Z' }// undefining if already set
}
if (value && value !== oldValue) { if (value && value !== oldValue) {
this.scrollToCursor(0) if (this.sourceCode === false && value.firstViewportVisibleItem && value.firstViewportVisibleItem.startsWith('M')) {
let indexStr = value.firstViewportVisibleItem.substring(1)
this.$nextTick(() => {
let elemId = this.editor.contentState.getBlockKeyByIndex(indexStr)
if (!elemId) { return }
this.scrollToElement('#' + elemId, 15, true)
})
} else { this.scrollToCursor(0) }
// Hide float tools if needed. // Hide float tools if needed.
this.editor && this.editor.hideAllFloatTools() this.editor && this.editor.hideAllFloatTools()
} }
@ -690,7 +703,6 @@ export default {
// //
// this.setImageViewerVisible(true) // this.setImageViewerVisible(true)
// }) // })
this.editor.on('selectionChange', changes => { this.editor.on('selectionChange', changes => {
const { y } = changes.cursorCoords const { y } = changes.cursorCoords
if (this.typewriter) { if (this.typewriter) {
@ -1065,14 +1077,51 @@ export default {
return this.scrollToElement(`#${slug}`) return this.scrollToElement(`#${slug}`)
}, },
scrollToElement (selector) { getFirstElementInViewport () {
let node = this.editor.container
if (node.childNodes.length === 0) { return null }
let offsetY = node.scrollTop
node = node.childNodes[0]// this gets us to the editors primary div
if (offsetY === 0) {
if (node.childNodes.length === 0) { return null }
return node.childNodes[0]
}
const nodeStack = []
while (node) {
// Only iterate over elements and text nodes
if (node.nodeType > 3) {
node = nodeStack.pop()
continue
}
if (node.offsetTop >= offsetY) { return node }
if (node.nodeType === 1) {
// this is an element
// add all its children to the stack
let i = node.childNodes.length - 1
while (i >= 0) {
nodeStack.push(node.childNodes[i])
i -= 1
}
}
node = nodeStack.pop()
}
},
scrollToElement (selector, duration = 300, dontAddStandardHeadroom = false) {
// Scroll to search highlight word // Scroll to search highlight word
const { container } = this.editor const { container } = this.editor
const anchor = document.querySelector(selector) const anchor = document.querySelector(selector)
if (anchor) { if (anchor) {
const { y } = anchor.getBoundingClientRect() const DURATION = duration
const DURATION = 300 const anchorY = anchor.getBoundingClientRect().y
animatedScrollTo(container, container.scrollTop + y - STANDAR_Y, DURATION) const containerY = container.getBoundingClientRect().y
const add = dontAddStandardHeadroom ? 0 : STANDAR_Y
animatedScrollTo(container, container.scrollTop + anchorY - containerY - add, DURATION)
} }
}, },
@ -1229,7 +1278,7 @@ export default {
if (cursor) { if (cursor) {
editor.setMarkdown(markdown, cursor, true) editor.setMarkdown(markdown, cursor, true)
} else { } else {
editor.setMarkdown(markdown) editor.setMarkdown(markdown, null, true)
} }
} }
}, },

View File

@ -10,10 +10,13 @@
import codeMirror, { setMode, setCursorAtLastLine, setTextDirection } from '../../codeMirror' import codeMirror, { setMode, setCursorAtLastLine, setTextDirection } from '../../codeMirror'
import { wordCount as getWordCount } from 'muya/lib/utils' import { wordCount as getWordCount } from 'muya/lib/utils'
import { mapState } from 'vuex' import { mapState } from 'vuex'
import { adjustCursor } from '../../util' import { adjustCursor, animatedScrollTo } from '../../util'
import bus from '../../bus' import bus from '../../bus'
import { oneDarkThemes, railscastsThemes } from '@/config' import { oneDarkThemes, railscastsThemes } from '@/config'
// Same as editor.vue
const STANDAR_Y = 320
export default { export default {
props: { props: {
markdown: String, markdown: String,
@ -43,6 +46,18 @@ export default {
}, },
watch: { watch: {
currentTab: function (value, oldValue) {
if (!this.sourceCode) { return }
// Don't need to worry about setting the line on the oldValue already did in prepareTabSwitch
if (value && value !== oldValue) {
if (value.firstViewportVisibleItem && value.firstViewportVisibleItem.startsWith('S')) {
let line = value.firstViewportVisibleItem.substring(1)
this.$nextTick(() => {
this.scrollToLineNumberInViewport(line, 15, true)
})
}
}
},
textDirection: function (value, oldValue) { textDirection: function (value, oldValue) {
const { editor } = this const { editor } = this
if (value !== oldValue && editor) { if (value !== oldValue && editor) {
@ -237,15 +252,28 @@ export default {
}, },
// Commit changes from old tab. Problem: tab was already switched, so commit changes with old tab id. // Commit changes from old tab. Problem: tab was already switched, so commit changes with old tab id.
prepareTabSwitch () { prepareTabSwitch () {
let firstViewportVisibleItem = 'S' + this.getFirstLineNumberInViewport()
if (this.sourceCode === false) { // this shouldn't happen
firstViewportVisibleItem = undefined
}
if (this.commitTimer) clearTimeout(this.commitTimer) if (this.commitTimer) clearTimeout(this.commitTimer)
if (this.tabId) { if (this.tabId) {
const { editor } = this const { editor } = this
const { cursor, markdown } = this.getMarkdownAndCursor(editor) const { cursor, markdown } = this.getMarkdownAndCursor(editor)
this.$store.dispatch('LISTEN_FOR_CONTENT_CHANGE', { id: this.tabId, markdown, cursor }) this.$store.dispatch('LISTEN_FOR_CONTENT_CHANGE', { id: this.tabId, markdown, cursor, firstViewportVisibleItem })
this.tabId = null // invalidate tab id this.tabId = null // invalidate tab id
} }
}, },
scrollToLineNumberInViewport (line, duration = 300, dontAddStandardHeadroom = false) {
let pos = this.editor.charCoords({ line: line, ch: 0 }, 'local').top
const DURATION = duration
if (!dontAddStandardHeadroom) { pos -= STANDAR_Y }
animatedScrollTo(this.$el, pos, DURATION)
},
getFirstLineNumberInViewport () {
return this.editor.coordsChar({ left: 0, top: this.$el.scrollTop }, 'local').line
},
handleSelectAll () { handleSelectAll () {
if (!this.sourceCode) { if (!this.sourceCode) {
return return

View File

@ -0,0 +1,389 @@
<template>
<div
class="source-code"
ref="sourceCode"
>
</div>
</template>
<script>
import codeMirror, { setMode, setCursorAtLastLine, setTextDirection } from '../../codeMirror'
import { wordCount as getWordCount } from 'muya/lib/utils'
import { mapState } from 'vuex'
import { adjustCursor, animatedScrollTo } from '../../util'
import bus from '../../bus'
import { oneDarkThemes, railscastsThemes } from '@/config'
const STANDAR_Y = 320
export default {
props: {
markdown: String,
cursor: Object,
textDirection: {
type: String,
required: true
}
},
computed: {
...mapState({
theme: state => state.preferences.theme,
sourceCode: state => state.preferences.sourceCode,
currentTab: state => state.editor.currentFile
})
},
data () {
return {
contentState: null,
editor: null,
commitTimer: null,
viewDestroyed: false,
tabId: null
}
},
watch: {
currentTab: function (value, oldValue) {
if (! this.sourceCode)
return;
//Don't need to worry about setting the line on the oldValue already did in prepareTabSwitch
if (value && value !== oldValue) {
if (value.firstViewportVisibleItem && value.firstViewportVisibleItem.startsWith("S")){
let line = value.firstViewportVisibleItem.substring(1);
this.$nextTick(() => {
this.scrollToLineNumberInViewport(line, 15,true);
});
}
}
},
textDirection: function (value, oldValue) {
const { editor } = this
if (value !== oldValue && editor) {
setTextDirection(editor, value)
}
}
},
created () {
this.$nextTick(() => {
// TODO: Should we load markdown from the tab or mapped vue property?
const { id } = this.currentTab
const { markdown = '', theme, cursor, textDirection } = this
const container = this.$refs.sourceCode
const codeMirrorConfig = {
value: markdown,
lineNumbers: true,
autofocus: true,
lineWrapping: true,
styleActiveLine: true,
direction: textDirection,
// The amount of updates needed when scrolling. Settings this to >Infinity< or use CSS
// >height: auto< result in bad performance because the whole document is always rendered.
// Since we are using >height: auto< setting this to >Infinity< to fix #171. The best
// solution would be to set a fixed height like in #791 but then the scrollbar is not on
// the right side. Please also see CodeMirror#1104.
viewportMargin: Infinity,
lineNumberFormatter (line) {
if (line % 10 === 0 || line === 1) {
return line
} else {
return ''
}
}
}
// Set theme
if (railscastsThemes.includes(theme)) {
codeMirrorConfig.theme = 'railscasts'
} else if (oneDarkThemes.includes(theme)) {
codeMirrorConfig.theme = 'one-dark'
}
// Init CodeMirror
const editor = this.editor = codeMirror(container, codeMirrorConfig)
bus.$on('file-loaded', this.handleFileChange)
bus.$on('invalidate-image-cache', this.handleInvalidateImageCache)
bus.$on('file-changed', this.handleFileChange)
bus.$on('selectAll', this.handleSelectAll)
bus.$on('image-action', this.handleImageAction)
bus.$on('undo', this.handleUndo)
bus.$on('redo', this.handleRedo)
bus.$on('find', this.handleFind)
bus.$on('replace', this.handleReplace)
bus.$on('findNext', this.handleFindNext)
bus.$on('findPrev', this.handleFindPrev)
document.addEventListener('click', this.docClick)
document.addEventListener('keyup', this.docKeyup)
setMode(editor, 'markdown')
this.listenChange()
// NOTE: Cursor may be not null but the inner values are.
if (cursor && cursor.anchor && cursor.focus) {
const { anchor, focus } = cursor
editor.setSelection(anchor, focus, { scroll: true }) // Scroll the focus into view.
} else {
setCursorAtLastLine(editor)
}
this.tabId = id
})
},
beforeDestroy () {
// NOTE: Clear timer and manually commit changes. After mode switching and cleanup may follow
// further key inputs, so ignore all inputs.
this.viewDestroyed = true
if (this.commitTimer) clearTimeout(this.commitTimer)
bus.$off('file-loaded', this.handleFileChange)
bus.$off('invalidate-image-cache', this.handleInvalidateImageCache)
bus.$off('file-changed', this.handleFileChange)
bus.$off('selectAll', this.handleSelectAll)
bus.$off('image-action', this.handleImageAction)
bus.$off('undo', this.handleUndo)
bus.$off('redo', this.handleRedo)
bus.$off('find', this.handleFind)
bus.$off('replace', this.handleReplace)
bus.$off('findNext', this.handleFindNext)
bus.$off('findPrev', this.handleFindPrev)
document.removeEventListener('click', this.docClick)
document.removeEventListener('keyup', this.docKeyup)
const { editor } = this
const { cursor, markdown } = this.getMarkdownAndCursor(editor)
bus.$emit('file-changed', { id: this.tabId, markdown, cursor, renderCursor: true })
},
methods: {
handleImageAction ({ id, result, alt }) {
const { editor } = this
const value = editor.getValue()
const focus = editor.getCursor('focus')
const anchor = editor.getCursor('anchor')
const lines = value.split('\n')
const index = lines.findIndex(line => line.indexOf(id) > 0)
if (index > -1) {
const oldLine = lines[index]
lines[index] = oldLine.replace(new RegExp(`!\\[${id}\\]\\(.*\\)`), `![${alt}](${result})`)
const newValue = lines.join('\n')
editor.setValue(newValue)
const match = /(!\[.*\]\(.*\))/.exec(oldLine)
if (!match) {
// User maybe delete `![]()` structure, and the match is null.
return
}
const range = {
start: match.index,
end: match.index + match[1].length
}
const delta = alt.length + result.length + 5 - match[1].length
const adjust = pointer => {
if (!pointer) {
return
}
if (pointer.line !== index) {
return
}
if (pointer.ch <= range.start) {
// do nothing.
} else if (pointer.ch > range.start && pointer.ch < range.end) {
pointer.ch = range.start + alt.length + result.length + 5
} else {
pointer.ch += delta
}
}
adjust(focus)
adjust(anchor)
if (focus && anchor) {
editor.setSelection(anchor, focus, { scroll: true })
} else {
setCursorAtLastLine()
}
}
},
listenChange () {
const { editor } = this
editor.on('cursorActivity', cm => {
const { cursor, markdown } = this.getMarkdownAndCursor(cm)
// Attention: the cursor may be `{focus: null, anchor: null}` when press `backspace`
const wordCount = getWordCount(markdown)
if (this.commitTimer) clearTimeout(this.commitTimer)
this.commitTimer = setTimeout(() => {
// See "beforeDestroy" note
if (!this.viewDestroyed) {
if (this.tabId) {
this.$store.dispatch('LISTEN_FOR_CONTENT_CHANGE', { id: this.tabId, markdown, wordCount, cursor })
} else {
// This may occur during tab switching but should not occur otherwise.
console.warn('LISTEN_FOR_CONTENT_CHANGE: Cannot commit changes because not tab id was set!')
}
}
}, 1000)
})
},
// Another tab was selected - only listen to get changes but don't set history or other things.
handleFileChange ({ id, markdown, cursor }) {
this.prepareTabSwitch()
const { editor } = this
if (typeof markdown === 'string') {
editor.setValue(markdown)
}
// Cursor is null when loading a file or creating a new tab in source code mode.
if (cursor) {
const { anchor, focus } = cursor
editor.setSelection(anchor, focus, { scroll: true }) // Scroll the focus into view.
} else {
setCursorAtLastLine(editor)
}
this.tabId = id
},
// Get markdown and cursor from CodeMirror.
getMarkdownAndCursor (cm) {
let focus = cm.getCursor('head')
let anchor = cm.getCursor('anchor')
const markdown = cm.getValue()
const convertToMuyaCursor = cursor => {
const line = cm.getLine(cursor.line)
const preLine = cm.getLine(cursor.line - 1)
const nextLine = cm.getLine(cursor.line + 1)
return adjustCursor(cursor, preLine, line, nextLine)
}
anchor = convertToMuyaCursor(anchor) // Selection start as Muya cursor
focus = convertToMuyaCursor(focus) // Selection end as Muya cursor
// Normalize cursor that `anchor` is always before `focus` because
// this is the expected behavior in Muya.
if (anchor && focus && anchor.line > focus.line) {
const tmpCursor = focus
focus = anchor
anchor = tmpCursor
}
return { cursor: { focus, anchor }, markdown }
},
// Commit changes from old tab. Problem: tab was already switched, so commit changes with old tab id.
prepareTabSwitch () {
let firstViewportVisibleItem = "S" + this.getFirstLineNumberInViewport();
if (this.sourceCode == false)//this shouldn't happen
firstViewportVisibleItem = undefined;
if (this.commitTimer) clearTimeout(this.commitTimer)
if (this.tabId) {
const { editor } = this
const { cursor, markdown } = this.getMarkdownAndCursor(editor)
this.$store.dispatch('LISTEN_FOR_CONTENT_CHANGE', { id: this.tabId, markdown, cursor, firstViewportVisibleItem })
this.tabId = null // invalidate tab id
}
},
<<<<<<< HEAD
scrollToLineNumberInViewport(line, duration=300, dontAddStandardHeadroom=false){
let pos = this.editor.charCoords({line:line,ch:0},"local").top;
const DURATION = duration;
if (! dontAddStandardHeadroom)
pos -= STANDAR_Y;
animatedScrollTo(this.$el, pos, DURATION)
},
getFirstLineNumberInViewport(){
return this.editor.coordsChar({left:0,top:this.$el.scrollTop},"local").line;
},
=======
handleUndo () {
if (this.editor && this.sourceCode) {
this.editor.execCommand('undo')
}
},
handleRedo () {
if (this.editor && this.sourceCode) {
this.editor.execCommand('redo')
}
},
docKeyup (event) {
if (this.editor && this.sourceCode && event.key === 'Escape') {
this.editor.execCommand('clearSearch')
}
},
docClick () {
if (this.editor && this.sourceCode)
this.editor.execCommand('clearSearch')
},
handleFind () {
if (this.editor && this.sourceCode) {
this.editor.execCommand('findPersistent')
}
},
handleReplace () {
if (this.editor && this.sourceCode) {
this.editor.execCommand('replace')
}
},
handleFindNext () {
if (this.editor && this.sourceCode) {
this.editor.execCommand('findNext')
}
},
handleFindPrev () {
if (this.editor && this.sourceCode) {
this.editor.execCommand('findPrev')
}
},
>>>>>>> sourcecode_editor_find_undo_redo_fixes
handleSelectAll () {
if (!this.sourceCode) {
return
}
const { editor } = this
if (editor && editor.hasFocus()) {
this.editor.execCommand('selectAll')
} else {
const activeElement = document.activeElement
const nodeName = activeElement.nodeName
if (nodeName === 'INPUT' || nodeName === 'TEXTAREA') {
activeElement.select()
}
}
},
handleInvalidateImageCache () {
if (this.editor) {
this.editor.invalidateImageCache()
}
}
}
}
</script>
<style>
.source-code {
height: calc(100vh - var(--titleBarHeight));
box-sizing: border-box;
overflow: auto;
}
.source-code .CodeMirror {
height: auto;
margin: 50px auto;
max-width: var(--editorAreaWidth);
background: transparent;
}
.source-code .CodeMirror-gutters {
border-right: none;
background-color: transparent;
}
.source-code .CodeMirror-activeline-background,
.source-code .CodeMirror-activeline-gutter {
background: var(--floatHoverColor);
}
</style>

View File

@ -247,6 +247,11 @@ const mutations = {
state.currentFile.history = history state.currentFile.history = history
} }
}, },
SET_FIRST_VIS_ITEM (state, firstViewportVisibleItem) {
if (hasKeys(state.currentFile)) {
state.currentFile.firstViewportVisibleItem = firstViewportVisibleItem
}
},
CLOSE_TABS (state, tabIdList) { CLOSE_TABS (state, tabIdList) {
if (!tabIdList || tabIdList.length === 0) return if (!tabIdList || tabIdList.length === 0) return
@ -922,7 +927,7 @@ const actions = {
// Content change from realtime preview editor and source code editor // Content change from realtime preview editor and source code editor
// WORKAROUND: id is "muya" if changes come from muya and not source code editor! So we don't have to apply the workaround. // WORKAROUND: id is "muya" if changes come from muya and not source code editor! So we don't have to apply the workaround.
LISTEN_FOR_CONTENT_CHANGE ({ commit, dispatch, state, rootState }, { id, markdown, wordCount, cursor, history, toc }) { LISTEN_FOR_CONTENT_CHANGE ({ commit, dispatch, state, rootState }, { id, markdown, wordCount, cursor, history, toc, firstViewportVisibleItem }) {
const { autoSave } = rootState.preferences const { autoSave } = rootState.preferences
const { const {
id: currentId, id: currentId,
@ -952,6 +957,10 @@ const actions = {
if (history) { if (history) {
tab.history = history tab.history = history
} }
// Set Line or First Visible Item Index
if (firstViewportVisibleItem) {
tab.firstViewportVisibleItem = firstViewportVisibleItem
}
break break
} }
} }
@ -978,6 +987,9 @@ const actions = {
if (history) { if (history) {
commit('SET_HISTORY', history) commit('SET_HISTORY', history)
} }
if (firstViewportVisibleItem) {
commit('SET_FIRST_VIS_ITEM', firstViewportVisibleItem)
}
// Set toc // Set toc
if (toc && !equal(toc, listToc)) { if (toc && !equal(toc, listToc)) {
commit('SET_TOC', toc) commit('SET_TOC', toc)