diff --git a/JsSnippets.md b/JsSnippets.md new file mode 100644 index 0000000..b27173d --- /dev/null +++ b/JsSnippets.md @@ -0,0 +1,61 @@ +# Useful JS Snippets for `jscommand` and `jsfile` + +In this document I will collect some of my and user-contributed ideas for how to utilize JS commands in the Vimrc plugin. + +If you have interesting snippets, please contribute by opening a pull request! + + +## Jump to Next/Prev Markdown Header + +To map `]]` and `[[` to next/prev markdown header, I use the following. + +In a file I 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 + +function regexIndexOf(string, regex, startpos) { + var indexOf = string.substring(startpos || 0).search(regex); + return (indexOf >= 0) ? (indexOf + (startpos || 0)) : indexOf; +} + +function regexLastIndexOf(string, regex, startpos) { + regex = (regex.global) ? regex : new RegExp(regex.source, "g" + (regex.ignoreCase ? "i" : "") + (regex.multiLine ? "m" : "")); + if(typeof (startpos) == "undefined") { + startpos = string.length; + } else if(startpos < 0) { + startpos = 0; + } + var stringToWorkWith = string.substring(0, startpos + 1); + var lastIndexOf = -1; + var nextStop = 0; + while((result = regex.exec(stringToWorkWith)) != null) { + lastIndexOf = result.index; + regex.lastIndex = ++nextStop; + } + return lastIndexOf; +} + +function jumpHeading(isForward) { + const editor = view.editor; + let posToSearchFrom = editor.getCursor(); + posToSearchFrom.line += isForward ? 1 : -1; + const cursorOffset = editor.posToOffset(posToSearchFrom); + const lookupToUse = isForward ? regexIndexOf : regexLastIndexOf; + let headingOffset = lookupToUse(editor.getValue(), /^#(#*) /gm, cursorOffset); + // If not found from the cursor position, try again from the document beginning (or reverse beginning) + if (headingOffset === -1) + headingOffset = lookupToUse(editor.getValue(), /^#(#*) /gm); + const newPos = editor.offsetToPos(headingOffset); + editor.setCursor(newPos); +} +``` + +Then in your `.obsidian.vimrc` file add the following: + +``` +exmap nextHeading jsfile mdHelpers.js {jumpHeading(true)} +exmap prevHeading jsfile mdHelpers.js {jumpHeading(false)} +nmap ]] :nextHeading +nmap [[ :prevHeading +``` diff --git a/README.md b/README.md index e022fb2..bddcbda 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ In addition to that: - `cmcommand` - execute arbitrary CodeMirror commands, see details below. - `surround` - surround your selected text in visual mode or word in normal mode with text. - `pasteinto` - paste your current clipboard into your selected text in visual mode or word in normal mode. Useful for creating hyperlinks. +- `jscommand` and `jsfile` - extend Vim mode using JavaScript snippets. Commands that fail don't generate any visible error for now. @@ -138,8 +139,6 @@ The full list of CodeMirror commands is available [here](https://codemirror.net/ ## Surround Text with `surround` -**Note:** this is currently unsupported for the new (CM6-based) editor. - The plugin defines a custom Ex command named `surround` to surround either your currently selected text in visual mode or the word your cursor is over in normal mode with text. This is particularly useful for creating wikilinks in Obsidian `[[WikiLink]]`. @@ -160,8 +159,6 @@ map [[ :wiki ## Inserting Links/Hyperlinks with `pasteinto` -**Note:** this is currently unsupported for the new (CM6-based) editor. - The plugin defines a custom Ex command named `pasteinto` to paste text into your currently selected text in visual mode, or the word your cursor is over in normal mode. This is particularly useful for creating links/hyperlinks `[selected-text](paste)`. @@ -197,8 +194,62 @@ When you enter insert mode, you will type in your actual current system layout/l Relative line numbers work very nicely with [this](https://github.com/nadavspi/obsidian-relative-line-numbers) Obsidian plugin (thank you @piotryordanov for bringing it to my attention!) +## Extending Vim Commands with JavaScript Snippets + +The plugin allows to define Vim commands that map to JavaScript snippets, which adds exciting new possibilities to what you can achieve with Vim key bindings. **But -- this comes with a price of a security risk, and is therefore disabled by default.** + +> :warning: **Security Warning** +> +> Before using this feature, you **must be sure** that you understand its potential security implications. +> +> Running JavaScript snippets with Vim commands stored in your vault means that anyone who gains access to +> your notes can run arbitrary code inside your Obsidian app. + +If you understand the risks and choose to use this feature, turn on "Support JS Commands" in the plugin settings and continue reading. + +### JavaScript Command Usage + +There are two ways to define JS-based commands. + +**The `jscommand` Ex command** defines a JS function that has an `editor: Editor` and `view: MarkdownView` arguments (see the [Obsidian API](https://github.com/obsidianmd/obsidian-api/blob/master/obsidian.d.ts) if you're not sure what these are). +You define only the syntax of the function, in a single line wrapped by curly braces, e.g.: + +``` +jscommand { console.log(editor.getCursor()); } +``` + +This will immediately log your current cursor position to the developer console. +If you want, you can make this an Ex command using `exmap`: + +``` +exmap logCursor jscommand { console.log(editor.getCursor()); } +nmap :logCursor +``` + +Another version of the same functionality is **the `jsfile` Ex command**, which executes code from a file you give as a parameter, then appends another optional piece of code to it (e.g. in case you want to store several helper methods in a file and launch different ones as part of different commands). + +As above, the code running as part of `jsfile` has `editor: Editor` and `view: MarkdownView` arguments. + +Here's an example from my own `.obsidian.vimrc` that maps `]]` and `[[` to jump to the next/previous Markdown header: + +``` +exmap nextHeading jsfile mdHelpers.js {jumpHeading(true)} +exmap prevHeading jsfile mdHelpers.js {jumpHeading(false)} +nmap ]] :nextHeading +nmap [[ :prevHeading +``` + +See [here](JsSnippets.md) for the full example, and please contribute your own! + + ## Changelog +### 0.6.0 + +- The `surround` and `pasteinto` commands now work with the new (CM6-based) editor. +- Made the existence of the Vimrc file not required for other plugin features to work (https://github.com/esm7/obsidian-vimrc-support/issues/89) +- New exciting, but dangerous, `jscommand` and `jsfile` commands, that allow extending the plugin with JavaScript snippets. + ### 0.5.2 - Fixed wrong detection of the editor (legacy vs new) on some occasions, leading to the plugin not really working in these situations. diff --git a/main.ts b/main.ts index f8956ca..2bb84d8 100644 --- a/main.ts +++ b/main.ts @@ -8,7 +8,8 @@ interface Settings { displayChord: boolean, displayVimMode: boolean, fixedNormalModeLayout: boolean, - capturedKeyboardMap: Record + capturedKeyboardMap: Record, + supportJsCommands?: boolean } const DEFAULT_SETTINGS: Settings = { @@ -16,7 +17,8 @@ const DEFAULT_SETTINGS: Settings = { displayChord: false, displayVimMode: false, fixedNormalModeLayout: false, - capturedKeyboardMap: {} + capturedKeyboardMap: {}, + supportJsCommands: false } const enum vimStatus { @@ -111,9 +113,13 @@ export default class VimrcPlugin extends Plugin { if (!this.initialized) await this.initialize(); const VIMRC_FILE_NAME = this.settings.vimrcFileName; - this.app.vault.adapter.read(VIMRC_FILE_NAME). - then((lines) => this.readVimInit(lines)). - catch(error => { console.log('Error loading vimrc file', VIMRC_FILE_NAME, 'from the vault root', error) }); + let vimrcContent = ''; + try { + vimrcContent = await this.app.vault.adapter.read(VIMRC_FILE_NAME); + } catch (e) { + console.log('Error loading vimrc file', VIMRC_FILE_NAME, 'from the vault root', e.message) + } + this.readVimInit(vimrcContent); }); } @@ -174,6 +180,8 @@ export default class VimrcPlugin extends Plugin { this.defineObCommand(this.codeMirrorVimObject); this.defineCmCommand(this.codeMirrorVimObject); this.defineSurround(this.codeMirrorVimObject); + this.defineJsCommand(this.codeMirrorVimObject); + this.defineJsFile(this.codeMirrorVimObject); // Record the position of selections CodeMirror.on(cmEditor, "cursorActivity", async (cm: any) => { @@ -344,8 +352,6 @@ export default class VimrcPlugin extends Plugin { defineSurround(vimObject: any) { // Function to surround selected text or highlighted word. var surroundFunc = (params: string[]) => { - if (this.editorMode === 'cm6') - throw new Error("surround is not yet supported in the new editor. To be added soon."); var editor = this.getActiveView().editor; if (!params.length) { throw new Error("surround requires exactly 2 parameters: prefix and postfix text."); @@ -512,6 +518,46 @@ export default class VimrcPlugin extends Plugin { } }); } + + defineJsCommand(vimObject: any) { + vimObject.defineEx('jscommand', '', (cm: any, params: any) => { + if (!this.settings.supportJsCommands) + throw new Error("JS commands are turned off; enable them via the Vimrc plugin configuration if you're sure you know what you're doing"); + const jsCode = params.argString.trim() as string; + if (jsCode[0] != '{' || jsCode[jsCode.length - 1] != '}') + throw new Error("Expected an argument which is JS code surrounded by curly brackets: {...}"); + const command = Function('editor', 'view', jsCode); + const view = this.getActiveView(); + command(view.editor, view); + }); + } + + defineJsFile(vimObject: any) { + vimObject.defineEx('jsfile', '', async (cm: any, params: any) => { + if (!this.settings.supportJsCommands) + throw new Error("JS commands are turned off; enable them via the Vimrc plugin configuration if you're sure you know what you're doing"); + if (params?.args?.length < 1) + throw new Error("Expected format: fileName {extraCode}"); + let extraCode = ''; + const fileName = params.args[0]; + if (params.args.length > 1) { + params.args.shift(); + extraCode = params.args.join(' ').trim() as string; + if (extraCode[0] != '{' || extraCode[extraCode.length - 1] != '}') + throw new Error("Expected an extra code argument which is JS code surrounded by curly brackets: {...}"); + } + let content = ''; + try { + content = await this.app.vault.adapter.read(fileName); + } catch (e) { + throw new Error(`Cannot read file ${params.args[0]} from vault root: ${e.message}`); + } + const command = Function('editor', 'view', content + extraCode); + const view = this.getActiveView(); + command(view.editor, view); + }); + } + } class SettingsTab extends PluginSettingTab { @@ -582,5 +628,16 @@ class SettingsTab extends PluginSettingTab { this.plugin.saveSettings(); }); }) + + new Setting(containerEl) + .setName('Support JS commands (beware!)') + .setDesc("Support the 'jscommand' and 'jsfile' commands, which allow defining Ex commands using Javascript. WARNING! Review the README to understand why this may be dangerous before enabling.") + .addToggle(toggle => { + toggle.setValue(this.plugin.settings.supportJsCommands ?? DEFAULT_SETTINGS.supportJsCommands); + toggle.onChange(value => { + this.plugin.settings.supportJsCommands = value; + this.plugin.saveSettings(); + }) + }); } } diff --git a/manifest.json b/manifest.json index 67fbb23..4c67893 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "obsidian-vimrc-support", "name": "Vimrc Support", - "version": "0.5.2", + "version": "0.6.0", "description": "Auto-load a startup file with Obsidian Vim commands.", "author": "esm", "authorUrl": "", diff --git a/package.json b/package.json index 910f903..701e42d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsidian-vimrc-support", - "version": "0.5.2", + "version": "0.6.0", "description": "Auto-load a startup file with Obsidian Vim commands.", "main": "main.js", "scripts": {