# HG changeset patch # User Ludovic Chabant # Date 1644385233 28800 # Node ID 7975d7c73f8ae5f6ac176ae908c48615512deb60 Initial commit diff -r 000000000000 -r 7975d7c73f8a .gitignore --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/.gitignore Tue Feb 08 21:40:33 2022 -0800 @@ -0,0 +1,3 @@ +main.js +node_modules +package-lock.json diff -r 000000000000 -r 7975d7c73f8a .hgignore --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/.hgignore Tue Feb 08 21:40:33 2022 -0800 @@ -0,0 +1,3 @@ +main.js +node_modules +package-lock.json diff -r 000000000000 -r 7975d7c73f8a README.md --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/README.md Tue Feb 08 21:40:33 2022 -0800 @@ -0,0 +1,9 @@ +## Remember File State + +This [Obsidian](https://obsidian.md) plugin remembers the editor state of files +as you switch between them. It restores the cursor position, scrolling position, +and so on. The plugin doesn't do any polling, and strives to only do work when +opening and closing files in order to no slow down the editing experience. + +Note that this plugin doesn't currently remember state across sessions. + diff -r 000000000000 -r 7975d7c73f8a esbuild.config.mjs --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/esbuild.config.mjs Tue Feb 08 21:40:33 2022 -0800 @@ -0,0 +1,35 @@ +import esbuild from "esbuild"; +import process from "process"; +import builtins from 'builtin-modules' + +const banner = +`/* +THIS IS A GENERATED/BUNDLED FILE BY ESBUILD +if you want to view the source, please visit the github repository of this plugin +*/ +`; + +const prod = (process.argv[2] === 'production'); + +esbuild.build({ + banner: { + js: banner, + }, + entryPoints: ['src/main.ts'], + bundle: true, + external: [ + 'obsidian', + 'electron', + '@codemirror', + '@codemirror/state', + '@codemirror/view', + ...builtins + ], + format: 'cjs', + watch: !prod, + target: 'es2016', + logLevel: "info", + sourcemap: prod ? false : 'inline', + treeShaking: true, + outfile: 'main.js', +}).catch(() => process.exit(1)); diff -r 000000000000 -r 7975d7c73f8a manifest.json --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/manifest.json Tue Feb 08 21:40:33 2022 -0800 @@ -0,0 +1,10 @@ +{ + "id": "remember-file-state", + "name": "Remember File State", + "version": "0.1.0", + "minAppVersion": "0.13.17", + "description": "Remembers cursor position, selection, scrolling, and more for each file", + "author": "Ludovic Chabant", + "authorUrl": "https://ludovic.chabant.com", + "isDesktopOnly": false +} diff -r 000000000000 -r 7975d7c73f8a package.json --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/package.json Tue Feb 08 21:40:33 2022 -0800 @@ -0,0 +1,29 @@ +{ + "name": "obsidian-remember-file-state", + "version": "0.1.0", + "description": "Plugin for Obsidian that remembers cursor position, selection, scrolling, and more for each file", + "main": "main.js", + "scripts": { + "dev": "node esbuild.config.mjs", + "build": "node esbuild.config.mjs production" + }, + "keywords": [], + "author": "", + "license": "MIT", + "devDependencies": { + "@codemirror/history": "^0.19.0", + "@codemirror/state": "^0.19.6", + "@codemirror/view": "^0.19.36", + "@types/node": "^16.11.6", + "@typescript-eslint/eslint-plugin": "^5.2.0", + "@typescript-eslint/parser": "^5.2.0", + "builtin-modules": "^3.2.0", + "esbuild": "0.13.12", + "monkey-around": "^2.2.0", + "obsidian": "^0.12.17", + "tslib": "2.3.1", + "typescript": "4.4.4" + }, + "dependencies": { + } +} diff -r 000000000000 -r 7975d7c73f8a src/main.ts --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/main.ts Tue Feb 08 21:40:33 2022 -0800 @@ -0,0 +1,214 @@ +import { + Editor, + MarkdownView, + OpenViewState, + Plugin, + View +} from 'obsidian'; + +import { + EditorSelection +} from '@codemirror/state'; + +import { + around +} from 'monkey-around'; + +import { + DEFAULT_SETTINGS, + RememberFileStatePluginSettings, + RememberFileStatePluginSettingTab +} from './settings'; + +// Interface for a file state. +interface RememberedFileState { + path: string; + lastSavedTime: number; + stateData: Object; +} + +// Interface for all currently remembered file states. +interface RememberFileStatePluginData { + rememberedFiles: RememberedFileState[]; +} + +// Default empty list of remembered file states. +const DEFAULT_DATA: RememberFileStatePluginData = { + rememberedFiles: [] +}; + +export default class RememberFileStatePlugin extends Plugin { + settings: RememberFileStatePluginSettings; + data: RememberFileStatePluginData; + + private _suppressNextFileOpen: boolean = false; + + private _viewUninstallers = {}; + private _globalUninstallers = []; + + async onload() { + console.log("Loading RememberFileState plugin"); + + await this.loadSettings(); + + this.data = Object.assign({}, DEFAULT_DATA); + + this.registerEvent(this.app.workspace.on('file-open', this.onFileOpen)); + this.registerEvent(this.app.vault.on('rename', this.onFileRename)); + this.registerEvent(this.app.vault.on('delete', this.onFileDelete)); + + this.app.workspace.getLeavesOfType("markdown").forEach( + (leaf) => { this.registerOnUnloadFile(leaf.view); }); + + const _this = this; + var uninstall = around(this.app.workspace, { + openLinkText: function(next) { + return async function( + linktext: string, sourcePath: string, + newLeaf?: boolean, openViewState?: OpenViewState) { + // When opening a link, we don't want to restore the + // scroll position/selection/etc because there's a + // good chance we want to show the file back at the + // top, or we're going straight to a specific block. + _this._suppressNextFileOpen = true; + return await next.call( + this, linktext, sourcePath, newLeaf, openViewState); + }; + } + }); + this._globalUninstallers.push(uninstall); + + this.addSettingTab(new RememberFileStatePluginSettingTab(this.app, this)); + } + + onunload() { + var uninstallers = this._viewUninstallers.values(); + console.debug(`Unregistering ${uninstallers.length} view callbacks`); + uninstallers.values().forEach((cb) => cb()); + this._viewUninstallers = {}; + + this._globalUninstallers.forEach((cb) => cb()); + } + + async loadSettings() { + this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + } + + async saveSettings() { + await this.saveData(this.settings); + } + + private readonly registerOnUnloadFile = function(view) { + if (view in this._viewUninstallers) { + return; + } + + console.debug("Registering view callback"); + const _this = this; + var uninstall = around(view, { + onUnloadFile: function(next) { + return async function (unloaded: TFile) { + _this.rememberFileState(unloaded, view); + return await next.call(this, unloaded); + }; + } + }); + this._viewUninstallers[view] = uninstall; + + view.register(() => { + console.debug("Unregistering view callback"); + delete this._viewUninstallers[view]; + uninstall(); + }); + } + + private readonly onFileOpen = async ( + openedFile: TFile + ): Promise => { + // If `openedFile` is null, it's because the last pane was closed + // and there is now an empty pane. + if (openedFile) { + var activeView = this.app.workspace.activeLeaf.view; + this.registerOnUnloadFile(activeView); + + if (!this._suppressNextFileOpen) { + await this.restoreFileState(openedFile, activeView); + } else { + this._suppressNextFileOpen = false; + } + } + } + + private readonly rememberFileState = async (file: TFile, view: View): Promise => { + const scrollInfo = view.editor.getScrollInfo(); + const stateSelectionJSON = view.editor.cm.state.selection.toJSON(); + const stateData = {'scrollInfo': scrollInfo, 'selection': stateSelectionJSON}; + + var existingFile = this.data.rememberedFiles.find( + curFile => curFile.path == file.path + ); + + if (existingFile) { + existingFile.lastSavedTime = Date.now(); + existingFile.stateData = stateData; + } else { + let newFileState = { + path: file.path, + lastSavedTime: Date.now(), + stateData: stateData + }; + this.data.rememberedFiles.push(newFileState); + + // If we need to keep the number remembered files under a maximum, + // do it now. + this.forgetExcessFiles(); + } + console.debug("Remember file state for:", file.path); + } + + private readonly restoreFileState = async (file: TFile, view: View): Promise => { + const existingFile = this.data.rememberedFiles.find( + (curFile) => curFile.path === file.path + ); + if (existingFile) { + console.debug("Restoring file state for:", file.path); + const stateData = existingFile.stateData; + view.editor.scrollTo(stateData.scrollInfo.left, stateData.scrollInfo.top); + view.editor.cm.state.selection = EditorSelection.fromJSON(stateData.selection); + } + } + + private forgetExcessFiles() { + const keepMax = this.settings.rememberMaxFiles; + if (keepMax <= 0) { + return; + } + + this.data.rememberedFiles.sort((a, b) => a.lastSavedTime < b.lastSavedTime); + + if (this.data.rememberedFiles.length > keepMax) { + this.data.rememberedFiles.splice(keepMax); + } + } + + private readonly onFileRename = async ( + file: TAbstractFile, + oldPath: string, + ): Promise => { + const existingFile = this.data.rememberedFiles.find( + (curFile) => curFile.path === oldPath + ); + if (existingFile) { + existingFile.path = file.path; + } + }; + + private readonly onFileDelete = async ( + file: TAbstractFile, + ): Promise => { + this.data.rememberedFiles = this.data.rememberedFiles.filter( + (curFile) => curFile.path !== file.path + ); + }; +} + diff -r 000000000000 -r 7975d7c73f8a src/settings.ts --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/settings.ts Tue Feb 08 21:40:33 2022 -0800 @@ -0,0 +1,46 @@ +import { + PluginSettingTab, + Setting +} from 'obsidian'; + +import { + RememberFileStatePlugin +} from './main' + +export interface RememberFileStatePluginSettings { + rememberMaxFiles: number; +} + +export const DEFAULT_SETTINGS: RememberFileStatePluginSettings = { + // Matches the number of files Obsidian remembers the undo/redo + // history for by default (at least as of 0.13.17). + rememberMaxFiles: 20 +} + +export class RememberFileStatePluginSettingTab extends PluginSettingTab { + plugin: RememberFileStatePlugin; + + constructor(app: App, plugin: RememberFileStatePlugin) { + super(app, plugin); + this.plugin = plugin; + } + + display(): void { + const {containerEl} = this; + + containerEl.empty(); + + new Setting(containerEl) + .setName('Remember files') + .setDesc('How many files to remember at most') + .addText(text => text + .setValue(this.plugin.settings.rememberMaxFiles?.toString())) + .onChange(async (value) => { + const intValue = parseInt(value); + if (!isNaN(intValue)) { + this.plugin.settings.rememberMaxFiles = intValue; + await this.plugin.saveSettings(); + } + }); + } +} diff -r 000000000000 -r 7975d7c73f8a styles.css diff -r 000000000000 -r 7975d7c73f8a tsconfig.json --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tsconfig.json Tue Feb 08 21:40:33 2022 -0800 @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "inlineSourceMap": true, + "inlineSources": true, + "module": "ESNext", + "target": "ES6", + "allowJs": true, + "noImplicitAny": true, + "moduleResolution": "node", + "importHelpers": true, + "lib": [ + "DOM", + "ES5", + "ES6", + "ES7" + ] + }, + "include": [ + "**/*.ts" + ] +} diff -r 000000000000 -r 7975d7c73f8a versions.json --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/versions.json Tue Feb 08 21:40:33 2022 -0800 @@ -0,0 +1,3 @@ +{ + "1.0.0": "0.13.17" +}