Fix cursor jumping

This commit is contained in:
Daniel Agafonov 2026-07-14 11:27:38 -07:00
parent 4581c6af7d
commit 9ebbba679d
7 changed files with 302 additions and 25 deletions

View file

@ -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).
- **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.

206
editorUtils.test.ts Normal file
View file

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

78
editorUtils.ts Normal file
View file

@ -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;
}

31
main.ts
View file

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

View file

@ -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",

View file

@ -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": {

View file

@ -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"
}