Compare commits

...

116 commits

Author SHA1 Message Date
Threeinone
1ff5d97afc
Merge pull request #267 from alythobani/alyt/4-fix-jump-to-link-in-checkbox
Fix: Jump to link in checkbox
2026-03-25 21:50:07 -05:00
Threeinone
ba2c8243c1
Merge pull request #282 from joaosa/feat/leader-key
feat: Add <leader> key support
2026-03-25 21:29:01 -05:00
Threeinone
b3beeae815
Note on <Space> is unnecessary 2026-03-25 21:26:58 -05:00
João Andrade
10c5a580f9 feat: Add <leader> key support
Parse `let mapleader` directives and substitute `<leader>` in mapping
commands before passing lines to CodeMirror's handleEx(). This is
pure text substitution at the plugin level — no CodeMirror changes needed.

- Default leader is `\` (backslash), matching Vim
- Case-insensitive: <leader>, <Leader>, <LEADER> all work
- Single-pass processing: each `let mapleader` affects subsequent mappings only
- Only substitutes in recognized mapping commands (no false positives)
- Backwards compatible: no change for users who don't use <leader>
- Works with `source` command (sourced files inherit/override leader)

Closes #117
2026-02-16 10:50:39 +00:00
Dmitry Surin
774de34205
Update README.md (#272)
Not enough examples in readme. Trying to fix that. Spend some time to find this ability to run command without exmap
2025-08-22 06:28:13 +03:00
Aly Thobani
923de9fe8a chore: fix ts error by replacing Any with any 2025-05-18 09:21:19 -07:00
Aly Thobani
2fe5ee588c test: add more unit tests to jumpToLink.test.ts 2025-05-18 09:20:21 -07:00
Aly Thobani
2f18ae8b56 fix: make sure the closing paren of a markdown link isnt escaped 2025-05-17 22:33:45 -07:00
Aly Thobani
3d3bf60c88 docs: update docstrings for jumpToLink regexes 2025-05-17 22:33:45 -07:00
Aly Thobani
e800087a72 fix: make jumpToLink work for links inside checkboxes 2025-05-17 22:33:45 -07:00
Aly Thobani
1dd8077282 chore: remove package-lock.json from gitignore and add it to repo 2025-05-17 22:33:45 -07:00
Aly Thobani
340931c975 chore: add words to workspace dictionary to resolve cspell warnings 2025-05-17 22:33:45 -07:00
Juan Luque
4aa4ca31f1
Fix pasteinto error when clipboard text has surrounding whitespace. (#262)
`pasteinto` is called by `surroundFunc` which a string into whitespace
delimited arguments. Surrounding whitespace in the link to be copied
thus errors due to an unhandled number of arguments.

The now fixed error can be recreated by using `yy` to yank a line and
then using <A-p> to paste the link over a word. Similarly, `yW` will
reproduce the error if the link has a trailing whitespace.

`pasteinto` will still error if the link contains internal whitespace,
but this is more reasonable behavior. Fixing this will require tweaking
the regex `surroundFunc` uses to split up arguments.
2025-04-09 08:47:08 +03:00
Juan Luque
47de579471
Update README.md (#261)
Fix typo:  surround with '[ ]' command  `s[` was listed twice.
2025-02-27 16:59:31 +02:00
firai
378753b0b4
Update vim links to point to codemirror-vim repo (#251) 2024-11-24 20:30:37 +02:00
ko-shin-ryo
d376bfd048
fix: prevent yank events registration issues in multi-window setup (#249)
When opening a vault with multiple windows, non-active windows could miss the timing to register yank events. Added a mechanism to manage windows that have registered yank events to ensure proper event handling across all windows.
2024-11-17 13:13:41 +02:00
esm7
0171572ac1 Small README fix 2024-11-03 11:35:15 +02:00
esm7
e6d24066d3 Version release 2024-11-03 11:33:54 +02:00
esm7
957c14bd14 Merge branch 'master' of github.com:esm7/obsidian-vimrc-support 2024-10-31 17:04:25 +02:00
esm7
ee5897383e Documentation fix 2024-10-31 17:04:19 +02:00
Bao
a5fdaec56c
fix: guard against uninitialized codemirror instance (#246)
In obsidian (>=v1.7.2?) the codemirror instance is often not unitialized
at times, triggering some uncaught exceptions. This is particularly
noticable when:

- (w/ "Vim chord display" enabled) opening the vault w/o a note opened
- closing all tabs and opening a note

Add some null guards to avoid these errors
2024-10-31 16:02:36 +02:00
esm7
ba94e13835
Update README.md 2024-10-18 20:59:21 +03:00
esm7
10d3c3c464 Merge branch 'master' of github.com:esm7/obsidian-vimrc-support 2024-10-17 06:47:21 +03:00
esm7
040478f988 Documentation update for Obsidian 1.7 2024-10-17 06:46:32 +03:00
ko-shin-ryo
10c2b613bf
Fix RangeError when executing surround command with backward selection (#234) 2024-09-11 11:29:45 +03:00
esm7
2a5fffa2d1 Fix to https://github.com/esm7/obsidian-vimrc-support/issues/232
(and a version bump)
2024-08-08 21:09:40 +03:00
Aly Thobani
2795bb62b1
Fix: jumpToHeading shouldn't match within codeblocks (also improve jumpToLink a bit) (#233)
* fix: jumpToHeading should not jump to "headings" within codeblocks

jumpToPattern now accepts an optional filterMatch param to make this
happen

* jumpToLink can now jump to standalone hyperlinks

* Adjust/clarify docstring for `jumpToNextLink`
2024-08-04 20:50:54 +03:00
esm7
b5b50320d7 README updates and version bump 2024-07-23 20:31:44 +03:00
Aly Thobani
d2583078ac
Implement and provide default mappings for some Obsidian-specific Vim motions/commands (#222)
* feat: define and expose obsidian-specific vim commands

jumpToNextHeading: g]
jumpToPreviousHeading: g[

* Implement jumpToPreviousLink motion

* Refactoring and implementing jumpToNextLink

* refactor: new jumpToPattern function that can be used for motions

* refactor: renamed file and removed unneeded exports

* fix: return last found index even if fewer than n instances found, instead of undefined

* feat: implement moveUpSkipFold and moveDownSkipFold

* refactor: extract out helper functions for defining obsidian vim actions

* refactor: split vimApi.ts into two files

* refactor: add comment

* refactor: update names, types, etc

* feat: followLinkUnderCursor action

* feat: jumpToLink now jumps to both markdown and wiki links

* refactor: rename fns

* refactor: add docstrings / change var names

* feat: implement looping around

* refactor: cleaner implementation of jumpToPattern

* Change mappings for next/prev heading to [[ and ]]

* Tiny fixes

* docs: update docs now that some more motions are provided by default

---------

Co-authored-by: Erez Shermer <erezshermer@gmail.com>
2024-07-23 20:21:29 +03:00
esm7
89cd8c71b8 1. Added LICENSE file (https://github.com/esm7/obsidian-vimrc-support/issues/202)
2. Fixed errors when Vim mode is not active in Obsidian (https://github.com/esm7/obsidian-vimrc-support/issues/228)
2024-07-23 20:12:48 +03:00
esm7
5867e784f5
Merge pull request #219 from WATA-Haru/fix-readme-gt-gT
fix README , validate gt and gT command without Tab Switcher plugin
2024-07-01 06:45:08 +03:00
WATA-Haru
b9625acc80 fix README , validate gt and gT command without Tab Switcher plugin 2024-04-08 21:56:14 +09:00
esm7
4657923eb3
Merge pull request #218 from jsrozner/navigate_folds
Add a jssnippet to skip folds
2024-03-25 17:15:29 +02:00
jsrozner
89ae5fd0aa Add a jssnipped to skip folds 2024-03-21 16:42:30 -07:00
esm7
9ccf52a590
Merge pull request #184 from twio142/patch-1
Improve `surround`
2023-07-01 15:30:59 +03:00
esm7
224425bb6c
Merge pull request #193 from MaciejWieczor/patch-1
Update JsSnippets.md
2023-07-01 15:30:21 +03:00
Maciej Wieczór-Retman
0895ca4d07
Update JsSnippets.md 2023-06-29 12:46:35 +02:00
Maciej Wieczór-Retman
dd1838a175
Update JsSnippets.md 2023-06-29 12:45:50 +02:00
Maciej Wieczór-Retman
a864306288
Update JsSnippets.md
Vimwiki style link navigation
2023-06-24 21:57:42 +02:00
Erez Shermer
05b3a43a8e Readme update 2023-06-13 10:26:24 +03:00
twio142
1c7ff072df Debug 2023-05-13 02:01:03 +02:00
twio142
5d36a519d6
Improve surround
When no text selected, use `editor.wordAt` to get word at cursor position. If no word available, place cursor between prefix and suffix.
2023-05-13 00:51:14 +02:00
esm7
bdf41a370f
Merge pull request #183 from stechermichal/chore/update-readme-installation-instructions
chore: update readme installation instructions
2023-05-07 13:12:25 +03:00
Michal Štecher
af7a99d54e chore: update readme installation instructions 2023-05-07 11:44:50 +02:00
esm7
de354d654a
Merge pull request #181 from rscircus/patch-1
Add surround backticks to README.md
2023-05-01 10:21:38 +03:00
Roland Siegbert
b48f0155ae
Add surround backticks to README.md
Extending the vim-surround example with backticks as it is used a lot.
2023-04-29 09:24:57 +02:00
esm7
d9dd46efd1
Merge pull request #178 from SPiCaRiA/master
Allow customized Vim mode prompt text on status bar
2023-04-10 15:41:49 +03:00
SPiCaRiA
02bc1bf173
Add screenshots 2023-04-09 19:23:54 -07:00
SPiCaRia
0d0a562f6e Add CSS showcase to README 2023-04-09 19:21:27 -07:00
SPiCaRia
b6d258f807 Remove status bar CSS class 2023-04-09 19:10:38 -07:00
SPiCaRia
00c1a0ee39 Merge branch 'dev-status-bar-css' 2023-04-04 14:11:07 -07:00
SPiCaRia
ec6de70c35 Polish settings page - replace the duplicate break line with a section title 2023-04-04 14:11:01 -07:00
SPiCaRia
5bff2b81a5 Merge branch 'dev-status-bar-css' 2023-04-04 13:36:18 -07:00
SPiCaRia
bf7b373f23 Fix nits: add missing data mode when initialization 2023-04-04 13:35:47 -07:00
SPiCaRiA
7fb79e6faf
Add the missing line break 2023-04-02 22:37:24 -07:00
Weixuan Lin
f8e9893126 Merge branch 'dev-status-bar-css' 2023-04-02 22:34:45 -07:00
Weixuan Lin
99b379952b Update README 2023-04-02 22:34:31 -07:00
Weixuan Lin
edace4ee7a Merge branch 'dev-status-bar-css' 2023-04-02 22:28:35 -07:00
Weixuan Lin
a1e9406450 Use .vimrc-support-vim-mode class name and data-vim-mode instead 2023-04-02 22:28:23 -07:00
SPiCaRiA
eb8926a476
Merge pull request #6 from SPiCaRiA/dev-status-bar-css
Add option for updating CSS class names to status bar as well
2023-03-27 21:18:20 -07:00
Weixuan Lin
93ee168b7d Add option for updating CSS class names to status bar as well 2023-03-27 21:16:53 -07:00
SPiCaRiA
97868caed8
Merge pull request #5 from SPiCaRiA/dev-fix-settings
Fix: use default values when setting values are empty strings
2023-03-27 16:29:00 -07:00
Weixuan Lin
210269dbdb Fix settings 2023-03-27 16:26:11 -07:00
SPiCaRiA
c6ee5570dc
Merge pull request #4 from SPiCaRiA/dev-readme
Add a break line in settings tab and update readme
2023-03-27 15:51:59 -07:00
Weixuan Lin
3f58d4db7e Update README 2023-03-27 15:50:52 -07:00
Weixuan Lin
1a1cd75dcc Add a break line in settings tab 2023-03-27 15:47:11 -07:00
SPiCaRiA
4d91711fcd
Merge pull request #3 from SPiCaRiA/dev-clean-up
Clean-up: use tabs instead of soft tabs
2023-03-27 15:27:40 -07:00
Weixuan Lin
5f82041bb1 Clean-up: use tabs instead of soft tabs 2023-03-27 15:25:47 -07:00
SPiCaRiA
9c20fb513f
Merge pull request #2 from SPiCaRiA/dev-custom-prompt-css
Add corresponding CSS class when changing status bar
2023-03-27 15:22:35 -07:00
Weixuan Lin
3776b54d9f Add corresponding CSS class when changing status bar 2023-03-27 15:14:31 -07:00
SPiCaRiA
0ab972cc70
Merge pull request #1 from SPiCaRiA/dev-custom-prompt
Allow setting custom prompt text for status bar
2023-03-27 14:53:23 -07:00
Weixuan Lin
0c8d9f12f4 Add custom prompt text settings for status bar 2023-03-27 14:49:34 -07:00
Weixuan Lin
72696afe11 Add .DS_Store to .gitignore 2023-03-27 14:40:11 -07:00
Erez Shermer
5093dca6dc Version 0.9.0 2023-01-25 20:27:50 +02:00
esm7
6756ac2b63
Merge pull request #159 from jiyee/master
fix: Vim Chord and Vim Mode display freeze / stop working when switch…
2023-01-25 15:27:40 +02:00
jiyee
d930d2233e FIX: fix error if chord display is off, and subscribe CodeMirror keydown event 2023-01-16 00:50:58 +08:00
jiyee
5e108b9da5 support source command for using .vimrc in home directory #157 2023-01-16 00:50:51 +08:00
jiyee
59173c32a1 fix: Vim Chord and Vim Mode display freeze / stop working when switched to a new note, on another tab and after some time #149 2023-01-16 00:50:41 +08:00
Erez Shermer
857ca9322f Readme and json files for new version 2022-11-10 15:39:45 +02:00
esm7
596fb9b9ca
Merge pull request #147 from dohsimpson/master
Implemented `nunmap` and `vunmap` and provided a more detailed example of surround
2022-11-10 15:32:30 +02:00
esm7
65e5d251b3
Merge pull request #145 from Geniucker/master
change "isDesktopOnly" to false
2022-11-10 15:30:33 +02:00
Geniucker
57c0aa0c7a
Update README.md 2022-11-08 17:55:42 +08:00
dohsimpson
b7b6d27982 Implemented nunmap and vunmap and provided a more detailed example of surround 2022-11-07 20:36:01 -05:00
Geniucker
097678fb4b
Update manifest.json 2022-11-08 00:39:42 +08:00
Erez Shermer
eae8d02942 Fix to the selection variable 2022-09-23 15:09:26 +03:00
esm7
52cdd584c1
Merge pull request #138 from rgulewich/vim-code-link
Fix link to vim source
2022-09-23 15:02:19 +03:00
esm7
a3e7973fde
Merge pull request #128 from Pomroka/master
fix: empty selection in jsfile command
2022-09-23 15:00:40 +03:00
Rob Gulewich
ef153c3182 Fix link to vim source 2022-09-22 13:13:15 -07:00
Pomroka
2b7458faed gitignore 2022-09-06 20:13:32 +02:00
Pomroka
e546461bfb
Delete data.json 2022-08-06 18:03:33 +02:00
Pomroka
ddcc99b2bd Merge branch 'master' of https://github.com/Pomroka/obsidian-vimrc-support 2022-08-06 18:02:04 +02:00
Pomroka
008649157e convert back to tabs 2022-08-06 17:55:47 +02:00
Pomroka
b0cd7f594d convert back to tabs 2022-08-06 17:54:11 +02:00
Pomroka
43c3a2a7ee fix: null currentSelection 2022-08-06 17:41:30 +02:00
Pomroka
11d56f9ef2 Remove log 2022-08-06 12:56:54 +02:00
Pomroka
adaf3bad2b remove commented out code 2022-08-06 12:24:54 +02:00
Pomroka
2eedc65a31 fix: not working in new pane
fix: make sure register once per CM editor
2022-08-06 12:21:29 +02:00
Pomroka
ccbaaefc23 fix: selection stop working on next file opened
issues: #104, #129
2022-08-05 01:56:54 +02:00
Pomroka
361c2686da fix: empty selection in jsfile command 2022-08-04 01:40:13 +02:00
Erez Shermer
f2417c0193 README 2022-07-29 14:54:06 +03:00
Erez Shermer
f25a54a0c8 Fix to the plugin breaking in new Obsidian versions, and some optimizations 2022-07-29 14:53:29 +03:00
Erez Shermer
f4814f065d Merge branch 'master' of https://github.com/esm7/obsidian-vimrc-support 2022-07-20 09:00:06 +03:00
Erez Shermer
bcc1e56935 Fixed jscommand and jsfile broken 2022-07-20 08:59:40 +03:00
esm7
1518987ab3
Update issue templates 2022-07-17 21:23:03 +03:00
esm7
ba70ed0558
Update issue templates 2022-07-17 21:19:44 +03:00
Erez Shermer
08b0c5461a README 2022-07-17 20:59:40 +03:00
Erez Shermer
9a0376745e Removal of CM5 support and some cleanups 2022-07-17 11:55:53 +03:00
Erez Shermer
ab34da73f5 Merge remote-tracking branch 'origin/master' 2022-07-17 11:48:10 +03:00
esm7
13a893372d
Merge pull request #100 from chrisgrieser/patch-1
Info how to add Folding & Tab Switching Commands
2022-07-17 11:47:51 +03:00
Erez Shermer
ccfbe55ebc Merge remote-tracking branch 'origin/master' 2022-07-17 11:43:15 +03:00
Erez Shermer
79c6b60519 Removed CM5 support 2022-07-17 11:43:06 +03:00
esm7
d79de28400
Merge pull request #110 from tadashi-aikawa/master
Support multi windows (yank <--> clipboard sync)
2022-07-17 11:42:26 +03:00
tadashi-aikawa
8c95b4c703 Fix a bug that doesn't put from clipboard when Obsidian just starts 2022-07-09 14:50:22 +09:00
tadashi-aikawa
0e90edaa18 Fix a bug that lost a line break from yanked value 2022-07-05 16:32:16 +09:00
tadashi-aikawa
f613efe7ae Support multi windows (required Obsidian v0.15.3 and upper) 2022-07-03 23:47:06 +09:00
pseudometa
d3ab0568cf
Info how to add Folding & Tab Switching Commands
`gt`, `zo`, etc.
2022-05-17 13:46:01 +02:00
21 changed files with 4553 additions and 255 deletions

30
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View 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.

View 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.

View 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
View file

@ -7,10 +7,12 @@
# npm
node_modules
package-lock.json
# build
main.js
*.js.map
data.json
obsidian.d.ts
.DS_Store

19
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,19 @@
{
"cSpell.words": [
"exmap",
"iunmap",
"jscommand",
"jsfile",
"Keybinds",
"keychord",
"keymap",
"nmap",
"noremap",
"nunmap",
"obcommand",
"pasteinto",
"sendkeys",
"unnamedplus",
"vunmap"
]
}

View file

@ -4,12 +4,11 @@ In this document I will collect some of my and user-contributed ideas for how to
If you have interesting snippets, please contribute by opening a pull request!
Note that these examples are included for demonstration purposes, and many of them are now provided by default in this plugin. Their actual implementations can be found under [`motions/`](https://github.com/esm7/obsidian-vimrc-support/blob/master/motions/), which you can also use as reference (either for your own custom motions, or if you wish to submit a PR for a new motion to be provided by this plugin).
## Jump to Next/Prev Markdown Header
## Jump to Next/Previous Markdown Heading
To map `]]` and `[[` to next/prev markdown header, I use the following.
In a file I call `mdHelpers.js`, put this:
In a file you can call `mdHelpers.js`, put this:
```js
// Taken from https://stackoverflow.com/questions/273789/is-there-a-version-of-javascripts-string-indexof-that-allows-for-regular-expr
@ -59,3 +58,59 @@ exmap prevHeading jsfile mdHelpers.js {jumpHeading(false)}
nmap ]] :nextHeading
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
View 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
View file

