view src/main.ts @ 37:8be02002ed66

Use Obsidian's view APIs for scrolling state Obsidian does some asynchronous formatting upon opening a file which can affect the vertical positioning of things, especially when there are tables for instance. Setting the scrolling position via the CM6 APIs gets invalidated by this, so we're using the Obsidian view API instead even if it only allows getting/setting vertical scrolling (not horizontal).
author Ludovic Chabant <ludovic@chabant.com>
date Mon, 14 Aug 2023 10:44:55 -0700
parents 16d304d586b1
children 96e86650043b
line wrap: on
line source

import {
	App,
	Editor,
	MarkdownView,
	Modal,
	OpenViewState,
	Plugin,
	TAbstractFile,
	TFile,
	View,
	WorkspaceLeaf
} from 'obsidian';

import {
	EditorView
} from '@codemirror/view';

import {
	EditorState,
	EditorSelection
} from '@codemirror/state';

import {
	around
} from 'monkey-around';

import {
	DEFAULT_SETTINGS,
	RememberFileStatePluginSettings,
	RememberFileStatePluginSettingTab
} from './settings';

declare var app: App;

// Interface for CM6 editor view
interface EditorWithCM6 extends Editor {
	cm: EditorView
};

// View with unique ID
interface ViewWithID extends View {
	__uniqueId: number
};

// Scroll info interface
interface ScrollInfo {
	top: number, left: number
};

interface StateData {
	selection: EditorSelection,
	scrollInfo: ScrollInfo
};

// Interface for a file state.
interface RememberedFileState {
	path: string;
	lastSavedTime: number;
	stateData: StateData;
}

// Interface for all currently remembered file states.
interface RememberFileStatePluginData {
	rememberedFiles: Record<string, RememberedFileState>;
}

// Default empty list of remembered file states.
const DEFAULT_DATA: RememberFileStatePluginData = {
	rememberedFiles: {}
};

// Simple warning message.
class WarningModal extends Modal {
	title: string = "";
	message: string = "";

	constructor(app: App, title: string, message: string) {
		super(app)
		this.title = title;
		this.message = message;
	}
	onOpen() {
		this.contentEl.createEl('h2', {text: this.title});
		this.contentEl.createEl('p', {text: this.message});
	}
};

export default class RememberFileStatePlugin extends Plugin {
	settings: RememberFileStatePluginSettings;
	data: RememberFileStatePluginData;

	// Don't restore state on the next file being opened.
	private _suppressNextFileOpen: boolean = false;
	// Next unique ID to identify views without keeping references to them.
	private _nextUniqueViewId: number = 0;

	// Remember last open file in each view.
	private _lastOpenFiles: Record<string, string> = {};

	// Functions to unregister any monkey-patched view hooks on plugin unload.
	private _viewUninstallers: Record<string, Function> = {};
	// Functions to unregister any global callbacks on plugin unload.
	private _globalUninstallers: Function[] = [];

	async onload() {
		console.log("RememberFileState: loading 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: WorkspaceLeaf) => { this.registerOnUnloadFile(leaf.view as MarkdownView); });

		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));

