From 7f35286f206973093e8783dff5b29fff82e4785b Mon Sep 17 00:00:00 2001 From: Jan Meyer Date: Wed, 8 Apr 2026 08:32:15 +0200 Subject: [PATCH] vault backup: 2026-04-08 08:32:15 --- .obsidian.vimrc | 16 + .obsidian/community-plugins.json | 3 +- .../plugins/obsidian-vimrc-support/data.json | 14 + .../plugins/obsidian-vimrc-support/main.js | 1247 +++++++++++++++++ .../obsidian-vimrc-support/manifest.json | 10 + 5 files changed, 1289 insertions(+), 1 deletion(-) create mode 100644 .obsidian.vimrc create mode 100644 .obsidian/plugins/obsidian-vimrc-support/data.json create mode 100644 .obsidian/plugins/obsidian-vimrc-support/main.js create mode 100644 .obsidian/plugins/obsidian-vimrc-support/manifest.json diff --git a/.obsidian.vimrc b/.obsidian.vimrc new file mode 100644 index 0000000..6b33cb2 --- /dev/null +++ b/.obsidian.vimrc @@ -0,0 +1,16 @@ +unmap +let mapleader = + +imap hj + +exmap switcher obcommand switcher:open +nmmap ff :switcher + +exmap graph obcommand graph:open +nmap gg :graph + +exmap leftbar obcommand app:toogle-left-sidebar +nmcap e :leftbar + +exmap focus obcommand zen-mode:toggle +nmap z :focus diff --git a/.obsidian/community-plugins.json b/.obsidian/community-plugins.json index 7fd6ffe..ac44d51 100644 --- a/.obsidian/community-plugins.json +++ b/.obsidian/community-plugins.json @@ -8,5 +8,6 @@ "highlightr-plugin", "obsidian-file-color", "darlal-switcher-plus", - "dataview" + "dataview", + "obsidian-vimrc-support" ] \ No newline at end of file diff --git a/.obsidian/plugins/obsidian-vimrc-support/data.json b/.obsidian/plugins/obsidian-vimrc-support/data.json new file mode 100644 index 0000000..a3bdce0 --- /dev/null +++ b/.obsidian/plugins/obsidian-vimrc-support/data.json @@ -0,0 +1,14 @@ +{ + "vimrcFileName": ".obsidian.vimrc", + "displayChord": true, + "displayVimMode": true, + "fixedNormalModeLayout": false, + "capturedKeyboardMap": {}, + "supportJsCommands": false, + "vimStatusPromptMap": { + "normal": "🟢", + "insert": "🟠", + "visual": "🟡", + "replace": "🔴" + } +} \ No newline at end of file diff --git a/.obsidian/plugins/obsidian-vimrc-support/main.js b/.obsidian/plugins/obsidian-vimrc-support/main.js new file mode 100644 index 0000000..10bbf0d --- /dev/null +++ b/.obsidian/plugins/obsidian-vimrc-support/main.js @@ -0,0 +1,1247 @@ +'use strict'; + +var obsidian = require('obsidian'); + +const modifiers = /^(CommandOrControl|CmdOrCtrl|Command|Cmd|Control|Ctrl|AltGr|Option|Alt|Shift|Super)/i; +const keyCodes = /^(Plus|Space|Tab|Backspace|Delete|Insert|Return|Enter|Up|Down|Left|Right|Home|End|PageUp|PageDown|Escape|Esc|VolumeUp|VolumeDown|VolumeMute|MediaNextTrack|MediaPreviousTrack|MediaStop|MediaPlayPause|PrintScreen|F24|F23|F22|F21|F20|F19|F18|F17|F16|F15|F14|F13|F12|F11|F10|F9|F8|F7|F6|F5|F4|F3|F2|F1|[0-9A-Z)!@#$%^&*(:+<_>?~{|}";=,\-./`[\\\]'])/i; +const UNSUPPORTED = {}; + +function _command(accelerator, event, modifier) { + if (process.platform !== 'darwin') { + return UNSUPPORTED; + } + + if (event.metaKey) { + throw new Error('Double `Command` modifier specified.'); + } + + return { + event: Object.assign({}, event, {metaKey: true}), + accelerator: accelerator.slice(modifier.length) + }; +} + +function _super(accelerator, event, modifier) { + if (event.metaKey) { + throw new Error('Double `Super` modifier specified.'); + } + + return { + event: Object.assign({}, event, {metaKey: true}), + accelerator: accelerator.slice(modifier.length) + }; +} + +function _commandorcontrol(accelerator, event, modifier) { + if (process.platform === 'darwin') { + if (event.metaKey) { + throw new Error('Double `Command` modifier specified.'); + } + + return { + event: Object.assign({}, event, {metaKey: true}), + accelerator: accelerator.slice(modifier.length) + }; + } + + if (event.ctrlKey) { + throw new Error('Double `Control` modifier specified.'); + } + + return { + event: Object.assign({}, event, {ctrlKey: true}), + accelerator: accelerator.slice(modifier.length) + }; +} + +function _alt(accelerator, event, modifier) { + if (modifier === 'option' && process.platform !== 'darwin') { + return UNSUPPORTED; + } + + if (event.altKey) { + throw new Error('Double `Alt` modifier specified.'); + } + + return { + event: Object.assign({}, event, {altKey: true}), + accelerator: accelerator.slice(modifier.length) + }; +} + +function _shift(accelerator, event, modifier) { + if (event.shiftKey) { + throw new Error('Double `Shift` modifier specified.'); + } + + return { + event: Object.assign({}, event, {shiftKey: true}), + accelerator: accelerator.slice(modifier.length) + }; +} + +function _control(accelerator, event, modifier) { + if (event.ctrlKey) { + throw new Error('Double `Control` modifier specified.'); + } + + return { + event: Object.assign({}, event, {ctrlKey: true}), + accelerator: accelerator.slice(modifier.length) + }; +} + +function reduceModifier({accelerator, event}, modifier) { + switch (modifier) { + case 'command': + case 'cmd': { + return _command(accelerator, event, modifier); + } + + case 'super': { + return _super(accelerator, event, modifier); + } + + case 'control': + case 'ctrl': { + return _control(accelerator, event, modifier); + } + + case 'commandorcontrol': + case 'cmdorctrl': { + return _commandorcontrol(accelerator, event, modifier); + } + + case 'option': + case 'altgr': + case 'alt': { + return _alt(accelerator, event, modifier); + } + + case 'shift': { + return _shift(accelerator, event, modifier); + } + + default: + console.error(modifier); + } +} + +function reducePlus({accelerator, event}) { + return { + event, + accelerator: accelerator.trim().slice(1) + }; +} + +const virtualKeyCodes = { + 0: 'Digit0', + 1: 'Digit1', + 2: 'Digit2', + 3: 'Digit3', + 4: 'Digit4', + 5: 'Digit5', + 6: 'Digit6', + 7: 'Digit7', + 8: 'Digit8', + 9: 'Digit9', + '-': 'Minus', + '=': 'Equal', + Q: 'KeyQ', + W: 'KeyW', + E: 'KeyE', + R: 'KeyR', + T: 'KeyT', + Y: 'KeyY', + U: 'KeyU', + I: 'KeyI', + O: 'KeyO', + P: 'KeyP', + '[': 'BracketLeft', + ']': 'BracketRight', + A: 'KeyA', + S: 'KeyS', + D: 'KeyD', + F: 'KeyF', + G: 'KeyG', + H: 'KeyH', + J: 'KeyJ', + K: 'KeyK', + L: 'KeyL', + ';': 'Semicolon', + '\'': 'Quote', + '`': 'Backquote', + '/': 'Backslash', + Z: 'KeyZ', + X: 'KeyX', + C: 'KeyC', + V: 'KeyV', + B: 'KeyB', + N: 'KeyN', + M: 'KeyM', + ',': 'Comma', + '.': 'Period', + '\\': 'Slash', + ' ': 'Space' +}; + +function reduceKey({accelerator, event}, key) { + if (key.length > 1 || event.key) { + throw new Error(`Unvalid keycode \`${key}\`.`); + } + + const code = + key.toUpperCase() in virtualKeyCodes ? + virtualKeyCodes[key.toUpperCase()] : + null; + + return { + event: Object.assign({}, event, {key}, code ? {code} : null), + accelerator: accelerator.trim().slice(key.length) + }; +} + +const domKeys = Object.assign(Object.create(null), { + plus: 'Add', + space: 'Space', + tab: 'Tab', + backspace: 'Backspace', + delete: 'Delete', + insert: 'Insert', + return: 'Return', + enter: 'Return', + up: 'ArrowUp', + down: 'ArrowDown', + left: 'ArrowLeft', + right: 'ArrowRight', + home: 'Home', + end: 'End', + pageup: 'PageUp', + pagedown: 'PageDown', + escape: 'Escape', + esc: 'Escape', + volumeup: 'AudioVolumeUp', + volumedown: 'AudioVolumeDown', + volumemute: 'AudioVolumeMute', + medianexttrack: 'MediaTrackNext', + mediaprevioustrack: 'MediaTrackPrevious', + mediastop: 'MediaStop', + mediaplaypause: 'MediaPlayPause', + printscreen: 'PrintScreen' +}); + +// Add function keys +for (let i = 1; i <= 24; i++) { + domKeys[`f${i}`] = `F${i}`; +} + +function reduceCode({accelerator, event}, {code, key}) { + if (event.code) { + throw new Error(`Duplicated keycode \`${key}\`.`); + } + + return { + event: Object.assign({}, event, {key}, code ? {code} : null), + accelerator: accelerator.trim().slice((key && key.length) || 0) + }; +} + +/** + * This function transform an Electron Accelerator string into + * a DOM KeyboardEvent object. + * + * @param {string} accelerator an Electron Accelerator string, e.g. `Ctrl+C` or `Shift+Space`. + * @return {object} a DOM KeyboardEvent object derivate from the `accelerator` argument. + */ +function toKeyEvent(accelerator) { + let state = {accelerator, event: {}}; + while (state.accelerator !== '') { + const modifierMatch = state.accelerator.match(modifiers); + if (modifierMatch) { + const modifier = modifierMatch[0].toLowerCase(); + state = reduceModifier(state, modifier); + if (state === UNSUPPORTED) { + return {unsupportedKeyForPlatform: true}; + } + } else if (state.accelerator.trim()[0] === '+') { + state = reducePlus(state); + } else { + const codeMatch = state.accelerator.match(keyCodes); + if (codeMatch) { + const code = codeMatch[0].toLowerCase(); + if (code in domKeys) { + state = reduceCode(state, { + code: domKeys[code], + key: code + }); + } else { + state = reduceKey(state, code); + } + } else { + throw new Error(`Unvalid accelerator: "${state.accelerator}"`); + } + } + } + + return state.event; +} + +var keyboardeventFromElectronAccelerator = { + UNSUPPORTED, + reduceModifier, + reducePlus, + reduceKey, + reduceCode, + toKeyEvent +}; + +/** + * 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). + */ +const followLinkUnderCursor = (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"); + } +}; + +/** + * Moves the cursor down `repeat` lines, skipping over folded sections. + */ +const moveDownSkippingFolds = (vimrcPlugin, cm, { repeat }) => { + moveSkippingFolds(vimrcPlugin, repeat, "down"); +}; +/** + * Moves the cursor up `repeat` lines, skipping over folded sections. + */ +const moveUpSkippingFolds = (vimrcPlugin, cm, { repeat }) => { + moveSkippingFolds(vimrcPlugin, repeat, "up"); +}; +function moveSkippingFolds(vimrcPlugin, repeat, direction) { + 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]; + } +} + +/** + * 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. + * + * @param cm The CodeMirror editor instance. + * @param cursorPosition The current cursor position. + * @param repeat The number of times to repeat the jump (e.g. 1 to jump to the very next match). Is + * modulo-ed for efficiency. + * @param regex The regex pattern to jump to. + * @param filterMatch Optional filter function to run on the regex matches. Return false to ignore + * a given match. + * @param direction The direction to jump in. + */ +function jumpToPattern({ cm, cursorPosition, repeat, regex, filterMatch = () => true, direction, }) { + const content = cm.getValue(); + const cursorIdx = cm.indexFromPos(cursorPosition); + const orderedMatches = getOrderedMatches({ content, regex, cursorIdx, filterMatch, 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, filterMatch, direction, }) { + const { previousMatches, currentMatches, nextMatches } = getAndGroupMatches(content, regex, cursorIdx, filterMatch); + 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, regex, cursorIdx, filterMatch) { + const globalRegex = makeGlobalRegex(regex); + const allMatches = [...content.matchAll(globalRegex)].filter(filterMatch); + const previousMatches = allMatches.filter((match) => match.index < cursorIdx && !isWithinMatch(match, cursorIdx)); + const currentMatches = allMatches.filter((match) => isWithinMatch(match, cursorIdx)); + const nextMatches = allMatches.filter((match) => match.index > cursorIdx); + return { previousMatches, currentMatches, nextMatches }; +} +function makeGlobalRegex(regex) { + const globalFlags = getGlobalFlags(regex); + return new RegExp(regex.source, globalFlags); +} +function getGlobalFlags(regex) { + const { flags } = regex; + return flags.includes("g") ? flags : `${flags}g`; +} +function isWithinMatch(match, index) { + return match.index <= index && index < match.index + match[0].length; +} + +/** Naive Regex for a Markdown heading (H1 through H6). "Naive" because it does not account for + * whether the match is within a codeblock (e.g. it could be a Python comment, not a heading). + */ +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. + */ +const FENCED_CODEBLOCK_REGEX = /(^```+)(.*?^\1|.*)/gms; +/** + * Jumps to the repeat-th next heading. + */ +const jumpToNextHeading = (cm, cursorPosition, { repeat }) => { + return jumpToHeading({ cm, cursorPosition, repeat, direction: "next" }); +}; +/** + * Jumps to the repeat-th previous heading. + */ +const jumpToPreviousHeading = (cm, cursorPosition, { repeat }) => { + return jumpToHeading({ cm, cursorPosition, repeat, direction: "previous" }); +}; +/** + * Jumps to the repeat-th heading in the given direction. + * + * Under the hood, we use the naive heading regex to find all headings, and then filter out those + * that are within codeblocks. `codeblockMatches` is passed in a closure to avoid repeated + * computation. + */ +function jumpToHeading({ cm, cursorPosition, repeat, direction, }) { + const codeblockMatches = findAllCodeblocks(cm); + const filterMatch = (match) => !isMatchWithinCodeblock(match, codeblockMatches); + return jumpToPattern({ + cm, + cursorPosition, + repeat, + regex: NAIVE_HEADING_REGEX, + filterMatch, + direction, + }); +} +function findAllCodeblocks(cm) { + const content = cm.getValue(); + return [...content.matchAll(FENCED_CODEBLOCK_REGEX)]; +} +function isMatchWithinCodeblock(match, codeblockMatches) { + return codeblockMatches.some((codeblockMatch) => isWithinMatch(codeblockMatch, match.index)); +} + +const WIKILINK_REGEX_STRING = "\\[\\[.*?\\]\\]"; +const MARKDOWN_LINK_REGEX_STRING = "\\[.*?\\]\\(.*?\\)"; +const URL_REGEX_STRING = "\\w+://\\S+"; +/** + * Regex for a link (which can be a wikilink, a markdown link, or a standalone URL). + */ +const LINK_REGEX_STRING = `${WIKILINK_REGEX_STRING}|${MARKDOWN_LINK_REGEX_STRING}|${URL_REGEX_STRING}`; +const LINK_REGEX = new RegExp(LINK_REGEX_STRING, "g"); +/** + * Jumps to the repeat-th next link. + * + * Note that `jumpToPattern` uses `String.matchAll`, which internally updates `lastIndex` after each + * match; and that `LINK_REGEX` matches wikilinks / markdown links first. So, this won't catch + * non-standalone URLs (e.g. the URL in a markdown link). This should be a good thing in most cases; + * otherwise it could be tedious (as a user) for each markdown link to contain two jumpable spots. +*/ +const jumpToNextLink = (cm, cursorPosition, { repeat }) => { + return jumpToPattern({ + cm, + cursorPosition, + repeat, + regex: LINK_REGEX, + direction: "next", + }); +}; +/** + * Jumps to the repeat-th previous link. + */ +const jumpToPreviousLink = (cm, cursorPosition, { repeat }) => { + return jumpToPattern({ + cm, + cursorPosition, + repeat, + regex: LINK_REGEX, + direction: "previous", + }); +}; + +/** + * Utility types and functions for defining Obsidian-specific Vim commands. + */ +function defineAndMapObsidianVimMotion(vimObject, motionFn, mapping) { + vimObject.defineMotion(motionFn.name, motionFn); + vimObject.mapCommand(mapping, "motion", motionFn.name, undefined, {}); +} +function defineAndMapObsidianVimAction(vimObject, vimrcPlugin, obsidianActionFn, mapping) { + vimObject.defineAction(obsidianActionFn.name, (cm, actionArgs) => { + obsidianActionFn(vimrcPlugin, cm, actionArgs); + }); + vimObject.mapCommand(mapping, "action", obsidianActionFn.name, undefined, {}); +} + +const DEFAULT_SETTINGS = { + vimrcFileName: ".obsidian.vimrc", + displayChord: false, + displayVimMode: false, + fixedNormalModeLayout: false, + capturedKeyboardMap: {}, + supportJsCommands: false, + vimStatusPromptMap: { + normal: '🟢', + insert: '🟠', + visual: '🟡', + replace: '🔴', + }, +}; +const vimStatusPromptClass = "vimrc-support-vim-mode"; +// NOTE: to future maintainers, please make sure all mapping commands are included in this array. +const mappingCommands = [ + "map", + "nmap", + "noremap", + "iunmap", + "nunmap", + "vunmap", +]; +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} +class VimrcPlugin extends obsidian.Plugin { + constructor() { + super(...arguments); + this.codeMirrorVimObject = null; + this.initialized = false; + this.lastYankBuffer = [""]; + this.lastSystemClipboard = ""; + this.yankToSystemClipboard = false; + this.currentKeyChord = []; + this.vimChordStatusBar = null; + this.vimStatusBar = null; + this.currentVimStatus = "normal" /* vimStatus.normal */; + this.customVimKeybinds = {}; + this.currentSelection = null; + this.isInsertMode = false; + this.logVimModeChange = async (cm) => { + if (!cm) + return; + this.isInsertMode = cm.mode === 'insert'; + switch (cm.mode) { + case "insert": + this.currentVimStatus = "insert" /* vimStatus.insert */; + break; + case "normal": + this.currentVimStatus = "normal" /* vimStatus.normal */; + break; + case "visual": + this.currentVimStatus = "visual" /* vimStatus.visual */; + break; + case "replace": + this.currentVimStatus = "replace" /* vimStatus.replace */; + break; + } + if (this.settings.displayVimMode) + this.updateVimStatusBar(); + }; + this.onVimKeypress = async (vimKey) => { + 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); + }; + this.onVimCommandDone = async (reason) => { + this.vimChordStatusBar?.setText(""); + this.currentKeyChord = []; + }; + this.onKeydown = (ev) => { + if (this.settings.fixedNormalModeLayout) { + const keyMap = this.settings.capturedKeyboardMap; + if (!this.isInsertMode && !ev.shiftKey && + ev.code in keyMap && ev.key != keyMap[ev.code]) { + let view = this.getActiveView(); + if (view) { + const cmEditor = this.getCodeMirror(view); + if (cmEditor) { + this.codeMirrorVimObject.handleKey(cmEditor, keyMap[ev.code], 'mapping'); + } + } + ev.preventDefault(); + return false; + } + } + }; + } + updateVimStatusBar() { + this.vimStatusBar.setText(this.settings.vimStatusPromptMap[this.currentVimStatus]); + this.vimStatusBar.dataset.vimMode = this.currentVimStatus; + } + async captureKeyboardLayout() { + // This is experimental API and it might break at some point: + // https://developer.mozilla.org/en-US/docs/Web/API/KeyboardLayoutMap + let keyMap = {}; + let layout = await navigator.keyboard.getLayoutMap(); + let doneIterating = new Promise((resolve, reject) => { + let counted = 0; + layout.forEach((value, index) => { + keyMap[index] = value; + counted += 1; + if (counted === layout.size) + resolve(); + }); + }); + await doneIterating; + new obsidian.Notice('Keyboard layout captured'); + return keyMap; + } + async initialize() { + if (this.initialized) + return; + this.codeMirrorVimObject = window.CodeMirrorAdapter?.Vim; + this.registerYankEvents(activeWindow); + this.app.workspace.on("window-open", (workspaceWindow, w) => { + this.registerYankEvents(w); + }); + this.prepareChordDisplay(); + this.prepareVimModeDisplay(); + // Two events cos + // this don't trigger on loading/reloading obsidian with note opened + this.app.workspace.on("active-leaf-change", async () => { + this.updateSelectionEvent(); + this.updateVimEvents(); + }); + // and this don't trigger on opening same file in new pane + this.app.workspace.on("file-open", async () => { + this.updateSelectionEvent(); + this.updateVimEvents(); + }); + this.initialized = true; + } + registerYankEvents(win) { + this.registerDomEvent(win.document, 'click', () => { + this.captureYankBuffer(win); + }); + this.registerDomEvent(win.document, 'keyup', () => { + this.captureYankBuffer(win); + }); + this.registerDomEvent(win.document, 'focusin', () => { + this.captureYankBuffer(win); + }); + } + async updateSelectionEvent() { + const view = this.getActiveView(); + if (!view) + return; + let cm = this.getCodeMirror(view); + if (!cm) + return; + if (this.getCursorActivityHandlers(cm).some((e) => e.name === "updateSelection")) + return; + cm.on("cursorActivity", async (cm) => this.updateSelection(cm)); + } + async updateSelection(cm) { + this.currentSelection = cm.listSelections(); + } + getCursorActivityHandlers(cm) { + return cm._handlers.cursorActivity; + } + async updateVimEvents() { + if (!this.app.isVimEnabled()) + return; + let view = this.getActiveView(); + if (view) { + const cmEditor = this.getCodeMirror(view); + // See https://codemirror.net/doc/manual.html#vimapi_events for events. + this.isInsertMode = false; + this.currentVimStatus = "normal" /* vimStatus.normal */; + if (this.settings.displayVimMode) + this.updateVimStatusBar(); + if (!cmEditor) + return; + cmEditor.off('vim-mode-change', this.logVimModeChange); + cmEditor.on('vim-mode-change', this.logVimModeChange); + this.currentKeyChord = []; + cmEditor.off('vim-keypress', this.onVimKeypress); + cmEditor.on('vim-keypress', this.onVimKeypress); + cmEditor.off('vim-command-done', this.onVimCommandDone); + cmEditor.on('vim-command-done', this.onVimCommandDone); + CodeMirror.off(cmEditor.getInputField(), 'keydown', this.onKeydown); + CodeMirror.on(cmEditor.getInputField(), 'keydown', this.onKeydown); + } + } + async onload() { + await this.loadSettings(); + this.addSettingTab(new SettingsTab(this.app, this)); + console.log('loading Vimrc plugin'); + this.app.workspace.on('active-leaf-change', async () => { + if (!this.initialized) + await this.initialize(); + if (this.codeMirrorVimObject.loadedVimrc) + return; + let fileName = this.settings.vimrcFileName; + if (!fileName || fileName.trim().length === 0) { + fileName = DEFAULT_SETTINGS.vimrcFileName; + console.log('Configured Vimrc file name is illegal, falling-back to default'); + } + let vimrcContent = ''; + try { + vimrcContent = await this.app.vault.adapter.read(fileName); + } + catch (e) { + console.log('Error loading vimrc file', fileName, 'from the vault root', e.message); + } + this.readVimInit(vimrcContent); + }); + } + async loadSettings() { + const data = await this.loadData(); + this.settings = Object.assign({}, DEFAULT_SETTINGS, data); + } + async saveSettings() { + await this.saveData(this.settings); + } + onunload() { + console.log('unloading Vimrc plugin (but Vim commands that were already loaded will still work)'); + } + getActiveView() { + return this.app.workspace.getActiveViewOfType(obsidian.MarkdownView); + } + getActiveObsidianEditor() { + return this.getActiveView().editor; + } + getCodeMirror(view) { + return view.editMode?.editor?.cm?.cm; + } + readVimInit(vimCommands) { + let view = this.getActiveView(); + if (view) { + 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); + this.defineJsCommand(this.codeMirrorVimObject); + this.defineJsFile(this.codeMirrorVimObject); + this.defineSource(this.codeMirrorVimObject); + this.loadVimCommands(vimCommands); + // 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 + this.codeMirrorVimObject.loadedVimrc = true; + } + if (cmEditor) { + cmEditor.off('vim-mode-change', this.logVimModeChange); + cmEditor.on('vim-mode-change', this.logVimModeChange); + CodeMirror.off(cmEditor.getInputField(), 'keydown', this.onKeydown); + CodeMirror.on(cmEditor.getInputField(), 'keydown', this.onKeydown); + } + } + } + loadVimCommands(vimCommands) { + let view = this.getActiveView(); + if (view) { + var cmEditor = this.getCodeMirror(view); + vimCommands.split("\n").forEach(function (line, index, arr) { + 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; + } + this.codeMirrorVimObject.handleEx(cmEditor, line); + } + }.bind(this) // Faster than an arrow function. https://stackoverflow.com/questions/50375440/binding-vs-arrow-function-for-react-onclick-event + ); + } + } + defineBasicCommands(vimObject) { + vimObject.defineOption('clipboard', '', 'string', ['clip'], (value, cm) => { + if (value) { + if (value.trim() == 'unnamed' || value.trim() == 'unnamedplus') { + if (!this.yankToSystemClipboard) { + this.yankToSystemClipboard = true; + console.log("Vim is now set to yank to system clipboard."); + } + } + else { + throw new Error("Unrecognized clipboard option, supported are 'unnamed' and 'unnamedplus' (and they do the same)"); + } + } + }); + vimObject.defineOption('tabstop', 4, 'number', [], (value, cm) => { + if (value && cm) { + cm.setOption('tabSize', value); + } + }); + vimObject.defineEx('iunmap', '', (cm, params) => { + if (params.argString.trim()) { + this.codeMirrorVimObject.unmap(params.argString.trim(), 'insert'); + } + }); + vimObject.defineEx('nunmap', '', (cm, params) => { + if (params.argString.trim()) { + this.codeMirrorVimObject.unmap(params.argString.trim(), 'normal'); + } + }); + vimObject.defineEx('vunmap', '', (cm, params) => { + if (params.argString.trim()) { + this.codeMirrorVimObject.unmap(params.argString.trim(), 'visual'); + } + }); + vimObject.defineEx('noremap', '', (cm, params) => { + if (!params?.args?.length) { + throw new Error('Invalid mapping: noremap'); + } + if (params.argString.trim()) { + this.codeMirrorVimObject.noremap.apply(this.codeMirrorVimObject, params.args); + } + }); + // Allow the user to register an Ex command + vimObject.defineEx('exmap', '', (cm, params) => { + if (params?.args?.length && params.args.length < 2) { + throw new Error(`exmap requires at least 2 parameters: [name] [actions...]`); + } + let commandName = params.args[0]; + params.args.shift(); + let commandContent = params.args.join(' '); + // The content of the user's Ex command is just the remaining parameters of the exmap command + this.codeMirrorVimObject.defineEx(commandName, '', (cm, params) => { + this.codeMirrorVimObject.handleEx(cm, commandContent); + }); + }); + } + defineAndMapObsidianVimCommands(vimObject) { + 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) { + vimObject.defineEx('sendkeys', '', async (cm, params) => { + 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 = []; + for (const key of params.args) { + if (key.startsWith('wait')) { + const delay = key.slice(4); + await sleep(delay * 1000); + } + else { + let keyEvent = null; + try { + keyEvent = new KeyboardEvent('keydown', keyboardeventFromElectronAccelerator.toKeyEvent(key)); + events.push(keyEvent); + } + catch (e) { + allGood = false; + throw new Error(`Key '${key}' couldn't be read as an Electron Accelerator`); + } + if (allGood) { + for (keyEvent of events) + window.postMessage(JSON.parse(JSON.stringify(keyEvent)), '*'); + // view.containerEl.dispatchEvent(keyEvent); + } + } + } + }); + } + executeObsidianCommand(commandName) { + const availableCommands = this.app.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) { + vimObject.defineEx('obcommand', '', async (cm, params) => { + if (!params?.args?.length || params.args.length != 1) { + const availableCommands = this.app.commands.commands; + console.log(`Available commands: ${Object.keys(availableCommands).join('\n')}`); + throw new Error(`obcommand requires exactly 1 parameter`); + } + const commandName = params.args[0]; + this.executeObsidianCommand(commandName); + }); + } + defineSurround(vimObject) { + // Function to surround selected text or highlighted word. + var surroundFunc = (params) => { + var editor = this.getActiveView().editor; + if (!params?.length) { + throw new Error("surround requires exactly 2 parameters: prefix and postfix text."); + } + let newArgs = params.join(" ").match(/(\\.|[^\s\\\\]+)+/g); + if (newArgs.length != 2) { + throw new Error("surround requires exactly 2 parameters: prefix and postfix text."); + } + let beginning = newArgs[0].replace("\\\\", "\\").replace("\\ ", " "); // Get the beginning surround text + let ending = newArgs[1].replace("\\\\", "\\").replace("\\ ", " "); // Get the ending surround text + let currentSelections = this.currentSelection; + var chosenSelection = currentSelections?.[0] ? currentSelections[0] : { anchor: editor.getCursor(), head: editor.getCursor() }; + if (currentSelections?.length > 1) { + console.log("WARNING: Multiple selections in surround. Attempt to select matching cursor. (obsidian-vimrc-support)"); + const cursorPos = editor.getCursor(); + for (const selection of currentSelections) { + if (selection.head.line == cursorPos.line && selection.head.ch == cursorPos.ch) { + console.log("RESOLVED: Selection matching cursor found. (obsidian-vimrc-support)"); + chosenSelection = selection; + break; + } + } + } + if (editor.posToOffset(chosenSelection.anchor) === editor.posToOffset(chosenSelection.head)) { + // No range of selected text, so select word. + let wordAt = editor.wordAt(chosenSelection.head); + if (wordAt) { + chosenSelection = { anchor: wordAt.from, head: wordAt.to }; + } + } + if (editor.posToOffset(chosenSelection.anchor) > editor.posToOffset(chosenSelection.head)) { + [chosenSelection.anchor, chosenSelection.head] = [chosenSelection.head, chosenSelection.anchor]; + } + let currText = editor.getRange(chosenSelection.anchor, chosenSelection.head); + editor.replaceRange(beginning + currText + ending, chosenSelection.anchor, chosenSelection.head); + // If no selection, place cursor between beginning and ending + if (editor.posToOffset(chosenSelection.anchor) === editor.posToOffset(chosenSelection.head)) { + chosenSelection.head.ch += beginning.length; + editor.setCursor(chosenSelection.head); + } + }; + vimObject.defineEx("surround", "", (cm, params) => { surroundFunc(params.args); }); + vimObject.defineEx("pasteinto", "", (cm, params) => { + // Using the register for when this.yankToSystemClipboard == false + surroundFunc(['[', + '](' + vimObject.getRegisterController().getRegister('yank').keyBuffer + ")"]); + }); + this.getActiveView().editor; + // Handle the surround dialog input + var surroundDialogCallback = (value) => { + if ((/^\[+$/).test(value)) { // check for 1-inf [ and match them with ] + surroundFunc([value, "]".repeat(value.length)]); + } + else if ((/^\(+$/).test(value)) { // check for 1-inf ( and match them with ) + surroundFunc([value, ")".repeat(value.length)]); + } + else if ((/^\{+$/).test(value)) { // check for 1-inf { and match them with } + surroundFunc([value, "}".repeat(value.length)]); + } + else { // Else, just put it before and after. + surroundFunc([value, value]); + } + }; + vimObject.defineOperator("surroundOperator", () => { + let p = "Surround with: "; + CodeMirror.openDialog(p, surroundDialogCallback, { bottom: true, selectValueOnOpen: false }); + }); + vimObject.mapCommand("s", "operator", "surroundOperator"); + } + async captureYankBuffer(win) { + if (!this.yankToSystemClipboard) { + return; + } + const yankRegister = this.codeMirrorVimObject.getRegisterController().getRegister('yank'); + const currentYankBuffer = yankRegister.keyBuffer; + // yank -> clipboard + const buf = currentYankBuffer[0]; + if (buf !== this.lastYankBuffer[0]) { + await win.navigator.clipboard.writeText(buf); + this.lastYankBuffer = currentYankBuffer; + this.lastSystemClipboard = await win.navigator.clipboard.readText(); + return; + } + // clipboard -> yank + try { + const currentClipboardText = await win.navigator.clipboard.readText(); + if (currentClipboardText !== this.lastSystemClipboard) { + yankRegister.setText(currentClipboardText); + this.lastYankBuffer = yankRegister.keyBuffer; + this.lastSystemClipboard = currentClipboardText; + } + } + catch (e) { + // XXX: Avoid "Uncaught (in promise) DOMException: Document is not focused." + // XXX: It is not good but easy workaround + } + } + prepareChordDisplay() { + 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"; + const view = this.getActiveView(); + if (!view) + return; + let cmEditor = this.getCodeMirror(view); + // See https://codemirror.net/doc/manual.html#vimapi_events for events. + cmEditor.off('vim-keypress', this.onVimKeypress); + cmEditor.on('vim-keypress', this.onVimKeypress); + cmEditor.off('vim-command-done', this.onVimCommandDone); + cmEditor.on('vim-command-done', this.onVimCommandDone); + } + } + prepareVimModeDisplay() { + if (this.settings.displayVimMode) { + this.vimStatusBar = this.addStatusBarItem(); // Add status bar item + this.vimStatusBar.setText(this.settings.vimStatusPromptMap["normal" /* vimStatus.normal */]); // Init the vimStatusBar with normal mode + this.vimStatusBar.addClass(vimStatusPromptClass); + this.vimStatusBar.dataset.vimMode = this.currentVimStatus; + } + } + defineJsCommand(vimObject) { + vimObject.defineEx('jscommand', '', (cm, params) => { + 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(); + if (jsCode[0] != '{' || jsCode[jsCode.length - 1] != '}') + throw new Error("Expected an argument which is JS code surrounded by curly brackets: {...}"); + let currentSelections = this.currentSelection; + var chosenSelection = currentSelections && currentSelections.length > 0 ? currentSelections[0] : null; + const command = Function('editor', 'view', 'selection', jsCode); + const view = this.getActiveView(); + command(view.editor, view, chosenSelection); + }); + } + defineJsFile(vimObject) { + vimObject.defineEx('jsfile', '', async (cm, params) => { + 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(); + if (extraCode[0] != '{' || extraCode[extraCode.length - 1] != '}') + throw new Error("Expected an extra code argument which is JS code surrounded by curly brackets: {...}"); + } + let currentSelections = this.currentSelection; + var chosenSelection = currentSelections && currentSelections.length > 0 ? currentSelections[0] : null; + 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', 'selection', content + extraCode); + const view = this.getActiveView(); + command(view.editor, view, chosenSelection); + }); + } + defineSource(vimObject) { + vimObject.defineEx('source', '', async (cm, params) => { + if (params?.args?.length > 1) + throw new Error("Expected format: source [fileName]"); + const fileName = params.argString.trim(); + try { + this.app.vault.adapter.read(fileName).then(vimrcContent => { + this.loadVimCommands(vimrcContent); + }); + } + catch (e) { + console.log('Error loading vimrc file', fileName, 'from the vault root', e.message); + } + }); + } +} +class SettingsTab extends obsidian.PluginSettingTab { + constructor(app, plugin) { + super(app, plugin); + this.plugin = plugin; + } + display() { + let { containerEl } = this; + containerEl.empty(); + containerEl.createEl('h2', { text: 'Vimrc Settings' }); + new obsidian.Setting(containerEl) + .setName('Vimrc file name') + .setDesc('Relative to vault directory (requires restart)') + .addText((text) => { + text.setPlaceholder(DEFAULT_SETTINGS.vimrcFileName); + text.setValue(this.plugin.settings.vimrcFileName || DEFAULT_SETTINGS.vimrcFileName); + text.onChange(value => { + this.plugin.settings.vimrcFileName = value; + this.plugin.saveSettings(); + }); + }); + new obsidian.Setting(containerEl) + .setName('Vim chord display') + .setDesc('Displays the current chord until completion. Ex: " f-" (requires restart)') + .addToggle((toggle) => { + toggle.setValue(this.plugin.settings.displayChord || DEFAULT_SETTINGS.displayChord); + toggle.onChange(value => { + this.plugin.settings.displayChord = value; + this.plugin.saveSettings(); + }); + }); + new obsidian.Setting(containerEl) + .setName('Vim mode display') + .setDesc('Displays the current vim mode (requires restart)') + .addToggle((toggle) => { + toggle.setValue(this.plugin.settings.displayVimMode || DEFAULT_SETTINGS.displayVimMode); + toggle.onChange(value => { + this.plugin.settings.displayVimMode = value; + this.plugin.saveSettings(); + }); + }); + new obsidian.Setting(containerEl) + .setName('Use a fixed keyboard layout for Normal mode') + .setDesc('Define a keyboard layout to always use when in Normal mode, regardless of the input language (experimental).') + .addButton(async (button) => { + button.setButtonText('Capture current layout'); + button.onClick(async () => { + this.plugin.settings.capturedKeyboardMap = await this.plugin.captureKeyboardLayout(); + this.plugin.saveSettings(); + }); + }) + .addToggle((toggle) => { + toggle.setValue(this.plugin.settings.fixedNormalModeLayout || DEFAULT_SETTINGS.fixedNormalModeLayout); + toggle.onChange(async (value) => { + this.plugin.settings.fixedNormalModeLayout = value; + if (value && Object.keys(this.plugin.settings.capturedKeyboardMap).length === 0) + this.plugin.settings.capturedKeyboardMap = await this.plugin.captureKeyboardLayout(); + this.plugin.saveSettings(); + }); + }); + new obsidian.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(); + }); + }); + containerEl.createEl('h2', { text: 'Vim Mode Display Prompt' }); + new obsidian.Setting(containerEl) + .setName('Normal mode prompt') + .setDesc('Set the status prompt text for normal mode.') + .addText((text) => { + text.setPlaceholder('Default: 🟢'); + text.setValue(this.plugin.settings.vimStatusPromptMap.normal || + DEFAULT_SETTINGS.vimStatusPromptMap.normal); + text.onChange((value) => { + this.plugin.settings.vimStatusPromptMap.normal = value || + DEFAULT_SETTINGS.vimStatusPromptMap.normal; + this.plugin.saveSettings(); + }); + }); + new obsidian.Setting(containerEl) + .setName('Insert mode prompt') + .setDesc('Set the status prompt text for insert mode.') + .addText((text) => { + text.setPlaceholder('Default: 🟠'); + text.setValue(this.plugin.settings.vimStatusPromptMap.insert || + DEFAULT_SETTINGS.vimStatusPromptMap.insert); + text.onChange((value) => { + this.plugin.settings.vimStatusPromptMap.insert = value || + DEFAULT_SETTINGS.vimStatusPromptMap.insert; + console.log(this.plugin.settings.vimStatusPromptMap); + this.plugin.saveSettings(); + }); + }); + new obsidian.Setting(containerEl) + .setName('Visual mode prompt') + .setDesc('Set the status prompt text for visual mode.') + .addText((text) => { + text.setPlaceholder('Default: 🟡'); + text.setValue(this.plugin.settings.vimStatusPromptMap.visual || + DEFAULT_SETTINGS.vimStatusPromptMap.visual); + text.onChange((value) => { + this.plugin.settings.vimStatusPromptMap.visual = value || + DEFAULT_SETTINGS.vimStatusPromptMap.visual; + this.plugin.saveSettings(); + }); + }); + new obsidian.Setting(containerEl) + .setName('Replace mode prompt') + .setDesc('Set the status prompt text for replace mode.') + .addText((text) => { + text.setPlaceholder('Default: 🔴'); + text.setValue(this.plugin.settings.vimStatusPromptMap.replace || + DEFAULT_SETTINGS.vimStatusPromptMap.replace); + text.onChange((value) => { + this.plugin.settings.vimStatusPromptMap.replace = value || + DEFAULT_SETTINGS.vimStatusPromptMap.replace; + this.plugin.saveSettings(); + }); + }); + } +} + +module.exports = VimrcPlugin; + + +/* nosourcemap */ \ No newline at end of file diff --git a/.obsidian/plugins/obsidian-vimrc-support/manifest.json b/.obsidian/plugins/obsidian-vimrc-support/manifest.json new file mode 100644 index 0000000..ca1e3e2 --- /dev/null +++ b/.obsidian/plugins/obsidian-vimrc-support/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "obsidian-vimrc-support", + "name": "Vimrc Support", + "version": "0.10.2", + "description": "Auto-load a startup file with Obsidian Vim commands.", + "minAppVersion": "0.15.3", + "author": "esm", + "authorUrl": "", + "isDesktopOnly": false +}