@ -1,8 +1,20 @@
# 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.
[!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](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.
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.
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.
**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:
```
```vim
" Have j and k navigate visual lines rather than logical ones
nmap j gj
nmap k gk
@ -25,7 +39,7 @@ nmap k gk
nmap H ^
nmap L $
" Quickly remove search highlights
nmap <F9> :nohl
nmap <F9> :nohl<CR>
" Yank to system clipboard
set clipboard=unnamed
@ -33,27 +47,36 @@ set clipboard=unnamed
" Go back and forward with Ctrl+O and Ctrl+I
" (make sure to remove default Obsidian shortcuts for these to work)
exmap back obcommand app:go-back
nmap <C-o> :back
nmap <C-o> :back<CR>
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
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:
- 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).
- 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.
- `obcommand` - execute Obsidian commands, see more 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.
- `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.
- `source` - loads Vim commands from a file (relative to the vault root).
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.
In some cases you can find workarounds by experimenting, and the easiest way to do that is by trying interactively rather than via the Vimrc file.
Finally, this plugin also provides the following motions/mappings by default:
- `[[` and `]]` to jump to the previous and next Markdown heading.
- `zk` and `zj` to move up and down while skipping folds.
- `gl` and `gL` to jump to the next and previous link.
- `gf` to open the link or file under the cursor (temporarily moving the cursor if necessary—e.g. if it's on the first square bracket of a [[Wikilink]]).
## Installation
In the Obsidian.md settings under "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/`.
@ -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\]..."
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 :)
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:
```
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.
## 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`
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]`.
Some examples:
- `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'
exmap wiki surround [[ ]]
map [[ :wiki
map [[ :surround_wiki<CR>
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`
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>`.
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.
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.
#### 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).
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()); }
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).
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`.
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 prevHeading jsfile mdHelpers.js {jumpHeading(false)}
nmap ]] :nextHeading
nmap [[ :prevHeading
nmap ]] :nextHeading<CR>
nmap [[ :prevHeading<CR>
```
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
### 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
- 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.)
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).

