mirror of
https://github.com/esm7/obsidian-vimrc-support.git
synced 2026-07-22 05:00:25 +00:00
Compare commits
116 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ff5d97afc | ||
|
|
ba2c8243c1 | ||
|
|
b3beeae815 | ||
|
|
10c5a580f9 | ||
|
|
774de34205 | ||
|
|
923de9fe8a | ||
|
|
2fe5ee588c | ||
|
|
2f18ae8b56 | ||
|
|
3d3bf60c88 | ||
|
|
e800087a72 | ||
|
|
1dd8077282 | ||
|
|
340931c975 | ||
|
|
4aa4ca31f1 | ||
|
|
47de579471 | ||
|
|
378753b0b4 | ||
|
|
d376bfd048 | ||
|
|
0171572ac1 | ||
|
|
e6d24066d3 | ||
|
|
957c14bd14 | ||
|
|
ee5897383e | ||
|
|
a5fdaec56c | ||
|
|
ba94e13835 | ||
|
|
10d3c3c464 | ||
|
|
040478f988 | ||
|
|
10c2b613bf | ||
|
|
2a5fffa2d1 | ||
|
|
2795bb62b1 | ||
|
|
b5b50320d7 | ||
|
|
d2583078ac | ||
|
|
89cd8c71b8 | ||
|
|
5867e784f5 | ||
|
|
b9625acc80 | ||
|
|
4657923eb3 | ||
|
|
89ae5fd0aa | ||
|
|
9ccf52a590 | ||
|
|
224425bb6c | ||
|
|
0895ca4d07 | ||
|
|
dd1838a175 | ||
|
|
a864306288 | ||
|
|
05b3a43a8e | ||
|
|
1c7ff072df | ||
|
|
5d36a519d6 | ||
|
|
bdf41a370f | ||
|
|
af7a99d54e | ||
|
|
de354d654a | ||
|
|
b48f0155ae | ||
|
|
d9dd46efd1 | ||
|
|
02bc1bf173 | ||
|
|
0d0a562f6e | ||
|
|
b6d258f807 | ||
|
|
00c1a0ee39 | ||
|
|
ec6de70c35 | ||
|
|
5bff2b81a5 | ||
|
|
bf7b373f23 | ||
|
|
7fb79e6faf | ||
|
|
f8e9893126 | ||
|
|
99b379952b | ||
|
|
edace4ee7a | ||
|
|
a1e9406450 | ||
|
|
eb8926a476 | ||
|
|
93ee168b7d | ||
|
|
97868caed8 | ||
|
|
210269dbdb | ||
|
|
c6ee5570dc | ||
|
|
3f58d4db7e | ||
|
|
1a1cd75dcc | ||
|
|
4d91711fcd | ||
|
|
5f82041bb1 | ||
|
|
9c20fb513f | ||
|
|
3776b54d9f | ||
|
|
0ab972cc70 | ||
|
|
0c8d9f12f4 | ||
|
|
72696afe11 | ||
|
|
5093dca6dc | ||
|
|
6756ac2b63 | ||
|
|
d930d2233e | ||
|
|
5e108b9da5 | ||
|
|
59173c32a1 | ||
|
|
857ca9322f | ||
|
|
596fb9b9ca | ||
|
|
65e5d251b3 | ||
|
|
57c0aa0c7a | ||
|
|
b7b6d27982 | ||
|
|
097678fb4b | ||
|
|
eae8d02942 | ||
|
|
52cdd584c1 | ||
|
|
a3e7973fde | ||
|
|
ef153c3182 | ||
|
|
2b7458faed | ||
|
|
e546461bfb | ||
|
|
ddcc99b2bd | ||
|
|
008649157e | ||
|
|
b0cd7f594d | ||
|
|
43c3a2a7ee | ||
|
|
11d56f9ef2 | ||
|
|
adaf3bad2b | ||
|
|
2eedc65a31 | ||
|
|
ccbaaefc23 | ||
|
|
361c2686da | ||
|
|
f2417c0193 | ||
|
|
f25a54a0c8 | ||
|
|
f4814f065d | ||
|
|
bcc1e56935 | ||
|
|
1518987ab3 | ||
|
|
ba70ed0558 | ||
|
|
08b0c5461a | ||
|
|
9a0376745e | ||
|
|
ab34da73f5 | ||
|
|
13a893372d | ||
|
|
ccfbe55ebc | ||
|
|
79c6b60519 | ||
|
|
d79de28400 | ||
|
|
8c95b4c703 | ||
|
|
0e90edaa18 | ||
|
|
f613efe7ae | ||
|
|
d3ab0568cf |
21 changed files with 4553 additions and 255 deletions
30
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
30
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: Create a report to help us improve
|
||||||
|
title: ''
|
||||||
|
labels: ''
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Please Review Before Posting!**
|
||||||
|
|
||||||
|
- [ ] I checked that the bug *does not happen* in the [CodeMirror Vim demo](https://codemirror-vim--util.repl.co/). If it does, please report it [there](https://github.com/replit/codemirror-vim) and not here.
|
||||||
|
- [ ] I'm reasonably sure that this bug is indeed about the Vimrc file support and not a general Vim in Obsidian issue. If it's a general Vim issue, report it [here](https://forum.obsidian.md/c/bug-reports/7).
|
||||||
|
|
||||||
|
**Describe the bug:**
|
||||||
|
|
||||||
|
A clear and concise description of what the bug is.
|
||||||
|
|
||||||
|
**To Reproduce:**
|
||||||
|
|
||||||
|
Steps to reproduce the behavior:
|
||||||
|
|
||||||
|
**Environment (please complete the following information):**
|
||||||
|
|
||||||
|
- OS:
|
||||||
|
- Vimrc plugin version:
|
||||||
|
|
||||||
|
**Additional context:**
|
||||||
|
|
||||||
|
Add any other context about the problem here.
|
||||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
---
|
||||||
|
name: Feature request
|
||||||
|
about: Suggest an idea for this project
|
||||||
|
title: ''
|
||||||
|
labels: ''
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Please make sure this feature request is within the scope of this plugin.**
|
||||||
|
|
||||||
|
- [ ] I understand that if my request is about supporting some Vim feature, it probably belongs [here](https://github.com/replit/codemirror-vim).
|
||||||
|
|
||||||
|
**Describe the feature that you'd like:**
|
||||||
|
|
||||||
|
A clear and concise description of what you want to happen.
|
||||||
|
|
||||||
|
**Additional context:**
|
||||||
|
|
||||||
|
Add any other context or screenshots about the feature request here.
|
||||||
14
.github/ISSUE_TEMPLATE/question--how-to----.md
vendored
Normal file
14
.github/ISSUE_TEMPLATE/question--how-to----.md
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
---
|
||||||
|
name: Question (how to...)
|
||||||
|
about: Various questions about how to do Vimrc-related stuff
|
||||||
|
title: ''
|
||||||
|
labels: ''
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Please make sure your question is within the scope of this plugin.**
|
||||||
|
|
||||||
|
- [ ] I know this plugin is about *persisting changes* to the Vim support built into Obsidian, plus some bonus features, and considered asking in the [Obsidian forum](https://forum.obsidian.md/c/get-help/19) first.
|
||||||
|
|
||||||
|
**Describe the question**
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -7,10 +7,12 @@
|
||||||
|
|
||||||
# npm
|
# npm
|
||||||
node_modules
|
node_modules
|
||||||
package-lock.json
|
|
||||||
|
|
||||||
# build
|
# build
|
||||||
main.js
|
main.js
|
||||||
*.js.map
|
*.js.map
|
||||||
|
data.json
|
||||||
|
|
||||||
obsidian.d.ts
|
obsidian.d.ts
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
|
|
||||||
19
.vscode/settings.json
vendored
Normal file
19
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"cSpell.words": [
|
||||||
|
"exmap",
|
||||||
|
"iunmap",
|
||||||
|
"jscommand",
|
||||||
|
"jsfile",
|
||||||
|
"Keybinds",
|
||||||
|
"keychord",
|
||||||
|
"keymap",
|
||||||
|
"nmap",
|
||||||
|
"noremap",
|
||||||
|
"nunmap",
|
||||||
|
"obcommand",
|
||||||
|
"pasteinto",
|
||||||
|
"sendkeys",
|
||||||
|
"unnamedplus",
|
||||||
|
"vunmap"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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!
|
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 you can call `mdHelpers.js`, put this:
|
||||||
|
|
||||||
In a file I call `mdHelpers.js`, put this:
|
|
||||||
|
|
||||||
```js
|
```js
|
||||||
// Taken from https://stackoverflow.com/questions/273789/is-there-a-version-of-javascripts-string-indexof-that-allows-for-regular-expr
|
// Taken from https://stackoverflow.com/questions/273789/is-there-a-version-of-javascripts-string-indexof-that-allows-for-regular-expr
|
||||||
|
|
@ -59,3 +58,59 @@ exmap prevHeading jsfile mdHelpers.js {jumpHeading(false)}
|
||||||
nmap ]] :nextHeading
|
nmap ]] :nextHeading
|
||||||
nmap [[ :prevHeading
|
nmap [[ :prevHeading
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Avoid unfolding folded sections
|
||||||
|
```javascript
|
||||||
|
function moveUpSkipFold() {
|
||||||
|
view.editor.exec('goUp');
|
||||||
|
}
|
||||||
|
function moveDownSkipFold() {
|
||||||
|
view.editor.exec('goDown');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Then in your `.obsidian.vimrc` file add the following:
|
||||||
|
|
||||||
|
```
|
||||||
|
exmap upSkipFold jsfile mdHelpers.js {moveUpSkipFold()}
|
||||||
|
exmap downSkipFold jsfile mdHelpers.js {moveDownSkipFold()}
|
||||||
|
nmap k :upSkipFold
|
||||||
|
nmap j :downSkipFold
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Vimwiki-like link navigation
|
||||||
|
|
||||||
|
This snippet allows to navigate next/previous links with Tab/Shift+Tab.
|
||||||
|
|
||||||
|
It mimicks the behaviour of vimwiki keybindings where you would press Tab several times to get to the link you want.
|
||||||
|
|
||||||
|
Append this function to the file mentioned above (after jumpHeading).
|
||||||
|
|
||||||
|
```js
|
||||||
|
function jumpNextLink(isForward) {
|
||||||
|
const editor = view.editor;
|
||||||
|
let posToSearchFrom = editor.getCursor();
|
||||||
|
posToSearchFrom.line += isForward ? 0 : -1;
|
||||||
|
const cursorOffset = editor.posToOffset(posToSearchFrom);
|
||||||
|
const lookupToUse = isForward ? regexIndexOf : regexLastIndexOf;
|
||||||
|
let headingOffset = lookupToUse(editor.getValue(), /\[\[/g, cursorOffset);
|
||||||
|
// If not found from the cursor position, try again from the document beginning (or reverse beginning)
|
||||||
|
if (headingOffset === -1)
|
||||||
|
headingOffset = lookupToUse(editor.getValue(), /\[\[/g);
|
||||||
|
const newPos = editor.offsetToPos(headingOffset+2);
|
||||||
|
editor.setCursor(newPos);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then put these lines in vimrc file to set the keybindings. Optionally (as in vimwiki) you can have ENTER bound to follow the link.
|
||||||
|
|
||||||
|
```
|
||||||
|
exmap nextLink jsfile mdHelpers.js {jumpNextLink(true)}
|
||||||
|
exmap prevLink jsfile mdHelpers.js {jumpNextLink(false)}
|
||||||
|
nmap <Tab> :nextLink
|
||||||
|
nmap <S-Tab> :prevLink
|
||||||
|
|
||||||
|
exmap followlink obcommand editor:follow-link
|
||||||
|
nmap <CR> :followlink
|
||||||
|
```
|
||||||
|
|
|
||||||
19
LICENSE
Normal file
19
LICENSE
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
Copyright (c) 2022 esm7
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
333
README.md
333
README.md
|
|
@ -1,8 +1,20 @@
|
||||||
# Obsidian Vimrc Support Plugin
|
# Obsidian Vimrc Support Plugin
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> In Obsidian 1.7.2 and beyond, `<CR>` needs to be added for normal mode Ex command mappings, e.g. `nmap <F9> :nohl<CR>` instead of `nmap <F9> :nohl` (similarly to "real" Vim).
|
||||||
|
> This is due to a breaking change in the underlying `codemirror-vim` library.
|
||||||
|
> Normal mode movements, e.g. `map j gj`, remain as-is.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> **Maintainer Needed!**
|
||||||
|
>
|
||||||
|
> While I am still around for some urgent fixes, especially when the plugin stops working due to Obsidian API changes, I am no longer able to give it the attention it deserves.
|
||||||
|
>
|
||||||
|
> Anyone who wishes to take over, please message me.
|
||||||
|
|
||||||
[](https://www.buymeacoffee.com/esm7)
|
[](https://www.buymeacoffee.com/esm7)
|
||||||
|
|
||||||
This plugin loads a file of Vim commands from `VAULT_ROOT/.obsidian.vimrc`.
|
This plugin loads a file of Vim commands from `path/to/VaultName/.obsidian.vimrc`.
|
||||||
For users of the Obsidian.md Vim mode, this is very useful for making various settings (most notably keymaps) persist.
|
For users of the Obsidian.md Vim mode, this is very useful for making various settings (most notably keymaps) persist.
|
||||||
|
|
||||||
Note that this plugin is **not** the Vim support of Obsidian -- that support is built-in and you can perfectly use Obsidian in Vim mode without this plugin.
|
Note that this plugin is **not** the Vim support of Obsidian -- that support is built-in and you can perfectly use Obsidian in Vim mode without this plugin.
|
||||||
|
|
@ -12,12 +24,14 @@ This plugin merely implements the ability to load a persistent configuration and
|
||||||
|
|
||||||
First and foremost, make sure you have the Obsidian Vim key bindings turned on -- see Editor -> Vim key bindings.
|
First and foremost, make sure you have the Obsidian Vim key bindings turned on -- see Editor -> Vim key bindings.
|
||||||
|
|
||||||
Now to keep some of your Vim settings permanent, install this plugin and put a file named `.obsidian.vimrc` in your vault root.
|
Now to keep some of your Vim settings permanent, install this plugin and put a file named `.obsidian.vimrc` in your vault root (*not* inside the `.obsidian` directory).
|
||||||
If you're using multiple vaults, you'll need this file on each one.
|
If you're using multiple vaults, you'll need this file on each one.
|
||||||
|
|
||||||
|
**For iOS/iPadOS users**, it's highly recommended to set "Paste from Other Apps" of Obsidian to Allow, so there won't be annoying popups.
|
||||||
|
|
||||||
Here's a simple & useful `.obsidian.vimrc` that I'm using:
|
Here's a simple & useful `.obsidian.vimrc` that I'm using:
|
||||||
|
|
||||||
```
|
```vim
|
||||||
" Have j and k navigate visual lines rather than logical ones
|
" Have j and k navigate visual lines rather than logical ones
|
||||||
nmap j gj
|
nmap j gj
|
||||||
nmap k gk
|
nmap k gk
|
||||||
|
|
@ -25,7 +39,7 @@ nmap k gk
|
||||||
nmap H ^
|
nmap H ^
|
||||||
nmap L $
|
nmap L $
|
||||||
" Quickly remove search highlights
|
" Quickly remove search highlights
|
||||||
nmap <F9> :nohl
|
nmap <F9> :nohl<CR>
|
||||||
|
|
||||||
" Yank to system clipboard
|
" Yank to system clipboard
|
||||||
set clipboard=unnamed
|
set clipboard=unnamed
|
||||||
|
|
@ -33,27 +47,36 @@ set clipboard=unnamed
|
||||||
" Go back and forward with Ctrl+O and Ctrl+I
|
" Go back and forward with Ctrl+O and Ctrl+I
|
||||||
" (make sure to remove default Obsidian shortcuts for these to work)
|
" (make sure to remove default Obsidian shortcuts for these to work)
|
||||||
exmap back obcommand app:go-back
|
exmap back obcommand app:go-back
|
||||||
nmap <C-o> :back
|
nmap <C-o> :back<CR>
|
||||||
exmap forward obcommand app:go-forward
|
exmap forward obcommand app:go-forward
|
||||||
nmap <C-i> :forward
|
nmap <C-i> :forward<CR>
|
||||||
|
|
||||||
|
" Also works
|
||||||
|
nmap <C-w>h :obcommand<space>workspace:split-horizontal<CR>
|
||||||
```
|
```
|
||||||
|
|
||||||
## Supported Commands
|
## Supported Commands
|
||||||
|
|
||||||
The commands that can be used are whatever CodeMirror supports.
|
The commands that can be used are whatever CodeMirror supports.
|
||||||
I couldn't find a formal list anywhere but you can look for `defaultExCommandMap` in [the source code](https://github.com/codemirror/CodeMirror/blob/master/keymap/vim.js), or play around with trying commands in Obsidian's Vim mode.
|
I couldn't find a formal list anywhere but you can look for `defaultExCommandMap` in [the source code](https://github.com/replit/codemirror-vim/blob/master/src/vim.js), or play around with trying commands in Obsidian's Vim mode.
|
||||||
|
|
||||||
In addition to that:
|
In addition to that:
|
||||||
- The plugin skips blank lines and lines starting with Vimscript comments (`" ...`).
|
- The plugin skips blank lines and lines starting with Vimscript comments (`" ...`).
|
||||||
- Special support for yanking to system clipboard can be activated by `set clipboard=unnamed` (`unnamedplus` will do the same thing).
|
- Special support for yanking to system clipboard can be activated by `set clipboard=unnamed` (`unnamedplus` will do the same thing).
|
||||||
- Support for the `tabstop` Vim option (e.g. `set tabstop=4`).
|
- Support for the `tabstop` Vim option (e.g. `set tabstop=4`).
|
||||||
- Custom mapping/unmapping commands in addition to the defaults: `noremap` and `iunmap` (PRs are welcome to implement more :) )
|
- Custom mapping/unmapping commands in addition to the defaults:
|
||||||
|
- `noremap`
|
||||||
|
- `iunmap`
|
||||||
|
- `nunmap`
|
||||||
|
- `vunmap`
|
||||||
|
- 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.
|
- `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.
|
- `obcommand` - execute Obsidian commands, see more details below.
|
||||||
- `cmcommand` - execute arbitrary CodeMirror commands, see details below.
|
- `cmcommand` - execute arbitrary CodeMirror commands, see details below.
|
||||||
- `surround` - surround your selected text in visual mode or word in normal mode with text.
|
- `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.
|
- `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.
|
- `jscommand` and `jsfile` - extend Vim mode using JavaScript snippets.
|
||||||
|
- `source` - loads Vim commands from a file (relative to the vault root).
|
||||||
|
|
||||||
Commands that fail don't generate any visible error for now.
|
Commands that fail don't generate any visible error for now.
|
||||||
|
|
||||||
|
|
@ -61,9 +84,16 @@ 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.
|
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.
|
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
|
## Installation
|
||||||
|
|
||||||
In the Obsidian.md settings under "Third-party plugin", turn off Safe mode, then browse to this plugin.
|
In the Obsidian.md settings under "Community plugins", click on "Turn on community plugins", then browse to this plugin.
|
||||||
|
|
||||||
Alternatively (and less recommended), you can install it manually: just copy `main.js` and `manifest.json` to your vault `VaultFolder/.obsidian/plugins/obsidian-vimrc-support/`.
|
Alternatively (and less recommended), you can install it manually: just copy `main.js` and `manifest.json` to your vault `VaultFolder/.obsidian/plugins/obsidian-vimrc-support/`.
|
||||||
|
|
||||||
|
|
@ -73,9 +103,9 @@ If you want to support the development of this plugin, please consider to [buy m
|
||||||
|
|
||||||
## "Please implement \[some Vim feature here\]..."
|
## "Please implement \[some Vim feature here\]..."
|
||||||
|
|
||||||
I'd like to emphasize again that this plugin is a tweak to Obsidian's built-in Vim mode, which is in turn mostly the [Vim mode of CodeMirror](https://codemirror.net/demo/vim.html). And while I am personally very fond of helping everybody make use of Vim modes everywhere, this plugin is often not the best place to implement some types of features.
|
I'd like to emphasize again that this plugin is a tweak to Obsidian's built-in Vim mode, which is in turn mostly the [`codemirror-vim` extension for CodeMirror](https://raw.githack.com/replit/codemirror-vim/master/dev/web-demo.html). And while I am personally very fond of helping everybody make use of Vim modes everywhere, this plugin is often not the best place to implement some types of features.
|
||||||
|
|
||||||
1. Vim editor features (e.g. new motions) would best be implemented in CodeMirror, so other editors using this component would enjoy them too! Please consider submitting issues or pull requests [there](https://github.com/codemirror/CodeMirror/) first.
|
1. Vim editor features (e.g. new motions) would best be implemented in `codemirror-vim`, so other editors using this component would enjoy them too! Please consider submitting issues or pull requests [there](https://github.com/replit/codemirror-vim) first.
|
||||||
2. Features that are already implemented by other Obsidian plugins are best to stay in these plugins. Please consider asking these plugin authors to add Vim support for their features (using the CodeMirror API), or even better -- help them out :)
|
2. Features that are already implemented by other Obsidian plugins are best to stay in these plugins. Please consider asking these plugin authors to add Vim support for their features (using the CodeMirror API), or even better -- help them out :)
|
||||||
|
|
||||||
Having said that, adding features here in this plugin is often very easy thanks to the CodeMirror [API for extending its Vim mode](https://codemirror.net/doc/manual.html#vimapi_extending), so as the path of least resistance I will occassionally implement some requested Vim features and be happy to accept PRs.
|
Having said that, adding features here in this plugin is often very easy thanks to the CodeMirror [API for extending its Vim mode](https://codemirror.net/doc/manual.html#vimapi_extending), so as the path of least resistance I will occassionally implement some requested Vim features and be happy to accept PRs.
|
||||||
|
|
@ -119,50 +149,56 @@ You now have a simple (0 argument) Ex command named `back` that goes back in Obs
|
||||||
To summarize, here's how you map `C-o` to Back:
|
To summarize, here's how you map `C-o` to Back:
|
||||||
```
|
```
|
||||||
exmap back obcommand app:go-back
|
exmap back obcommand app:go-back
|
||||||
nmap <C-o> :back
|
nmap <C-o> :back<CR>
|
||||||
```
|
```
|
||||||
|
|
||||||
Note how `exmap` lists command names without colons and in `nmap` the colon is required.
|
Note how `exmap` lists command names without colons and in `nmap` the colon is required.
|
||||||
|
|
||||||
## Executing CodeMirror Commands with `cmcommand`
|
|
||||||
|
|
||||||
The plugin defines a custom Ex command named `cmcommand` that exposes the full set of CodeMirror commands, some are not exposed by Obsidian's `editor:...` commands that can be used with `obcommand`.
|
|
||||||
|
|
||||||
**This currently only works with the legacy (CM5) editor.**
|
|
||||||
Support for the CM6-based editor may be added in the future.
|
|
||||||
|
|
||||||
For example, in combination with `exmap` explained above:
|
|
||||||
|
|
||||||
```
|
|
||||||
exmap lineStart cmcommand goLineLeftSmart
|
|
||||||
exmap lineEnd cmcommand goLineRight
|
|
||||||
```
|
|
||||||
|
|
||||||
The above defines two new Ex commands, `lineStart` and `lineEnd`, that go to the *visual* beginning and end of line (in contrast to the logical line).
|
|
||||||
I find this useful to map.
|
|
||||||
|
|
||||||
The full list of CodeMirror commands is available [here](https://codemirror.net/doc/manual.html#commands).
|
|
||||||
|
|
||||||
## Surround Text with `surround`
|
## 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.
|
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]]`.
|
This is useful for Vim users who are used to the `vim-surround` plugin.
|
||||||
|
|
||||||
The syntax follows `:surround [prefixText] [postfixText]`.
|
The syntax follows `:surround [prefixText] [postfixText]`.
|
||||||
|
|
||||||
Some examples:
|
Some examples:
|
||||||
|
|
||||||
- `surround ( )`
|
- `surround ( )`
|
||||||
|
- `surround " "`
|
||||||
- `surround [[ ]]`
|
- `surround [[ ]]`
|
||||||
|
|
||||||
Here's my surround config as an example:
|
Here's an example config that implements many of the features from vim-surround:
|
||||||
|
|
||||||
```
|
```
|
||||||
" Surround text with [[ ]] to make a wikilink
|
exmap surround_wiki surround [[ ]]
|
||||||
|
exmap surround_double_quotes surround " "
|
||||||
|
exmap surround_single_quotes surround ' '
|
||||||
|
exmap surround_backticks surround ` `
|
||||||
|
exmap surround_brackets surround ( )
|
||||||
|
exmap surround_square_brackets surround [ ]
|
||||||
|
exmap surround_curly_brackets surround { }
|
||||||
|
|
||||||
" NOTE: must use 'map' and not 'nmap'
|
" NOTE: must use 'map' and not 'nmap'
|
||||||
exmap wiki surround [[ ]]
|
map [[ :surround_wiki<CR>
|
||||||
map [[ :wiki
|
nunmap s
|
||||||
|
vunmap s
|
||||||
|
map s" :surround_double_quotes<CR>
|
||||||
|
map s' :surround_single_quotes<CR>
|
||||||
|
map s` :surround_backticks<CR>
|
||||||
|
map sb :surround_brackets<CR>
|
||||||
|
map s( :surround_brackets<CR>
|
||||||
|
map s) :surround_brackets<CR>
|
||||||
|
map s[ :surround_square_brackets<CR>
|
||||||
|
map s] :surround_square_brackets<CR>
|
||||||
|
map s{ :surround_curly_brackets<CR>
|
||||||
|
map s} :surround_curly_brackets<CR>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
|
||||||
|
1. Select some text in visual mode, then press `s` and then the desired surround character. e.g. `s"` to surround the selected text with double quotes.
|
||||||
|
2. Place your cursor over a word in normal mode, then press `s` and then the desired surround character. e.g. `s"` to surround the word with double quotes.
|
||||||
|
|
||||||
## Inserting Links/Hyperlinks with `pasteinto`
|
## 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.
|
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.
|
||||||
|
|
@ -180,9 +216,48 @@ map <A-p> :pasteinto
|
||||||
But first `<Space>` must be unbound with `unmap <Space>`.
|
But first `<Space>` must be unbound with `unmap <Space>`.
|
||||||
Afterwards `<Space>` can be mapped normally as any other key.
|
Afterwards `<Space>` can be mapped normally as any other key.
|
||||||
|
|
||||||
## Fixed Keyboard Layout in Normal Mode
|
|
||||||
|
|
||||||
**Note:** this is currently unsupported for the new (CM6-based) editor.
|
## Leader Key Support
|
||||||
|
|
||||||
|
You can use the standard Vim `<leader>` key in your mappings. Set your leader key with `let mapleader` and then use `<leader>` in any mapping command:
|
||||||
|
|
||||||
|
```vim
|
||||||
|
let mapleader = ","
|
||||||
|
nmap <leader>f :nohl<CR>
|
||||||
|
nmap <leader>w :obcommand editor:save-file<CR>
|
||||||
|
```
|
||||||
|
|
||||||
|
The default leader key is `\` (backslash), matching Vim's default.
|
||||||
|
|
||||||
|
`<leader>` works in all mapping commands (`map`, `nmap`, `noremap`, `imap`, `vmap`, etc.) and is case-insensitive — `<Leader>`, `<LEADER>`, and `<leader>` all work.
|
||||||
|
|
||||||
|
You can change the leader key mid-file — each `let mapleader` only affects mappings that come after it, matching Vim's behavior.
|
||||||
|
|
||||||
|
## Emulate Common Vim Commands via Obsidian commands
|
||||||
|
|
||||||
|
Using `obcommand`, it is possible to emulate some additional Vim commands that aren't included in Obsidian's Vim mode, like for example `gt` and `zo`.
|
||||||
|
|
||||||
|
```vim
|
||||||
|
" Emulate Folding https://vimhelp.org/fold.txt.html#fold-commands
|
||||||
|
exmap togglefold obcommand editor:toggle-fold
|
||||||
|
nmap zo :togglefold<CR>
|
||||||
|
nmap zc :togglefold<CR>
|
||||||
|
nmap za :togglefold<CR>
|
||||||
|
|
||||||
|
exmap unfoldall obcommand editor:unfold-all
|
||||||
|
nmap zR :unfoldall<CR>
|
||||||
|
|
||||||
|
exmap foldall obcommand editor:fold-all
|
||||||
|
nmap zM :foldall<CR>
|
||||||
|
|
||||||
|
exmap tabnext obcommand workspace:next-tab
|
||||||
|
nmap gt :tabnext<CR>
|
||||||
|
exmap tabprev obcommand workspace:previous-tab
|
||||||
|
nmap gT :tabprev<CR>
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fixed Keyboard Layout in Normal Mode
|
||||||
|
|
||||||
In many languages and keyboard layouts it becomes problematic or plain impossible to use Vim keys.
|
In many languages and keyboard layouts it becomes problematic or plain impossible to use Vim keys.
|
||||||
The Vim keys are located in different positions on some keyboard layouts, which could be confusing when switching
|
The Vim keys are located in different positions on some keyboard layouts, which could be confusing when switching
|
||||||
|
|
@ -217,6 +292,7 @@ If you understand the risks and choose to use this feature, turn on "Support JS
|
||||||
|
|
||||||
There are two ways to define JS-based commands.
|
There are two ways to define JS-based commands.
|
||||||
|
|
||||||
|
#### JSCommand - JSFunction
|
||||||
**The `jscommand` Ex command** defines a JS function that has an `editor: Editor`, a `view: MarkdownView` and a `selection: EditorSelection` arguments (see the [Obsidian API](https://github.com/obsidianmd/obsidian-api/blob/master/obsidian.d.ts) if you're not sure what these are).
|
**The `jscommand` Ex command** defines a JS function that has an `editor: Editor`, a `view: MarkdownView` and a `selection: EditorSelection` 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 body of the function, in a single line wrapped by curly braces, e.g.:
|
You define only the body of the function, in a single line wrapped by curly braces, e.g.:
|
||||||
|
|
||||||
|
|
@ -229,27 +305,199 @@ If you want, you can make this an Ex command using `exmap`:
|
||||||
|
|
||||||
```
|
```
|
||||||
exmap logCursor jscommand { console.log(editor.getCursor()); }
|
exmap logCursor jscommand { console.log(editor.getCursor()); }
|
||||||
nmap <C-q> :logCursor
|
nmap <C-q> :logCursor<CR>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### JSCommand - JSFile
|
||||||
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).
|
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).
|
||||||
|
|
||||||
|
The `jsfile` should be placed in your vault (alongside, e.g., your markdown files).
|
||||||
|
|
||||||
As above, the code running as part of `jsfile` has the arguments `editor: Editor`, `view: MarkdownView` and `selection: EditorSelection`.
|
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)}
|
exmap nextHeading jsfile mdHelpers.js {jumpHeading(true)}
|
||||||
exmap prevHeading jsfile mdHelpers.js {jumpHeading(false)}
|
exmap prevHeading jsfile mdHelpers.js {jumpHeading(false)}
|
||||||
nmap ]] :nextHeading
|
nmap ]] :nextHeading<CR>
|
||||||
nmap [[ :prevHeading
|
nmap [[ :prevHeading<CR>
|
||||||
```
|
```
|
||||||
|
|
||||||
See [here](JsSnippets.md) for the full example, and please contribute your own!
|
See [here](JsSnippets.md) for the full example, and please contribute your own!
|
||||||
|
|
||||||
|
### Custom Styles for Status Bar
|
||||||
|
|
||||||
|
You may utilize the vim mode display prompt class and the `data-vim-mode` attribute to
|
||||||
|
add styles to the status bar prompt (e.g. assign colors for different status). You can
|
||||||
|
also toggle the setting in settings page to also apply the display prompt class to the
|
||||||
|
entire status bar container if you want some customizations on it.
|
||||||
|
|
||||||
|
The display prompt class: `vimrc-support-vim-mode`
|
||||||
|
|
||||||
|
The `data-vim-mode` values:
|
||||||
|
|
||||||
|
| Mode | Data Value (`data-vim-mode`) |
|
||||||
|
| ------- | ---------------------------- |
|
||||||
|
| normal | `normal` |
|
||||||
|
| insert | `insert` |
|
||||||
|
| visual | `visual` |
|
||||||
|
| replace | `replace` |
|
||||||
|
|
||||||
|
#### Powerline Styling Snippet
|
||||||
|
|
||||||
|
**Screenshots**
|
||||||
|
|
||||||
|
<img width="1470" alt="Screen Shot 2023-04-09 at 19 19 53" src="https://user-images.githubusercontent.com/11176415/230812728-731e24d3-d667-478c-831c-14e0010e7973.png">
|
||||||
|
<img width="1470" alt="Screen Shot 2023-04-09 at 19 20 09" src="https://user-images.githubusercontent.com/11176415/230812731-26ba0c02-1948-4a26-89d2-3e73a11076d1.png">
|
||||||
|
<img width="1470" alt="Screen Shot 2023-04-09 at 19 20 22" src="https://user-images.githubusercontent.com/11176415/230812734-f51a01c9-5f80-4da7-b051-04018cc805cc.png">
|
||||||
|
<img width="1470" alt="Screen Shot 2023-04-09 at 19 20 37" src="https://user-images.githubusercontent.com/11176415/230812736-6fa688db-37b9-4b70-bacb-a6553b0762cb.png">
|
||||||
|
|
||||||
|
```css
|
||||||
|
div.status-bar-item.plugin-obsidian-vimrc-support {
|
||||||
|
/* Papercolor theme */
|
||||||
|
--text-color-normal: #585858;
|
||||||
|
--text-color-insert: #005f87;
|
||||||
|
--text-color-visual: white;
|
||||||
|
--text-color-replace: white;
|
||||||
|
|
||||||
|
--background-color-normal: #eeeeee;
|
||||||
|
--background-color-insert: #eeeeee;
|
||||||
|
--background-color-visual: #d75f00;
|
||||||
|
--background-color-replace: #d70087;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.status-bar-item.plugin-obsidian-vimrc-support {
|
||||||
|
/*
|
||||||
|
Move to bottom left corner and discard top/left/bottom space
|
||||||
|
from container paddings.
|
||||||
|
*/
|
||||||
|
order: -9999;
|
||||||
|
margin: -4px auto -5px -5px;
|
||||||
|
|
||||||
|
/*
|
||||||
|
We have the :after pseudo-element next, so padding-right
|
||||||
|
is not needed
|
||||||
|
*/
|
||||||
|
padding-right: 0px;
|
||||||
|
padding-left: 1em;
|
||||||
|
|
||||||
|
/* Use Monospace font */
|
||||||
|
font-family: 'MesloLGM Nerd Font Mono'; /* !!! Needs to be a powerline font */
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1.2em;
|
||||||
|
|
||||||
|
/* Clear spaces made from radius borders */
|
||||||
|
border-top-right-radius: 0px;
|
||||||
|
border-bottom-right-radius: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.status-bar-item.plugin-obsidian-vimrc-support:after {
|
||||||
|
/* Powerline separator character */
|
||||||
|
content: '';
|
||||||
|
position: relative;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
left: 0.9rem;
|
||||||
|
|
||||||
|
/* Fine adjust the position */
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Normal */
|
||||||
|
div.status-bar-item.vimrc-support-vim-mode[data-vim-mode="normal"]:after {
|
||||||
|
color: var(--background-color-normal);
|
||||||
|
}
|
||||||
|
div.status-bar-item.vimrc-support-vim-mode[data-vim-mode="normal"] {
|
||||||
|
color: var(--text-color-normal);
|
||||||
|
background-color: var(--background-color-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Insert */
|
||||||
|
div.status-bar-item.vimrc-support-vim-mode[data-vim-mode="insert"]:after {
|
||||||
|
color: var(--background-color-insert);
|
||||||
|
}
|
||||||
|
div.status-bar-item.vimrc-support-vim-mode[data-vim-mode="insert"] {
|
||||||
|
color: var(--text-color-insert);
|
||||||
|
background-color: var(--background-color-insert);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Visual */
|
||||||
|
div.status-bar-item.vimrc-support-vim-mode[data-vim-mode="visual"]:after {
|
||||||
|
color: var(--background-color-visual);
|
||||||
|
}
|
||||||
|
div.status-bar-item.vimrc-support-vim-mode[data-vim-mode="visual"] {
|
||||||
|
color: var(--text-color-visual);
|
||||||
|
background-color: var(--background-color-visual);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Replace */
|
||||||
|
div.status-bar-item.vimrc-support-vim-mode[data-vim-mode="replace"]:after {
|
||||||
|
color: var(--background-color-replace);
|
||||||
|
}
|
||||||
|
div.status-bar-item.vimrc-support-vim-mode[data-vim-mode="replace"] {
|
||||||
|
color: var(--text-color-replace);
|
||||||
|
background-color: var(--background-color-replace);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note that the above snippet uses powerline glygh for the triangular shape, so you need to install a [powerline font](https://github.com/powerline/fonts) to display correctly. And of course, feel free to change the CSS variables to whatever color palette you want!
|
||||||
|
|
||||||
## Changelog
|
## Changelog
|
||||||
|
|
||||||
|
### 0.10.2
|
||||||
|
|
||||||
|
- Fixed https://github.com/esm7/obsidian-vimrc-support/pull/246 (thanks @baodrate!)
|
||||||
|
|
||||||
|
### 0.10.1
|
||||||
|
|
||||||
|
- Fixed https://github.com/esm7/obsidian-vimrc-support/issues/231 (thanks @alythobani!)
|
||||||
|
- Fixed https://github.com/esm7/obsidian-vimrc-support/issues/232.
|
||||||
|
|
||||||
|
### 0.10.0
|
||||||
|
|
||||||
|
- Most notably, support for *many* new default mappings, see [here](https://github.com/esm7/obsidian-vimrc-support/pull/222) -- thanks @alythobani for this great contribution!
|
||||||
|
- Fixed https://github.com/esm7/obsidian-vimrc-support/issues/228.
|
||||||
|
- Added a license file, at last!
|
||||||
|
|
||||||
|
|
||||||
|
### 0.9.0
|
||||||
|
|
||||||
|
Multiple fixes and improvements, all contributed by @jiyee - thank you!
|
||||||
|
|
||||||
|
- Fixed chord and Vim mode display issues (https://github.com/esm7/obsidian-vimrc-support/issues/149).
|
||||||
|
- Added `source` command (https://github.com/esm7/obsidian-vimrc-support/issues/157)
|
||||||
|
- Fixed Normal Mode Layout is back!
|
||||||
|
|
||||||
|
### 0.8.0
|
||||||
|
|
||||||
|
- The plugin is now marked as supporting mobile; thanks @Geniucker for taking this step, testing and documenting it!
|
||||||
|
- Implemented `nunmap` and `vunmap`, which allow really nice `surround` implementation (see README). Thanks @dohsimpson!
|
||||||
|
|
||||||
|
### 0.7.3
|
||||||
|
|
||||||
|
Fixed an issue in updating the last selection variable, leading to `jsFile` receiving an incorrect argument and (thanks @zjhcn!)
|
||||||
|
|
||||||
|
### 0.7.2
|
||||||
|
|
||||||
|
Fixed the plugin breaking for some users after Obsidian's recent updates, plus some optimizations.
|
||||||
|
|
||||||
|
### 0.7.1
|
||||||
|
|
||||||
|
Fixed `jscommand` and `jsfile` broken in the latest Obsidian update.
|
||||||
|
|
||||||
|
### 0.7.0
|
||||||
|
|
||||||
|
**IMPORTANT: this version drops support for the legacy (CM5) Obsidian editor.**
|
||||||
|
If you are sticking to the legacy editor until Obsidian removes it, you cannot upgrade to this version of the plugin.
|
||||||
|
|
||||||
|
- Fixed multiple issues (like [this one](https://github.com/esm7/obsidian-vimrc-support/issues/118)) related to bad detection of the editor type.
|
||||||
|
- They were fixed by no longer trying to detect the editor type ;) Support for the legacy editor has become clumsy and it was time to drop it.
|
||||||
|
- Support for Obsidian 0.15 multi windows (https://github.com/esm7/obsidian-vimrc-support/pull/110).
|
||||||
|
|
||||||
|
### 0.6.3
|
||||||
|
|
||||||
|
Added `selection` also to `jsfile` (thanks @twio142!)
|
||||||
|
|
||||||
### 0.6.2
|
### 0.6.2
|
||||||
|
|
||||||
- Fixed the default Vimrc file name not used if the setting is overriden & empty.
|
- Fixed the default Vimrc file name not used if the setting is overriden & empty.
|
||||||
|
|
@ -348,3 +596,4 @@ Fixed [an issue](https://github.com/esm7/obsidian-vimrc-support/issues/2) caused
|
||||||
The plugin now injects the Vimrc just once for the CodeMirror class (for the class -- not object instance, because that's where CodeMirror keeps the Vim settings.)
|
The plugin now injects the Vimrc just once for the CodeMirror class (for the class -- not object instance, because that's where CodeMirror keeps the Vim settings.)
|
||||||
|
|
||||||
This seems to work well, but in theory there could be Vimrc settings that are CodeMirror-object bound and not class-bound, and in that case we'll be in trouble (these settings will be lost when Obsidian replaces CodeMirror objects).
|
This seems to work well, but in theory there could be Vimrc settings that are CodeMirror-object bound and not class-bound, and in that case we'll be in trouble (these settings will be lost when Obsidian replaces CodeMirror objects).
|
||||||
|
|
||||||
|
|
|
||||||
26
actions/followLinkUnderCursor.ts
Normal file
26
actions/followLinkUnderCursor.ts
Normal file
|
|
@ -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");
|
||||||
|
}
|
||||||
|
};
|
||||||
43
actions/moveSkippingFolds.ts
Normal file
43
actions/moveSkippingFolds.ts
Normal file
|
|
@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
624
main.ts
624
main.ts
|
|
@ -1,8 +1,26 @@
|
||||||
import * as keyFromAccelerator from 'keyboardevent-from-electron-accelerator';
|
import * as keyFromAccelerator from 'keyboardevent-from-electron-accelerator';
|
||||||
import { Editor, 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;
|
declare const CodeMirror: any;
|
||||||
|
|
||||||
|
const enum vimStatus {
|
||||||
|
normal = 'normal',
|
||||||
|
insert = 'insert',
|
||||||
|
visual = 'visual',
|
||||||
|
replace = 'replace',
|
||||||
|
}
|
||||||
|
type VimStatusPrompt = string;
|
||||||
|
type VimStatusPromptMap = {
|
||||||
|
[status in vimStatus]: VimStatusPrompt;
|
||||||
|
};
|
||||||
|
|
||||||
interface Settings {
|
interface Settings {
|
||||||
vimrcFileName: string,
|
vimrcFileName: string,
|
||||||
displayChord: boolean,
|
displayChord: boolean,
|
||||||
|
|
@ -10,6 +28,7 @@ interface Settings {
|
||||||
fixedNormalModeLayout: boolean,
|
fixedNormalModeLayout: boolean,
|
||||||
capturedKeyboardMap: Record<string, string>,
|
capturedKeyboardMap: Record<string, string>,
|
||||||
supportJsCommands?: boolean
|
supportJsCommands?: boolean
|
||||||
|
vimStatusPromptMap: VimStatusPromptMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_SETTINGS: Settings = {
|
const DEFAULT_SETTINGS: Settings = {
|
||||||
|
|
@ -18,23 +37,35 @@ const DEFAULT_SETTINGS: Settings = {
|
||||||
displayVimMode: false,
|
displayVimMode: false,
|
||||||
fixedNormalModeLayout: false,
|
fixedNormalModeLayout: false,
|
||||||
capturedKeyboardMap: {},
|
capturedKeyboardMap: {},
|
||||||
supportJsCommands: false
|
supportJsCommands: false,
|
||||||
|
vimStatusPromptMap: {
|
||||||
|
normal: '🟢',
|
||||||
|
insert: '🟠',
|
||||||
|
visual: '🟡',
|
||||||
|
replace: '🔴',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const enum vimStatus {
|
const vimStatusPromptClass = "vimrc-support-vim-mode";
|
||||||
normal = "🟢",
|
|
||||||
insert = "🟠",
|
|
||||||
replace = "🔴",
|
|
||||||
visual = "🟡"
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE: to future maintainers, please make sure all mapping commands are included in this array.
|
// NOTE: to future maintainers, please make sure all mapping commands are included in this array.
|
||||||
const mappingCommands: String[] = [
|
const mappingCommands: String[] = [
|
||||||
"map",
|
"map",
|
||||||
"nmap",
|
"nmap",
|
||||||
"noremap",
|
"noremap",
|
||||||
|
"iunmap",
|
||||||
|
"nunmap",
|
||||||
|
"vunmap",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// All Ex commands that take key-sequence arguments where <leader> should be expanded.
|
||||||
|
// This is broader than mappingCommands because CodeMirror supports more mapping variants.
|
||||||
|
const leaderMapCommands: string[] = [
|
||||||
|
"map", "nmap", "imap", "vmap", "omap",
|
||||||
|
"noremap", "nnoremap", "vnoremap", "inoremap", "onoremap",
|
||||||
|
"unmap", "iunmap", "nunmap", "vunmap",
|
||||||
|
];
|
||||||
|
|
||||||
function sleep(ms: number) {
|
function sleep(ms: number) {
|
||||||
return new Promise(resolve => setTimeout(resolve, ms));
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
@ -43,12 +74,12 @@ export default class VimrcPlugin extends Plugin {
|
||||||
settings: Settings;
|
settings: Settings;
|
||||||
|
|
||||||
private codeMirrorVimObject: any = null;
|
private codeMirrorVimObject: any = null;
|
||||||
private editorMode: 'cm5' | 'cm6' = null;
|
|
||||||
private initialized = false;
|
private initialized = false;
|
||||||
|
|
||||||
private lastYankBuffer = new Array<string>(0);
|
private lastYankBuffer: string[] = [""];
|
||||||
private lastSystemClipboard = "";
|
private lastSystemClipboard = "";
|
||||||
private yankToSystemClipboard: boolean = false;
|
private yankToSystemClipboard: boolean = false;
|
||||||
|
private registeredYankEventsWindows: Set<Window> = new Set();
|
||||||
private currentKeyChord: any = [];
|
private currentKeyChord: any = [];
|
||||||
private vimChordStatusBar: HTMLElement = null;
|
private vimChordStatusBar: HTMLElement = null;
|
||||||
private vimStatusBar: HTMLElement = null;
|
private vimStatusBar: HTMLElement = null;
|
||||||
|
|
@ -56,13 +87,21 @@ export default class VimrcPlugin extends Plugin {
|
||||||
private customVimKeybinds: { [name: string]: boolean } = {};
|
private customVimKeybinds: { [name: string]: boolean } = {};
|
||||||
private currentSelection: [EditorSelection] = null;
|
private currentSelection: [EditorSelection] = null;
|
||||||
private isInsertMode: boolean = false;
|
private isInsertMode: boolean = false;
|
||||||
|
private leaderKey: string = "\\";
|
||||||
|
|
||||||
|
updateVimStatusBar() {
|
||||||
|
this.vimStatusBar.setText(
|
||||||
|
this.settings.vimStatusPromptMap[this.currentVimStatus]
|
||||||
|
);
|
||||||
|
this.vimStatusBar.dataset.vimMode = this.currentVimStatus;
|
||||||
|
}
|
||||||
|
|
||||||
async captureKeyboardLayout() {
|
async captureKeyboardLayout() {
|
||||||
// This is experimental API and it might break at some point:
|
// This is experimental API and it might break at some point:
|
||||||
// https://developer.mozilla.org/en-US/docs/Web/API/KeyboardLayoutMap
|
// https://developer.mozilla.org/en-US/docs/Web/API/KeyboardLayoutMap
|
||||||
let keyMap: Record<string, string> = {};
|
let keyMap: Record<string, string> = {};
|
||||||
let layout = await (navigator as any).keyboard.getLayoutMap();
|
let layout = await (navigator as any).keyboard.getLayoutMap();
|
||||||
let doneIterating = new Promise((resolve, reject) => {
|
let doneIterating = new Promise<void>((resolve, reject) => {
|
||||||
let counted = 0;
|
let counted = 0;
|
||||||
layout.forEach((value: any, index: any) => {
|
layout.forEach((value: any, index: any) => {
|
||||||
keyMap[index] = value;
|
keyMap[index] = value;
|
||||||
|
|
@ -80,38 +119,109 @@ export default class VimrcPlugin extends Plugin {
|
||||||
if (this.initialized)
|
if (this.initialized)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Determine if we have the legacy Obsidian editor (CM5) or the new one (CM6).
|
this.codeMirrorVimObject = (window as any).CodeMirrorAdapter?.Vim;
|
||||||
// This is only available after Obsidian is fully loaded, so we do it as part of the `file-open` event.
|
|
||||||
if ('editor:toggle-source' in (this.app as any).commands.editorCommands) {
|
|
||||||
this.codeMirrorVimObject = (window as any).CodeMirrorAdapter?.Vim;
|
|
||||||
this.editorMode = 'cm6';
|
|
||||||
console.log('Vimrc plugin: using CodeMirror 6 mode');
|
|
||||||
} else {
|
|
||||||
this.codeMirrorVimObject = CodeMirror.Vim;
|
|
||||||
this.editorMode = 'cm5';
|
|
||||||
console.log('Vimrc plugin: using CodeMirror 5 mode');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.registerDomEvent(document, 'click', () => {
|
this.registerYankEvents(activeWindow);
|
||||||
this.captureYankBuffer();
|
this.app.workspace.on("window-open", (workspaceWindow, w) => {
|
||||||
});
|
this.registerYankEvents(w);
|
||||||
this.registerDomEvent(document, 'keyup', () => {
|
})
|
||||||
this.captureYankBuffer();
|
this.app.workspace.on("window-close", (workspaceWindow, w) => {
|
||||||
});
|
this.registeredYankEventsWindows.delete(w);
|
||||||
this.registerDomEvent(document, 'focusin', () => {
|
|
||||||
this.captureYankBuffer();
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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();
|
||||||
|
this.registerYankEvents(activeWindow);
|
||||||
|
});
|
||||||
|
// 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;
|
this.initialized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
registerYankEvents(win: Window) {
|
||||||
|
if (this.registeredYankEventsWindows.has(win))
|
||||||
|
return;
|
||||||
|
this.registerDomEvent(win.document, 'click', () => {
|
||||||
|
this.captureYankBuffer(win);
|
||||||
|
});
|
||||||
|
this.registerDomEvent(win.document, 'keyup', () => {
|
||||||
|
this.captureYankBuffer(win);
|
||||||
|
});
|
||||||
|
this.registerDomEvent(win.document, 'focusin', () => {
|
||||||
|
this.captureYankBuffer(win);
|
||||||
|
})
|
||||||
|
this.registeredYankEventsWindows.add(win)
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSelectionEvent() {
|
||||||
|
const view = this.getActiveView();
|
||||||
|
if (!view) return;
|
||||||
|
|
||||||
|
let cm = this.getCodeMirror(view);
|
||||||
|
if (!cm) return;
|
||||||
|
if (
|
||||||
|
this.getCursorActivityHandlers(cm).some(
|
||||||
|
(e: { name: string }) => e.name === "updateSelection")
|
||||||
|
) return;
|
||||||
|
cm.on("cursorActivity", async (cm: CodeMirror.Editor) => this.updateSelection(cm));
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSelection(cm: any) {
|
||||||
|
this.currentSelection = cm.listSelections();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCursorActivityHandlers(cm: CodeMirror.Editor) {
|
||||||
|
return (cm as any)._handlers.cursorActivity;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateVimEvents() {
|
||||||
|
if (!(this.app as any).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 = 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() {
|
async onload() {
|
||||||
await this.loadSettings();
|
await this.loadSettings();
|
||||||
this.addSettingTab(new SettingsTab(this.app, this))
|
this.addSettingTab(new SettingsTab(this.app, this))
|
||||||
|
|
||||||
this.app.workspace.on('file-open', async (file: TFile) => {
|
console.log('loading Vimrc plugin');
|
||||||
|
|
||||||
|
this.app.workspace.on('active-leaf-change', async () => {
|
||||||
if (!this.initialized)
|
if (!this.initialized)
|
||||||
await this.initialize();
|
await this.initialize();
|
||||||
|
if (this.codeMirrorVimObject.loadedVimrc)
|
||||||
|
return;
|
||||||
let fileName = this.settings.vimrcFileName;
|
let fileName = this.settings.vimrcFileName;
|
||||||
if (!fileName || fileName.trim().length === 0) {
|
if (!fileName || fileName.trim().length === 0) {
|
||||||
fileName = DEFAULT_SETTINGS.vimrcFileName;
|
fileName = DEFAULT_SETTINGS.vimrcFileName;
|
||||||
|
|
@ -121,7 +231,7 @@ export default class VimrcPlugin extends Plugin {
|
||||||
try {
|
try {
|
||||||
vimrcContent = await this.app.vault.adapter.read(fileName);
|
vimrcContent = await this.app.vault.adapter.read(fileName);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('Error loading vimrc file', fileName, 'from the vault root', e.message)
|
console.log('Error loading vimrc file', fileName, 'from the vault root', e.message)
|
||||||
}
|
}
|
||||||
this.readVimInit(vimrcContent);
|
this.readVimInit(vimrcContent);
|
||||||
});
|
});
|
||||||
|
|
@ -136,9 +246,10 @@ export default class VimrcPlugin extends Plugin {
|
||||||
await this.saveData(this.settings);
|
await this.saveData(this.settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
logVimModeChange(modeObj: any) {
|
logVimModeChange = async (cm: any) => {
|
||||||
this.isInsertMode = modeObj.mode === 'insert';
|
if (!cm) return;
|
||||||
switch (modeObj.mode) {
|
this.isInsertMode = cm.mode === 'insert';
|
||||||
|
switch (cm.mode) {
|
||||||
case "insert":
|
case "insert":
|
||||||
this.currentVimStatus = vimStatus.insert;
|
this.currentVimStatus = vimStatus.insert;
|
||||||
break;
|
break;
|
||||||
|
|
@ -155,7 +266,7 @@ export default class VimrcPlugin extends Plugin {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (this.settings.displayVimMode)
|
if (this.settings.displayVimMode)
|
||||||
this.vimStatusBar.setText(this.currentVimStatus);
|
this.updateVimStatusBar();
|
||||||
}
|
}
|
||||||
|
|
||||||
onunload() {
|
onunload() {
|
||||||
|
|
@ -166,12 +277,12 @@ export default class VimrcPlugin extends Plugin {
|
||||||
return this.app.workspace.getActiveViewOfType(MarkdownView);
|
return this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getActiveObsidianEditor(): ObsidianEditor {
|
||||||
|
return this.getActiveView().editor;
|
||||||
|
}
|
||||||
|
|
||||||
private getCodeMirror(view: MarkdownView): CodeMirror.Editor {
|
private getCodeMirror(view: MarkdownView): CodeMirror.Editor {
|
||||||
// For CM6 this actually returns an instance of the object named CodeMirror from cm_adapter of codemirror_vim
|
return (view as any).editMode?.editor?.cm?.cm;
|
||||||
if (this.editorMode == 'cm6')
|
|
||||||
return (view as any).sourceMode?.cmEditor?.cm?.cm;
|
|
||||||
else
|
|
||||||
return (view as any).sourceMode?.cmEditor;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
readVimInit(vimCommands: string) {
|
readVimInit(vimCommands: string) {
|
||||||
|
|
@ -180,33 +291,15 @@ export default class VimrcPlugin extends Plugin {
|
||||||
var cmEditor = this.getCodeMirror(view);
|
var cmEditor = this.getCodeMirror(view);
|
||||||
if (cmEditor && !this.codeMirrorVimObject.loadedVimrc) {
|
if (cmEditor && !this.codeMirrorVimObject.loadedVimrc) {
|
||||||
this.defineBasicCommands(this.codeMirrorVimObject);
|
this.defineBasicCommands(this.codeMirrorVimObject);
|
||||||
|
this.defineAndMapObsidianVimCommands(this.codeMirrorVimObject);
|
||||||
this.defineSendKeys(this.codeMirrorVimObject);
|
this.defineSendKeys(this.codeMirrorVimObject);
|
||||||
this.defineObCommand(this.codeMirrorVimObject);
|
this.defineObCommand(this.codeMirrorVimObject);
|
||||||
this.defineCmCommand(this.codeMirrorVimObject);
|
|
||||||
this.defineSurround(this.codeMirrorVimObject);
|
this.defineSurround(this.codeMirrorVimObject);
|
||||||
this.defineJsCommand(this.codeMirrorVimObject);
|
this.defineJsCommand(this.codeMirrorVimObject);
|
||||||
this.defineJsFile(this.codeMirrorVimObject);
|
this.defineJsFile(this.codeMirrorVimObject);
|
||||||
|
this.defineSource(this.codeMirrorVimObject);
|
||||||
|
|
||||||
// Record the position of selections
|
this.loadVimCommands(vimCommands);
|
||||||
CodeMirror.on(cmEditor, "cursorActivity", async (cm: any) => {
|
|
||||||
this.currentSelection = cm.listSelections()
|
|
||||||
});
|
|
||||||
|
|
||||||
vimCommands.split("\n").forEach(
|
|
||||||
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
|
|
||||||
}
|
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
||||||
this.prepareChordDisplay();
|
|
||||||
this.prepareVimModeDisplay();
|
|
||||||
|
|
||||||
// Make sure that we load it just once per CodeMirror instance.
|
// 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
|
// This is supposed to work because the Vim state is kept at the keymap level, hopefully
|
||||||
|
|
@ -215,15 +308,61 @@ export default class VimrcPlugin extends Plugin {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cmEditor) {
|
if (cmEditor) {
|
||||||
cmEditor.on('vim-mode-change', (modeObj: any) => {
|
cmEditor.off('vim-mode-change', this.logVimModeChange);
|
||||||
if (modeObj)
|
cmEditor.on('vim-mode-change', this.logVimModeChange);
|
||||||
this.logVimModeChange(modeObj);
|
CodeMirror.off(cmEditor.getInputField(), 'keydown', this.onKeydown);
|
||||||
});
|
CodeMirror.on(cmEditor.getInputField(), 'keydown', this.onKeydown);
|
||||||
this.defineFixedLayout(cmEditor);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loadVimCommands(vimCommands: string) {
|
||||||
|
let view = this.getActiveView();
|
||||||
|
if (view) {
|
||||||
|
var cmEditor = this.getCodeMirror(view);
|
||||||
|
|
||||||
|
vimCommands.split("\n").forEach(
|
||||||
|
function (line: string, index: number, arr: [string]) {
|
||||||
|
if (line.trim().length > 0 && line.trim()[0] != '"') {
|
||||||
|
// Parse "let mapleader" directives (consumed, not forwarded to CodeMirror).
|
||||||
|
const leader = this.parseLeaderDirective(line.trim());
|
||||||
|
if (leader !== null) {
|
||||||
|
this.leaderKey = leader;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Substitute <leader> in mapping commands before handing to CodeMirror.
|
||||||
|
const processedLine = this.substituteLeader(line, this.leaderKey);
|
||||||
|
|
||||||
|
let split = processedLine.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, processedLine);
|
||||||
|
}
|
||||||
|
}.bind(this) // Faster than an arrow function. https://stackoverflow.com/questions/50375440/binding-vs-arrow-function-for-react-onclick-event
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseLeaderDirective(line: string): string | null {
|
||||||
|
// Match: let mapleader = "x" or let mapleader = 'x'
|
||||||
|
// Case-insensitive on "let mapleader" to be forgiving, matching Vim behavior.
|
||||||
|
const match = line.match(/^\s*let\s+mapleader\s*=\s*["'](.+?)["']\s*$/i);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private substituteLeader(line: string, leaderKey: string): string {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
const firstSpace = trimmed.indexOf(" ");
|
||||||
|
if (firstSpace === -1) return line;
|
||||||
|
const command = trimmed.substring(0, firstSpace);
|
||||||
|
if (!leaderMapCommands.includes(command)) return line;
|
||||||
|
// Replace all occurrences of <leader> (case-insensitive) with the leader key.
|
||||||
|
return line.replace(/<leader>/gi, leaderKey);
|
||||||
|
}
|
||||||
|
|
||||||
defineBasicCommands(vimObject: any) {
|
defineBasicCommands(vimObject: any) {
|
||||||
vimObject.defineOption('clipboard', '', 'string', ['clip'], (value: string, cm: any) => {
|
vimObject.defineOption('clipboard', '', 'string', ['clip'], (value: string, cm: any) => {
|
||||||
if (value) {
|
if (value) {
|
||||||
|
|
@ -250,6 +389,18 @@ export default class VimrcPlugin extends Plugin {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vimObject.defineEx('nunmap', '', (cm: any, params: any) => {
|
||||||
|
if (params.argString.trim()) {
|
||||||
|
this.codeMirrorVimObject.unmap(params.argString.trim(), 'normal');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
vimObject.defineEx('vunmap', '', (cm: any, params: any) => {
|
||||||
|
if (params.argString.trim()) {
|
||||||
|
this.codeMirrorVimObject.unmap(params.argString.trim(), 'visual');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
vimObject.defineEx('noremap', '', (cm: any, params: any) => {
|
vimObject.defineEx('noremap', '', (cm: any, params: any) => {
|
||||||
if (!params?.args?.length) {
|
if (!params?.args?.length) {
|
||||||
throw new Error('Invalid mapping: noremap');
|
throw new Error('Invalid mapping: noremap');
|
||||||
|
|
@ -275,6 +426,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) {
|
defineSendKeys(vimObject: any) {
|
||||||
vimObject.defineEx('sendkeys', '', async (cm: any, params: any) => {
|
vimObject.defineEx('sendkeys', '', async (cm: any, params: any) => {
|
||||||
if (!params?.args?.length) {
|
if (!params?.args?.length) {
|
||||||
|
|
@ -309,47 +471,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) {
|
defineObCommand(vimObject: any) {
|
||||||
vimObject.defineEx('obcommand', '', async (cm: any, params: 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) {
|
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')}`)
|
console.log(`Available commands: ${Object.keys(availableCommands).join('\n')}`)
|
||||||
throw new Error(`obcommand requires exactly 1 parameter`);
|
throw new Error(`obcommand requires exactly 1 parameter`);
|
||||||
}
|
}
|
||||||
let view = this.getActiveView();
|
const commandName = params.args[0];
|
||||||
let editor = view.editor;
|
this.executeObsidianCommand(commandName);
|
||||||
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`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
defineCmCommand(vimObject: any) {
|
|
||||||
vimObject.defineEx('cmcommand', '', async (cm: any, params: any) => {
|
|
||||||
if (!params?.args?.length || params.args.length != 1) {
|
|
||||||
throw new Error(`cmcommand requires exactly 1 parameter`);
|
|
||||||
}
|
|
||||||
if (this.editorMode === 'cm5') {
|
|
||||||
let cmEditor = this.getCodeMirror(this.getActiveView());
|
|
||||||
cmEditor.execCommand(params.args[0]);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
throw new Error('cmcommand currently only works on the legacy CM5 editor');
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -357,20 +508,20 @@ export default class VimrcPlugin extends Plugin {
|
||||||
// Function to surround selected text or highlighted word.
|
// Function to surround selected text or highlighted word.
|
||||||
var surroundFunc = (params: string[]) => {
|
var surroundFunc = (params: string[]) => {
|
||||||
var editor = this.getActiveView().editor;
|
var editor = this.getActiveView().editor;
|
||||||
if (!params.length) {
|
if (!params?.length) {
|
||||||
throw new Error("surround requires exactly 2 parameters: prefix and postfix text.");
|
throw new Error("surround requires exactly 2 parameters: prefix and postfix text.");
|
||||||
}
|
}
|
||||||
let newArgs = params.join(" ").match(/(\\.|[^\s\\\\]+)+/g);
|
let newArgs = params.join(" ").match(/(\\.|[^\s\\\\]+)+/g);
|
||||||
if (newArgs.length != 2) {
|
if (newArgs.length != 2) {
|
||||||
throw new Error("surround requires exactly 2 parameters: prefix and postfix text.");
|
throw new Error("surround requires exactly 2 parameters: prefix and postfix text.");
|
||||||
}
|
}
|
||||||
|
|
||||||
let beginning = newArgs[0].replace("\\\\", "\\").replace("\\ ", " "); // Get the beginning surround text
|
let beginning = newArgs[0].replace("\\\\", "\\").replace("\\ ", " "); // Get the beginning surround text
|
||||||
let ending = newArgs[1].replace("\\\\", "\\").replace("\\ ", " "); // Get the ending surround text
|
let ending = newArgs[1].replace("\\\\", "\\").replace("\\ ", " "); // Get the ending surround text
|
||||||
|
|
||||||
let currentSelections = this.currentSelection;
|
let currentSelections = this.currentSelection;
|
||||||
var chosenSelection = currentSelections[0];
|
var chosenSelection = currentSelections?.[0] ? currentSelections[0] : {anchor: editor.getCursor(), head: editor.getCursor()};
|
||||||
if (this.currentSelection && currentSelections.length > 1) {
|
if (currentSelections?.length > 1) {
|
||||||
console.log("WARNING: Multiple selections in surround. Attempt to select matching cursor. (obsidian-vimrc-support)")
|
console.log("WARNING: Multiple selections in surround. Attempt to select matching cursor. (obsidian-vimrc-support)")
|
||||||
const cursorPos = editor.getCursor();
|
const cursorPos = editor.getCursor();
|
||||||
for (const selection of currentSelections) {
|
for (const selection of currentSelections) {
|
||||||
|
|
@ -381,35 +532,23 @@ export default class VimrcPlugin extends Plugin {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (JSON.stringify(chosenSelection.anchor) === JSON.stringify(chosenSelection.head)) {
|
if (editor.posToOffset(chosenSelection.anchor) === editor.posToOffset(chosenSelection.head)) {
|
||||||
// No range of selected text, so select word.
|
// No range of selected text, so select word.
|
||||||
var line = editor.getLine(chosenSelection.anchor.line);
|
let wordAt = editor.wordAt(chosenSelection.head);
|
||||||
if (line.length === 0)
|
if (wordAt) {
|
||||||
throw new Error("can't surround on an empty line");
|
chosenSelection = {anchor: wordAt.from, head: wordAt.to};
|
||||||
// Go to the beginning of the word
|
}
|
||||||
let wordStart = chosenSelection.anchor.ch;
|
|
||||||
for ( ; wordStart >= 0 ; wordStart--)
|
|
||||||
if (line[wordStart].match(/\s/))
|
|
||||||
break;
|
|
||||||
wordStart++;
|
|
||||||
let wordEnd = chosenSelection.anchor.ch;
|
|
||||||
for ( ; wordEnd < line.length ; wordEnd++)
|
|
||||||
if (line[wordEnd].match(/\s/))
|
|
||||||
break;
|
|
||||||
var word = line.substring(wordStart, wordEnd);
|
|
||||||
chosenSelection.anchor.ch = wordStart;
|
|
||||||
chosenSelection.head.ch = wordEnd;
|
|
||||||
chosenSelection = {
|
|
||||||
anchor: {line: chosenSelection.anchor.line, ch: wordStart},
|
|
||||||
head: {line: chosenSelection.head.line, ch: wordEnd}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
// If the selection is reverse, switch the variables
|
if (editor.posToOffset(chosenSelection.anchor) > editor.posToOffset(chosenSelection.head)) {
|
||||||
if (chosenSelection.anchor.line > chosenSelection.head.line ||
|
|
||||||
(chosenSelection.anchor.line == chosenSelection.head.line && chosenSelection.anchor.ch > chosenSelection.head.ch))
|
|
||||||
[chosenSelection.anchor, chosenSelection.head] = [chosenSelection.head, chosenSelection.anchor];
|
[chosenSelection.anchor, chosenSelection.head] = [chosenSelection.head, chosenSelection.anchor];
|
||||||
|
}
|
||||||
let currText = editor.getRange(chosenSelection.anchor, chosenSelection.head);
|
let currText = editor.getRange(chosenSelection.anchor, chosenSelection.head);
|
||||||
editor.replaceRange(beginning + currText + ending, 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: any, params: any) => { surroundFunc(params.args); });
|
vimObject.defineEx("surround", "", (cm: any, params: any) => { surroundFunc(params.args); });
|
||||||
|
|
@ -418,7 +557,7 @@ export default class VimrcPlugin extends Plugin {
|
||||||
// Using the register for when this.yankToSystemClipboard == false
|
// Using the register for when this.yankToSystemClipboard == false
|
||||||
surroundFunc(
|
surroundFunc(
|
||||||
['[',
|
['[',
|
||||||
'](' + vimObject.getRegisterController().getRegister('yank').keyBuffer + ")"]);
|
'](' + vimObject.getRegisterController().getRegister('yank').keyBuffer[0].trim() + ")"]);
|
||||||
})
|
})
|
||||||
|
|
||||||
var editor = this.getActiveView().editor;
|
var editor = this.getActiveView().editor;
|
||||||
|
|
@ -445,25 +584,34 @@ export default class VimrcPlugin extends Plugin {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
captureYankBuffer() {
|
async captureYankBuffer(win: Window) {
|
||||||
if (this.yankToSystemClipboard) {
|
if (!this.yankToSystemClipboard) {
|
||||||
let currentBuffer = this.codeMirrorVimObject.getRegisterController().getRegister('yank').keyBuffer;
|
return
|
||||||
if (currentBuffer != this.lastYankBuffer) {
|
}
|
||||||
if (this.lastYankBuffer.length > 0 && currentBuffer.length > 0 && currentBuffer[0]) {
|
|
||||||
navigator.clipboard.writeText(currentBuffer[0]);
|
const yankRegister = this.codeMirrorVimObject.getRegisterController().getRegister('yank');
|
||||||
navigator.clipboard.readText().then((value) => { this.lastSystemClipboard = value; });
|
const currentYankBuffer = yankRegister.keyBuffer;
|
||||||
}
|
|
||||||
this.lastYankBuffer = currentBuffer;
|
// yank -> clipboard
|
||||||
return;
|
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;
|
||||||
}
|
}
|
||||||
let currentClipboard = navigator.clipboard.readText().then((value) => {
|
} catch (e) {
|
||||||
if (value != this.lastSystemClipboard) {
|
// XXX: Avoid "Uncaught (in promise) DOMException: Document is not focused."
|
||||||
let yankRegister = this.codeMirrorVimObject.getRegisterController().getRegister('yank')
|
// XXX: It is not good but easy workaround
|
||||||
yankRegister.setText(value);
|
|
||||||
this.lastYankBuffer = yankRegister.keyBuffer;
|
|
||||||
this.lastSystemClipboard = value;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -477,54 +625,70 @@ export default class VimrcPlugin extends Plugin {
|
||||||
this.vimChordStatusBar.parentElement.insertBefore(this.vimChordStatusBar, parent.firstChild);
|
this.vimChordStatusBar.parentElement.insertBefore(this.vimChordStatusBar, parent.firstChild);
|
||||||
this.vimChordStatusBar.style.marginRight = "auto";
|
this.vimChordStatusBar.style.marginRight = "auto";
|
||||||
|
|
||||||
let cmEditor = this.getCodeMirror(this.getActiveView());
|
const view = this.getActiveView();
|
||||||
|
if (!view) return;
|
||||||
|
let cmEditor = this.getCodeMirror(view);
|
||||||
// See https://codemirror.net/doc/manual.html#vimapi_events for events.
|
// See https://codemirror.net/doc/manual.html#vimapi_events for events.
|
||||||
CodeMirror.on(cmEditor, "vim-keypress", async (vimKey: any) => {
|
cmEditor.off('vim-keypress', this.onVimKeypress);
|
||||||
if (vimKey != "<Esc>") { // TODO figure out what to actually look for to exit commands.
|
cmEditor.on('vim-keypress', this.onVimKeypress);
|
||||||
this.currentKeyChord.push(vimKey);
|
cmEditor.off('vim-command-done', this.onVimCommandDone);
|
||||||
if (this.customVimKeybinds[this.currentKeyChord.join("")] != undefined) { // Custom key chord exists.
|
cmEditor.on('vim-command-done', this.onVimCommandDone);
|
||||||
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 = [];
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onVimKeypress = async (vimKey: any) => {
|
||||||
|
if (vimKey != "<Esc>") { // 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
onVimCommandDone = async (reason: any) => {
|
||||||
|
this.vimChordStatusBar?.setText("");
|
||||||
|
this.currentKeyChord = [];
|
||||||
|
}
|
||||||
|
|
||||||
prepareVimModeDisplay() {
|
prepareVimModeDisplay() {
|
||||||
if (this.settings.displayVimMode) {
|
if (this.settings.displayVimMode) {
|
||||||
this.vimStatusBar = this.addStatusBarItem() // Add status bar item
|
this.vimStatusBar = this.addStatusBarItem() // Add status bar item
|
||||||
this.vimStatusBar.setText(vimStatus.normal) // Init the vimStatusBar with normal mode
|
this.vimStatusBar.setText(
|
||||||
|
this.settings.vimStatusPromptMap[vimStatus.normal]
|
||||||
|
); // Init the vimStatusBar with normal mode
|
||||||
|
this.vimStatusBar.addClass(vimStatusPromptClass);
|
||||||
|
this.vimStatusBar.dataset.vimMode = this.currentVimStatus;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
defineFixedLayout(cm: CodeMirror.Editor) {
|
onKeydown = (ev: KeyboardEvent) => {
|
||||||
cm.on('keydown', (instance: CodeMirror.Editor, ev: KeyboardEvent) => {
|
if (this.settings.fixedNormalModeLayout) {
|
||||||
if (this.settings.fixedNormalModeLayout) {
|
const keyMap = this.settings.capturedKeyboardMap;
|
||||||
const keyMap = this.settings.capturedKeyboardMap;
|
if (!this.isInsertMode && !ev.shiftKey &&
|
||||||
if (!this.isInsertMode && !ev.shiftKey &&
|
ev.code in keyMap && ev.key != keyMap[ev.code]) {
|
||||||
ev.code in keyMap && ev.key != keyMap[ev.code]) {
|
let view = this.getActiveView();
|
||||||
this.codeMirrorVimObject.handleKey(instance, keyMap[ev.code], 'mapping');
|
if (view) {
|
||||||
ev.preventDefault();
|
const cmEditor = this.getCodeMirror(view);
|
||||||
return false;
|
if (cmEditor) {
|
||||||
|
this.codeMirrorVimObject.handleKey(cmEditor, keyMap[ev.code], 'mapping');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
ev.preventDefault();
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
defineJsCommand(vimObject: any) {
|
defineJsCommand(vimObject: any) {
|
||||||
|
|
@ -535,7 +699,7 @@ export default class VimrcPlugin extends Plugin {
|
||||||
if (jsCode[0] != '{' || jsCode[jsCode.length - 1] != '}')
|
if (jsCode[0] != '{' || jsCode[jsCode.length - 1] != '}')
|
||||||
throw new Error("Expected an argument which is JS code surrounded by curly brackets: {...}");
|
throw new Error("Expected an argument which is JS code surrounded by curly brackets: {...}");
|
||||||
let currentSelections = this.currentSelection;
|
let currentSelections = this.currentSelection;
|
||||||
var chosenSelection = currentSelections[0];
|
var chosenSelection = currentSelections && currentSelections.length > 0 ? currentSelections[0] : null;
|
||||||
const command = Function('editor', 'view', 'selection', jsCode);
|
const command = Function('editor', 'view', 'selection', jsCode);
|
||||||
const view = this.getActiveView();
|
const view = this.getActiveView();
|
||||||
command(view.editor, view, chosenSelection);
|
command(view.editor, view, chosenSelection);
|
||||||
|
|
@ -556,20 +720,35 @@ export default class VimrcPlugin extends Plugin {
|
||||||
if (extraCode[0] != '{' || extraCode[extraCode.length - 1] != '}')
|
if (extraCode[0] != '{' || extraCode[extraCode.length - 1] != '}')
|
||||||
throw new Error("Expected an extra code argument which is JS code surrounded by curly brackets: {...}");
|
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 = '';
|
let content = '';
|
||||||
try {
|
try {
|
||||||
content = await this.app.vault.adapter.read(fileName);
|
content = await this.app.vault.adapter.read(fileName);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`Cannot read file ${params.args[0]} from vault root: ${e.message}`);
|
throw new Error(`Cannot read file ${params.args[0]} from vault root: ${e.message}`);
|
||||||
}
|
}
|
||||||
let currentSelections = this.currentSelection;
|
|
||||||
var chosenSelection = currentSelections[0];
|
|
||||||
const command = Function('editor', 'view', 'selection', content + extraCode);
|
const command = Function('editor', 'view', 'selection', content + extraCode);
|
||||||
const view = this.getActiveView();
|
const view = this.getActiveView();
|
||||||
command(view.editor, view, chosenSelection);
|
command(view.editor, view, chosenSelection);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
defineSource(vimObject: any) {
|
||||||
|
vimObject.defineEx('source', '', async (cm: any, params: any) => {
|
||||||
|
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 PluginSettingTab {
|
class SettingsTab extends PluginSettingTab {
|
||||||
|
|
@ -651,5 +830,72 @@ class SettingsTab extends PluginSettingTab {
|
||||||
this.plugin.saveSettings();
|
this.plugin.saveSettings();
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
containerEl.createEl('h2', {text: 'Vim Mode Display Prompt'});
|
||||||
|
|
||||||
|
new 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 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 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 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
{
|
{
|
||||||
"id": "obsidian-vimrc-support",
|
"id": "obsidian-vimrc-support",
|
||||||
"name": "Vimrc Support",
|
"name": "Vimrc Support",
|
||||||
"version": "0.6.2",
|
"version": "0.10.2",
|
||||||
"description": "Auto-load a startup file with Obsidian Vim commands.",
|
"description": "Auto-load a startup file with Obsidian Vim commands.",
|
||||||
|
"minAppVersion": "0.15.3",
|
||||||
"author": "esm",
|
"author": "esm",
|
||||||
"authorUrl": "",
|
"authorUrl": "",
|
||||||
"isDesktopOnly": true
|
"isDesktopOnly": false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
75
motions/jumpToHeading.ts
Normal file
75
motions/jumpToHeading.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { Editor as CodeMirrorEditor } from "codemirror";
|
||||||
|
import { EditorPosition } from "obsidian";
|
||||||
|
import { isWithinMatch, jumpToPattern } from "../utils/jumpToPattern";
|
||||||
|
import { MotionFn } from "../utils/vimApi";
|
||||||
|
|
||||||
|
/** 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.
|
||||||
|
*/
|
||||||
|
export const jumpToNextHeading: MotionFn = (cm, cursorPosition, { repeat }) => {
|
||||||
|
return jumpToHeading({ cm, cursorPosition, repeat, direction: "next" });
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Jumps to the repeat-th previous heading.
|
||||||
|
*/
|
||||||
|
export const jumpToPreviousHeading: MotionFn = (
|
||||||
|
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,
|
||||||
|
}: {
|
||||||
|
cm: CodeMirrorEditor;
|
||||||
|
cursorPosition: EditorPosition;
|
||||||
|
repeat: number;
|
||||||
|
direction: "next" | "previous";
|
||||||
|
}): EditorPosition {
|
||||||
|
const codeblockMatches = findAllCodeblocks(cm);
|
||||||
|
const filterMatch = (match: RegExpExecArray) => !isMatchWithinCodeblock(match, codeblockMatches);
|
||||||
|
return jumpToPattern({
|
||||||
|
cm,
|
||||||
|
cursorPosition,
|
||||||
|
repeat,
|
||||||
|
regex: NAIVE_HEADING_REGEX,
|
||||||
|
filterMatch,
|
||||||
|
direction,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function findAllCodeblocks(cm: CodeMirrorEditor): RegExpExecArray[] {
|
||||||
|
const content = cm.getValue();
|
||||||
|
return [...content.matchAll(FENCED_CODEBLOCK_REGEX)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMatchWithinCodeblock(
|
||||||
|
match: RegExpExecArray,
|
||||||
|
codeblockMatches: RegExpExecArray[]
|
||||||
|
): boolean {
|
||||||
|
return codeblockMatches.some((codeblockMatch) => isWithinMatch(codeblockMatch, match.index));
|
||||||
|
}
|
||||||
99
motions/jumpToLink.ts
Normal file
99
motions/jumpToLink.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import { jumpToPattern } from "../utils/jumpToPattern";
|
||||||
|
import { MotionFn } from "../utils/vimApi";
|
||||||
|
|
||||||
|
/** Regex for a wikilink. Starts off with a negative lookbehind for a backslash, to make sure the
|
||||||
|
* opening square bracket isn't escaped.
|
||||||
|
*
|
||||||
|
* Note that although Obsidian doesn't allow most special characters inside a file name (e.g. `[`,
|
||||||
|
* `]`, `:`, `#`, `|`, `^`, `\`, or `/`), its Markdown parser is lenient enough to result in
|
||||||
|
* basically anything inside non-escaped double square brackets being rendered as a wikilink. This
|
||||||
|
* even includes a trailing backslash, which you'd think would escape the first closing square
|
||||||
|
* bracket! So we follow suit and allow any character inside the double square brackets, except for
|
||||||
|
* a newline (which `.` won't match as long as we don't use the `s` flag).
|
||||||
|
*/
|
||||||
|
const WIKILINK_REGEX = /(?<!\\)\[\[.*?\]\]/g;
|
||||||
|
|
||||||
|
/** Regex for a markdown link of the form `[display text](url)`.
|
||||||
|
*
|
||||||
|
* Obsidian's parsing of markdown links is inconsistent between Live Preview and Reading mode. We
|
||||||
|
* align more with Reading mode, differing mainly in our handling of square brackets in the display
|
||||||
|
* text.
|
||||||
|
*
|
||||||
|
* Our markdown link regex matches a string with the following structure:
|
||||||
|
* - An opening square bracket `[` that is not escaped (i.e. not preceded by a backslash)
|
||||||
|
* - A sequence of display text characters:
|
||||||
|
* - Any character that is not a backslash, square bracket, or newline; or
|
||||||
|
* - A backslash followed by another character. This simultaneously allows escaped square
|
||||||
|
* brackets within the display text, and ensures that the backslash is not escaping the closing
|
||||||
|
* square bracket of the display text.
|
||||||
|
* - A closing square bracket `]`
|
||||||
|
* - A URL in non-escaped parentheses `( ... )`
|
||||||
|
*
|
||||||
|
* **Note**: This regex does not allow for unescaped square brackets within the display text, even
|
||||||
|
* though Obsidian's Markdown parser may render such links in Reading mode. In particular, Obsidian
|
||||||
|
* allows unescaped brackets as long as they come in pairs (e.g. `[Label [2]](url)`).
|
||||||
|
*
|
||||||
|
* Our stricter behavior is intentional:
|
||||||
|
* - It provides a simple way to prevent things like checkboxes being treated as part of the link
|
||||||
|
* (e.g. `- [ ] [display text](url)`)
|
||||||
|
* - Fully aligning with Obsidian's parsing would require a more complex regex that would be less
|
||||||
|
* performant (e.g. nested lookbehinds to only match pairs of brackets) and harder to maintain
|
||||||
|
* - Obsidian's markdown link parsing is not super consistent or coherent anyway
|
||||||
|
* - Users can still use square brackets in display text if they escape them, which is arguably
|
||||||
|
* better practice anyway
|
||||||
|
*/
|
||||||
|
const MARKDOWN_LINK_REGEX = /(?<!\\)\[(?:[^\\\[\]\n]|\\.)*?\]\(.*?(?<!\\)\)/g;
|
||||||
|
|
||||||
|
/** Regex for a standalone URL. This is a naive regex that matches any string that starts with a
|
||||||
|
* protocol-looking prefix (any lowercase letters followed by `://`) followed by one or more
|
||||||
|
* non-whitespace characters (from domain name to URL end).
|
||||||
|
*
|
||||||
|
* Note that Obsidian's markdown parser again, just like for wikilinks and markdown links, is
|
||||||
|
* inconsistent between Live Preview and Reading modes, but seems to be more accurate in Reading
|
||||||
|
* mode. Our regex is a bit more lenient than both (e.g. we don't restrict to a subset of known
|
||||||
|
* protocols), but it should be good enough for most cases, and it's better to be lenient than to be
|
||||||
|
* too strict.
|
||||||
|
*/
|
||||||
|
const URL_REGEX = /[a-z]+:\/\/\S+/g;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regex for a link (which can be a wikilink, a markdown link, or a standalone URL).
|
||||||
|
*
|
||||||
|
* Ordered to match on wikilinks first, then markdown links, and finally standalone URLs. This
|
||||||
|
* matches how Obsidian's markdown parser works. E.g. Obsidian treats the following as:
|
||||||
|
* - `[[[display text](https://example.com)]]]` - a wikilink
|
||||||
|
* - `[[2]](https://example.com)` - a wikilink plus a standalone URL
|
||||||
|
*/
|
||||||
|
const LINK_REGEX_STRING = `${WIKILINK_REGEX.source}|${MARKDOWN_LINK_REGEX.source}|${URL_REGEX.source}`;
|
||||||
|
export 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.
|
||||||
|
*/
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
};
|
||||||
2889
package-lock.json
generated
Normal file
2889
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
20
package.json
20
package.json
|
|
@ -1,11 +1,13 @@
|
||||||
{
|
{
|
||||||
"name": "obsidian-vimrc-support",
|
"name": "obsidian-vimrc-support",
|
||||||
"version": "0.6.2",
|
"version": "0.10.2",
|
||||||
"description": "Auto-load a startup file with Obsidian Vim commands.",
|
"description": "Auto-load a startup file with Obsidian Vim commands.",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "rollup --config rollup.config.js -w",
|
"dev": "rollup --config rollup.config.js -w",
|
||||||
"build": "rollup --config rollup.config.js"
|
"build": "rollup --config rollup.config.js",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:watch": "vitest --watch"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
|
|
@ -13,13 +15,17 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@rollup/plugin-commonjs": "^15.1.0",
|
"@rollup/plugin-commonjs": "^15.1.0",
|
||||||
"@rollup/plugin-node-resolve": "^9.0.0",
|
"@rollup/plugin-node-resolve": "^9.0.0",
|
||||||
"@rollup/plugin-typescript": "^6.1.0",
|
"@rollup/plugin-typescript": "^11.0.0",
|
||||||
"@types/node": "^14.14.6",
|
"@types/string.prototype.matchall": "^4.0.4",
|
||||||
"codemirror": "^5.62.2",
|
"codemirror": "^5.62.2",
|
||||||
"keyboardevent-from-electron-accelerator": "*",
|
"keyboardevent-from-electron-accelerator": "*",
|
||||||
"obsidian": "^0.12.17",
|
"obsidian": "^1.1.1",
|
||||||
"rollup": "^2.33.0",
|
"rollup": "^2.33.0",
|
||||||
"tslib": "^2.0.3",
|
"tslib": "^2.6.3",
|
||||||
"typescript": "^4.0.5"
|
"typescript": "^5.5.3",
|
||||||
|
"vitest": "^3.1.3"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"string.prototype.matchall": "^4.0.11"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
325
tests/jumpToLink.test.ts
Normal file
325
tests/jumpToLink.test.ts
Normal file
|
|
@ -0,0 +1,325 @@
|
||||||
|
import { LINK_REGEX } from "motions/jumpToLink";
|
||||||
|
import { describe, test, expect } from "vitest";
|
||||||
|
|
||||||
|
function expectMatchesForLines(contentLines: string[], expectedMatches: string[]): void {
|
||||||
|
expect(getMatches(contentLines)).toEqual(expectedMatches);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMatches(contentLines: string[]): string[] {
|
||||||
|
const content = contentLines.join("\n");
|
||||||
|
return [...content.matchAll(LINK_REGEX)].map((match) => match[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function testSingleLinkLine(link: string): void {
|
||||||
|
const lineWithLink = makeLineWithLink(link);
|
||||||
|
expectMatchesForLines([lineWithLink], [link]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function testSingleMarkdownLinkLine(displayText: string): void {
|
||||||
|
const markdownLink = makeExampleHttpsMarkdownLink(displayText);
|
||||||
|
testSingleLinkLine(markdownLink);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMarkdownLink(displayText: string, link: string): string {
|
||||||
|
return `[${displayText}](${link})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeExampleHttpMarkdownLink(displayText: string): string {
|
||||||
|
return makeMarkdownLink(displayText, EXAMPLE_HTTP_URL);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeExampleHttpsMarkdownLink(displayText: string): string {
|
||||||
|
return makeMarkdownLink(displayText, EXAMPLE_HTTPS_URL);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeLineWithLink(link: string): string {
|
||||||
|
return `Check out this link: ${link} - click it!`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeLineWithMultipleLinks(links: string[]): string {
|
||||||
|
return `Check out these links: ${links.join(" | ")} - click any of them!`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WIKILINK_TEXT = "[[Some internal note]]";
|
||||||
|
|
||||||
|
const EXAMPLE_HTTP_URL = "http://example.com";
|
||||||
|
const EXAMPLE_HTTPS_URL = "https://example.com";
|
||||||
|
|
||||||
|
const MARKDOWN_HTTP_LINK = makeExampleHttpMarkdownLink("example");
|
||||||
|
const MARKDOWN_HTTPS_LINK = makeExampleHttpsMarkdownLink("example");
|
||||||
|
|
||||||
|
describe("regex matching links", () => {
|
||||||
|
describe("wikilink", () => {
|
||||||
|
test("simple wikilink", () => testSingleLinkLine(WIKILINK_TEXT));
|
||||||
|
test("escaped open bracket makes wikilink invalid", () => {
|
||||||
|
expectMatchesForLines([`\\${WIKILINK_TEXT}`], []);
|
||||||
|
});
|
||||||
|
describe("shouldn't be matched over multiple lines", () => {
|
||||||
|
test("split after first open bracket", () => {
|
||||||
|
const firstLine = "Check out this link: [";
|
||||||
|
const secondLine = `[Some internal note]] - click it!`;
|
||||||
|
expectMatchesForLines([firstLine, secondLine], []);
|
||||||
|
});
|
||||||
|
test("split after second open bracket", () => {
|
||||||
|
const firstLine = "Check out this link: [[";
|
||||||
|
const secondLine = `Some internal note]] - click it!`;
|
||||||
|
expectMatchesForLines([firstLine, secondLine], []);
|
||||||
|
});
|
||||||
|
test("split in middle of text", () => {
|
||||||
|
const firstLine = "Check out this link: [[Some internal";
|
||||||
|
const secondLine = ` note]] - click it!`;
|
||||||
|
expectMatchesForLines([firstLine, secondLine], []);
|
||||||
|
});
|
||||||
|
test("split before first close bracket", () => {
|
||||||
|
const firstLine = "Check out this link: [[Some internal note";
|
||||||
|
const secondLine = `]] - click it!`;
|
||||||
|
expectMatchesForLines([firstLine, secondLine], []);
|
||||||
|
});
|
||||||
|
test("split before second close bracket", () => {
|
||||||
|
const firstLine = "Check out this link: [[Some internal note]";
|
||||||
|
const secondLine = `] - click it!`;
|
||||||
|
expectMatchesForLines([firstLine, secondLine], []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("markdown link", () => {
|
||||||
|
describe("simple display text", () => {
|
||||||
|
test("http", () => testSingleLinkLine(MARKDOWN_HTTP_LINK));
|
||||||
|
test("https", () => testSingleLinkLine(MARKDOWN_HTTPS_LINK));
|
||||||
|
});
|
||||||
|
describe("square brackets inside display text", () => {
|
||||||
|
describe("escaped square brackets", () => {
|
||||||
|
describe("escaped open bracket", () => {
|
||||||
|
test("at start", () => testSingleMarkdownLinkLine("\\[some display text"));
|
||||||
|
test("in middle", () => testSingleMarkdownLinkLine("some display \\[ text"));
|
||||||
|
test("at end", () => testSingleMarkdownLinkLine("some display text \\["));
|
||||||
|
});
|
||||||
|
describe("escaped close bracket", () => {
|
||||||
|
test("at start", () => testSingleMarkdownLinkLine("\\] some display text"));
|
||||||
|
test("in middle", () => testSingleMarkdownLinkLine("some display \\] text"));
|
||||||
|
test("at end", () => testSingleMarkdownLinkLine("some display text \\]"));
|
||||||
|
});
|
||||||
|
describe("escaped open and close bracket", () => {
|
||||||
|
test("at start", () => testSingleMarkdownLinkLine("\\[ some display \\] text"));
|
||||||
|
test("in middle", () => testSingleMarkdownLinkLine("some \\[ display \\] text"));
|
||||||
|
test("at end", () => testSingleMarkdownLinkLine("some display \\[ text \\]"));
|
||||||
|
test("footnote link", () => testSingleMarkdownLinkLine("\\[2\\]"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe("non-escaped square brackets", () => {
|
||||||
|
test("non-escaped open bracket should become the start of the markdown link", () => {
|
||||||
|
const markdownLink = `[some display [ text](${EXAMPLE_HTTPS_URL})`;
|
||||||
|
expectMatchesForLines(
|
||||||
|
[makeLineWithLink(markdownLink)],
|
||||||
|
[`[ text](${EXAMPLE_HTTPS_URL})`]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test("non-escaped close bracket should invalidate the markdown link", () => {
|
||||||
|
const markdownLink = `[some display ] text](${EXAMPLE_HTTPS_URL})`;
|
||||||
|
// Markdown link isn't valid, so the regex just matches the url as a standalone url
|
||||||
|
expectMatchesForLines([makeLineWithLink(markdownLink)], [`${EXAMPLE_HTTPS_URL})`]);
|
||||||
|
});
|
||||||
|
test("non-escaped footnote attempt should end up being a wikilink plus standalone url", () => {
|
||||||
|
const markdownLink = makeExampleHttpsMarkdownLink("[2]");
|
||||||
|
const lineWithLink = makeLineWithLink(markdownLink);
|
||||||
|
expectMatchesForLines([lineWithLink], ["[[2]]", `${EXAMPLE_HTTPS_URL})`]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe("square brackets before markdown link", () => {
|
||||||
|
test("single open bracket before markdown link shouldn't affect link match", () => {
|
||||||
|
const baseLineWithLink = makeLineWithLink(MARKDOWN_HTTP_LINK);
|
||||||
|
const fullLineWithLink = `some text [ ${baseLineWithLink}`;
|
||||||
|
expectMatchesForLines([fullLineWithLink], [MARKDOWN_HTTP_LINK]);
|
||||||
|
});
|
||||||
|
test("single close bracket before markdown link shouldn't affect link match", () => {
|
||||||
|
const baseLineWithLink = makeLineWithLink(MARKDOWN_HTTP_LINK);
|
||||||
|
const fullLineWithLink = `some text ] ${baseLineWithLink}`;
|
||||||
|
expectMatchesForLines([fullLineWithLink], [MARKDOWN_HTTP_LINK]);
|
||||||
|
});
|
||||||
|
test("open and closed bracket before markdown link shouldn't affect link match", () => {
|
||||||
|
const baseLineWithLink = makeLineWithLink(MARKDOWN_HTTP_LINK);
|
||||||
|
const fullLineWithLink = `[some] text ${baseLineWithLink}`;
|
||||||
|
expectMatchesForLines([fullLineWithLink], [MARKDOWN_HTTP_LINK]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe("markdown link across lines shouldn't be matched", () => {
|
||||||
|
test("split before ] should just match the url as a standalone url", () => {
|
||||||
|
const firstLine = "Check out this link: [example";
|
||||||
|
const secondLine = `](${EXAMPLE_HTTPS_URL}) - click it!`;
|
||||||
|
const expectedMatch = `${EXAMPLE_HTTPS_URL})`; // it'll include the closing parenthesis
|
||||||
|
expectMatchesForLines([firstLine, secondLine], [expectedMatch]);
|
||||||
|
});
|
||||||
|
test("split after ] should just match the url as a standalone url", () => {
|
||||||
|
const firstLine = "Check out this link: [example]";
|
||||||
|
const secondLine = `(${EXAMPLE_HTTPS_URL}) - click it!`;
|
||||||
|
const expectedMatch = `${EXAMPLE_HTTPS_URL})`; // it'll include the opening parenthesis
|
||||||
|
expectMatchesForLines([firstLine, secondLine], [expectedMatch]);
|
||||||
|
});
|
||||||
|
test("split after ( should just match the url as a standalone url", () => {
|
||||||
|
const firstLine = "Check out this link: [example](";
|
||||||
|
const secondLine = `${EXAMPLE_HTTPS_URL}) - click it!`;
|
||||||
|
const expectedMatch = `${EXAMPLE_HTTPS_URL})`; // it'll include the closing parenthesis
|
||||||
|
expectMatchesForLines([firstLine, secondLine], [expectedMatch]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("standalone URL", () => {
|
||||||
|
describe("various protocols", () => {
|
||||||
|
test("http", () => testSingleLinkLine(EXAMPLE_HTTP_URL));
|
||||||
|
test("https", () => testSingleLinkLine(EXAMPLE_HTTPS_URL));
|
||||||
|
test("ftp", () => testSingleLinkLine("ftp://example.com"));
|
||||||
|
test("file", () => testSingleLinkLine("file://example/path/to/file"));
|
||||||
|
test("chrome extensions", () => testSingleLinkLine("chrome://extensions"));
|
||||||
|
test("chrome settings", () => testSingleLinkLine("chrome://settings"));
|
||||||
|
test("chrome bookmarks", () => testSingleLinkLine("chrome://bookmarks"));
|
||||||
|
test("chrome history", () => testSingleLinkLine("chrome://history"));
|
||||||
|
test("chrome downloads", () => testSingleLinkLine("chrome://downloads"));
|
||||||
|
test("chrome flags", () => testSingleLinkLine("chrome://flags"));
|
||||||
|
});
|
||||||
|
describe("shouldn't be matched over multiple lines", () => {
|
||||||
|
test("split before colon", () => {
|
||||||
|
const firstLine = "Check out this link: http";
|
||||||
|
const secondLine = `://example.com - click it!`;
|
||||||
|
expectMatchesForLines([firstLine, secondLine], []);
|
||||||
|
});
|
||||||
|
test("split after colon", () => {
|
||||||
|
const firstLine = "Check out this link: http:";
|
||||||
|
const secondLine = `//example.com - click it!`;
|
||||||
|
expectMatchesForLines([firstLine, secondLine], []);
|
||||||
|
});
|
||||||
|
test("split between slashes", () => {
|
||||||
|
const firstLine = "Check out this link: http:/";
|
||||||
|
const secondLine = `/example.com - click it!`;
|
||||||
|
expectMatchesForLines([firstLine, secondLine], []);
|
||||||
|
});
|
||||||
|
test("split after slashes", () => {
|
||||||
|
const firstLine = "Check out this link: http://";
|
||||||
|
const secondLine = `example.com - click it!`;
|
||||||
|
expectMatchesForLines([firstLine, secondLine], []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("multiple link types", () => {
|
||||||
|
const linkOrderings = [
|
||||||
|
[WIKILINK_TEXT, MARKDOWN_HTTP_LINK, EXAMPLE_HTTP_URL],
|
||||||
|
[WIKILINK_TEXT, MARKDOWN_HTTPS_LINK, EXAMPLE_HTTPS_URL],
|
||||||
|
[WIKILINK_TEXT, EXAMPLE_HTTP_URL, MARKDOWN_HTTP_LINK],
|
||||||
|
[WIKILINK_TEXT, EXAMPLE_HTTPS_URL, MARKDOWN_HTTPS_LINK],
|
||||||
|
[MARKDOWN_HTTP_LINK, WIKILINK_TEXT, EXAMPLE_HTTP_URL],
|
||||||
|
[MARKDOWN_HTTPS_LINK, WIKILINK_TEXT, EXAMPLE_HTTPS_URL],
|
||||||
|
[MARKDOWN_HTTP_LINK, EXAMPLE_HTTP_URL, WIKILINK_TEXT],
|
||||||
|
[MARKDOWN_HTTPS_LINK, EXAMPLE_HTTPS_URL, WIKILINK_TEXT],
|
||||||
|
[EXAMPLE_HTTP_URL, WIKILINK_TEXT, MARKDOWN_HTTP_LINK],
|
||||||
|
[EXAMPLE_HTTPS_URL, WIKILINK_TEXT, MARKDOWN_HTTPS_LINK],
|
||||||
|
[EXAMPLE_HTTP_URL, MARKDOWN_HTTP_LINK, WIKILINK_TEXT],
|
||||||
|
[EXAMPLE_HTTPS_URL, MARKDOWN_HTTPS_LINK, WIKILINK_TEXT],
|
||||||
|
];
|
||||||
|
test("various orderings of multiple link types within a line", () => {
|
||||||
|
for (const links of linkOrderings) {
|
||||||
|
const multiLinkLine = makeLineWithMultipleLinks(links);
|
||||||
|
expectMatchesForLines([multiLinkLine], links);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test("various orderings of multiple link types across lines", () => {
|
||||||
|
for (const links of linkOrderings) {
|
||||||
|
const separateLines = links.map((link) => makeLineWithLink(link));
|
||||||
|
expectMatchesForLines(separateLines, links);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("interaction with other markdown", () => {
|
||||||
|
function makeBulletPoint(text: string): string {
|
||||||
|
return `- ${text}`;
|
||||||
|
}
|
||||||
|
describe("bullet point", () => {
|
||||||
|
test("wikilink alone", () => {
|
||||||
|
const wikilinkBulletPoint = makeBulletPoint(WIKILINK_TEXT);
|
||||||
|
expectMatchesForLines([wikilinkBulletPoint], [WIKILINK_TEXT]);
|
||||||
|
});
|
||||||
|
test("wikilink with other text", () => {
|
||||||
|
const wikilinkBulletPoint = makeBulletPoint(makeLineWithLink(WIKILINK_TEXT));
|
||||||
|
expectMatchesForLines([wikilinkBulletPoint], [WIKILINK_TEXT]);
|
||||||
|
});
|
||||||
|
test("markdown link alone", () => {
|
||||||
|
const markdownLinkBulletPoint = makeBulletPoint(MARKDOWN_HTTP_LINK);
|
||||||
|
expectMatchesForLines([markdownLinkBulletPoint], [MARKDOWN_HTTP_LINK]);
|
||||||
|
});
|
||||||
|
test("markdown link with other text", () => {
|
||||||
|
const markdownLinkBulletPoint = makeBulletPoint(makeLineWithLink(MARKDOWN_HTTP_LINK));
|
||||||
|
expectMatchesForLines([markdownLinkBulletPoint], [MARKDOWN_HTTP_LINK]);
|
||||||
|
});
|
||||||
|
test("standalone URL alone", () => {
|
||||||
|
const standaloneUrlBulletPoint = makeBulletPoint(EXAMPLE_HTTP_URL);
|
||||||
|
expectMatchesForLines([standaloneUrlBulletPoint], [EXAMPLE_HTTP_URL]);
|
||||||
|
});
|
||||||
|
test("standalone URL with other text", () => {
|
||||||
|
const standaloneUrlBulletPoint = makeBulletPoint(makeLineWithLink(EXAMPLE_HTTP_URL));
|
||||||
|
expectMatchesForLines([standaloneUrlBulletPoint], [EXAMPLE_HTTP_URL]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeUncheckedCheckbox(text: string): string {
|
||||||
|
return `- [ ] ${text}`;
|
||||||
|
}
|
||||||
|
function makeCheckedCheckbox(text: string): string {
|
||||||
|
return `- [x] ${text}`;
|
||||||
|
}
|
||||||
|
describe("unchecked checkbox", () => {
|
||||||
|
test("wikilink alone", () => {
|
||||||
|
const wikilinkCheckbox = makeUncheckedCheckbox(WIKILINK_TEXT);
|
||||||
|
expectMatchesForLines([wikilinkCheckbox], [WIKILINK_TEXT]);
|
||||||
|
});
|
||||||
|
test("wikilink with other text", () => {
|
||||||
|
const wikilinkCheckbox = makeUncheckedCheckbox(makeLineWithLink(WIKILINK_TEXT));
|
||||||
|
expectMatchesForLines([wikilinkCheckbox], [WIKILINK_TEXT]);
|
||||||
|
});
|
||||||
|
test("markdown link alone", () => {
|
||||||
|
const markdownLinkCheckbox = makeUncheckedCheckbox(MARKDOWN_HTTP_LINK);
|
||||||
|
expectMatchesForLines([markdownLinkCheckbox], [MARKDOWN_HTTP_LINK]);
|
||||||
|
});
|
||||||
|
test("markdown link with other text", () => {
|
||||||
|
const markdownLinkCheckbox = makeUncheckedCheckbox(makeLineWithLink(MARKDOWN_HTTP_LINK));
|
||||||
|
expectMatchesForLines([markdownLinkCheckbox], [MARKDOWN_HTTP_LINK]);
|
||||||
|
});
|
||||||
|
test("standalone URL alone", () => {
|
||||||
|
const standaloneUrlCheckbox = makeUncheckedCheckbox(EXAMPLE_HTTP_URL);
|
||||||
|
expectMatchesForLines([standaloneUrlCheckbox], [EXAMPLE_HTTP_URL]);
|
||||||
|
});
|
||||||
|
test("standalone URL with other text", () => {
|
||||||
|
const standaloneUrlCheckbox = makeUncheckedCheckbox(makeLineWithLink(EXAMPLE_HTTP_URL));
|
||||||
|
expectMatchesForLines([standaloneUrlCheckbox], [EXAMPLE_HTTP_URL]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe("checked checkbox", () => {
|
||||||
|
test("wikilink alone", () => {
|
||||||
|
const wikilinkCheckbox = makeCheckedCheckbox(WIKILINK_TEXT);
|
||||||
|
expectMatchesForLines([wikilinkCheckbox], [WIKILINK_TEXT]);
|
||||||
|
});
|
||||||
|
test("wikilink with other text", () => {
|
||||||
|
const wikilinkCheckbox = makeCheckedCheckbox(makeLineWithLink(WIKILINK_TEXT));
|
||||||
|
expectMatchesForLines([wikilinkCheckbox], [WIKILINK_TEXT]);
|
||||||
|
});
|
||||||
|
test("markdown link alone", () => {
|
||||||
|
const markdownLinkCheckbox = makeCheckedCheckbox(MARKDOWN_HTTP_LINK);
|
||||||
|
expectMatchesForLines([markdownLinkCheckbox], [MARKDOWN_HTTP_LINK]);
|
||||||
|
});
|
||||||
|
test("markdown link with other text", () => {
|
||||||
|
const markdownLinkCheckbox = makeCheckedCheckbox(makeLineWithLink(MARKDOWN_HTTP_LINK));
|
||||||
|
expectMatchesForLines([markdownLinkCheckbox], [MARKDOWN_HTTP_LINK]);
|
||||||
|
});
|
||||||
|
test("standalone URL alone", () => {
|
||||||
|
const standaloneUrlCheckbox = makeCheckedCheckbox(EXAMPLE_HTTP_URL);
|
||||||
|
expectMatchesForLines([standaloneUrlCheckbox], [EXAMPLE_HTTP_URL]);
|
||||||
|
});
|
||||||
|
test("standalone URL with other text", () => {
|
||||||
|
const standaloneUrlCheckbox = makeCheckedCheckbox(makeLineWithLink(EXAMPLE_HTTP_URL));
|
||||||
|
expectMatchesForLines([standaloneUrlCheckbox], [EXAMPLE_HTTP_URL]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -4,19 +4,13 @@
|
||||||
"inlineSourceMap": true,
|
"inlineSourceMap": true,
|
||||||
"inlineSources": true,
|
"inlineSources": true,
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"target": "es5",
|
"target": "ES2020",
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"noImplicitAny": true,
|
"noImplicitAny": true,
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
|
"downlevelIteration": true,
|
||||||
"importHelpers": true,
|
"importHelpers": true,
|
||||||
"lib": [
|
"lib": ["dom", "scripthost", "ES2020"]
|
||||||
"dom",
|
|
||||||
"es5",
|
|
||||||
"scripthost",
|
|
||||||
"es2015"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"include": [
|
"include": ["**/*.ts"]
|
||||||
"**/*.ts"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
118
utils/jumpToPattern.ts
Normal file
118
utils/jumpToPattern.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
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.
|
||||||
|
*
|
||||||
|
* @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.
|
||||||
|
*/
|
||||||
|
export function jumpToPattern({
|
||||||
|
cm,
|
||||||
|
cursorPosition,
|
||||||
|
repeat,
|
||||||
|
regex,
|
||||||
|
filterMatch = () => true,
|
||||||
|
direction,
|
||||||
|
}: {
|
||||||
|
cm: CodeMirrorEditor;
|
||||||
|
cursorPosition: EditorPosition;
|
||||||
|
repeat: number;
|
||||||
|
regex: RegExp;
|
||||||
|
filterMatch?: (match: RegExpExecArray) => boolean;
|
||||||
|
direction: "next" | "previous";
|
||||||
|
}): EditorPosition {
|
||||||
|
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,
|
||||||
|
}: {
|
||||||
|
content: string;
|
||||||
|
regex: RegExp;
|
||||||
|
cursorIdx: number;
|
||||||
|
filterMatch: (match: RegExpExecArray) => boolean;
|
||||||
|
direction: "next" | "previous";
|
||||||
|
}): RegExpExecArray[] {
|
||||||
|
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: string,
|
||||||
|
regex: RegExp,
|
||||||
|
cursorIdx: number,
|
||||||
|
filterMatch: (match: RegExpExecArray) => boolean
|
||||||
|
): {
|
||||||
|
previousMatches: RegExpExecArray[];
|
||||||
|
currentMatches: RegExpExecArray[];
|
||||||
|
nextMatches: RegExpExecArray[];
|
||||||
|
} {
|
||||||
|
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: 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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWithinMatch(match: RegExpExecArray, index: number): boolean {
|
||||||
|
return match.index <= index && index < match.index + match[0].length;
|
||||||
|
}
|
||||||
35
utils/obsidianVimCommand.ts
Normal file
35
utils/obsidianVimCommand.ts
Normal file
|
|
@ -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, {});
|
||||||
|
}
|
||||||
33
utils/vimApi.ts
Normal file
33
utils/vimApi.ts
Normal file
|
|
@ -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;
|
||||||
|
};
|
||||||
Loading…
Reference in a new issue