Skip to content

Commit d258307

Browse files
alythobaniesm7
andauthored
Implement and provide default mappings for some Obsidian-specific Vim motions/commands (#222)
* feat: define and expose obsidian-specific vim commands jumpToNextHeading: g] jumpToPreviousHeading: g[ * Implement jumpToPreviousLink motion * Refactoring and implementing jumpToNextLink * refactor: new jumpToPattern function that can be used for motions * refactor: renamed file and removed unneeded exports * fix: return last found index even if fewer than n instances found, instead of undefined * feat: implement moveUpSkipFold and moveDownSkipFold * refactor: extract out helper functions for defining obsidian vim actions * refactor: split vimApi.ts into two files * refactor: add comment * refactor: update names, types, etc * feat: followLinkUnderCursor action * feat: jumpToLink now jumps to both markdown and wiki links * refactor: rename fns * refactor: add docstrings / change var names * feat: implement looping around * refactor: cleaner implementation of jumpToPattern * Change mappings for next/prev heading to [[ and ]] * Tiny fixes * docs: update docs now that some more motions are provided by default --------- Co-authored-by: Erez Shermer <[email protected]>
1 parent 89cd8c7 commit d258307

12 files changed

+382
-32
lines changed

JsSnippets.md

+3-4
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,11 @@ In this document I will collect some of my and user-contributed ideas for how to
44

55
If you have interesting snippets, please contribute by opening a pull request!
66

7+
Note that these examples are included for demonstration purposes, and many of them are now provided by default in this plugin. Their actual implementations can be found under [`motions/`](https://github.com/esm7/obsidian-vimrc-support/blob/master/motions/), which you can also use as reference (either for your own custom motions, or if you wish to submit a PR for a new motion to be provided by this plugin).
78

8-
## Jump to Next/Prev Markdown Header
9+
## Jump to Next/Previous Markdown Heading
910

10-
To map `]]` and `[[` to next/prev markdown header, I use the following.
11-
12-
In a file I call `mdHelpers.js`, put this:
11+
In a file you can call `mdHelpers.js`, put this:
1312

1413
```js
1514
// Taken from https://stackoverflow.com/questions/273789/is-there-a-version-of-javascripts-string-indexof-that-allows-for-regular-expr

README.md

+8-1
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,13 @@ Commands that fail don't generate any visible error for now.
7575
CodeMirror's Vim mode has some limitations and bugs and not all commands will work like you'd expect.
7676
In some cases you can find workarounds by experimenting, and the easiest way to do that is by trying interactively rather than via the Vimrc file.
7777

78+
Finally, this plugin also provides the following motions/mappings by default:
79+
80+
- `[[` and `]]` to jump to the previous and next Markdown heading.
81+
- `zk` and `zj` to move up and down while skipping folds.
82+
- `gl` and `gL` to jump to the next and previous link.
83+
- `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]]).
84+
7885
## Installation
7986

