diff --git a/.gitignore b/.gitignore index de9609f..9f5547d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ *.iml .idea +# My deployment script +*.sh + # npm node_modules package-lock.json diff --git a/README.md b/README.md index a282073..70062cb 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ In addition to that: - Custom mapping/unmapping commands in addition to the defaults: `noremap` and `iunmap` (PRs are welcome to implement more :) ) - `exmap [commandName] [command...]`: a command to map Ex commands. This should basically be supported in regular `:map`, but doesn't work with multi-argument commands due to a CodeMirror bug, so this is a workaround. - `obcommand` - execute Obsidian commands, see more 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. Commands that fail don't generate any visible error for now. @@ -97,6 +99,37 @@ And many more. **WARNING:** this is not a formal API that Obsidian provides and is done in a rather hacky manner. It's definitely possible that some future version of Obsidian will break this functionality. +## Surround Text with `surround` + +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]]`. + +The syntax follows `:surround [prefixText] [postfixText]`. + +Some examples: +- `surround ( )` +- `surround [[ ]]` + +Here's my surround config as an example: + +``` +" Surround text with [[ ]] to make a wikilink +" NOTE: must use 'map' and not 'nmap' +exmap wiki surround [[ ]] +map [[ :wiki +``` + +## Inserting Links/Hyperlinks with `pasteinto` + +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)`. + +Here's my hyperlink config as an example: +``` +" Maps pasteinto to Alt-p +map :pasteinto +``` + ### Mapping Obsidian Commands Within Vim Next thing you probably wanna ask is "how do I map the great Obsidian commands to Vim commands?" @@ -115,6 +148,12 @@ nmap :back Note how `exmap` lists command names without colons and in `nmap` the colon is required. +## Some Help with Binding Space Chords (Doom and Spacemacs fans) + +`` can be bound to make chords, such as `fs` in conjunction with obcommand to save your current file and more. +But first `` must be unbound with `unmap `. +Afterwards `` can be mapped normally as any other key. + ## Changelog ### 0.3.1 diff --git a/main.ts b/main.ts index 5d81c95..76cec60 100644 --- a/main.ts +++ b/main.ts @@ -1,16 +1,34 @@ -import { App, Plugin, TFile, MarkdownView, PluginSettingTab, Setting } from 'obsidian'; import * as keyFromAccelerator from 'keyboardevent-from-electron-accelerator'; +import { App, MarkdownView, Plugin, PluginSettingTab, Setting, TFile } from 'obsidian'; declare const CodeMirror: any; interface Settings { - vimrcFileName: string + vimrcFileName: string, + displayChord: boolean, + displayVimMode: boolean } const DEFAULT_SETTINGS: Settings = { - vimrcFileName: ".obsidian.vimrc" + vimrcFileName: ".obsidian.vimrc", + displayChord: false, + displayVimMode: false } +const enum vimStatus { + normal = "🟢", + insert = "🟠", + replace = "🔴", + visual = "🟡" +} + +// NOTE: to future maintainers, please make sure all mapping commands are included in this array. +const mappingCommands: String[] = [ + "map", + "nmap", + "noremap", +] + function sleep(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)); } @@ -21,6 +39,12 @@ export default class VimrcPlugin extends Plugin { private lastYankBuffer = new Array(0); private lastSystemClipboard = ""; private yankToSystemClipboard: boolean = false; + private currentKeyChord: any = []; + private vimChordStatusBar: HTMLElement = null; + private vimStatusBar: HTMLElement = null; + private currentVimStatus: vimStatus = vimStatus.normal; + private customVimKeybinds: { [name: string]: boolean } = {}; + private currentSelection: CodeMirror.Range = null; async onload() { console.log('loading Vimrc plugin'); @@ -77,6 +101,7 @@ export default class VimrcPlugin extends Plugin { } } }); + CodeMirror.Vim.defineOption('tabstop', 4, 'number', [], (value: number, cm: any) => { if (value) { cmEditor.setOption('tabSize', value); @@ -93,7 +118,7 @@ export default class VimrcPlugin extends Plugin { if (!params?.args?.length) { throw new Error('Invalid mapping: noremap'); } - + if (params.argString.trim()) { CodeMirror.Vim.noremap.apply(CodeMirror.Vim, params.args); } @@ -112,13 +137,13 @@ export default class VimrcPlugin extends Plugin { CodeMirror.Vim.handleEx(cm, commandContent); }); }); - + CodeMirror.Vim.defineEx('sendkeys', '', async (cm: any, params: any) => { if (!params?.args?.length) { console.log(params); throw new Error(`The sendkeys command requires a list of keys, e.g. sendKeys Ctrl+p a b Enter`); } - + let allGood = true; let events: KeyboardEvent[] = []; for (const key of params.args) { @@ -139,7 +164,7 @@ export default class VimrcPlugin extends Plugin { if (allGood) { for (keyEvent of events) window.postMessage(JSON.parse(JSON.stringify(keyEvent)), '*'); - // view.containerEl.dispatchEvent(keyEvent); + // view.containerEl.dispatchEvent(keyEvent); } } } @@ -171,14 +196,137 @@ export default class VimrcPlugin extends Plugin { throw new Error(`Command ${command} was not found, try 'obcommand' with no params to see in the developer console what's available`); }); + // Function to surround selected text or highlighted word. + var surroundFunc = (cm: CodeMirror.Editor, params: any) => { + if (!params?.args?.length || params.args.length != 2) { + throw new Error("surround requires exactly 2 parameters: prefix and postfix text.") + } + let beginning = params.args[0] // Get the beginning surround text + let ending = params.args[1] // Get the ending surround text + if (this.currentSelection.anchor == this.currentSelection.head) { + // No range of selected text, so select word. + let wordRange = cmEditor.findWordAt(this.currentSelection.anchor) + let currText = cmEditor.getRange(wordRange.from(), wordRange.to()) + cmEditor.replaceRange(beginning + currText + ending, wordRange.from(), wordRange.to()) + } else { + let currText = cmEditor.getRange(this.currentSelection.from(), this.currentSelection.to()) + cmEditor.replaceRange(beginning + currText + ending, this.currentSelection.from(), this.currentSelection.to()) + } + } + + CodeMirror.Vim.defineEx("surround", "", surroundFunc); + + CodeMirror.Vim.defineEx("pasteinto", "", (cm: CodeMirror.Editor, params: any) => { + // Using the register for when this.yankToSystemClipboard == false + surroundFunc(cm, { args: ["[", "](" + CodeMirror.Vim.getRegisterController().getRegister('yank').keyBuffer + ")"] }) + }) + + // Handle the surround dialog input + var surroundDialogCallback = (value: string) => { + if ((/^\[+$/).test(value)) { // check for 1-inf [ and match them with ] + surroundFunc(cmEditor, { args: [value, "]".repeat(value.length)] }) + } else if ((/^\(+$/).test(value)) { // check for 1-inf ( and match them with ) + surroundFunc(cmEditor, { args: [value, ")".repeat(value.length)] }) + } else if ((/^\{+$/).test(value)) { // check for 1-inf { and match them with } + surroundFunc(cmEditor, { args: [value, "}".repeat(value.length)] }) + } else { // Else, just put it before and after. + surroundFunc(cmEditor, { args: [value, value] }) + } + } + + CodeMirror.Vim.defineOperator("surroundOperator", (cm: any, args: any, ranges: any) => { + let p = "Surround with: " + cm.openDialog(p, surroundDialogCallback, { bottom: true, selectValueOnOpen: false }) + }) + + + CodeMirror.Vim.mapCommand("s", "operator", "surroundOperator") + // CodeMirror.Vim.mapCommand("s", "operator", "surroundOperator") + + // Record the position of selections + CodeMirror.on(cmEditor, "cursorActivity", async (cm: any) => { + this.currentSelection = cmEditor.listSelections()[0] + }) + vimCommands.split("\n").forEach( - function(line, index, arr) { + function (line: string, index: number, arr: [string]) { if (line.trim().length > 0 && line.trim()[0] != '"') { + let split = line.split(" ") + if (mappingCommands.includes(split[0])) { + // Have to do this because "vim-command-done" event doesn't actually work properly, or something. + this.customVimKeybinds[split[1]] = true + } CodeMirror.Vim.handleEx(cmEditor, line); } - } + }.bind(this) // Faster than an arrow function. https://stackoverflow.com/questions/50375440/binding-vs-arrow-function-for-react-onclick-event ) + if (this.settings.displayChord) { + // Add status bar item + this.vimChordStatusBar = this.addStatusBarItem() + + // Move vimChordStatusBar to the leftmost position and center it. + let parent = this.vimChordStatusBar.parentElement + this.vimChordStatusBar.parentElement.insertBefore(this.vimChordStatusBar, parent.firstChild) + this.vimChordStatusBar.style.marginRight = "auto" + // this.vimChordStatusBar.style.marginLeft = "auto" + + // See https://codemirror.net/doc/manual.html#vimapi_events for events. + CodeMirror.on(cmEditor, "vim-keypress", async (vimKey: any) => { + + if (vimKey != "") { // TODO figure out what to actually look for to exit commands. + this.currentKeyChord.push(vimKey); + if (this.customVimKeybinds[this.currentKeyChord.join("")] != undefined) { // Custom key chord exists. + this.currentKeyChord = []; + } + } else { + this.currentKeyChord = []; + } + + // Build keychord text + let tempS = "" + for (const s of this.currentKeyChord) { + tempS += " " + s + } + if (tempS != "") { + tempS += "-" + } + this.vimChordStatusBar.setText(tempS); + }); + CodeMirror.on(cmEditor, "vim-command-done", async (reason: any) => { // Reset display + this.vimChordStatusBar.setText(""); + this.currentKeyChord = []; + }); + + } + + if (this.settings.displayVimMode) { + this.vimStatusBar = this.addStatusBarItem() // Add status bar item + this.vimStatusBar.setText(vimStatus.normal) // Init the vimStatusBar with normal mode + + // See https://codemirror.net/doc/manual.html#vimapi_events for events. + CodeMirror.on(cmEditor, "vim-mode-change", async (modeObj: any) => { + switch (modeObj.mode) { + case "insert": + this.currentVimStatus = vimStatus.insert; + break; + case "normal": + this.currentVimStatus = vimStatus.normal; + break; + case "visual": + this.currentVimStatus = vimStatus.visual; + break; + case "replace": + this.currentVimStatus = vimStatus.replace; + break; + default: + break; + } + + this.vimStatusBar.setText(this.currentVimStatus); + }); + } + // Make sure that we load it just once per CodeMirror instance. // This is supposed to work because the Vim state is kept at the keymap level, hopefully // there will not be bugs caused by operations that are kept at the object level instead @@ -219,11 +367,11 @@ class SettingsTab extends PluginSettingTab { } display(): void { - let {containerEl} = this; + let { containerEl } = this; containerEl.empty(); - containerEl.createEl('h2', {text: 'Vimrc Settings'}); + containerEl.createEl('h2', { text: 'Vimrc Settings' }); new Setting(containerEl) .setName('Vimrc file name') @@ -237,5 +385,29 @@ class SettingsTab extends PluginSettingTab { this.plugin.saveSettings(); }) }); + + new Setting(containerEl) + .setName('Vim chord display') + .setDesc('Displays the current chord until completion. Ex: " f-" (requires restart)') + .addToggle((toggle) => { + if (this.plugin.settings.displayChord !== DEFAULT_SETTINGS.displayChord) + toggle.setValue(this.plugin.settings.displayChord) + toggle.onChange(value => { + this.plugin.settings.displayChord = value || DEFAULT_SETTINGS.displayChord; + this.plugin.saveSettings(); + }) + }); + + new Setting(containerEl) + .setName('Vim mode display') + .setDesc('Displays the current vim mode (requires restart)') + .addToggle((toggle) => { + if (this.plugin.settings.displayVimMode !== DEFAULT_SETTINGS.displayVimMode) + toggle.setValue(this.plugin.settings.displayVimMode) + toggle.onChange(value => { + this.plugin.settings.displayVimMode = value || DEFAULT_SETTINGS.displayVimMode; + this.plugin.saveSettings(); + }) + }); } } diff --git a/manifest.json b/manifest.json index c9e1bbf..af12f58 100644 --- a/manifest.json +++ b/manifest.json @@ -5,5 +5,5 @@ "description": "Auto-load a startup file with Obsidian Vim commands.", "author": "esm", "authorUrl": "", - "isDesktopOnly": false + "isDesktopOnly": true }