mirror of
https://github.com/alphahasher/obsidian-remove-links.git
synced 2026-07-22 05:51:41 +00:00
Fix cursor jumping
This commit is contained in:
parent
4581c6af7d
commit
9ebbba679d
7 changed files with 302 additions and 25 deletions
|
|
@ -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
206
editorUtils.test.ts
Normal 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
78
editorUtils.ts
Normal 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
31
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');
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
Loading…
Reference in a new issue