8087
In the Obsidian.md settings under "Community plugins", click on "Turn on community plugins", then browse to this plugin.
@@ -283,7 +290,7 @@ The `jsfile` should be placed in your vault (alongside, e.g., your markdown file
283290

284291
As above, the code running as part of `jsfile` has the arguments `editor: Editor`, `view: MarkdownView` and `selection: EditorSelection`.
285292

286-
Here's an example from my own `.obsidian.vimrc` that maps `]]` and `[[` to jump to the next/previous Markdown header:
293+
Here's an example `.obsidian.vimrc` entry that maps `]]` and `[[` to jump to the next/previous Markdown heading. Note that `]]` and `[[` are already provided by default in this plugin, but this is a good example of how to use `jsfile`:
287294

288295
```
289296
exmap nextHeading jsfile mdHelpers.js {jumpHeading(true)}

actions/followLinkUnderCursor.ts

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { ObsidianActionFn } from "../utils/obsidianVimCommand";
2+
3+
/**
4+
* Follows the link under the cursor, temporarily moving the cursor if necessary for follow-link to
5+
* work (i.e. if the cursor is on a starting square bracket).
6+
*/
7+
export const followLinkUnderCursor: ObsidianActionFn = (vimrcPlugin) => {
8+
const obsidianEditor = vimrcPlugin.getActiveObsidianEditor();
9+
const { line, ch } = obsidianEditor.getCursor();
10+
const firstTwoChars = obsidianEditor.getRange(
11+
{ line, ch },
12+
{ line, ch: ch + 2 }
13+
);
14+
let numCharsMoved = 0;
15+
for (const char of firstTwoChars) {
16+
if (char === "[") {
17+
obsidianEditor.exec("goRight");
18+
numCharsMoved++;
19+
}
20+
}
21+
vimrcPlugin.executeObsidianCommand("editor:follow-link");
22+
// Move the cursor back to where it was
23+
for (let i = 0; i < numCharsMoved; i++) {
24+
obsidianEditor.exec("goLeft");
25+
}
26+
};

actions/moveSkippingFolds.ts

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import VimrcPlugin from "../main";
2+
import { ObsidianActionFn } from "../utils/obsidianVimCommand";
3+
4+
/**
5+
* Moves the cursor down `repeat` lines, skipping over folded sections.
6+
*/
7+
export const moveDownSkippingFolds: ObsidianActionFn = (
8+
vimrcPlugin,
9+
cm,
10+
{ repeat }
11+
) => {
12+
moveSkippingFolds(vimrcPlugin, repeat, "down");
13+
};
14+
15+
/**
16+
* Moves the cursor up `repeat` lines, skipping over folded sections.
17+
*/
18+
export const moveUpSkippingFolds: ObsidianActionFn = (
19+
vimrcPlugin,
20+
cm,
21+
{ repeat }
22+
) => {
23+
moveSkippingFolds(vimrcPlugin, repeat, "up");
24+
};
25+
26+
function moveSkippingFolds(
27+
vimrcPlugin: VimrcPlugin,
28+
repeat: number,
29+
direction: "up" | "down"
30+
) {
31+
const obsidianEditor = vimrcPlugin.getActiveObsidianEditor();
32+
let { line: oldLine, ch: oldCh } = obsidianEditor.getCursor();
33+
const commandName = direction === "up" ? "goUp" : "goDown";
34+
for (let i = 0; i < repeat; i++) {
35+
obsidianEditor.exec(commandName);
36+
const { line: newLine, ch: newCh } = obsidianEditor.getCursor();
37+
if (newLine === oldLine && newCh === oldCh) {
38+
// Going in the specified direction doesn't do anything anymore, stop now
39+
return;
40+
}
41+
[oldLine, oldCh] = [newLine, newCh];
42+
}
43+
}

main.ts

+48-22
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
import * as keyFromAccelerator from 'keyboardevent-from-electron-accelerator';
2-
import { EditorSelection, Notice, App, MarkdownView, Plugin, PluginSettingTab, Setting, TFile } from 'obsidian';
2+
import { App, EditorSelection, MarkdownView, Notice, Editor as ObsidianEditor, Plugin, PluginSettingTab, Setting } from 'obsidian';
3+
4+
import { followLinkUnderCursor } from './actions/followLinkUnderCursor';
5+
import { moveDownSkippingFolds, moveUpSkippingFolds } from './actions/moveSkippingFolds';
6+
import { jumpToNextHeading, jumpToPreviousHeading } from './motions/jumpToHeading';
7+
import { jumpToNextLink, jumpToPreviousLink } from './motions/jumpToLink';
8+
import { defineAndMapObsidianVimAction, defineAndMapObsidianVimMotion } from './utils/obsidianVimCommand';
9+
import { VimApi } from './utils/vimApi';
310

411
declare const CodeMirror: any;
512

@@ -249,6 +256,10 @@ export default class VimrcPlugin extends Plugin {
249256
return this.app.workspace.getActiveViewOfType(MarkdownView);
250257
}
251258

259+
getActiveObsidianEditor(): ObsidianEditor {
260+
return this.getActiveView().editor;
261+
}
262+
252263
private getCodeMirror(view: MarkdownView): CodeMirror.Editor {
253264
return (view as any).editMode?.editor?.cm?.cm;
254265
}
@@ -259,6 +270,7 @@ export default class VimrcPlugin extends Plugin {
259270
var cmEditor = this.getCodeMirror(view);
260271
if (cmEditor && !this.codeMirrorVimObject.loadedVimrc) {
261272
this.defineBasicCommands(this.codeMirrorVimObject);
273+
this.defineAndMapObsidianVimCommands(this.codeMirrorVimObject);
262274
this.defineSendKeys(this.codeMirrorVimObject);
263275
this.defineObCommand(this.codeMirrorVimObject);
264276
this.defineSurround(this.codeMirrorVimObject);
@@ -369,6 +381,17 @@ export default class VimrcPlugin extends Plugin {
369381
});
370382
}
371383

384+
defineAndMapObsidianVimCommands(vimObject: VimApi) {
385+
defineAndMapObsidianVimMotion(vimObject, jumpToNextHeading, ']]');
386+
defineAndMapObsidianVimMotion(vimObject, jumpToPreviousHeading, '[[');
387+
defineAndMapObsidianVimMotion(vimObject, jumpToNextLink, 'gl');
388+
defineAndMapObsidianVimMotion(vimObject, jumpToPreviousLink, 'gL');
389+
390+
defineAndMapObsidianVimAction(vimObject, this, moveDownSkippingFolds, 'zj');
391+
defineAndMapObsidianVimAction(vimObject, this, moveUpSkippingFolds, 'zk');
392+
defineAndMapObsidianVimAction(vimObject, this, followLinkUnderCursor, 'gf');
393+
}
394+
372395
defineSendKeys(vimObject: any) {
373396
vimObject.defineEx('sendkeys', '', async (cm: any, params: any) => {
374397
if (!params?.args?.length) {
@@ -403,33 +426,36 @@ export default class VimrcPlugin extends Plugin {
403426
});
404427
}
405428

429+
executeObsidianCommand(commandName: string) {
430+
const availableCommands = (this.app as any).commands.commands;
431+
if (!(commandName in availableCommands)) {
432+
throw new Error(`Command ${commandName} was not found, try 'obcommand' with no params to see in the developer console what's available`);
433+
}
434+
const view = this.getActiveView();
435+
const editor = view.editor;
436+
const command = availableCommands[commandName];
437+
const {callback, checkCallback, editorCallback, editorCheckCallback} = command;
438+
if (editorCheckCallback)
439+
editorCheckCallback(false, editor, view);
440+
else if (editorCallback)
441+
editorCallback(editor, view);
442+
else if (checkCallback)
443+
checkCallback(false);
444+
else if (callback)
445+
callback();
446+
else
447+
throw new Error(`Command ${commandName} doesn't have an Obsidian callback`);
448+
}
449+
406450
defineObCommand(vimObject: any) {
407451
vimObject.defineEx('obcommand', '', async (cm: any, params: any) => {
408-
const availableCommands = (this.app as any).commands.commands;
409452
if (!params?.args?.length || params.args.length != 1) {
453+
const availableCommands = (this.app as any).commands.commands;
410454
console.log(`Available commands: ${Object.keys(availableCommands).join('\n')}`)
411455
throw new Error(`obcommand requires exactly 1 parameter`);
412456
}
413-
let view = this.getActiveView();
414-
let editor = view.editor;
415-
const command = params.args[0];
416-
if (command in availableCommands) {
417-
let callback = availableCommands[command].callback;
418-
let checkCallback = availableCommands[command].checkCallback;
419-
let editorCallback = availableCommands[command].editorCallback;
420-
let editorCheckCallback = availableCommands[command].editorCheckCallback;
421-
if (editorCheckCallback)
422-
editorCheckCallback(false, editor, view);
423-
else if (editorCallback)
424-
editorCallback(editor, view);
425-
else if (checkCallback)
426-
checkCallback(false);
427-
else if (callback)
428-
callback();
429-
else
430-
throw new Error(`Command ${command} doesn't have an Obsidian callback`);
431-
} else
432-
throw new Error(`Command ${command} was not found, try 'obcommand' with no params to see in the developer console what's available`);
457+
const commandName = params.args[0];
458+
this.executeObsidianCommand(commandName);
433459
});
434460
}
435461

