This commit is contained in:
Aly Thobani 2026-05-24 17:33:44 -07:00 committed by GitHub
commit e9975172ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 246 additions and 18 deletions

View file

@ -86,11 +86,19 @@ In some cases you can find workarounds by experimenting, and the easiest way to
Finally, this plugin also provides the following motions/mappings by default:
- `[[` and `]]` to jump to the previous and next Markdown heading.
- `zk` and `zj` to move up and down while skipping folds.
- `gl` and `gL` to jump to the next and previous link.
- `[[` and `]]` to jump to the previous and next Markdown heading. Supports repeat (e.g. `2]]` to jump two headings forward).
- `zk` and `zj` to move up and down while skipping folds. Supports repeat.
- `gc` and `gC` to jump to the next and previous Markdown code fence (opening or closing fence; just any line starting with >= 3 backticks). Supports repeat.
- `gl` and `gL` to jump to the next and previous link. Supports repeat.
- `gf` to open the link or file under the cursor (temporarily moving the cursor if necessary—e.g. if it's on the first square bracket of a [[Wikilink]]).
You can of course remap these as you wish. E.g. if you prefer `'h` and `gh` for jumping to headings:
```vim
map 'h [[
map gh ]]
```
## Installation
In the Obsidian.md settings under "Community plugins", click on "Turn on community plugins", then browse to this plugin.

View file

@ -3,6 +3,7 @@ import { App, EditorSelection, MarkdownView, Notice, Editor as ObsidianEditor, P
import { followLinkUnderCursor } from './actions/followLinkUnderCursor';
import { moveDownSkippingFolds, moveUpSkippingFolds } from './actions/moveSkippingFolds';
import { jumpToNextCodeFence, jumpToPreviousCodeFence } from './motions/jumpToCodeFence';
import { jumpToNextHeading, jumpToPreviousHeading } from './motions/jumpToHeading';
import { jumpToNextLink, jumpToPreviousLink } from './motions/jumpToLink';
import { defineAndMapObsidianVimAction, defineAndMapObsidianVimMotion } from './utils/obsidianVimCommand';
@ -429,6 +430,8 @@ export default class VimrcPlugin extends Plugin {
defineAndMapObsidianVimCommands(vimObject: VimApi) {
defineAndMapObsidianVimMotion(vimObject, jumpToNextHeading, ']]');
defineAndMapObsidianVimMotion(vimObject, jumpToPreviousHeading, '[[');
defineAndMapObsidianVimMotion(vimObject, jumpToNextCodeFence, 'gc');
defineAndMapObsidianVimMotion(vimObject, jumpToPreviousCodeFence, 'gC');
defineAndMapObsidianVimMotion(vimObject, jumpToNextLink, 'gl');
defineAndMapObsidianVimMotion(vimObject, jumpToPreviousLink, 'gL');

View file

@ -0,0 +1,41 @@
import { jumpToPattern } from "../utils/jumpToPattern";
import { MotionFn } from "../utils/vimApi";
/** Naive Regex for a Markdown code fence, i.e. a line beginning with at least three backticks.
*
* "Naive" for two reasons:
* 1. False negatives: Fences could be made of tildes instead of backticks. This is less common, but
* should be easy to add support for if users request it.
* 2. False positives: It could match lines within non-Markdown codeblocks. But since triple
* backticks aren't a syntax feature of any other programming language (AFAIK, at the time of this
* writing), this should be rare enough that the naive regex should work well in practice.
*
* Matches only the fence itself, so can be used for jumping to either an opening or closing fence.
*/
const NAIVE_CODE_FENCE_REGEX = /^```+/gm;
/**
* Jumps to the repeat-th next code fence.
*/
export const jumpToNextCodeFence: MotionFn = (cm, cursorPosition, { repeat }) => {
return jumpToPattern({
cm,
cursorPosition,
repeat,
regex: NAIVE_CODE_FENCE_REGEX,
direction: "next",
});
};
/**
* Jumps to the repeat-th previous code fence.
*/
export const jumpToPreviousCodeFence: MotionFn = (cm, cursorPosition, { repeat }) => {
return jumpToPattern({
cm,
cursorPosition,
repeat,
regex: NAIVE_CODE_FENCE_REGEX,
direction: "previous",
});
};

View file

@ -1,4 +1,3 @@
import { Editor as CodeMirrorEditor } from "codemirror";
import { EditorPosition } from "obsidian";
import { isWithinMatch, jumpToPattern } from "../utils/jumpToPattern";
import { MotionFn } from "../utils/vimApi";
@ -11,6 +10,8 @@ const NAIVE_HEADING_REGEX = /^#{1,6} /gm;
/** Regex for a Markdown fenced codeblock, which begins with some number >=3 of backticks at the
* start of a line. It either ends on the nearest future line that starts with at least as many
* backticks (\1 back-reference), or extends to the end of the string if no such future line exists.
*
* Matches the entire codeblock.
*/
const FENCED_CODEBLOCK_REGEX = /(^```+)(.*?^\1|.*)/gms;
@ -24,11 +25,7 @@ export const jumpToNextHeading: MotionFn = (cm, cursorPosition, { repeat }) => {
/**
* Jumps to the repeat-th previous heading.
*/
export const jumpToPreviousHeading: MotionFn = (
cm,
cursorPosition,
{ repeat }
) => {
export const jumpToPreviousHeading: MotionFn = (cm, cursorPosition, { repeat }) => {
return jumpToHeading({ cm, cursorPosition, repeat, direction: "previous" });
};
@ -45,7 +42,7 @@ function jumpToHeading({
repeat,
direction,
}: {
cm: CodeMirrorEditor;
cm: CodeMirror.Editor;
cursorPosition: EditorPosition;
repeat: number;
direction: "next" | "previous";
@ -62,7 +59,7 @@ function jumpToHeading({
});
}
function findAllCodeblocks(cm: CodeMirrorEditor): RegExpExecArray[] {
function findAllCodeblocks(cm: CodeMirror.Editor): RegExpExecArray[] {
const content = cm.getValue();
return [...content.matchAll(FENCED_CODEBLOCK_REGEX)];
}

View file

@ -0,0 +1,38 @@
import { EditorPosition } from "obsidian";
/**
* Returns a fake CodeMirror editor bound to the given `contentLines`.
*/
export function createFakeCodeMirrorEditor(
contentLines: string[]
): Pick<CodeMirror.Editor, "getValue" | "indexFromPos" | "posFromIndex"> {
const content = contentLines.join("\n");
const lineStartIndexes = getLineStartIndexes(contentLines);
return {
getValue: () => content,
indexFromPos: ({ line, ch }: EditorPosition) => lineStartIndexes[line] + ch,
posFromIndex: (index: number) => getEditorPositionForIndex(index, lineStartIndexes),
};
}
function getLineStartIndexes(lines: string[]): number[] {
const lineStartIndexes: number[] = [];
let currentIndex = 0;
for (const line of lines) {
lineStartIndexes.push(currentIndex);
currentIndex += line.length + 1;
}
return lineStartIndexes;
}
function getEditorPositionForIndex(
index: number,
lineStartIndexes: number[]
): EditorPosition {
for (let line = lineStartIndexes.length - 1; line >= 0; line -= 1) {
if (lineStartIndexes[line] <= index) {
return { line, ch: index - lineStartIndexes[line] };
}
}
return { line: 0, ch: 0 };
}

View file

@ -0,0 +1,144 @@
import { EditorPosition } from "obsidian";
import { describe, expect, test } from "vitest";
import { jumpToNextCodeFence, jumpToPreviousCodeFence } from "../motions/jumpToCodeFence";
import { createFakeCodeMirrorEditor } from "./createFakeCodeMirrorEditor";
const CODE_FENCE_CONTENT_LINES = ["intro", "```ts", "const answer = 42;", "```", "outro"];
const INLINE_BACKTICK_CONTENT_LINES = [
"here are inline backticks ``` not a fence",
"```ts",
"const answer = 42;",
"```",
];
const MULTIPLE_CODE_FENCE_CONTENT_LINES = [
"intro",
"```ts",
"const firstAnswer = 42;",
"```",
"middle",
"````js",
"const secondAnswer = 7;",
"````",
"outro",
];
const NO_CODE_FENCE_CONTENT_LINES = ["intro", "plain text", "outro"];
describe("jumpToNextCodeFence", () => {
test("jumps to the next opening code fence", () => {
expectNextCodeFencePosition({ line: 0, ch: 0 }, { line: 1, ch: 0 });
});
test("jumps to the next closing code fence", () => {
expectNextCodeFencePosition({ line: 2, ch: 0 }, { line: 3, ch: 0 });
});
test("wraps to the first code fence after the last one", () => {
expectNextCodeFencePosition({ line: 4, ch: 0 }, { line: 1, ch: 0 });
});
test("ignores backticks that are not code fence lines", () => {
expectNextCodeFencePosition(
{ line: 0, ch: 0 },
{ line: 1, ch: 0 },
INLINE_BACKTICK_CONTENT_LINES
);
});
test("jumps to the second next code fence when repeat is two", () => {
expectNextCodeFencePosition(
{ line: 0, ch: 0 },
{ line: 3, ch: 0 },
MULTIPLE_CODE_FENCE_CONTENT_LINES,
2
);
});
test("jumps to the closing code fence when cursor is within a code block", () => {
expectNextCodeFencePosition({ line: 2, ch: 5 }, { line: 3, ch: 0 });
});
test("jumps to the next distinct code fence when cursor is on the first backtick of a fence", () => {
expectNextCodeFencePosition({ line: 1, ch: 0 }, { line: 3, ch: 0 });
});
test("jumps to the next distinct code fence when cursor is in the middle of a fence", () => {
expectNextCodeFencePosition({ line: 1, ch: 1 }, { line: 3, ch: 0 });
});
test("returns the original cursor position when there are no code fences", () => {
expectNextCodeFencePosition(
{ line: 1, ch: 2 },
{ line: 1, ch: 2 },
NO_CODE_FENCE_CONTENT_LINES
);
});
test("matches fences longer than three backticks", () => {
expectNextCodeFencePosition(
{ line: 4, ch: 0 },
{ line: 5, ch: 0 },
MULTIPLE_CODE_FENCE_CONTENT_LINES
);
});
});
describe("jumpToPreviousCodeFence", () => {
test("jumps to the previous closing code fence", () => {
expectPreviousCodeFencePosition({ line: 4, ch: 0 }, { line: 3, ch: 0 });
});
test("jumps to the previous opening code fence", () => {
expectPreviousCodeFencePosition({ line: 2, ch: 0 }, { line: 1, ch: 0 });
});
test("jumps to the second previous code fence when repeat is two", () => {
expectPreviousCodeFencePosition(
{ line: 8, ch: 0 },
{ line: 5, ch: 0 },
MULTIPLE_CODE_FENCE_CONTENT_LINES,
2
);
});
test("jumps to the opening code fence when cursor is within a code block", () => {
expectPreviousCodeFencePosition({ line: 2, ch: 5 }, { line: 1, ch: 0 });
});
test("jumps to the previous distinct code fence when cursor is on the first backtick of a fence", () => {
expectPreviousCodeFencePosition({ line: 3, ch: 0 }, { line: 1, ch: 0 });
});
test("jumps to the previous distinct code fence when cursor is in the middle of a fence", () => {
expectPreviousCodeFencePosition({ line: 3, ch: 1 }, { line: 1, ch: 0 });
});
test("returns the original cursor position when there are no code fences", () => {
expectPreviousCodeFencePosition(
{ line: 1, ch: 2 },
{ line: 1, ch: 2 },
NO_CODE_FENCE_CONTENT_LINES
);
});
});
function expectNextCodeFencePosition(
cursorPosition: EditorPosition,
expectedPosition: EditorPosition,
contentLines: string[] = CODE_FENCE_CONTENT_LINES,
repeat = 1
): void {
const cm = createFakeCodeMirrorEditor(contentLines);
const nextCodeFence = jumpToNextCodeFence(cm as any, cursorPosition, { repeat });
expect(nextCodeFence).toEqual(expectedPosition);
}
function expectPreviousCodeFencePosition(
cursorPosition: EditorPosition,
expectedPosition: EditorPosition,
contentLines: string[] = CODE_FENCE_CONTENT_LINES,
repeat = 1
): void {
const cm = createFakeCodeMirrorEditor(contentLines);
const previousCodeFence = jumpToPreviousCodeFence(cm as any, cursorPosition, { repeat });
expect(previousCodeFence).toEqual(expectedPosition);
}

View file

@ -1,4 +1,3 @@
import { Editor as CodeMirrorEditor } from "codemirror";
import { EditorPosition } from "obsidian";
/**
@ -27,7 +26,7 @@ export function jumpToPattern({
filterMatch = () => true,
direction,
}: {
cm: CodeMirrorEditor;
cm: CodeMirror.Editor;
cursorPosition: EditorPosition;
repeat: number;
regex: RegExp;

View file

@ -2,14 +2,13 @@
* Utility types and functions for defining Obsidian-specific Vim commands.
*/
import { Editor as CodeMirrorEditor } from "codemirror";
import VimrcPlugin from "../main";
import { MotionFn, VimApi } from "./vimApi";
export type ObsidianActionFn = (
vimrcPlugin: VimrcPlugin, // Included so we can run Obsidian commands as part of the action
cm: CodeMirrorEditor,
cm: CodeMirror.Editor,
actionArgs: { repeat: number },
) => void;

View file

@ -6,17 +6,16 @@
* https://libvoyant.ucr.edu/resources/codemirror/doc/manual.html
*/
import { Editor as CodeMirrorEditor } from "codemirror";
import { EditorPosition } from "obsidian";
export type MotionFn = (
cm: CodeMirrorEditor,
cm: CodeMirror.Editor,
cursorPosition: EditorPosition, // called `head` in the API
motionArgs: { repeat: number }
) => EditorPosition;
export type ActionFn = (
cm: CodeMirrorEditor,
cm: CodeMirror.Editor,
actionArgs: { repeat: number },
) => void;