From 9ebbba679dc532dc9c6303d59ddd64d48ec63329 Mon Sep 17 00:00:00 2001 From: Daniel Agafonov Date: Tue, 14 Jul 2026 11:27:38 -0700 Subject: [PATCH] Fix cursor jumping --- README.md | 5 +- editorUtils.test.ts | 206 ++++++++++++++++++++++++++++++++++++++++++++ editorUtils.ts | 78 +++++++++++++++++ main.ts | 31 +++---- manifest.json | 2 +- package.json | 2 +- versions.json | 3 +- 7 files changed, 302 insertions(+), 25 deletions(-) create mode 100644 editorUtils.test.ts create mode 100644 editorUtils.ts diff --git a/README.md b/README.md index 98ba5ad..20f0bb7 100644 --- a/README.md +++ b/README.md @@ -96,8 +96,9 @@ Blacklist mode is the opposite of whitelist mode - it **only removes** links tha - **2.1.1**: Added hyperlink whitelist feature to preserve specified domains/URLs. - **2.2.0**: Added hyperlink type filtering (internal/external/both) and hyperlink/wikilink whitelist feature. - **2.3.0**: Added explicit commands to remove links from either internal or extrnal origin. -- **2.4.0**: Added blacklist mode to explicitly remove only links you want (opposite of whitelist). +- **2.4.0**: Added sblacklist mode to explicitly remove only links you want (opposite of whitelist). - **2.4.2**: Upgrade all dependencies to latest versions and fix all issues reported in community page. - **2.5.0**: Added support for stripping AI and Wikipedia citation links. - **2.5.1**: Fix issues reported in community page. -- **3.0.0**: Added support for filtering out what types of embeds to remove (image, audio, video, pdf, etc). \ No newline at end of file +- **3.0.0**: Added support for filtering out what types of embeds to remove (image, audio, video, pdf, etc). +- **3.1.0**: Fix cursor position jumping when using the remove link command. \ No newline at end of file diff --git a/editorUtils.test.ts b/editorUtils.test.ts new file mode 100644 index 0000000..1602eca --- /dev/null +++ b/editorUtils.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, test } from "@jest/globals"; +import type { Editor, EditorPosition } from "obsidian"; + +import { applyTextUpdate, diffRange } from "./editorUtils"; + +/** + * Minimal Obsidian Editor, covering only the methods + * applyTextUpdate touches. Records the calls the tests assert on. + */ +class StubEditor { + value: string; + anchor: EditorPosition; + head: EditorPosition; + scroll = { top: 420, left: 7 }; + replacedRanges: { + insert: string; + from: EditorPosition; + to: EditorPosition; + }[] = []; + scrolledTo: { left: number; top: number }[] = []; + setValueCalls = 0; + + constructor(value: string, cursor: EditorPosition = { line: 0, ch: 0 }) { + this.value = value; + this.anchor = cursor; + this.head = cursor; + } + + getValue(): string { + return this.value; + } + + setValue(value: string): void { + this.setValueCalls++; + this.value = value; + } + + getLine(line: number): string { + return this.value.split("\n")[line] ?? ""; + } + + lastLine(): number { + return this.value.split("\n").length - 1; + } + + getCursor(mode: "anchor" | "head"): EditorPosition { + return mode === "anchor" ? this.anchor : this.head; + } + + setSelection(anchor: EditorPosition, head: EditorPosition): void { + this.anchor = anchor; + this.head = head; + } + + getScrollInfo(): { top: number; left: number } { + return this.scroll; + } + + scrollTo(left: number, top: number): void { + this.scrolledTo.push({ left, top }); + } + + offsetToPos(offset: number): EditorPosition { + const lines = this.value.slice(0, offset).split("\n"); + return { + line: lines.length - 1, + ch: (lines[lines.length - 1] ?? "").length, + }; + } + + posToOffset(pos: EditorPosition): number { + const lines = this.value.split("\n"); + let offset = 0; + for (let i = 0; i < pos.line; i++) { + offset += (lines[i] ?? "").length + 1; + } + return offset + pos.ch; + } + + replaceRange( + insert: string, + from: EditorPosition, + to: EditorPosition, + ): void { + this.replacedRanges.push({ insert, from, to }); + this.value = + this.value.slice(0, this.posToOffset(from)) + + insert + + this.value.slice(this.posToOffset(to)); + } + + asEditor(): Editor { + return this as unknown as Editor; + } +} + +describe("diffRange", () => { + test("identical text produces no change", () => { + expect(diffRange("same text", "same text")).toBeNull(); + }); + + test("change at the start", () => { + expect(diffRange("[a](http://a) tail", "a tail")).toEqual({ + from: 0, + to: 13, + insert: "a", + }); + }); + + test("change in the middle", () => { + expect(diffRange("head [a](http://a) tail", "head a tail")).toEqual({ + from: 5, + to: 18, + insert: "a", + }); + }); + + test("change at the end", () => { + expect(diffRange("head [a](http://a)", "head a")).toEqual({ + from: 5, + to: 18, + insert: "a", + }); + }); + + test("pure deletion leaves an empty insert", () => { + expect(diffRange("keep [[gone]] keep", "keep keep")).toEqual({ + from: 5, + to: 13, + insert: "", + }); + }); + + test("everything removed", () => { + expect(diffRange("[[gone]]", "")).toEqual({ + from: 0, + to: 8, + insert: "", + }); + }); + + test("prefix and suffix do not overlap on repeated text", () => { + const diff = diffRange("aaaa", "aa"); + expect(diff).not.toBeNull(); + const { from, to, insert } = diff!; + expect(from).toBeLessThanOrEqual(to); + expect("aaaa".slice(0, from) + insert + "aaaa".slice(to)).toBe("aa"); + }); +}); + +describe("applyTextUpdate", () => { + test("returns false and touches nothing when content is unchanged", () => { + const editor = new StubEditor("no links here"); + + expect(applyTextUpdate(editor.asEditor(), "no links here")).toBe(false); + expect(editor.replacedRanges).toHaveLength(0); + expect(editor.scrolledTo).toHaveLength(0); + expect(editor.setValueCalls).toBe(0); + }); + + test("replaces only the changed span and never calls setValue", () => { + const editor = new StubEditor( + "line one\nline [two](http://two)\nline three", + ); + + expect( + applyTextUpdate( + editor.asEditor(), + "line one\nline two\nline three", + ), + ).toBe(true); + expect(editor.value).toBe("line one\nline two\nline three"); + expect(editor.setValueCalls).toBe(0); + expect(editor.replacedRanges).toEqual([ + { + insert: "two", + from: { line: 1, ch: 5 }, + to: { line: 1, ch: 22 }, + }, + ]); + }); + + test("restores the cursor and the scroll position", () => { + const editor = new StubEditor( + "line one\nline [two](http://two)\nline three", + { line: 2, ch: 4 }, + ); + + applyTextUpdate(editor.asEditor(), "line one\nline two\nline three"); + + expect(editor.head).toEqual({ line: 2, ch: 4 }); + expect(editor.anchor).toEqual({ line: 2, ch: 4 }); + expect(editor.scrolledTo).toEqual([{ left: 7, top: 420 }]); + }); + + test("clamps a cursor that the edit pushed past the end of its line", () => { + const editor = new StubEditor("line [two](http://two)", { + line: 0, + ch: 22, + }); + + applyTextUpdate(editor.asEditor(), "line two"); + + expect(editor.head).toEqual({ line: 0, ch: 8 }); + }); +}); diff --git a/editorUtils.ts b/editorUtils.ts new file mode 100644 index 0000000..836138e --- /dev/null +++ b/editorUtils.ts @@ -0,0 +1,78 @@ +import type { Editor, EditorPosition } from "obsidian"; + +export interface TextDiff { + from: number; + to: number; + insert: string; +} + +/** + * Narrow a whole-document rewrite down to the single span that actually changed, + * so the editor can map the cursor and scroll position through the edit instead + * of collapsing them to the start of the document. + */ +export function diffRange(oldText: string, newText: string): TextDiff | null { + if (oldText === newText) { + return null; + } + + let prefix = 0; + const maxPrefix = Math.min(oldText.length, newText.length); + while (prefix < maxPrefix && oldText[prefix] === newText[prefix]) { + prefix++; + } + + let suffix = 0; + const maxSuffix = maxPrefix - prefix; + while ( + suffix < maxSuffix && + oldText[oldText.length - 1 - suffix] === + newText[newText.length - 1 - suffix] + ) { + suffix++; + } + + return { + from: prefix, + to: oldText.length - suffix, + insert: newText.slice(prefix, newText.length - suffix), + }; +} + +function clampPosition( + editor: Editor, + position: EditorPosition, +): EditorPosition { + const line = Math.min(Math.max(position.line, 0), editor.lastLine()); + const ch = Math.min(Math.max(position.ch, 0), editor.getLine(line).length); + return { line, ch }; +} + +/** + * Replace the editor's content, keeping the cursor and viewport in place. + * Returns false when the new content is identical to the current content. + */ +export function applyTextUpdate(editor: Editor, newContent: string): boolean { + const diff = diffRange(editor.getValue(), newContent); + if (!diff) { + return false; + } + + const anchor = editor.getCursor("anchor"); + const head = editor.getCursor("head"); + const scroll = editor.getScrollInfo(); + + editor.replaceRange( + diff.insert, + editor.offsetToPos(diff.from), + editor.offsetToPos(diff.to), + ); + + editor.setSelection( + clampPosition(editor, anchor), + clampPosition(editor, head), + ); + editor.scrollTo(scroll.left, scroll.top); + + return true; +} diff --git a/main.ts b/main.ts index 45dd8ce..a88ef07 100644 --- a/main.ts +++ b/main.ts @@ -1,4 +1,5 @@ import { App, Editor, Notice, Plugin, PluginSettingTab, Setting } from 'obsidian'; +import { applyTextUpdate } from './editorUtils'; import { EmbedTypeOptions, removeCitations, removeHyperlinks, removeWikilinks, removeWikipediaCitations } from './removeLinks'; interface HyperlinkRemoverSettings { @@ -70,10 +71,8 @@ export default class HyperlinkRemover extends Plugin { id: 'remove-links-from-file', name: 'Remove links from file', editorCallback: (editor: Editor) => { - const content = editor.getValue(); - const updatedContent = this.processText(content); - if (content !== updatedContent) { - editor.setValue(updatedContent); + const updatedContent = this.processText(editor.getValue()); + if (applyTextUpdate(editor, updatedContent)) { new Notice('Links removed from file'); } else { new Notice('No links found in the file'); @@ -85,10 +84,8 @@ export default class HyperlinkRemover extends Plugin { id: 'remove-external-links-from-file', name: 'Remove external links from file', editorCallback: (editor: Editor) => { - const content = editor.getValue(); - const updatedContent = this.processText(content, 'external'); - if (content !== updatedContent) { - editor.setValue(updatedContent); + const updatedContent = this.processText(editor.getValue(), 'external'); + if (applyTextUpdate(editor, updatedContent)) { new Notice('External links removed from file'); } else { new Notice('No external links found in the file'); @@ -100,10 +97,8 @@ export default class HyperlinkRemover extends Plugin { id: 'remove-internal-links-from-file', name: 'Remove internal links from file', editorCallback: (editor: Editor) => { - const content = editor.getValue(); - const updatedContent = this.processText(content, 'internal'); - if (content !== updatedContent) { - editor.setValue(updatedContent); + const updatedContent = this.processText(editor.getValue(), 'internal'); + if (applyTextUpdate(editor, updatedContent)) { new Notice('Internal links removed from file'); } else { new Notice('No internal links found in the file'); @@ -134,10 +129,8 @@ export default class HyperlinkRemover extends Plugin { id: 'remove-blacklisted-links-from-file', name: 'Remove blacklisted links from file', editorCallback: (editor: Editor) => { - const content = editor.getValue(); - const updatedContent = this.processText(content, undefined, true); - if (content !== updatedContent) { - editor.setValue(updatedContent); + const updatedContent = this.processText(editor.getValue(), undefined, true); + if (applyTextUpdate(editor, updatedContent)) { new Notice('Blacklisted links removed from file'); } else { new Notice('No blacklisted links found in the file'); @@ -171,10 +164,8 @@ export default class HyperlinkRemover extends Plugin { item.setTitle("Remove links from file") .setIcon("unlink") .onClick(() => { - const content = editor.getValue(); - const updatedContent = this.processText(content); - if (content !== updatedContent) { - editor.setValue(updatedContent); + const updatedContent = this.processText(editor.getValue()); + if (applyTextUpdate(editor, updatedContent)) { new Notice('Links removed from file'); } else { new Notice('No links found in the file'); diff --git a/manifest.json b/manifest.json index 652fafc..008e45a 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "hyperlink-remover", "name": "Link Remover", - "version": "3.0.0", + "version": "3.1.0", "minAppVersion": "0.16.2", "description": "Easily remove hyperlinks and wikilinks from selected text or the entire note.", "author": "Daniel Agafonov", diff --git a/package.json b/package.json index 196586b..10f718c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "link-remover", - "version": "3.0.0", + "version": "3.1.0", "description": "Easily remove hyperlinks and wikilinks from selected text or the entire note", "main": "main.js", "scripts": { diff --git a/versions.json b/versions.json index 9793257..a6e281f 100644 --- a/versions.json +++ b/versions.json @@ -16,5 +16,6 @@ "2.4.2": "0.16.2", "2.5.0": "0.16.2", "2.5.1": "0.16.2", - "3.0.0": "0.16.2" + "3.0.0": "0.16.2", + "3.1.0": "0.16.2" } \ No newline at end of file