motions/jumpToHeading.ts

+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { jumpToPattern } from "../utils/jumpToPattern";
2+
import { MotionFn } from "../utils/vimApi";
3+
4+
const HEADING_REGEX = /^#+ /gm;
5+
6+
/**
7+
* Jumps to the repeat-th next heading.
8+
*/
9+
export const jumpToNextHeading: MotionFn = (cm, cursorPosition, { repeat }) => {
10+
return jumpToPattern({
11+
cm,
12+
cursorPosition,
13+
repeat,
14+
regex: HEADING_REGEX,
15+
direction: "next",
16+
});
17+
};
18+
19+
/**
20+
* Jumps to the repeat-th previous heading.
21+
*/
22+
export const jumpToPreviousHeading: MotionFn = (
23+
cm,
24+
cursorPosition,
25+
{ repeat }
26+
) => {
27+
return jumpToPattern({
28+
cm,
29+
cursorPosition,
30+
repeat,
31+
regex: HEADING_REGEX,
32+
direction: "previous",
33+
});
34+
};

motions/jumpToLink.ts

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { jumpToPattern } from "../utils/jumpToPattern";
2+
import { MotionFn } from "../utils/vimApi";
3+
4+
const WIKILINK_REGEX_STRING = "\\[\\[[^\\]\\]]+?\\]\\]";
5+
const MARKDOWN_LINK_REGEX_STRING = "\\[[^\\]]+?\\]\\([^)]+?\\)";
6+
const LINK_REGEX_STRING = `${WIKILINK_REGEX_STRING}|${MARKDOWN_LINK_REGEX_STRING}`;
7+
const LINK_REGEX = new RegExp(LINK_REGEX_STRING, "g");
8+
9+
/**
10+
* Jumps to the repeat-th next link.
11+
*/
12+
export const jumpToNextLink: MotionFn = (cm, cursorPosition, { repeat }) => {
13+
return jumpToPattern({
14+
cm,
15+
cursorPosition,
16+
repeat,
17+
regex: LINK_REGEX,
18+
direction: "next",
19+
});
20+
};
21+
22+
/**
23+
* Jumps to the repeat-th previous link.
24+
*/
25+
export const jumpToPreviousLink: MotionFn = (cm, cursorPosition, { repeat }) => {
26+
return jumpToPattern({
27+
cm,
28+
cursorPosition,
29+
repeat,
30+
regex: LINK_REGEX,
31+
direction: "previous",
32+
});
33+
};

