diff --git a/JsSnippets.md b/JsSnippets.md index c23ba00..4ccc6eb 100644 --- a/JsSnippets.md +++ b/JsSnippets.md @@ -4,12 +4,11 @@ In this document I will collect some of my and user-contributed ideas for how to If you have interesting snippets, please contribute by opening a pull request! +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). -## Jump to Next/Prev Markdown Header +## Jump to Next/Previous Markdown Heading -To map `]]` and `[[` to next/prev markdown header, I use the following. - -In a file I call `mdHelpers.js`, put this: +In a file you can call `mdHelpers.js`, put this: ```js // Taken from https://stackoverflow.com/questions/273789/is-there-a-version-of-javascripts-string-indexof-that-allows-for-regular-expr diff --git a/README.md b/README.md index fc6a058..1225fce 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,13 @@ Commands that fail don't generate any visible error for now. CodeMirror's Vim mode has some limitations and bugs and not all commands will work like you'd expect. 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. +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. +- `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]]). + ## Installation 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 As above, the code running as part of `jsfile` has the arguments `editor: Editor`, `view: MarkdownView` and `selection: EditorSelection`. -Here's an example from my own `.obsidian.vimrc` that maps `]]` and `[[` to jump to the next/previous Markdown header: +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`: ``` exmap nextHeading jsfile mdHelpers.js {jumpHeading(true)} diff --git a/actions/followLinkUnderCursor.ts b/actions/followLinkUnderCursor.ts new file mode 100644 index 0000000..fe4faaa --- /dev/null +++ b/actions/followLinkUnderCursor.ts @@ -0,0 +1,26 @@ +import { ObsidianActionFn } from "../utils/obsidianVimCommand"; + +/** + * Follows the link under the cursor, temporarily moving the cursor if necessary for follow-link to + * work (i.e. if the cursor is on a starting square bracket). + */ +export const followLinkUnderCursor: ObsidianActionFn = (vimrcPlugin) => { + const obsidianEditor = vimrcPlugin.getActiveObsidianEditor(); + const { line, ch } = obsidianEditor.getCursor(); + const firstTwoChars = obsidianEditor.getRange( + { line, ch }, + { line, ch: ch + 2 } + ); + let numCharsMoved = 0; + for (const char of firstTwoChars) { + if (char === "[") { + obsidianEditor.exec("goRight"); + numCharsMoved++; + } + } + vimrcPlugin.executeObsidianCommand("editor:follow-link"); + // Move the cursor back to where it was + for (let i = 0; i < numCharsMoved; i++) { + obsidianEditor.exec("goLeft"); + } +}; diff --git a/actions/moveSkippingFolds.ts b/actions/moveSkippingFolds.ts new file mode 100644 index 0000000..a513a93 --- /dev/null +++ b/actions/moveSkippingFolds.ts @@ -0,0 +1,43 @@ +import VimrcPlugin from "../main"; +import { ObsidianActionFn } from "../utils/obsidianVimCommand"; + +/** + * Moves the cursor down `repeat` lines, skipping over folded sections. + */ +export const moveDownSkippingFolds: ObsidianActionFn = ( + vimrcPlugin, + cm, + { repeat } +) => { + moveSkippingFolds(vimrcPlugin, repeat, "down"); +}; + +/** + * Moves the cursor up `repeat` lines, skipping over folded sections. + */ +export const moveUpSkippingFolds: ObsidianActionFn = ( + vimrcPlugin, + cm, + { repeat } +) => { + moveSkippingFolds(vimrcPlugin, repeat, "up"); +}; + +function moveSkippingFolds( + vimrcPlugin: VimrcPlugin, + repeat: number, + direction: "up" | "down" +) { + const obsidianEditor = vimrcPlugin.getActiveObsidianEditor(); + let { line: oldLine, ch: oldCh } = obsidianEditor.getCursor(); + const commandName = direction === "up" ? "goUp" : "goDown"; + for (let i = 0; i < repeat; i++) { + obsidianEditor.exec(commandName); + const { line: newLine, ch: newCh } = obsidianEditor.getCursor(); + if (newLine === oldLine && newCh === oldCh) { + // Going in the specified direction doesn't do anything anymore, stop now + return; + } + [oldLine, oldCh] = [newLine, newCh]; + } +} diff --git a/main.ts b/main.ts index ed0c6d7..4ace892 100644 --- a/main.ts +++ b/main.ts @@ -1,5 +1,12 @@ import * as keyFromAccelerator from 'keyboardevent-from-electron-accelerator'; -import { EditorSelection, Notice, App, MarkdownView, Plugin, PluginSettingTab, Setting, TFile } from 'obsidian'; +import { App, EditorSelection, MarkdownView, Notice, Editor as ObsidianEditor, Plugin, PluginSettingTab, Setting } from 'obsidian'; + +import { followLinkUnderCursor } from './actions/followLinkUnderCursor'; +import { moveDownSkippingFolds, moveUpSkippingFolds } from './actions/moveSkippingFolds'; +import { jumpToNextHeading, jumpToPreviousHeading } from './motions/jumpToHeading'; +import { jumpToNextLink, jumpToPreviousLink } from './motions/jumpToLink'; +import { defineAndMapObsidianVimAction, defineAndMapObsidianVimMotion } from './utils/obsidianVimCommand'; +import { VimApi } from './utils/vimApi'; declare const CodeMirror: any; @@ -249,6 +256,10 @@ export default class VimrcPlugin extends Plugin { return this.app.workspace.getActiveViewOfType(MarkdownView); } + getActiveObsidianEditor(): ObsidianEditor { + return this.getActiveView().editor; + } + private getCodeMirror(view: MarkdownView): CodeMirror.Editor { return (view as any).editMode?.editor?.cm?.cm; } @@ -259,6 +270,7 @@ export default class VimrcPlugin extends Plugin { var cmEditor = this.getCodeMirror(view); if (cmEditor && !this.codeMirrorVimObject.loadedVimrc) { this.defineBasicCommands(this.codeMirrorVimObject); + this.defineAndMapObsidianVimCommands(this.codeMirrorVimObject); this.defineSendKeys(this.codeMirrorVimObject); this.defineObCommand(this.codeMirrorVimObject); this.defineSurround(this.codeMirrorVimObject); @@ -369,6 +381,17 @@ export default class VimrcPlugin extends Plugin { }); } + defineAndMapObsidianVimCommands(vimObject: VimApi) { + defineAndMapObsidianVimMotion(vimObject, jumpToNextHeading, ']]'); + defineAndMapObsidianVimMotion(vimObject, jumpToPreviousHeading, '[['); + defineAndMapObsidianVimMotion(vimObject, jumpToNextLink, 'gl'); + defineAndMapObsidianVimMotion(vimObject, jumpToPreviousLink, 'gL'); + + defineAndMapObsidianVimAction(vimObject, this, moveDownSkippingFolds, 'zj'); + defineAndMapObsidianVimAction(vimObject, this, moveUpSkippingFolds, 'zk'); + defineAndMapObsidianVimAction(vimObject, this, followLinkUnderCursor, 'gf'); + } + defineSendKeys(vimObject: any) { vimObject.defineEx('sendkeys', '', async (cm: any, params: any) => { if (!params?.args?.length) { @@ -403,33 +426,36 @@ export default class VimrcPlugin extends Plugin { }); } + executeObsidianCommand(commandName: string) { + const availableCommands = (this.app as any).commands.commands; + if (!(commandName in availableCommands)) { + throw new Error(`Command ${commandName} was not found, try 'obcommand' with no params to see in the developer console what's available`); + } + const view = this.getActiveView(); + const editor = view.editor; + const command = availableCommands[commandName]; + const {callback, checkCallback, editorCallback, editorCheckCallback} = command; + if (editorCheckCallback) + editorCheckCallback(false, editor, view); + else if (editorCallback) + editorCallback(editor, view); + else if (checkCallback) + checkCallback(false); + else if (callback) + callback(); + else + throw new Error(`Command ${commandName} doesn't have an Obsidian callback`); + } + defineObCommand(vimObject: any) { vimObject.defineEx('obcommand', '', async (cm: any, params: any) => { - const availableCommands = (this.app as any).commands.commands; if (!params?.args?.length || params.args.length != 1) { + const availableCommands = (this.app as any).commands.commands; console.log(`Available commands: ${Object.keys(availableCommands).join('\n')}`) throw new Error(`obcommand requires exactly 1 parameter`); } - let view = this.getActiveView(); - let editor = view.editor; - const command = params.args[0]; - if (command in availableCommands) { - let callback = availableCommands[command].callback; - let checkCallback = availableCommands[command].checkCallback; - let editorCallback = availableCommands[command].editorCallback; - let editorCheckCallback = availableCommands[command].editorCheckCallback; - if (editorCheckCallback) - editorCheckCallback(false, editor, view); - else if (editorCallback) - editorCallback(editor, view); - else if (checkCallback) - checkCallback(false); - else if (callback) - callback(); - else - throw new Error(`Command ${command} doesn't have an Obsidian callback`); - } else - throw new Error(`Command ${command} was not found, try 'obcommand' with no params to see in the developer console what's available`); + const commandName = params.args[0]; + this.executeObsidianCommand(commandName); }); } diff --git a/motions/jumpToHeading.ts b/motions/jumpToHeading.ts new file mode 100644 index 0000000..74e9b67 --- /dev/null +++ b/motions/jumpToHeading.ts @@ -0,0 +1,34 @@ +import { jumpToPattern } from "../utils/jumpToPattern"; +import { MotionFn } from "../utils/vimApi"; + +const HEADING_REGEX = /^#+ /gm; + +/** + * Jumps to the repeat-th next heading. + */ +export const jumpToNextHeading: MotionFn = (cm, cursorPosition, { repeat }) => { + return jumpToPattern({ + cm, + cursorPosition, + repeat, + regex: HEADING_REGEX, + direction: "next", + }); +}; + +/** + * Jumps to the repeat-th previous heading. + */ +export const jumpToPreviousHeading: MotionFn = ( + cm, + cursorPosition, + { repeat } +) => { + return jumpToPattern({ + cm, + cursorPosition, + repeat, + regex: HEADING_REGEX, + direction: "previous", + }); +}; diff --git a/motions/jumpToLink.ts b/motions/jumpToLink.ts new file mode 100644 index 0000000..c60a6b3 --- /dev/null +++ b/motions/jumpToLink.ts @@ -0,0 +1,33 @@ +import { jumpToPattern } from "../utils/jumpToPattern"; +import { MotionFn } from "../utils/vimApi"; + +const WIKILINK_REGEX_STRING = "\\[\\[[^\\]\\]]+?\\]\\]"; +const MARKDOWN_LINK_REGEX_STRING = "\\[[^\\]]+?\\]\\([^)]+?\\)"; +const LINK_REGEX_STRING = `${WIKILINK_REGEX_STRING}|${MARKDOWN_LINK_REGEX_STRING}`; +const LINK_REGEX = new RegExp(LINK_REGEX_STRING, "g"); + +/** + * Jumps to the repeat-th next link. +*/ +export const jumpToNextLink: MotionFn = (cm, cursorPosition, { repeat }) => { + return jumpToPattern({ + cm, + cursorPosition, + repeat, + regex: LINK_REGEX, + direction: "next", + }); +}; + +/** + * Jumps to the repeat-th previous link. + */ +export const jumpToPreviousLink: MotionFn = (cm, cursorPosition, { repeat }) => { + return jumpToPattern({ + cm, + cursorPosition, + repeat, + regex: LINK_REGEX, + direction: "previous", + }); +}; diff --git a/package.json b/package.json index c9b1394..112f2ae 100644 --- a/package.json +++ b/package.json @@ -15,11 +15,15 @@ "@rollup/plugin-node-resolve": "^9.0.0", "@rollup/plugin-typescript": "^11.0.0", "@types/node": "^14.14.6", + "@types/string.prototype.matchall": "^4.0.4", "codemirror": "^5.62.2", "keyboardevent-from-electron-accelerator": "*", "obsidian": "^1.1.1", "rollup": "^2.33.0", - "tslib": "^2.0.3", - "typescript": "^4.9.4" + "tslib": "^2.6.3", + "typescript": "^5.5.3" + }, + "dependencies": { + "string.prototype.matchall": "^4.0.11" } } diff --git a/tsconfig.json b/tsconfig.json index d148fe0..502e950 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,16 +4,16 @@ "inlineSourceMap": true, "inlineSources": true, "module": "ESNext", - "target": "es5", + "target": "ES2020", "allowJs": true, "noImplicitAny": true, "moduleResolution": "node", + "downlevelIteration": true, "importHelpers": true, "lib": [ "dom", - "es5", "scripthost", - "es2015" + "ES2020" ] }, "include": [ diff --git a/utils/jumpToPattern.ts b/utils/jumpToPattern.ts new file mode 100644 index 0000000..3fe23ec --- /dev/null +++ b/utils/jumpToPattern.ts @@ -0,0 +1,110 @@ +import { Editor as CodeMirrorEditor } from "codemirror"; +import { EditorPosition } from "obsidian"; + +/** + * Returns the position of the repeat-th instance of a pattern from a given cursor position, in the + * given direction; looping to the other end of the document when reaching one end. Returns the + * original cursor position if no match is found. + * + * Under the hood, to avoid repeated loops of the document: we get all matches at once, order them + * according to `direction` and `cursorPosition`, and use modulo arithmetic to return the + * appropriate match. + */ +export function jumpToPattern({ + cm, + cursorPosition, + repeat, + regex, + direction, +}: { + cm: CodeMirrorEditor; + cursorPosition: EditorPosition; + repeat: number; + regex: RegExp; + direction: "next" | "previous"; +}): EditorPosition { + const content = cm.getValue(); + const cursorIdx = cm.indexFromPos(cursorPosition); + const orderedMatches = getOrderedMatches({ + content, + regex, + cursorIdx, + direction, + }); + const effectiveRepeat = (repeat % orderedMatches.length) || orderedMatches.length; + const matchIdx = orderedMatches[effectiveRepeat - 1]?.index; + if (matchIdx === undefined) { + return cursorPosition; + } + const newCursorPosition = cm.posFromIndex(matchIdx); + return newCursorPosition; +} + +/** + * Returns an ordered array of all matches of a regex in a string in the given direction from the + * cursor index (looping around to the other end of the document when reaching one end). + */ +function getOrderedMatches({ + content, + regex, + cursorIdx, + direction, +}: { + content: string; + regex: RegExp; + cursorIdx: number; + direction: "next" | "previous"; +}): RegExpExecArray[] { + const { previousMatches, currentMatches, nextMatches } = getAndGroupMatches( + content, + regex, + cursorIdx + ); + if (direction === "next") { + return [...nextMatches, ...previousMatches, ...currentMatches]; + } + return [ + ...previousMatches.reverse(), + ...nextMatches.reverse(), + ...currentMatches.reverse(), + ]; +} + +/** + * Finds all matches of a regex in a string and groups them by their positions relative to the + * cursor. + */ +function getAndGroupMatches( + content: string, + regex: RegExp, + cursorIdx: number +): { + previousMatches: RegExpExecArray[]; + currentMatches: RegExpExecArray[]; + nextMatches: RegExpExecArray[]; +} { + const globalRegex = makeGlobalRegex(regex); + const allMatches = [...content.matchAll(globalRegex)]; + const previousMatches = allMatches.filter( + (match) => match.index < cursorIdx && !isCursorOnMatch(match, cursorIdx) + ); + const currentMatches = allMatches.filter((match) => + isCursorOnMatch(match, cursorIdx) + ); + const nextMatches = allMatches.filter((match) => match.index > cursorIdx); + return { previousMatches, currentMatches, nextMatches }; +} + +function makeGlobalRegex(regex: RegExp): RegExp { + const globalFlags = getGlobalFlags(regex); + return new RegExp(regex.source, globalFlags); +} + +function getGlobalFlags(regex: RegExp): string { + const { flags } = regex; + return flags.includes("g") ? flags : `${flags}g`; +} + +function isCursorOnMatch(match: RegExpExecArray, cursorIdx: number): boolean { + return match.index <= cursorIdx && cursorIdx < match.index + match[0].length; +} diff --git a/utils/obsidianVimCommand.ts b/utils/obsidianVimCommand.ts new file mode 100644 index 0000000..39f1499 --- /dev/null +++ b/utils/obsidianVimCommand.ts @@ -0,0 +1,35 @@ +/** + * 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, + actionArgs: { repeat: number }, +) => void; + +export function defineAndMapObsidianVimMotion( + vimObject: VimApi, + motionFn: MotionFn, + mapping: string +) { + vimObject.defineMotion(motionFn.name, motionFn); + vimObject.mapCommand(mapping, "motion", motionFn.name, undefined, {}); +} + +export function defineAndMapObsidianVimAction( + vimObject: VimApi, + vimrcPlugin: VimrcPlugin, + obsidianActionFn: ObsidianActionFn, + mapping: string +) { + vimObject.defineAction(obsidianActionFn.name, (cm, actionArgs) => { + obsidianActionFn(vimrcPlugin, cm, actionArgs); + }); + vimObject.mapCommand(mapping, "action", obsidianActionFn.name, undefined, {}); +} diff --git a/utils/vimApi.ts b/utils/vimApi.ts new file mode 100644 index 0000000..60a662c --- /dev/null +++ b/utils/vimApi.ts @@ -0,0 +1,33 @@ +/** + * Partial representation of the CodeMirror Vim API that we use to define motions, commands, etc. + * + * References: + * https://github.com/replit/codemirror-vim/blob/master/src/vim.js + * https://libvoyant.ucr.edu/resources/codemirror/doc/manual.html + */ + +import { Editor as CodeMirrorEditor } from "codemirror"; +import { EditorPosition } from "obsidian"; + +export type MotionFn = ( + cm: CodeMirrorEditor, + cursorPosition: EditorPosition, // called `head` in the API + motionArgs: { repeat: number } +) => EditorPosition; + +export type ActionFn = ( + cm: CodeMirrorEditor, + actionArgs: { repeat: number }, +) => void; + +export type VimApi = { + defineMotion: (name: string, fn: MotionFn) => void; + defineAction: (name: string, fn: ActionFn) => void; + mapCommand: ( + keys: string, + type: string, + name: string, + args: any, + extra: { [x: string]: any } + ) => void; +};