View 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");
}
};

View 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
View file

@ -1,8 +1,26 @@
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;
const enum vimStatus {
normal = 'normal',
insert = 'insert',
visual = 'visual',
replace = 'replace',
}
type VimStatusPrompt = string;
type VimStatusPromptMap = {
[status in vimStatus]: VimStatusPrompt;
};
interface Settings {
vimrcFileName: string,
displayChord: boolean,
@ -10,6 +28,7 @@ interface Settings {
fixedNormalModeLayout: boolean,
capturedKeyboardMap: Record<string, string>,
supportJsCommands?: boolean
vimStatusPromptMap: VimStatusPromptMap;
}
const DEFAULT_SETTINGS: Settings = {
@ -18,23 +37,35 @@ const DEFAULT_SETTINGS: Settings = {
displayVimMode: false,
fixedNormalModeLayout: false,
capturedKeyboardMap: {},
supportJsCommands: false
supportJsCommands: false,
vimStatusPromptMap: {
normal: '🟢',
insert: '🟠',
visual: '🟡',
replace: '🔴',
},
}
const enum vimStatus {
normal = "🟢",
insert = "🟠",
replace = "🔴",
visual = "🟡"
}
const vimStatusPromptClass = "vimrc-support-vim-mode";
// NOTE: to future maintainers, please make sure all mapping commands are included in this array.
const mappingCommands: String[] = [
"map",
"nmap",
"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) {
return new Promise(resolve => setTimeout(resolve, ms));
}
@ -43,12 +74,12 @@ export default class VimrcPlugin extends Plugin {
settings: Settings;
private codeMirrorVimObject: any = null;
private editorMode: 'cm5' | 'cm6' = null;
private initialized = false;
private lastYankBuffer = new Array<string>(0);
private lastYankBuffer: string[] = [""];
private lastSystemClipboard = "";
private yankToSystemClipboard: boolean = false;
private registeredYankEventsWindows: Set<Window> = new Set();
private currentKeyChord: any = [];
private vimChordStatusBar: HTMLElement = null;
private vimStatusBar: HTMLElement = null;
@ -56,13 +87,21 @@ export default class VimrcPlugin extends Plugin {
private customVimKeybinds: { [name: string]: boolean } = {};
private currentSelection: [EditorSelection] = null;
private isInsertMode: boolean = false;
private leaderKey: string = "\\";
updateVimStatusBar() {
this.vimStatusBar.setText(
this.settings.vimStatusPromptMap[this.currentVimStatus]
);
this.vimStatusBar.dataset.vimMode = this.currentVimStatus;
}
async captureKeyboardLayout() {
// This is experimental API and it might break at some point:
// https://developer.mozilla.org/en-US/docs/Web/API/KeyboardLayoutMap
let keyMap: Record<string, string> = {};
let layout = await (navigator as any).keyboard.getLayoutMap();
let doneIterating = new Promise((resolve, reject) => {
let doneIterating = new Promise<void>((resolve, reject) => {
let counted = 0;
layout.forEach((value: any, index: any) => {
keyMap[index] = value;
@ -80,38 +119,109 @@ export default class VimrcPlugin extends Plugin {
if (this.initialized)
return;
// Determine if we have the legacy Obsidian editor (CM5) or the new one (CM6).
// 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.codeMirrorVimObject = (window as any).CodeMirrorAdapter?.Vim;
this.registerDomEvent(document, 'click', () => {
this.captureYankBuffer();
});
this.registerDomEvent(document, 'keyup', () => {
this.captureYankBuffer();
});
this.registerDomEvent(document, 'focusin', () => {
this.captureYankBuffer();
this.registerYankEvents(activeWindow);
this.app.workspace.on("window-open", (workspaceWindow, w) => {
this.registerYankEvents(w);
})
this.app.workspace.on("window-close", (workspaceWindow, w) => {
this.registeredYankEventsWindows.delete(w);
})
this.prepareChordDisplay();
this.prepareVimModeDisplay();
// Two events cos
// this don't trigger on loading/reloading obsidian with note opened
this.app.workspace.on("active-leaf-change", async () => {
this.updateSelectionEvent();
this.updateVimEvents();
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;
}
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() {
await this.loadSettings();
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)
await this.initialize();
if (this.codeMirrorVimObject.loadedVimrc)
return;
let fileName = this.settings.vimrcFileName;
if (!fileName || fileName.trim().length === 0) {
fileName = DEFAULT_SETTINGS.vimrcFileName;
@ -121,7 +231,7 @@ export default class VimrcPlugin extends Plugin {
try {
vimrcContent = await this.app.vault.adapter.read(fileName);
} 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);
});
@ -136,9 +246,10 @@ export default class VimrcPlugin extends Plugin {
await this.saveData(this.settings);
}
logVimModeChange(modeObj: any) {
this.isInsertMode = modeObj.mode === 'insert';
switch (modeObj.mode) {
logVimModeChange = async (cm: any) => {
if (!cm) return;
this.isInsertMode = cm.mode === 'insert';
switch (cm.mode) {
case "insert":
this.currentVimStatus = vimStatus.insert;
break;
@ -155,7 +266,7 @@ export default class VimrcPlugin extends Plugin {
break;
}
if (this.settings.displayVimMode)
this.vimStatusBar.setText(this.currentVimStatus);
this.updateVimStatusBar();
}
onunload() {
@ -166,12 +277,12 @@ export default class VimrcPlugin extends Plugin {
return this.app.workspace.getActiveViewOfType(MarkdownView);
}
getActiveObsidianEditor(): ObsidianEditor {
return this.getActiveView().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
if (this.editorMode == 'cm6')
return (view as any).sourceMode?.cmEditor?.cm?.cm;
else
return (view as any).sourceMode?.cmEditor;
return (view as any).editMode?.editor?.cm?.cm;
}
readVimInit(vimCommands: string) {
@ -180,33 +291,15 @@ export default class VimrcPlugin extends Plugin {
var cmEditor = this.getCodeMirror(view);
if (cmEditor && !this.codeMirrorVimObject.loadedVimrc) {
this.defineBasicCommands(this.codeMirrorVimObject);
this.defineAndMapObsidianVimCommands(this.codeMirrorVimObject);
this.defineSendKeys(this.codeMirrorVimObject);
this.defineObCommand(this.codeMirrorVimObject);
this.defineCmCommand(this.codeMirrorVimObject);
this.defineSurround(this.codeMirrorVimObject);
this.defineJsCommand(this.codeMirrorVimObject);
this.defineJsFile(this.codeMirrorVimObject);
this.defineSource(this.codeMirrorVimObject);
// Record the position of selections
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();
this.loadVimCommands(vimCommands);
// Make sure that we load it just once per CodeMirror instance.
// This is supposed to work because the Vim state is kept at the keymap level, hopefully
@ -215,15 +308,61 @@ export default class VimrcPlugin extends Plugin {
}
if (cmEditor) {
cmEditor.on('vim-mode-change', (modeObj: any) => {
if (modeObj)
this.logVimModeChange(modeObj);
});
this.defineFixedLayout(cmEditor);
cmEditor.off('vim-mode-change', this.logVimModeChange);
cmEditor.on('vim-mode-change', this.logVimModeChange);
CodeMirror.off(cmEditor.getInputField(), 'keydown', this.onKeydown);
CodeMirror.on(cmEditor.getInputField(), 'keydown', this.onKeydown);
}
}
}
loadVimCommands(vimCommands: 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) {
vimObject.defineOption('clipboard', '', 'string', ['clip'], (value: string, cm: any) => {
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) => {
if (!params?.args?.length) {
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) {
vimObject.defineEx('sendkeys', '', async (cm: any, params: any) => {
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) {
vimObject.defineEx('obcommand', '', async (cm: any, params: any) => {
const availableCommands = (this.app as any).commands.commands;
if (!params?.args?.length || params.args.length != 1) {
const availableCommands = (this.app as any).commands.commands;
console.log(`Available commands: ${Object.keys(availableCommands).join('\n')}`)
throw new Error(`obcommand requires exactly 1 parameter`);
}
let view = this.getActiveView();
let editor = view.editor;
const command = params.args[0];
if (command in availableCommands) {
let callback = availableCommands[command].callback;
let checkCallback = availableCommands[command].checkCallback;
let editorCallback = availableCommands[command].editorCallback;
let editorCheckCallback = availableCommands[command].editorCheckCallback;
if (editorCheckCallback)
editorCheckCallback(false, editor, view);
else if (editorCallback)
editorCallback(editor, view);
else if (checkCallback)
checkCallback(false);
else if (callback)
callback();
else
throw new Error(`Command ${command} doesn't have an Obsidian callback`);
} else
throw new Error(`Command ${command} was not found, try 'obcommand' with no params to see in the developer console what's available`);
});
}
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');
const commandName = params.args[0];
this.executeObsidianCommand(commandName);
});
}
@ -357,20 +508,20 @@ export default class VimrcPlugin extends Plugin {
// Function to surround selected text or highlighted word.
var surroundFunc = (params: string[]) => {
var editor = this.getActiveView().editor;
if (!params.length) {
if (!params?.length) {
throw new Error("surround requires exactly 2 parameters: prefix and postfix text.");
}
let newArgs = params.join(" ").match(/(\\.|[^\s\\\\]+)+/g);
if (newArgs.length != 2) {
throw new Error("surround requires exactly 2 parameters: prefix and postfix text.");
}
let beginning = newArgs[0].replace("\\\\", "\\").replace("\\ ", " "); // Get the beginning surround text
let ending = newArgs[1].replace("\\\\", "\\").replace("\\ ", " "); // Get the ending surround text
let currentSelections = this.currentSelection;
var chosenSelection = currentSelections[0];
if (this.currentSelection && currentSelections.length > 1) {
let currentSelections = this.currentSelection;
var chosenSelection = currentSelections?.[0] ? currentSelections[0] : {anchor: editor.getCursor(), head: editor.getCursor()};
if (currentSelections?.length > 1) {
console.log("WARNING: Multiple selections in surround. Attempt to select matching cursor. (obsidian-vimrc-support)")
const cursorPos = editor.getCursor();
for (const selection of currentSelections) {
@ -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.
var line = editor.getLine(chosenSelection.anchor.line);
if (line.length === 0)
throw new Error("can't surround on an empty line");
// 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}
};
let wordAt = editor.wordAt(chosenSelection.head);
if (wordAt) {
chosenSelection = {anchor: wordAt.from, head: wordAt.to};
}
}
// If the selection is reverse, switch the variables
if (chosenSelection.anchor.line > chosenSelection.head.line ||
(chosenSelection.anchor.line == chosenSelection.head.line && chosenSelection.anchor.ch > chosenSelection.head.ch))
if (editor.posToOffset(chosenSelection.anchor) > editor.posToOffset(chosenSelection.head)) {
[chosenSelection.anchor, chosenSelection.head] = [chosenSelection.head, chosenSelection.anchor];
}
let currText = editor.getRange(chosenSelection.anchor, chosenSelection.head);
editor.replaceRange(beginning + currText + ending, chosenSelection.anchor, chosenSelection.head);
// If no selection, place cursor between beginning and ending
if (editor.posToOffset(chosenSelection.anchor) === editor.posToOffset(chosenSelection.head)) {
chosenSelection.head.ch += beginning.length;
editor.setCursor(chosenSelection.head);
}
}
vimObject.defineEx("surround", "", (cm: any, params: any) => { surroundFunc(params.args); });
@ -418,7 +557,7 @@ export default class VimrcPlugin extends Plugin {
// Using the register for when this.yankToSystemClipboard == false
surroundFunc(
['[',
'](' + vimObject.getRegisterController().getRegister('yank').keyBuffer + ")"]);
'](' + vimObject.getRegisterController().getRegister('yank').keyBuffer[0].trim() + ")"]);
})
var editor = this.getActiveView().editor;
@ -445,25 +584,34 @@ export default class VimrcPlugin extends Plugin {
}
captureYankBuffer() {
if (this.yankToSystemClipboard) {
let currentBuffer = this.codeMirrorVimObject.getRegisterController().getRegister('yank').keyBuffer;
if (currentBuffer != this.lastYankBuffer) {
if (this.lastYankBuffer.length > 0 && currentBuffer.length > 0 && currentBuffer[0]) {
navigator.clipboard.writeText(currentBuffer[0]);
navigator.clipboard.readText().then((value) => { this.lastSystemClipboard = value; });
}
this.lastYankBuffer = currentBuffer;
return;
async captureYankBuffer(win: Window) {
if (!this.yankToSystemClipboard) {
return
}
const yankRegister = this.codeMirrorVimObject.getRegisterController().getRegister('yank');
const currentYankBuffer = yankRegister.keyBuffer;
// yank -> clipboard
const buf = currentYankBuffer[0]
if (buf !== this.lastYankBuffer[0]) {
await win.navigator.clipboard.writeText(buf);
this.lastYankBuffer = currentYankBuffer;
this.lastSystemClipboard = await win.navigator.clipboard.readText();
return
}
// clipboard -> yank
try {
const currentClipboardText = await win.navigator.clipboard.readText();
if (currentClipboardText !== this.lastSystemClipboard) {
yankRegister.setText(currentClipboardText);
this.lastYankBuffer = yankRegister.keyBuffer;
this.lastSystemClipboard = currentClipboardText;
}
let currentClipboard = navigator.clipboard.readText().then((value) => {
if (value != this.lastSystemClipboard) {
let yankRegister = this.codeMirrorVimObject.getRegisterController().getRegister('yank')
yankRegister.setText(value);
this.lastYankBuffer = yankRegister.keyBuffer;
this.lastSystemClipboard = value;
}
})
} catch (e) {
// XXX: Avoid "Uncaught (in promise) DOMException: Document is not focused."
// XXX: It is not good but easy workaround
}
}
@ -477,54 +625,70 @@ export default class VimrcPlugin extends Plugin {
this.vimChordStatusBar.parentElement.insertBefore(this.vimChordStatusBar, parent.firstChild);
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.
CodeMirror.on(cmEditor, "vim-keypress", 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);
});
CodeMirror.on(cmEditor, "vim-command-done", async (reason: any) => { // Reset display
this.vimChordStatusBar.setText("");
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);
}
}
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() {
if (this.settings.displayVimMode) {
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) {
cm.on('keydown', (instance: CodeMirror.Editor, ev: KeyboardEvent) => {
if (this.settings.fixedNormalModeLayout) {
const keyMap = this.settings.capturedKeyboardMap;
if (!this.isInsertMode && !ev.shiftKey &&
ev.code in keyMap && ev.key != keyMap[ev.code]) {
this.codeMirrorVimObject.handleKey(instance, keyMap[ev.code], 'mapping');
ev.preventDefault();
return false;
onKeydown = (ev: KeyboardEvent) => {
if (this.settings.fixedNormalModeLayout) {
const keyMap = this.settings.capturedKeyboardMap;
if (!this.isInsertMode && !ev.shiftKey &&
ev.code in keyMap && ev.key != keyMap[ev.code]) {
let view = this.getActiveView();
if (view) {
const cmEditor = this.getCodeMirror(view);
if (cmEditor) {
this.codeMirrorVimObject.handleKey(cmEditor, keyMap[ev.code], 'mapping');
}
}
ev.preventDefault();
return false;
}
});
}
}
defineJsCommand(vimObject: any) {
@ -535,7 +699,7 @@ export default class VimrcPlugin extends Plugin {
if (jsCode[0] != '{' || jsCode[jsCode.length - 1] != '}')
throw new Error("Expected an argument which is JS code surrounded by curly brackets: {...}");
let currentSelections = this.currentSelection;
var chosenSelection = currentSelections[0];
var chosenSelection = currentSelections && currentSelections.length > 0 ? currentSelections[0] : null;
const command = Function('editor', 'view', 'selection', jsCode);
const view = this.getActiveView();
command(view.editor, view, chosenSelection);
@ -556,20 +720,35 @@ export default class VimrcPlugin extends Plugin {
if (extraCode[0] != '{' || extraCode[extraCode.length - 1] != '}')
throw new Error("Expected an extra code argument which is JS code surrounded by curly brackets: {...}");
}
let currentSelections = this.currentSelection;
var chosenSelection = currentSelections && currentSelections.length > 0 ? currentSelections[0] : null;
let content = '';
try {
content = await this.app.vault.adapter.read(fileName);
} catch (e) {
throw new Error(`Cannot read file ${params.args[0]} from vault root: ${e.message}`);
}
let currentSelections = this.currentSelection;
var chosenSelection = currentSelections[0];
const command = Function('editor', 'view', 'selection', content + extraCode);
const view = this.getActiveView();
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 {
@ -651,5 +830,72 @@ class SettingsTab extends PluginSettingTab {
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();
});
});
}
}

View file

@ -1,9 +1,10 @@
{
"id": "obsidian-vimrc-support",
"name": "Vimrc Support",
"version": "0.6.2",
"version": "0.10.2",
"description": "Auto-load a startup file with Obsidian Vim commands.",
"minAppVersion": "0.15.3",
"author": "esm",
"authorUrl": "",
"isDesktopOnly": true
"isDesktopOnly": false
}

75
motions/jumpToHeading.ts Normal file
View 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
View 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

File diff suppressed because it is too large Load diff

View file

@ -1,11 +1,13 @@
{
"name": "obsidian-vimrc-support",
"version": "0.6.2",
"version": "0.10.2",
"description": "Auto-load a startup file with Obsidian Vim commands.",
"main": "main.js",
"scripts": {
"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": [],
"author": "",
@ -13,13 +15,17 @@
"devDependencies": {
"@rollup/plugin-commonjs": "^15.1.0",
"@rollup/plugin-node-resolve": "^9.0.0",
"@rollup/plugin-typescript": "^6.1.0",
"@types/node": "^14.14.6",
"@rollup/plugin-typescript": "^11.0.0",
"@types/string.prototype.matchall": "^4.0.4",
"codemirror": "^5.62.2",
"keyboardevent-from-electron-accelerator": "*",
"obsidian": "^0.12.17",
"obsidian": "^1.1.1",
"rollup": "^2.33.0",
"tslib": "^2.0.3",
"typescript": "^4.0.5"
"tslib": "^2.6.3",
"typescript": "^5.5.3",
"vitest": "^3.1.3"
},
"dependencies": {
"string.prototype.matchall": "^4.0.11"
}
}

325
tests/jumpToLink.test.ts Normal file
View 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]);
});
});
});
});

View file

@ -4,19 +4,13 @@
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "es5",
"target": "ES2020",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "node",
"downlevelIteration": true,
"importHelpers": true,
"lib": [
"dom",
"es5",
"scripthost",
"es2015"
]
"lib": ["dom", "scripthost", "ES2020"]
},
"include": [
"**/*.ts"
]
"include": ["**/*.ts"]
}

118
utils/jumpToPattern.ts Normal file
View 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;
}

View 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
View 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;
};