changeset 0:7975d7c73f8a 1.0.0

Initial commit
author Ludovic Chabant <ludovic@chabant.com>
date Tue, 08 Feb 2022 21:40:33 -0800
parents
children f6dee41d58da
files .gitignore .hgignore README.md esbuild.config.mjs manifest.json package.json src/main.ts src/settings.ts styles.css tsconfig.json versions.json
diffstat 10 files changed, 374 insertions(+), 0 deletions(-) [+]
line wrap: on
line diff
--- /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
--- /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
--- /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.
+
--- /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));
--- /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
+}
--- /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": {
+	}
+}
--- /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<void> => {
+		// 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<void> => {
+		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<void> => {
+		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<void> => {
+		const existingFile = this.data.rememberedFiles.find(
+			(curFile) => curFile.path === oldPath
+		);
+		if (existingFile) {
+			existingFile.path = file.path;
+		}
+	};
+
+	private readonly onFileDelete = async (
+		file: TAbstractFile,
+	): Promise<void> => {
+		this.data.rememberedFiles = this.data.rememberedFiles.filter(
+			(curFile) => curFile.path !== file.path
+		);
+	};
+}
+
--- /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();
+					}
+				});
+	}
+}
--- /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"
+  ]
+}
--- /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"
+}