		if ((this.app.vault as any).getConfig('legacyEditor') !== false) {
			new WarningModal(
				this.app,
				"Legacy Editor Not Supported",
				"The 'Remember File State' plugin works only with the new editor. Please turn off 'Legacy Editor' in the options."
			).open();
		}
	}

	onunload() {
		// Run view uninstallers on all current views.
		var numViews: number = 0;
		this.app.workspace.getLeavesOfType("markdown").forEach(
			(leaf: WorkspaceLeaf) => {
				const filePath = (leaf.view as MarkdownView).file.path;
				const viewId = this.getUniqueViewId(leaf.view as ViewWithID);
				if (viewId != undefined) {
					var uninstaller = this._viewUninstallers[viewId];
					if (uninstaller) {
						console.debug(`RememberedFileState: uninstalling hooks for view ${viewId}`, filePath);
						uninstaller(leaf.view);
						++numViews;
					} else {
						console.debug("RememberedFileState: found markdown view without an uninstaller!", filePath);
					}
					// Clear the ID so we don't get confused if the plugin
					// is re-enabled later.
					this.clearUniqueViewId(leaf.view as ViewWithID);
				} else {
					console.debug("RememberedFileState: found markdown view without an ID!", filePath);
				}
			});
		console.debug(`RememberedFileState: unregistered ${numViews} view callbacks`);
		this._viewUninstallers = {};
		this._lastOpenFiles = {};

		// Run global unhooks.
		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: MarkdownView) {
		var filePath = view.file.path;
		var viewId = this.getUniqueViewId(view as unknown as ViewWithID, true);
		if (viewId in this._viewUninstallers) {
			return;
		}

		console.debug(`RememberedFileState: registering callback on view ${viewId}`, filePath);
		const _this = this;
		var uninstall = around(view, {
			onUnloadFile: function(next) {
				return async function (unloaded: TFile) {
					_this.rememberFileState(unloaded, this);
					return await next.call(this, unloaded);
				};
			}
		});
		this._viewUninstallers[viewId] = uninstall;

		view.register(() => {
			// Don't hold a reference to this plugin here because this callback
			// will outlive it if it gets deactivated. So let's find it, and
			// do nothing if we don't find it.
			// @ts-ignore
			var plugin: RememberFileStatePlugin = app.plugins.getPlugin("obsidian-remember-file-state");
			if (plugin) {
				console.debug(`RememberedFileState: unregistering view ${viewId} callback`, filePath);
				delete plugin._viewUninstallers[viewId];
				delete plugin._lastOpenFiles[viewId];
				uninstall();
			} else {
				console.debug(
					"RememberedFileState: plugin was unloaded, ignoring unregister");
			}
		});
	}

	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: MarkdownView = this.app.workspace.getActiveViewOfType(MarkdownView);
			if (activeView) {
				this.registerOnUnloadFile(activeView);

				var isRealFileOpen = true;
				const viewId = this.getUniqueViewId(activeView as unknown as ViewWithID);
				if (viewId != undefined) {
					const lastOpenFileInView = this._lastOpenFiles[viewId];
					isRealFileOpen = (lastOpenFileInView != openedFile.path);
					this._lastOpenFiles[viewId] = openedFile.path;
				}

				// Don't restore the file state if:
				// - We are suppressing it explicitly (such as if the file was
				//     opened via clicking a hyperlink)
				// - The file is already currently open in another pane
				// - The file was already opened in this pane, and we're just
				//     returning to it.
				if (!this._suppressNextFileOpen &&
					!this.isFileMultiplyOpen(openedFile) &&
				    isRealFileOpen
				   ) {
					try {
						this.restoreFileState(openedFile, activeView);
					} catch (err) {
						console.error("RememberedFileState: couldn't restore file state: ", err);
					}
				}
				else {
					console.debug("RememberedFileState: not restoring file state because:");
					if (this._suppressNextFileOpen) {
						console.debug("...we were told to not do it.");
					} else if (this.isFileMultiplyOpen(openedFile)) {
						console.debug("...it's open in multiple panes.");
					} else if (!isRealFileOpen) {
						console.debug("...that file was already open in this pane.");
					} else {
						console.debug("...unknown reason.");
					}
				}
			}
			// else: the file isn't handled by a markdown editor.
			else {
				console.debug("RememberedFileState: not restoring anything, it's not a markdown view");
			}

			this._suppressNextFileOpen = false;
		}
	}

	private readonly rememberFileState = async (file: TFile, view: MarkdownView): Promise<void> => {
		// Save scrolling position (Obsidian API only gives vertical position).
		const scrollInfo = {top: view.currentMode.getScroll(), left: 0};

		// Save current selection.
		// If state selection is undefined, we have a legacy editor. Just ignore that part.
		const cm6editor = view.editor as EditorWithCM6;
		const stateSelection: EditorSelection = cm6editor.cm.state.selection;
		const stateSelectionJSON = (stateSelection !== undefined) ? stateSelection.toJSON() : "";

		const stateData = {'scrollInfo': scrollInfo, 'selection': stateSelectionJSON};

		var existingFile = this.data.rememberedFiles[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[file.path] = newFileState;

			// If we need to keep the number of remembered files under a maximum,
			// do it now.
			this.forgetExcessFiles();
		}
		console.debug("RememberedFileState: remembered state for:", file.path, stateData);
	}

	private readonly restoreFileState = function(file: TFile, view: MarkdownView) {
		const existingFile = this.data.rememberedFiles[file.path];
		if (existingFile) {
			console.debug("RememberedFileState: restoring state for:", file.path, existingFile.stateData);
			const stateData = existingFile.stateData;

			// Restore scrolling position (Obsidian API only allows setting vertical position).
			view.currentMode.applyScroll(stateData.scrollInfo.top);

			// Restore last known selection, if any.
			if (stateData.selection != "") {
				const cm6editor = view.editor as EditorWithCM6;
				var transaction = cm6editor.cm.state.update({
					selection: EditorSelection.fromJSON(stateData.selection)})
				
				cm6editor.cm.dispatch(transaction);
			}
		}
	}
	
	private readonly isFileMultiplyOpen = function(file: TFile) {
		var numFound: number = 0;
		this.app.workspace.getLeavesOfType("markdown").forEach(
			(leaf: WorkspaceLeaf) => {
				const filePath = (leaf.view as MarkdownView).file.path;
				if (filePath == file.path) {
					++numFound;
				}
			});
		return numFound >= 2;
	}

	private readonly forgetExcessFiles = function() {
		const keepMax = this.settings.rememberMaxFiles;
		if (keepMax <= 0) {
			return;
		}

		// Sort newer files first, older files last.
		var filesData: RememberedFileState[] = Object.values(this.data.rememberedFiles);
		filesData.sort((a, b) => {
			if (a.lastSavedTime > b.lastSavedTime) return -1; // a before b
			if (a.lastSavedTime < b.lastSavedTime) return 1;  // b before a
			return 0;
		});

		// Remove older files past the limit.
		for (var i = keepMax; i < filesData.length; ++i) {
			var fileData = filesData[i];
			delete this.data.rememberedFiles[fileData.path];
		}
	}

	private readonly getUniqueViewId = function(view: ViewWithID, autocreateId: boolean = false) {
		if (view.__uniqueId == undefined) {
			if (!autocreateId) {
				return -1;
			}
			view.__uniqueId = (this._nextUniqueViewId++);
			return view.__uniqueId;
		}
		return view.__uniqueId;
	}

	private readonly clearUniqueViewId = function(view: ViewWithID) {
		delete view["__uniqueId"];
	}

	private readonly onFileRename = async (
		file: TAbstractFile,
		oldPath: string,
	): Promise<void> => {
		const existingFile: RememberedFileState = this.data.rememberedFiles[oldPath];
		if (existingFile) {
			existingFile.path = file.path;
			delete this.data.rememberedFiles[oldPath];
			this.data.rememberedFiles[file.path] = existingFile;
		}
	};

	private readonly onFileDelete = async (
		file: TAbstractFile,
	): Promise<void> => {
		delete this.data.rememberedFiles[file.path];
	};
}