package.json

+6-2
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,15 @@
1515
"@rollup/plugin-node-resolve": "^9.0.0",
1616
"@rollup/plugin-typescript": "^11.0.0",
1717
"@types/node": "^14.14.6",
18+
"@types/string.prototype.matchall": "^4.0.4",
1819
"codemirror": "^5.62.2",
1920
"keyboardevent-from-electron-accelerator": "*",
2021
"obsidian": "^1.1.1",
2122
"rollup": "^2.33.0",
22-
"tslib": "^2.0.3",
23-
"typescript": "^4.9.4"
23+
"tslib": "^2.6.3",
24+
"typescript": "^5.5.3"
25+
},
26+
"dependencies": {
27+
"string.prototype.matchall": "^4.0.11"
2428
}
2529
}

tsconfig.json

+3-3
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,16 @@
44
"inlineSourceMap": true,
55
"inlineSources": true,
66
"module": "ESNext",
7-
"target": "es5",
7+
"target": "ES2020",
88
"allowJs": true,
99
"noImplicitAny": true,
1010
"moduleResolution": "node",
11+
"downlevelIteration": true,
1112
"importHelpers": true,
1213
"lib": [
1314
"dom",
14-
"es5",
1515
"scripthost",
16-
"es2015"
16+
"ES2020"
1717
]
1818
},
1919
"include": [

0 commit comments

Comments
 (0)