├── styles.css ├── .nvmrc ├── .github ├── FUNDING.yml └── workflows │ └── ci.yml ├── .husky └── pre-commit ├── .yarnrc.yml ├── .prettierrc ├── babel.config.js ├── .gitignore ├── src ├── state.ts ├── custom-selection-handlers.ts ├── modals.ts ├── constants.ts ├── settings.ts ├── __tests__ │ ├── actions-range-new.spec.ts │ ├── test-helpers.ts │ ├── actions-multi-new.spec.ts │ ├── utils.spec.ts │ ├── actions-cursor-new.spec.ts │ ├── actions-cursor.spec.ts │ ├── actions-multi.spec.ts │ └── actions-range.spec.ts ├── main.ts ├── utils.ts └── actions.ts ├── manifest.json ├── tsconfig.json ├── versions.json ├── jest.config.js ├── .eslintrc.json ├── LICENSE ├── package.json └── README.md /styles.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 16.10.0 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: timhor 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn lint-staged 5 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | yarnPath: .yarn/releases/yarn-3.2.0.cjs 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": false, 3 | "tabWidth": 2, 4 | "trailingComma": "all", 5 | "semi": true, 6 | "singleQuote": true, 7 | "endOfLine": "lf" 8 | } 9 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', { targets: { node: 'current' } }], 4 | '@babel/preset-typescript', 5 | ], 6 | }; 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IDE 2 | *.iml 3 | .idea 4 | .vscode 5 | 6 | # npm 7 | node_modules 8 | 9 | # build 10 | main.js 11 | *.js.map 12 | 13 | # obsidian 14 | data.json 15 | 16 | # yarn 17 | .pnp.* 18 | .yarn/* 19 | !.yarn/patches 20 | !.yarn/plugins 21 | !.yarn/releases 22 | !.yarn/sdks 23 | !.yarn/versions 24 | -------------------------------------------------------------------------------- /src/state.ts: -------------------------------------------------------------------------------- 1 | type CodeEditorShortcutsState = { 2 | autoInsertListPrefix: boolean; 3 | }; 4 | 5 | /** 6 | * Simple state object used to hold information from saved settings (accessible 7 | * anywhere it's imported without needing to thread it down to dependent 8 | * functions as an argument) 9 | */ 10 | export const SettingsState: CodeEditorShortcutsState = { 11 | autoInsertListPrefix: true, 12 | }; 13 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | lint-and-test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Install dependencies 15 | run: yarn install 16 | - name: Lint 17 | run: yarn lint 18 | - name: Test 19 | run: yarn test 20 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "obsidian-editor-shortcuts", 3 | "name": "Code Editor Shortcuts", 4 | "version": "1.14.0", 5 | "minAppVersion": "1.1.0", 6 | "description": "Add keyboard shortcuts (hotkeys) commonly found in code editors such as Visual Studio Code (vscode) or Sublime Text", 7 | "author": "Tim Hor", 8 | "authorUrl": "https://github.com/timhor", 9 | "fundingUrl": "https://ko-fi.com/timhor", 10 | "isDesktopOnly": false 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "es6", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "allowSyntheticDefaultImports": true, 13 | "lib": ["dom", "es5", "scripthost", "es2015"] 14 | }, 15 | "include": ["**/*.ts"] 16 | } 17 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.0": "0.9.12", 3 | "1.1.0": "0.9.12", 4 | "1.2.0": "0.9.12", 5 | "1.2.1": "0.9.12", 6 | "1.3.0": "0.9.12", 7 | "1.4.0": "0.9.12", 8 | "1.4.1": "0.9.12", 9 | "1.5.0": "0.9.12", 10 | "1.6.0": "0.13.31", 11 | "1.7.0": "0.13.31", 12 | "1.8.0": "0.14.6", 13 | "1.9.0": "0.14.6", 14 | "1.10.0": "0.14.6", 15 | "1.11.0": "1.1.0", 16 | "1.12.0": "1.1.0", 17 | "1.13.0": "1.1.0", 18 | "1.13.1": "1.1.0", 19 | "1.14.0": "1.1.0" 20 | } 21 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /* 2 | * For a detailed explanation regarding each configuration property, visit: 3 | * https://jestjs.io/docs/configuration 4 | */ 5 | 6 | module.exports = { 7 | // The test environment that will be used for testing 8 | testEnvironment: 'jsdom', 9 | 10 | // The glob patterns Jest uses to detect test files 11 | testMatch: ['**/__tests__/**/*.[jt]s?(x)'], 12 | 13 | // If the test path matches any of the patterns, it will be skipped 14 | testPathIgnorePatterns: ['/node_modules/', 'test-helpers.ts'], 15 | 16 | // A map from regular expressions to paths to transformers 17 | transform: { 18 | '^.+\\.[t|j]sx?$': 'babel-jest', 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true, 5 | "node": true 6 | }, 7 | "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"], 8 | "parser": "@typescript-eslint/parser", 9 | "parserOptions": { 10 | "ecmaVersion": 13, 11 | "sourceType": "module" 12 | }, 13 | "ignorePatterns": ["node_modules", "*.js"], 14 | "plugins": ["@typescript-eslint"], 15 | "rules": { 16 | "linebreak-style": ["error", "unix"], 17 | "quotes": [ 18 | "error", 19 | "single", 20 | { 21 | "avoidEscape": true, 22 | "allowTemplateLiterals": true 23 | } 24 | ], 25 | "semi": ["error", "always"], 26 | "@typescript-eslint/no-explicit-any": "off" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/custom-selection-handlers.ts: -------------------------------------------------------------------------------- 1 | import { EditorSelectionOrCaret } from 'obsidian'; 2 | 3 | export type CustomSelectionHandler = ( 4 | selections: EditorSelectionOrCaret[], 5 | ) => EditorSelectionOrCaret[]; 6 | 7 | // For multiple cursors on the same line, the new cursors should be on 8 | // consecutive following lines 9 | export const insertLineBelowHandler: CustomSelectionHandler = (selections) => { 10 | const seenLines: number[] = []; 11 | let lineIncrement = 0; 12 | let processedPos: EditorSelectionOrCaret; 13 | 14 | return selections.reduce((processed, currentPos) => { 15 | const currentLine = currentPos.anchor.line; 16 | if (!seenLines.includes(currentLine)) { 17 | seenLines.push(currentLine); 18 | lineIncrement = 0; 19 | processedPos = currentPos; 20 | } else { 21 | lineIncrement++; 22 | processedPos = { 23 | anchor: { 24 | line: currentLine + lineIncrement, 25 | ch: currentPos.anchor.ch, 26 | }, 27 | }; 28 | } 29 | processed.push(processedPos); 30 | return processed; 31 | }, []); 32 | }; 33 | -------------------------------------------------------------------------------- /src/modals.ts: -------------------------------------------------------------------------------- 1 | import { App, SuggestModal } from 'obsidian'; 2 | 3 | export class GoToLineModal extends SuggestModal { 4 | private lineCount; 5 | private onSubmit; 6 | 7 | constructor( 8 | app: App, 9 | lineCount: number, 10 | onSubmit: (lineNumber: number) => void, 11 | ) { 12 | super(app); 13 | this.lineCount = lineCount; 14 | this.onSubmit = onSubmit; 15 | 16 | const PROMPT_TEXT = `Enter a line number between 1 and ${lineCount}`; 17 | this.limit = 1; 18 | this.setPlaceholder(PROMPT_TEXT); 19 | this.emptyStateText = PROMPT_TEXT; 20 | } 21 | 22 | getSuggestions(line: string): string[] { 23 | const lineNumber = parseInt(line); 24 | if (line.length > 0 && lineNumber > 0 && lineNumber <= this.lineCount) { 25 | return [line]; 26 | } 27 | return []; 28 | } 29 | 30 | renderSuggestion(line: string, el: HTMLElement) { 31 | el.createEl('div', { text: line }); 32 | } 33 | 34 | onChooseSuggestion(line: string) { 35 | // Subtract 1 as line numbers are zero-indexed 36 | this.onSubmit(parseInt(line) - 1); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Tim Hor 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export enum CASE { 2 | UPPER = 'upper', 3 | LOWER = 'lower', 4 | TITLE = 'title', 5 | NEXT = 'next', 6 | } 7 | 8 | export const LOWERCASE_ARTICLES = ['the', 'a', 'an']; 9 | 10 | export enum SEARCH_DIRECTION { 11 | FORWARD = 'forward', 12 | BACKWARD = 'backward', 13 | } 14 | 15 | export type MatchingCharacterMap = { [key: string]: string }; 16 | 17 | export const MATCHING_BRACKETS: MatchingCharacterMap = { 18 | '[': ']', 19 | '(': ')', 20 | '{': '}', 21 | }; 22 | 23 | export const MATCHING_QUOTES: MatchingCharacterMap = { 24 | "'": "'", 25 | '"': '"', 26 | '`': '`', 27 | }; 28 | 29 | export const MATCHING_QUOTES_BRACKETS: MatchingCharacterMap = { 30 | ...MATCHING_QUOTES, 31 | ...MATCHING_BRACKETS, 32 | }; 33 | 34 | export enum CODE_EDITOR { 35 | SUBLIME = 'sublime', 36 | VSCODE = 'vscode', 37 | } 38 | 39 | export const MODIFIER_KEYS = [ 40 | 'Control', 41 | 'Shift', 42 | 'Alt', 43 | 'Meta', 44 | 'CapsLock', 45 | 'Fn', 46 | ]; 47 | 48 | /** 49 | * Captures the prefix (including space) for bullet lists, numbered lists 50 | * and checklists 51 | */ 52 | export const LIST_CHARACTER_REGEX = /^\s*(-|\+|\*|\d+\.|>) (\[.\] )?/; 53 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-editor-shortcuts", 3 | "version": "1.14.0", 4 | "description": "Add keyboard shortcuts (hotkeys) commonly found in code editors such as Visual Studio Code (vscode) or Sublime Text", 5 | "main": "main.js", 6 | "scripts": { 7 | "start": "esbuild src/main.ts --bundle --external:obsidian --outdir=. --target=es2016 --format=cjs --sourcemap=inline --watch", 8 | "build": "esbuild src/main.ts --bundle --external:obsidian --outdir=. --target=es2016 --format=cjs", 9 | "lint": "eslint src --max-warnings=0", 10 | "test": "jest", 11 | "prepare": "husky install" 12 | }, 13 | "keywords": [], 14 | "author": "Tim Hor", 15 | "license": "MIT", 16 | "devDependencies": { 17 | "@babel/preset-env": "^7.16.7", 18 | "@babel/preset-typescript": "^7.16.7", 19 | "@codemirror/state": "^6.1.2", 20 | "@codemirror/view": "^6.4.0", 21 | "@types/jest": "^27.4.0", 22 | "@types/node": "^16.11.1", 23 | "@typescript-eslint/eslint-plugin": "^5.9.0", 24 | "@typescript-eslint/parser": "^5.9.0", 25 | "babel-jest": "^27.4.5", 26 | "codemirror": "^5.65.0", 27 | "esbuild": "0.13.8", 28 | "eslint": "^8.6.0", 29 | "husky": "^7.0.2", 30 | "jest": "^27.4.5", 31 | "lint-staged": "^11.1.2", 32 | "obsidian": "^0.12.17", 33 | "prettier": "^2.5.0", 34 | "tslib": "2.3.1", 35 | "typescript": "4.4.4" 36 | }, 37 | "lint-staged": { 38 | "**/*.{ts,tsx,html,css,md,json}": [ 39 | "prettier --write" 40 | ], 41 | "**/*.{ts,tsx,html,css}": [ 42 | "eslint --fix --max-warnings=0" 43 | ] 44 | }, 45 | "packageManager": "yarn@3.2.0" 46 | } 47 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | import { PluginSettingTab, App, Setting, ToggleComponent } from 'obsidian'; 2 | import CodeEditorShortcuts from './main'; 3 | 4 | export interface PluginSettings { 5 | autoInsertListPrefix: boolean; 6 | } 7 | 8 | export const DEFAULT_SETTINGS: PluginSettings = { 9 | autoInsertListPrefix: true, 10 | }; 11 | 12 | export class SettingTab extends PluginSettingTab { 13 | plugin: CodeEditorShortcuts; 14 | 15 | constructor(app: App, plugin: CodeEditorShortcuts) { 16 | super(app, plugin); 17 | this.plugin = plugin; 18 | } 19 | 20 | display() { 21 | const { containerEl } = this; 22 | 23 | containerEl.empty(); 24 | 25 | containerEl.createEl('h2', { text: 'Code Editor Shortcuts' }); 26 | 27 | const listPrefixSetting = new Setting(containerEl) 28 | .setName('Auto insert list prefix') 29 | .setDesc( 30 | 'Automatically insert list prefix when inserting a line above or below', 31 | ) 32 | .addToggle((toggle) => 33 | toggle 34 | .setValue(this.plugin.settings.autoInsertListPrefix) 35 | .onChange(async (value) => { 36 | this.plugin.settings.autoInsertListPrefix = value; 37 | await this.plugin.saveSettings(); 38 | }), 39 | ); 40 | 41 | new Setting(containerEl).setName('Reset defaults').addButton((btn) => { 42 | btn.setButtonText('Reset').onClick(async () => { 43 | this.plugin.settings = { ...DEFAULT_SETTINGS }; 44 | (listPrefixSetting.components[0] as ToggleComponent).setValue( 45 | DEFAULT_SETTINGS.autoInsertListPrefix, 46 | ); 47 | await this.plugin.saveSettings(); 48 | }); 49 | }); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/__tests__/actions-range-new.spec.ts: -------------------------------------------------------------------------------- 1 | import { EditorView } from '@codemirror/view'; 2 | import { 3 | defineLegacyEditorMethods, 4 | EditorViewWithLegacyMethods, 5 | getDocumentAndSelection, 6 | } from './test-helpers'; 7 | import { insertLineAbove, insertLineBelow, deleteLine } from '../actions'; 8 | import { withMultipleSelectionsNew } from '../utils'; 9 | import { SettingsState } from '../state'; 10 | 11 | describe('Code Editor Shortcuts: actions - single range selection', () => { 12 | let view: EditorViewWithLegacyMethods; 13 | 14 | const originalDoc = 'lorem ipsum\ndolor sit\namet'; 15 | 16 | beforeAll(() => { 17 | view = new EditorView({ 18 | parent: document.body, 19 | }); 20 | 21 | defineLegacyEditorMethods(view); 22 | }); 23 | 24 | beforeEach(() => { 25 | SettingsState.autoInsertListPrefix = true; 26 | view.setValue(originalDoc); 27 | view.setSelection({ line: 0, ch: 6 }, { line: 1, ch: 5 }); 28 | }); 29 | 30 | describe('insertLineAbove', () => { 31 | it('should insert line above', () => { 32 | withMultipleSelectionsNew(view as any, insertLineAbove); 33 | 34 | const { doc, cursor } = getDocumentAndSelection(view as any); 35 | expect(doc).toEqual('lorem ipsum\n\ndolor sit\namet'); 36 | expect(cursor.line).toEqual(1); 37 | }); 38 | }); 39 | 40 | describe('insertLineBelow', () => { 41 | it('should insert line below', () => { 42 | withMultipleSelectionsNew(view as any, insertLineBelow); 43 | 44 | const { doc, cursor } = getDocumentAndSelection(view as any); 45 | expect(doc).toEqual('lorem ipsum\ndolor sit\n\namet'); 46 | expect(cursor.line).toEqual(2); 47 | }); 48 | 49 | it('should insert prefix when inside a list', () => { 50 | view.setValue('- aaa\n- bbb'); 51 | view.setSelection({ line: 0, ch: 2 }, { line: 0, ch: 5 }); 52 | 53 | withMultipleSelectionsNew(view as any, insertLineBelow); 54 | 55 | const { doc, cursor } = getDocumentAndSelection(view as any); 56 | expect(doc).toEqual('- aaa\n- \n- bbb'); 57 | expect(cursor).toEqual( 58 | expect.objectContaining({ 59 | line: 1, 60 | ch: 2, 61 | }), 62 | ); 63 | }); 64 | }); 65 | 66 | describe('deleteLine', () => { 67 | it('should delete selected lines', () => { 68 | withMultipleSelectionsNew(view as any, deleteLine); 69 | 70 | const { doc, cursor } = getDocumentAndSelection(view as any); 71 | expect(doc).toEqual('amet'); 72 | expect(cursor.line).toEqual(0); 73 | }); 74 | 75 | it('should delete all lines if entire document is selected', () => { 76 | view.setSelection({ line: 0, ch: 0 }, { line: 2, ch: 4 }); 77 | 78 | withMultipleSelectionsNew(view as any, deleteLine); 79 | 80 | const { doc, cursor } = getDocumentAndSelection(view as any); 81 | expect(doc).toEqual(''); 82 | expect(cursor.line).toEqual(0); 83 | }); 84 | 85 | it('should exclude line starting at trailing newline from being deleted', () => { 86 | view.setSelection({ line: 0, ch: 0 }, { line: 1, ch: 0 }); 87 | 88 | withMultipleSelectionsNew(view as any, deleteLine); 89 | 90 | const { doc, cursor } = getDocumentAndSelection(view as any); 91 | expect(doc).toEqual('dolor sit\namet'); 92 | expect(cursor.line).toEqual(0); 93 | }); 94 | 95 | it('should exclude line starting at trailing newline at end of document from being deleted', () => { 96 | view.setSelection({ line: 0, ch: 0 }, { line: 2, ch: 0 }); 97 | 98 | withMultipleSelectionsNew(view as any, deleteLine); 99 | 100 | const { doc, cursor } = getDocumentAndSelection(view as any); 101 | expect(doc).toEqual('amet'); 102 | expect(cursor.line).toEqual(0); 103 | }); 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /src/__tests__/test-helpers.ts: -------------------------------------------------------------------------------- 1 | import { EditorState, EditorSelection, Text } from '@codemirror/state'; 2 | import type { EditorView } from '@codemirror/view'; 3 | import type { Editor } from 'codemirror'; 4 | import type { 5 | EditorPosition, 6 | EditorSelection as ObsidianEditorSelection, 7 | EditorTransaction, 8 | } from 'obsidian'; 9 | 10 | export const getDocumentAndSelection = (editor: Editor) => { 11 | return { 12 | doc: editor.getValue(), 13 | cursor: editor.getCursor(), 14 | selectedText: editor.getSelection(), 15 | selectedTextMultiple: editor.getSelections(), 16 | selections: editor.listSelections(), 17 | }; 18 | }; 19 | 20 | export interface EditorViewWithLegacyMethods extends EditorView { 21 | getValue?: () => string; 22 | setValue?: (value: string) => void; 23 | getLine?: (n: number) => string; 24 | lineCount?: () => number; 25 | lastLine?: () => number; 26 | getCursor?: () => EditorPosition; 27 | setCursor?: (pos: EditorPosition) => void; 28 | getSelection?: () => string; 29 | setSelection?: (anchor: EditorPosition, head: EditorPosition) => void; 30 | getSelections?: () => string[]; 31 | setSelections?: (selectionRanges: ObsidianEditorSelection[]) => void; 32 | listSelections?: () => ObsidianEditorSelection[]; 33 | transaction?: (tx: EditorTransaction) => void; 34 | textAtSelection?: (length?: number) => string; 35 | } 36 | 37 | export const posToOffset = (doc: Text, pos: EditorPosition) => { 38 | if (!pos) { 39 | return null; 40 | } 41 | return doc.line(pos.line + 1).from + pos.ch; 42 | }; 43 | 44 | export const offsetToPos = (doc: Text, offset: number) => { 45 | const line = doc.lineAt(offset); 46 | return { line: line.number - 1, ch: offset - line.from }; 47 | }; 48 | 49 | /** 50 | * Defines legacy methods on an EditorView instance 51 | * 52 | * @see https://codemirror.net/docs/migration/ 53 | */ 54 | export const defineLegacyEditorMethods = ( 55 | view: EditorViewWithLegacyMethods, 56 | ) => { 57 | view.getValue = () => view.state.doc.toString(); 58 | 59 | view.setValue = (value) => { 60 | view.setState(EditorState.create({ doc: value })); 61 | }; 62 | 63 | view.getLine = (n: number) => view.state.doc.line(n + 1).text; 64 | 65 | view.lineCount = () => view.state.doc.lines; 66 | 67 | view.lastLine = () => view.lineCount() - 1; 68 | 69 | view.getCursor = () => 70 | offsetToPos(view.state.doc, view.state.selection.main.head); 71 | 72 | view.setCursor = (pos) => { 73 | view.dispatch({ 74 | selection: { anchor: posToOffset(view.state.doc, pos) }, 75 | }); 76 | }; 77 | 78 | view.getSelection = () => 79 | view.state.sliceDoc( 80 | view.state.selection.main.from, 81 | view.state.selection.main.to, 82 | ); 83 | 84 | view.setSelection = (anchor, head) => { 85 | const selection = EditorSelection.range( 86 | posToOffset(view.state.doc, anchor), 87 | posToOffset(view.state.doc, head), 88 | ); 89 | view.dispatch({ selection: EditorSelection.create([selection]) }); 90 | }; 91 | 92 | view.getSelections = () => 93 | view.state.selection.ranges.map((r) => view.state.sliceDoc(r.from, r.to)); 94 | 95 | view.setSelections = (selectionRanges) => { 96 | const selections = selectionRanges.map((range) => 97 | EditorSelection.range( 98 | posToOffset(view.state.doc, range.anchor), 99 | posToOffset(view.state.doc, range.head), 100 | ), 101 | ); 102 | view.dispatch({ 103 | selection: EditorSelection.create(selections), 104 | }); 105 | }; 106 | 107 | view.listSelections = () => 108 | view.state.selection.ranges.map((range) => ({ 109 | anchor: offsetToPos(view.state.doc, range.anchor), 110 | head: offsetToPos(view.state.doc, range.head ? range.head : range.anchor), 111 | })); 112 | 113 | view.transaction = (tx) => { 114 | if (tx.changes) { 115 | const changes = tx.changes.map((change) => ({ 116 | from: posToOffset(view.state.doc, change.from), 117 | // Spread to only assign 'to' if it exists: https://stackoverflow.com/a/60492828 118 | ...(change.to && { to: posToOffset(view.state.doc, change.to) }), 119 | insert: change.text, 120 | })); 121 | view.dispatch({ 122 | changes, 123 | }); 124 | } 125 | 126 | // Dispatch selections separately as they depend on the updated document 127 | if (tx.selections) { 128 | const selections = tx.selections.map((selection) => { 129 | const fromOffset = posToOffset(view.state.doc, selection.from); 130 | const toOffset = posToOffset( 131 | view.state.doc, 132 | selection.to ? selection.to : selection.from, 133 | ); 134 | return EditorSelection.range(fromOffset, toOffset); 135 | }); 136 | view.dispatch({ 137 | selection: EditorSelection.create(selections), 138 | }); 139 | } 140 | }; 141 | 142 | // For debugging tests 143 | view.textAtSelection = (length = 10) => { 144 | const from = view.state.selection.main.from; 145 | return view.state.doc.slice(from, from + length).toString(); 146 | }; 147 | }; 148 | -------------------------------------------------------------------------------- /src/__tests__/actions-multi-new.spec.ts: -------------------------------------------------------------------------------- 1 | import { EditorView } from '@codemirror/view'; 2 | import { 3 | defineLegacyEditorMethods, 4 | EditorViewWithLegacyMethods, 5 | getDocumentAndSelection, 6 | } from './test-helpers'; 7 | import { insertLineAbove, insertLineBelow, deleteLine } from '../actions'; 8 | import { withMultipleSelectionsNew } from '../utils'; 9 | 10 | describe('Code Editor Shortcuts: actions - multiple mixed selections', () => { 11 | let view: EditorViewWithLegacyMethods; 12 | 13 | const originalDoc = 14 | `lorem ipsum\ndolor sit\namet\n\n` + 15 | `consectetur "adipiscing" 'elit'\n(donec [mattis])\ntincidunt metus`; 16 | const originalSelectionRanges = [ 17 | { anchor: { line: 1, ch: 5 }, head: { line: 0, ch: 6 } }, // {<}ipsum\ndolor{>} 18 | { anchor: { line: 2, ch: 2 }, head: { line: 2, ch: 2 } }, // am{<>}et 19 | { anchor: { line: 4, ch: 14 }, head: { line: 4, ch: 17 } }, // a{<}dip{>}iscing 20 | { anchor: { line: 4, ch: 26 }, head: { line: 4, ch: 26 } }, // '{<>}elit 21 | ]; 22 | 23 | beforeAll(() => { 24 | view = new EditorView({ 25 | parent: document.body, 26 | }); 27 | defineLegacyEditorMethods(view); 28 | }); 29 | 30 | beforeEach(() => { 31 | view.setValue(originalDoc); 32 | view.setSelections(originalSelectionRanges); 33 | }); 34 | 35 | describe('insertLineAbove', () => { 36 | it('should insert line above', () => { 37 | withMultipleSelectionsNew(view as any, insertLineAbove); 38 | 39 | const { doc, selections } = getDocumentAndSelection(view as any); 40 | expect(doc).toEqual( 41 | `\nlorem ipsum\ndolor sit\n\namet\n\n\n\n` + 42 | `consectetur "adipiscing" 'elit'\n(donec [mattis])\ntincidunt metus`, 43 | ); 44 | expect(selections).toEqual([ 45 | { 46 | anchor: expect.objectContaining({ line: 0, ch: 0 }), 47 | head: expect.objectContaining({ line: 0, ch: 0 }), 48 | }, 49 | { 50 | anchor: expect.objectContaining({ line: 3, ch: 0 }), 51 | head: expect.objectContaining({ line: 3, ch: 0 }), 52 | }, 53 | { 54 | anchor: expect.objectContaining({ line: 6, ch: 0 }), 55 | head: expect.objectContaining({ line: 6, ch: 0 }), 56 | }, 57 | { 58 | anchor: expect.objectContaining({ line: 7, ch: 0 }), 59 | head: expect.objectContaining({ line: 7, ch: 0 }), 60 | }, 61 | ]); 62 | }); 63 | }); 64 | 65 | describe('insertLineBelow', () => { 66 | it('should insert lines below', () => { 67 | withMultipleSelectionsNew(view as any, insertLineBelow); 68 | 69 | const { doc, selections } = getDocumentAndSelection(view as any); 70 | expect(doc).toEqual( 71 | `lorem ipsum\n\ndolor sit\namet\n\n\n` + 72 | `consectetur "adipiscing" 'elit'\n\n\n(donec [mattis])\ntincidunt metus`, 73 | ); 74 | expect(selections).toEqual([ 75 | { 76 | anchor: expect.objectContaining({ line: 1, ch: 0 }), 77 | head: expect.objectContaining({ line: 1, ch: 0 }), 78 | }, 79 | { 80 | anchor: expect.objectContaining({ line: 4, ch: 0 }), 81 | head: expect.objectContaining({ line: 4, ch: 0 }), 82 | }, 83 | { 84 | anchor: expect.objectContaining({ line: 7, ch: 0 }), 85 | head: expect.objectContaining({ line: 7, ch: 0 }), 86 | }, 87 | { 88 | anchor: expect.objectContaining({ line: 8, ch: 0 }), 89 | head: expect.objectContaining({ line: 8, ch: 0 }), 90 | }, 91 | ]); 92 | }); 93 | 94 | it('should insert prefixes when inside a list', () => { 95 | view.setValue('- aaa\n - bbb'); 96 | view.setSelections([ 97 | { anchor: { line: 0, ch: 2 }, head: { line: 0, ch: 2 } }, 98 | { anchor: { line: 1, ch: 6 }, head: { line: 1, ch: 6 } }, 99 | ]); 100 | 101 | withMultipleSelectionsNew(view as any, insertLineBelow); 102 | 103 | const { doc, selections } = getDocumentAndSelection(view as any); 104 | expect(doc).toEqual('- aaa\n- \n - bbb\n - '); 105 | expect(selections).toEqual([ 106 | { 107 | anchor: expect.objectContaining({ line: 1, ch: 2 }), 108 | head: expect.objectContaining({ line: 1, ch: 2 }), 109 | }, 110 | { 111 | anchor: expect.objectContaining({ line: 3, ch: 4 }), 112 | head: expect.objectContaining({ line: 3, ch: 4 }), 113 | }, 114 | ]); 115 | }); 116 | }); 117 | 118 | describe('deleteLine', () => { 119 | it('should delete selected lines', () => { 120 | withMultipleSelectionsNew(view as any, deleteLine, { 121 | combineSameLineSelections: true, 122 | }); 123 | 124 | const { doc, selections } = getDocumentAndSelection(view as any); 125 | expect(doc).toEqual(`\n(donec [mattis])\ntincidunt metus`); 126 | expect(selections).toEqual([ 127 | { 128 | anchor: expect.objectContaining({ line: 0, ch: 0 }), 129 | head: expect.objectContaining({ line: 0, ch: 0 }), 130 | }, 131 | { 132 | anchor: expect.objectContaining({ line: 1, ch: 16 }), 133 | head: expect.objectContaining({ line: 1, ch: 16 }), 134 | }, 135 | ]); 136 | }); 137 | }); 138 | }); 139 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Code Editor Shortcuts 2 | 3 | > [!NOTE] 4 | > Due to personal circumstances, active development is **paused** on this project until approximately mid-2024. Please continue to use the existing features, submit issues and contribute pull requests, but expect a delayed response. 5 | > 6 | > Feature requests won't be individually acknowledged but will still be tracked on the [project board](https://github.com/timhor/obsidian-editor-shortcuts/projects/1) as before. 7 | 8 | This [Obsidian](https://obsidian.md) plugin adds keyboard shortcuts (hotkeys) commonly found in code editors such as Visual Studio Code or Sublime Text. 9 | 10 | | Command | Shortcut \* | 11 | | ------------------------------------------------ | -------------------------- | 12 | | Insert line below | `Ctrl` + `Enter` | 13 | | Insert line above | `Ctrl` + `Shift` + `Enter` | 14 | | Delete line | `Ctrl` + `Shift` + `K` | 15 | | Duplicate line | `Ctrl` + `Shift` + `D` | 16 | | Copy line up | `Alt` + `Shift` + `Up` | 17 | | Copy line down | `Alt` + `Shift` + `Down` | 18 | | Join line below to current line | `Ctrl` + `J` | 19 | | Select line (repeat to keep expanding selection) | `Ctrl` + `L` | 20 | | Add cursors to selection ends | `Alt` + `Shift` + `I` | 21 | | Select word or next occurrence of selection | `Ctrl` + `D` | 22 | | Select all occurrences of selection | `Ctrl` + `Shift` + `L` | 23 | | Move cursor up | Not set | 24 | | Move cursor down | Not set | 25 | | Move cursor left | Not set | 26 | | Move cursor right | Not set | 27 | | Go to previous word | Not set | 28 | | Go to next word | Not set | 29 | | Go to start of line | Not set | 30 | | Go to end of line | Not set | 31 | | Go to previous line | Not set | 32 | | Go to next line | Not set | 33 | | Go to first line | Not set | 34 | | Go to last line | Not set | 35 | | Go to line number | Not set | 36 | | Delete to start of line | Not set | 37 | | Delete to end of line | Not set | 38 | | Transform selection to uppercase | Not set | 39 | | Transform selection to lowercase | Not set | 40 | | Transform selection to title case | Not set | 41 | | Toggle case of selection | Not set | 42 | | Expand selection to brackets | Not set | 43 | | Expand selection to quotes | Not set | 44 | | Expand selection to quotes or brackets | Not set | 45 | | Insert cursor above | Not set | 46 | | Insert cursor below | Not set | 47 | | Go to next heading | Not set | 48 | | Go to previous heading | Not set | 49 | | Toggle line numbers | Not set | 50 | | Indent using tabs | Not set | 51 | | Indent using spaces | Not set | 52 | | Undo | Not set | 53 | | Redo | Not set | 54 | 55 | \* On macOS, replace `Ctrl` with `Cmd` and `Alt` with `Opt` 56 | 57 | ### Important notes 58 | 59 | - `Ctrl` + `Enter` for 'Insert line below' may conflict with the default shortcut for _Open link under cursor in new tab_; changing/removing one of the bindings is recommended. 60 | - `Ctrl` + `L` for 'Select line' may conflict with the default shortcut for _Toggle checkbox status_; changing/removing one of the bindings is recommended. 61 | - `Ctrl` + `D` for 'Select word or next occurrence of selection' will behave differently depending on how the initial selection was made. If it was also done via `Ctrl` + `D`, the command will only look for the entire word in subsequent matches. However, if the selection was done by hand, it will search within words as well. 62 | - 'Toggle case of selection' will cycle between uppercase, lowercase and title case. 63 | - If you are looking for the `Alt` + `Up` and `Alt` + `Down` shortcuts from VS Code, you can assign those hotkeys to Obsidian's built in actions "Move line up" and "Move line down". 64 | 65 | ### Multiple Cursors 66 | 67 | Most\* of these shortcuts work with [multiple cursors](https://help.obsidian.md/Editing+and+formatting/Multiple+cursors). However, undo and redo will not work intuitively in Live Preview – actions will be handled individually for each cursor, rather than grouped together. Work is underway to incrementally migrate them to the newer Obsidian editor API so they are grouped, and has been completed for the following: 68 | 69 | - Insert line above 70 | - Insert line below 71 | - Delete line 72 | 73 | As a workaround, you can also switch back to the legacy editor in Settings as all actions will be grouped in that case. 74 | 75 | \* These shortcuts currently do not support multiple cursors: 76 | 77 | - Expand selection to quotes or brackets 78 | - Go to next/previous heading 79 | 80 | ## Installing the plugin 81 | 82 | Refer to the official installation instructions for third-party plugins [here](https://help.obsidian.md/Extending+Obsidian/Community+plugins) and search for the plugin `Code Editor Shortcuts`. 83 | 84 | ## Configuring settings 85 | 86 | Go to Settings → Hotkeys to customise the keyboard shortcut for each action. 87 | 88 | The following behaviour can be configured via the settings tab for this plugin: 89 | 90 | | Setting | Description | 91 | | ----------------------- | --------------------------------------------------------------------- | 92 | | Auto insert list prefix | Automatically insert list prefix when inserting a line above or below | 93 | 94 | ## Contributing 95 | 96 | Contributions and suggestions are welcome – feel free to open an issue or raise a pull request. 97 | 98 | To get started: 99 | 100 | - Switch to the specified Node version: `nvm use` 101 | - Install dependencies: `yarn install` 102 | - Run the extension: `yarn start` 103 | - Run tests: `yarn test` (use `--watch` for watch mode) 104 | 105 | ## Support 106 | 107 | This plugin is completely free to use, but if you'd like to say thanks, consider buying me a coffee! 😄 108 | 109 | [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/timhor) 110 | -------------------------------------------------------------------------------- /src/__tests__/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import CodeMirror from 'codemirror'; 2 | import type { Editor } from 'codemirror'; 3 | import { 4 | withMultipleSelections, 5 | getLineStartPos, 6 | getLineEndPos, 7 | getSelectionBoundaries, 8 | getLeadingWhitespace, 9 | wordRangeAtPos, 10 | findPosOfNextCharacter, 11 | isNumeric, 12 | getNextListPrefix, 13 | } from '../utils'; 14 | import { SEARCH_DIRECTION } from '../constants'; 15 | 16 | // fixes jsdom type error - https://github.com/jsdom/jsdom/issues/3002#issuecomment-655748833 17 | document.createRange = () => { 18 | const range = new Range(); 19 | 20 | range.getBoundingClientRect = jest.fn(); 21 | 22 | range.getClientRects = jest.fn(() => ({ 23 | item: () => null, 24 | length: 0, 25 | })); 26 | 27 | return range; 28 | }; 29 | 30 | describe('Code Editor Shortcuts: utils', () => { 31 | let editor: Editor; 32 | const originalDoc = 'lorem ipsum'; 33 | 34 | beforeAll(() => { 35 | editor = CodeMirror(document.body); 36 | }); 37 | 38 | beforeEach(() => { 39 | editor.setValue(originalDoc); 40 | editor.setCursor({ line: 0, ch: 0 }); 41 | }); 42 | 43 | describe('withMultipleSelections', () => { 44 | const emptySelection = { 45 | anchor: { line: 0, ch: 0 }, 46 | head: { line: 0, ch: 0 }, 47 | }; 48 | const firstWordSelection = { 49 | anchor: { line: 0, ch: 2 }, 50 | head: { line: 0, ch: 2 }, 51 | }; 52 | const secondWordSelection = { 53 | anchor: { line: 0, ch: 8 }, 54 | head: { line: 0, ch: 11 }, 55 | }; 56 | let mockCallback: jest.Mock; 57 | 58 | beforeEach(() => { 59 | // expect error to be thrown due to cm object not existing in the test editor 60 | jest.spyOn(console, 'error').mockImplementation(() => jest.fn()); 61 | 62 | editor.setSelections([firstWordSelection, secondWordSelection]); 63 | mockCallback = jest.fn().mockReturnValue(emptySelection); 64 | }); 65 | 66 | it('should execute callback for each editor selection', () => { 67 | withMultipleSelections(editor as any, mockCallback); 68 | expect(mockCallback).toHaveBeenCalled(); 69 | }); 70 | 71 | it('should forward any arguments to the callback', () => { 72 | withMultipleSelections(editor as any, mockCallback, { 73 | args: 'foobar', 74 | }); 75 | expect(mockCallback).toHaveBeenCalledWith( 76 | expect.anything(), 77 | expect.anything(), 78 | 'foobar', 79 | ); 80 | }); 81 | 82 | it('should post-process new selections if customSelectionHandler is provided', () => { 83 | const customSelectionHandler = jest.fn().mockReturnValue([ 84 | { 85 | anchor: { line: 0, ch: 3 }, 86 | head: { line: 0, ch: 7 }, 87 | }, 88 | ]); 89 | withMultipleSelections(editor as any, mockCallback, { 90 | customSelectionHandler, 91 | }); 92 | expect(customSelectionHandler).toHaveBeenCalledWith([emptySelection]); 93 | expect(editor.listSelections()).toEqual([ 94 | { 95 | anchor: expect.objectContaining({ line: 0, ch: 3 }), 96 | head: expect.objectContaining({ line: 0, ch: 7 }), 97 | }, 98 | ]); 99 | }); 100 | 101 | it('should filter out subsequent selections on the same line if repeatSameLineActions is false', () => { 102 | withMultipleSelections(editor as any, mockCallback, { 103 | repeatSameLineActions: false, 104 | }); 105 | expect(mockCallback).toHaveBeenCalledWith( 106 | expect.anything(), 107 | firstWordSelection, 108 | undefined, 109 | ); 110 | expect(mockCallback).not.toHaveBeenCalledWith( 111 | expect.anything(), 112 | secondWordSelection, 113 | undefined, 114 | ); 115 | }); 116 | }); 117 | 118 | describe('getLineStartPos', () => { 119 | it('should get line start position', () => { 120 | const pos = getLineStartPos(0); 121 | expect(pos).toEqual({ line: 0, ch: 0 }); 122 | }); 123 | }); 124 | 125 | describe('getLineEndPos', () => { 126 | it('should get line end position', () => { 127 | const pos = getLineEndPos(0, editor as any); 128 | expect(pos).toEqual({ line: 0, ch: 11 }); 129 | }); 130 | }); 131 | 132 | describe('getSelectionBoundaries', () => { 133 | it('should get selection boundaries', () => { 134 | const anchor = { line: 0, ch: 5 }; 135 | const head = { line: 0, ch: 6 }; 136 | const pos = getSelectionBoundaries({ anchor, head }); 137 | expect(pos).toEqual({ 138 | from: anchor, 139 | to: head, 140 | hasTrailingNewline: false, 141 | }); 142 | }); 143 | 144 | it('should swap selection boundaries if user selects upwards', () => { 145 | const anchor = { line: 1, ch: 5 }; 146 | const head = { line: 0, ch: 6 }; 147 | const pos = getSelectionBoundaries({ anchor, head }); 148 | expect(pos).toEqual({ 149 | from: head, 150 | to: anchor, 151 | hasTrailingNewline: false, 152 | }); 153 | }); 154 | 155 | it('should swap selection boundaries if user selects backwards on the same line', () => { 156 | const anchor = { line: 0, ch: 8 }; 157 | const head = { line: 0, ch: 4 }; 158 | const pos = getSelectionBoundaries({ anchor, head }); 159 | expect(pos).toEqual({ 160 | from: head, 161 | to: anchor, 162 | hasTrailingNewline: false, 163 | }); 164 | }); 165 | 166 | it('should determine if selection has a trailing newline', () => { 167 | const anchor = { line: 0, ch: 0 }; 168 | const head = { line: 1, ch: 0 }; 169 | const pos = getSelectionBoundaries({ anchor, head }); 170 | expect(pos).toEqual({ from: anchor, to: head, hasTrailingNewline: true }); 171 | }); 172 | }); 173 | 174 | describe('getLeadingWhitespace', () => { 175 | it('should get leading whitespace', () => { 176 | const whitespace = getLeadingWhitespace(' hello'); 177 | expect(whitespace.length).toEqual(2); 178 | }); 179 | }); 180 | 181 | describe('wordRangeAtPos', () => { 182 | it('should get boundaries of word at given position', () => { 183 | const range = wordRangeAtPos({ line: 0, ch: 8 }, editor.getLine(0)); 184 | expect(range).toEqual({ 185 | anchor: { 186 | line: 0, 187 | ch: 6, 188 | }, 189 | head: { 190 | line: 0, 191 | ch: 11, 192 | }, 193 | }); 194 | }); 195 | }); 196 | 197 | describe('findPosOfNextCharacter', () => { 198 | it('should search forwards', () => { 199 | const pos = findPosOfNextCharacter({ 200 | editor: editor as any, 201 | startPos: { line: 0, ch: 5 }, 202 | checkCharacter: (char: string) => /s/.test(char), 203 | searchDirection: SEARCH_DIRECTION.FORWARD, 204 | }); 205 | expect(pos).toEqual({ 206 | match: 's', 207 | pos: { 208 | line: 0, 209 | ch: 8, 210 | }, 211 | }); 212 | }); 213 | 214 | it('should search backwards', () => { 215 | const pos = findPosOfNextCharacter({ 216 | editor: editor as any, 217 | startPos: { line: 0, ch: 5 }, 218 | checkCharacter: (char: string) => /r/.test(char), 219 | searchDirection: SEARCH_DIRECTION.BACKWARD, 220 | }); 221 | expect(pos).toEqual({ 222 | match: 'r', 223 | pos: { 224 | line: 0, 225 | ch: 3, 226 | }, 227 | }); 228 | }); 229 | 230 | it('should return null if no matches', () => { 231 | const pos = findPosOfNextCharacter({ 232 | editor: editor as any, 233 | startPos: { line: 0, ch: 0 }, 234 | checkCharacter: (char: string) => /x/.test(char), 235 | searchDirection: SEARCH_DIRECTION.FORWARD, 236 | }); 237 | expect(pos).toBeNull(); 238 | }); 239 | }); 240 | 241 | describe('isNumeric', () => { 242 | it('should return true if input string is numeric', () => { 243 | expect(isNumeric('1.')).toBe(true); 244 | }); 245 | 246 | it('should return false if input string is non-numeric', () => { 247 | expect(isNumeric('-')).toBe(false); 248 | }); 249 | 250 | it('should return false if input string is empty', () => { 251 | expect(isNumeric('')).toBe(false); 252 | }); 253 | }); 254 | 255 | describe('getNextListPrefix', () => { 256 | it.each([['- '], ['* '], ['+ '], ['> '], ['- [ ] ']])( 257 | 'should return the same prefix for %s', 258 | (currentPrefix) => { 259 | const prefix = getNextListPrefix( 260 | `${currentPrefix} lorem ipsum`, 261 | 'after', 262 | ); 263 | expect(prefix).toBe(currentPrefix); 264 | }, 265 | ); 266 | 267 | it('should return the next number for a numeric prefix when going forwards', () => { 268 | const prefix = getNextListPrefix('23. lorem ipsum', 'after'); 269 | expect(prefix).toBe('24. '); 270 | }); 271 | 272 | it('should return the same number for a numeric prefix when going backwards', () => { 273 | const prefix = getNextListPrefix('23. lorem ipsum', 'before'); 274 | expect(prefix).toBe('23. '); 275 | }); 276 | 277 | it('should return an unticked checkbox for a checkbox prefix', () => { 278 | const prefix = getNextListPrefix('- [x] lorem ipsum', 'after'); 279 | expect(prefix).toBe('- [ ] '); 280 | }); 281 | 282 | it('should return no prefix for frontmatter fence', () => { 283 | const prefix = getNextListPrefix('---', 'after'); 284 | expect(prefix).toBe(''); 285 | }); 286 | 287 | it('should return no prefix for other non-numeric characters', () => { 288 | const prefix = getNextListPrefix('x', 'after'); 289 | expect(prefix).toBe(''); 290 | }); 291 | 292 | it('should return null if list item is empty', () => { 293 | const prefix = getNextListPrefix('- ', 'after'); 294 | expect(prefix).toBeNull(); 295 | }); 296 | }); 297 | }); 298 | -------------------------------------------------------------------------------- /src/__tests__/actions-cursor-new.spec.ts: -------------------------------------------------------------------------------- 1 | import { EditorView } from '@codemirror/view'; 2 | import { 3 | defineLegacyEditorMethods, 4 | EditorViewWithLegacyMethods, 5 | getDocumentAndSelection, 6 | } from './test-helpers'; 7 | import { insertLineAbove, insertLineBelow, deleteLine } from '../actions'; 8 | import { withMultipleSelectionsNew } from '../utils'; 9 | import { SettingsState } from '../state'; 10 | 11 | describe('Code Editor Shortcuts: actions - single cursor selection', () => { 12 | let view: EditorViewWithLegacyMethods; 13 | 14 | const originalDoc = 'lorem ipsum\ndolor sit\namet'; 15 | 16 | beforeAll(() => { 17 | view = new EditorView({ 18 | parent: document.body, 19 | }); 20 | defineLegacyEditorMethods(view); 21 | }); 22 | 23 | beforeEach(() => { 24 | view.setValue(originalDoc); 25 | view.setCursor({ line: 1, ch: 0 }); 26 | }); 27 | 28 | describe('insertLineAbove', () => { 29 | it('should insert line above', () => { 30 | withMultipleSelectionsNew(view as any, insertLineAbove); 31 | 32 | const { doc, cursor } = getDocumentAndSelection(view as any); 33 | expect(doc).toEqual('lorem ipsum\n\ndolor sit\namet'); 34 | expect(cursor.line).toEqual(1); 35 | }); 36 | 37 | it('should insert line above first line', () => { 38 | view.setCursor({ line: 0, ch: 0 }); 39 | 40 | withMultipleSelectionsNew(view as any, insertLineAbove); 41 | 42 | const { doc, cursor } = getDocumentAndSelection(view as any); 43 | expect(doc).toEqual('\nlorem ipsum\ndolor sit\namet'); 44 | expect(cursor.line).toEqual(0); 45 | }); 46 | 47 | describe('when inside a list', () => { 48 | afterEach(() => { 49 | SettingsState.autoInsertListPrefix = true; 50 | }); 51 | 52 | it('should not insert a prefix when setting is disabled', () => { 53 | SettingsState.autoInsertListPrefix = false; 54 | view.setValue('- aaa\n- bbb'); 55 | view.setCursor({ line: 1, ch: 4 }); 56 | 57 | withMultipleSelectionsNew(view as any, insertLineAbove); 58 | 59 | const { doc, cursor } = getDocumentAndSelection(view as any); 60 | expect(doc).toEqual('- aaa\n\n- bbb'); 61 | expect(cursor).toEqual( 62 | expect.objectContaining({ 63 | line: 1, 64 | ch: 0, 65 | }), 66 | ); 67 | }); 68 | 69 | it('should not insert a prefix when at the first list item', () => { 70 | view.setValue('- aaa\n- bbb'); 71 | view.setCursor({ line: 0, ch: 4 }); 72 | 73 | withMultipleSelectionsNew(view as any, insertLineAbove); 74 | 75 | const { doc, cursor } = getDocumentAndSelection(view as any); 76 | expect(doc).toEqual('\n- aaa\n- bbb'); 77 | expect(cursor).toEqual( 78 | expect.objectContaining({ 79 | line: 0, 80 | ch: 0, 81 | }), 82 | ); 83 | }); 84 | 85 | it.each([ 86 | ['-', '- aaa\n- bbb', '- aaa\n- \n- bbb'], 87 | ['*', '* aaa\n* bbb', '* aaa\n* \n* bbb'], 88 | ['+', '+ aaa\n+ bbb', '+ aaa\n+ \n+ bbb'], 89 | ['>', '> aaa\n> bbb', '> aaa\n> \n> bbb'], 90 | ])('should insert `%s` prefix', (_scenario, content, expectedDoc) => { 91 | view.setValue(content); 92 | view.setCursor({ line: 1, ch: 4 }); 93 | 94 | withMultipleSelectionsNew(view as any, insertLineAbove); 95 | 96 | const { doc, cursor } = getDocumentAndSelection(view as any); 97 | expect(doc).toEqual(expectedDoc); 98 | expect(cursor).toEqual( 99 | expect.objectContaining({ 100 | line: 1, 101 | ch: 2, 102 | }), 103 | ); 104 | }); 105 | 106 | it.each([ 107 | ['- [ ]', '- [ ] aaa\n- [ ] bbb', '- [ ] aaa\n- [ ] \n- [ ] bbb'], 108 | ['- [x]', '- [x] aaa\n- [x] bbb', '- [x] aaa\n- [ ] \n- [x] bbb'], 109 | ])( 110 | 'should insert empty checkbox for `%s` prefix', 111 | (_scenario, content, expectedDoc) => { 112 | view.setValue(content); 113 | view.setCursor({ line: 1, ch: 7 }); 114 | 115 | withMultipleSelectionsNew(view as any, insertLineAbove); 116 | 117 | const { doc, cursor } = getDocumentAndSelection(view as any); 118 | expect(doc).toEqual(expectedDoc); 119 | expect(cursor).toEqual( 120 | expect.objectContaining({ 121 | line: 1, 122 | ch: 6, 123 | }), 124 | ); 125 | }, 126 | ); 127 | 128 | it('should insert list prefix at the correct indentation', () => { 129 | view.setValue('- aaa\n - bbb'); 130 | view.setCursor({ line: 1, ch: 4 }); 131 | 132 | withMultipleSelectionsNew(view as any, insertLineAbove); 133 | 134 | const { doc, cursor } = getDocumentAndSelection(view as any); 135 | expect(doc).toEqual('- aaa\n - \n - bbb'); 136 | expect(cursor).toEqual( 137 | expect.objectContaining({ 138 | line: 1, 139 | ch: 4, 140 | }), 141 | ); 142 | }); 143 | 144 | it('should insert number prefix and format the remaining number prefixes', () => { 145 | view.setValue('1. aaa\n2. bbb'); 146 | view.setCursor({ line: 1, ch: 4 }); 147 | 148 | withMultipleSelectionsNew(view as any, insertLineAbove); 149 | 150 | const { doc, cursor } = getDocumentAndSelection(view as any); 151 | expect(doc).toEqual('1. aaa\n2. \n3. bbb'); 152 | expect(cursor).toEqual( 153 | expect.objectContaining({ 154 | line: 1, 155 | ch: 3, 156 | }), 157 | ); 158 | }); 159 | }); 160 | }); 161 | 162 | describe('insertLineBelow', () => { 163 | it('should insert line below', () => { 164 | withMultipleSelectionsNew(view as any, insertLineBelow); 165 | 166 | const { doc, cursor } = getDocumentAndSelection(view as any); 167 | expect(doc).toEqual('lorem ipsum\ndolor sit\n\namet'); 168 | expect(cursor.line).toEqual(2); 169 | }); 170 | 171 | it('should insert line below with the same indentation level', () => { 172 | view.setValue(' lorem ipsum\n dolor sit\n amet'); 173 | view.setCursor({ line: 1, ch: 0 }); 174 | 175 | withMultipleSelectionsNew(view as any, insertLineBelow); 176 | 177 | const { doc, cursor } = getDocumentAndSelection(view as any); 178 | expect(doc).toEqual(' lorem ipsum\n dolor sit\n \n amet'); 179 | expect(cursor.line).toEqual(2); 180 | expect(cursor.ch).toEqual(4); 181 | }); 182 | 183 | it('should insert line below last line', () => { 184 | view.setCursor({ line: 2, ch: 0 }); 185 | 186 | withMultipleSelectionsNew(view as any, insertLineBelow); 187 | 188 | const { doc, cursor } = getDocumentAndSelection(view as any); 189 | expect(doc).toEqual('lorem ipsum\ndolor sit\namet\n'); 190 | expect(cursor.line).toEqual(3); 191 | }); 192 | 193 | describe('when inside a list', () => { 194 | afterEach(() => { 195 | SettingsState.autoInsertListPrefix = true; 196 | }); 197 | 198 | it('should not insert a prefix when setting is disabled', () => { 199 | SettingsState.autoInsertListPrefix = false; 200 | view.setValue('- aaa\n- bbb'); 201 | view.setCursor({ line: 0, ch: 4 }); 202 | 203 | withMultipleSelectionsNew(view as any, insertLineBelow); 204 | 205 | const { doc, cursor } = getDocumentAndSelection(view as any); 206 | expect(doc).toEqual('- aaa\n\n- bbb'); 207 | expect(cursor).toEqual( 208 | expect.objectContaining({ 209 | line: 1, 210 | ch: 0, 211 | }), 212 | ); 213 | }); 214 | 215 | it.each([ 216 | ['-', '- aaa\n- bbb', '- aaa\n- \n- bbb'], 217 | ['*', '* aaa\n* bbb', '* aaa\n* \n* bbb'], 218 | ['+', '+ aaa\n+ bbb', '+ aaa\n+ \n+ bbb'], 219 | ['>', '> aaa\n> bbb', '> aaa\n> \n> bbb'], 220 | ])('should insert `%s` prefix', (_scenario, content, expectedDoc) => { 221 | view.setValue(content); 222 | view.setCursor({ line: 0, ch: 4 }); 223 | 224 | withMultipleSelectionsNew(view as any, insertLineBelow); 225 | 226 | const { doc, cursor } = getDocumentAndSelection(view as any); 227 | expect(doc).toEqual(expectedDoc); 228 | expect(cursor).toEqual( 229 | expect.objectContaining({ 230 | line: 1, 231 | ch: 2, 232 | }), 233 | ); 234 | }); 235 | 236 | it.each([ 237 | ['- [ ]', '- [ ] aaa\n- [ ] bbb', '- [ ] aaa\n- [ ] \n- [ ] bbb'], 238 | ['- [x]', '- [x] aaa\n- [x] bbb', '- [x] aaa\n- [ ] \n- [x] bbb'], 239 | ])( 240 | 'should insert empty checkbox for `%s` prefix', 241 | (_scenario, content, expectedDoc) => { 242 | view.setValue(content); 243 | view.setCursor({ line: 0, ch: 7 }); 244 | 245 | withMultipleSelectionsNew(view as any, insertLineBelow); 246 | 247 | const { doc, cursor } = getDocumentAndSelection(view as any); 248 | expect(doc).toEqual(expectedDoc); 249 | expect(cursor).toEqual( 250 | expect.objectContaining({ 251 | line: 1, 252 | ch: 6, 253 | }), 254 | ); 255 | }, 256 | ); 257 | 258 | it('should insert list prefix at the correct indentation', () => { 259 | view.setValue('- aaa\n - bbb'); 260 | view.setCursor({ line: 1, ch: 4 }); 261 | 262 | withMultipleSelectionsNew(view as any, insertLineBelow); 263 | 264 | const { doc, cursor } = getDocumentAndSelection(view as any); 265 | expect(doc).toEqual('- aaa\n - bbb\n - '); 266 | expect(cursor).toEqual( 267 | expect.objectContaining({ 268 | line: 2, 269 | ch: 4, 270 | }), 271 | ); 272 | }); 273 | 274 | it('should insert number prefix and format the remaining number prefixes', () => { 275 | view.setValue('1. aaa\n2. bbb'); 276 | view.setCursor({ line: 0, ch: 4 }); 277 | 278 | withMultipleSelectionsNew(view as any, insertLineBelow); 279 | 280 | const { doc, cursor } = getDocumentAndSelection(view as any); 281 | expect(doc).toEqual('1. aaa\n2. \n3. bbb'); 282 | expect(cursor).toEqual( 283 | expect.objectContaining({ 284 | line: 1, 285 | ch: 3, 286 | }), 287 | ); 288 | }); 289 | 290 | it('should delete line contents if list item is empty', () => { 291 | view.setValue('- aaa\n - '); 292 | view.setCursor({ line: 1, ch: 4 }); 293 | 294 | withMultipleSelectionsNew(view as any, insertLineBelow); 295 | 296 | const { doc, cursor } = getDocumentAndSelection(view as any); 297 | expect(doc).toEqual('- aaa\n'); 298 | expect(cursor).toEqual( 299 | expect.objectContaining({ 300 | line: 1, 301 | ch: 0, 302 | }), 303 | ); 304 | }); 305 | }); 306 | }); 307 | 308 | describe('deleteLine', () => { 309 | it('should delete line at cursor', () => { 310 | withMultipleSelectionsNew(view as any, deleteLine); 311 | 312 | const { doc, cursor } = getDocumentAndSelection(view as any); 313 | expect(doc).toEqual('lorem ipsum\namet'); 314 | expect(cursor.line).toEqual(1); 315 | }); 316 | 317 | it('should delete last line', () => { 318 | view.setCursor({ line: 2, ch: 2 }); 319 | 320 | withMultipleSelectionsNew(view as any, deleteLine); 321 | 322 | const { doc, cursor } = getDocumentAndSelection(view as any); 323 | expect(doc).toEqual('lorem ipsum\ndolor sit'); 324 | expect(cursor).toMatchObject({ 325 | line: 1, 326 | ch: 2, 327 | }); 328 | }); 329 | 330 | it('should move cursor to correct position when deleting a line that is longer than the following line', () => { 331 | view.setValue( 332 | 'testing with a line that is longer than the following line\nshorter line', 333 | ); 334 | view.setCursor({ line: 0, ch: 53 }); 335 | 336 | withMultipleSelectionsNew(view as any, deleteLine); 337 | 338 | const { doc, cursor } = getDocumentAndSelection(view as any); 339 | expect(doc).toEqual('shorter line'); 340 | expect(cursor).toEqual( 341 | expect.objectContaining({ 342 | line: 0, 343 | ch: 12, 344 | }), 345 | ); 346 | }); 347 | }); 348 | }); 349 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from 'obsidian'; 2 | import { 3 | addCursorsToSelectionEnds, 4 | copyLine, 5 | deleteLine, 6 | deleteToStartOfLine, 7 | deleteToEndOfLine, 8 | expandSelectionToBrackets, 9 | expandSelectionToQuotes, 10 | expandSelectionToQuotesOrBrackets, 11 | goToHeading, 12 | goToLineBoundary, 13 | insertLineAbove, 14 | insertLineBelow, 15 | joinLines, 16 | moveCursor, 17 | navigateLine, 18 | isProgrammaticSelectionChange, 19 | selectAllOccurrences, 20 | selectLine, 21 | selectWordOrNextOccurrence, 22 | setIsManualSelection, 23 | setIsProgrammaticSelectionChange, 24 | transformCase, 25 | insertCursorAbove, 26 | insertCursorBelow, 27 | moveWord, 28 | } from './actions'; 29 | import { 30 | defaultMultipleSelectionOptions, 31 | iterateCodeMirrorDivs, 32 | setVaultConfig, 33 | toggleVaultConfig, 34 | withMultipleSelections, 35 | withMultipleSelectionsNew, 36 | } from './utils'; 37 | import { CASE, MODIFIER_KEYS } from './constants'; 38 | import { SettingTab, DEFAULT_SETTINGS, PluginSettings } from './settings'; 39 | import { SettingsState } from './state'; 40 | import { GoToLineModal } from './modals'; 41 | 42 | export default class CodeEditorShortcuts extends Plugin { 43 | settings: PluginSettings; 44 | 45 | async onload() { 46 | await this.loadSettings(); 47 | 48 | this.addCommand({ 49 | id: 'insertLineAbove', 50 | name: 'Insert line above', 51 | hotkeys: [ 52 | { 53 | modifiers: ['Mod', 'Shift'], 54 | key: 'Enter', 55 | }, 56 | ], 57 | editorCallback: (editor) => 58 | withMultipleSelectionsNew(editor, insertLineAbove), 59 | }); 60 | 61 | this.addCommand({ 62 | id: 'insertLineBelow', 63 | name: 'Insert line below', 64 | hotkeys: [ 65 | { 66 | modifiers: ['Mod'], 67 | key: 'Enter', 68 | }, 69 | ], 70 | editorCallback: (editor) => 71 | withMultipleSelectionsNew(editor, insertLineBelow), 72 | }); 73 | 74 | this.addCommand({ 75 | id: 'deleteLine', 76 | name: 'Delete line', 77 | hotkeys: [ 78 | { 79 | modifiers: ['Mod', 'Shift'], 80 | key: 'K', 81 | }, 82 | ], 83 | editorCallback: (editor) => 84 | withMultipleSelectionsNew(editor, deleteLine, { 85 | ...defaultMultipleSelectionOptions, 86 | combineSameLineSelections: true, 87 | }), 88 | }); 89 | 90 | this.addCommand({ 91 | id: 'deleteToStartOfLine', 92 | name: 'Delete to start of line', 93 | editorCallback: (editor) => 94 | withMultipleSelections(editor, deleteToStartOfLine), 95 | }); 96 | 97 | this.addCommand({ 98 | id: 'deleteToEndOfLine', 99 | name: 'Delete to end of line', 100 | editorCallback: (editor) => 101 | withMultipleSelections(editor, deleteToEndOfLine), 102 | }); 103 | 104 | this.addCommand({ 105 | id: 'joinLines', 106 | name: 'Join lines', 107 | hotkeys: [ 108 | { 109 | modifiers: ['Mod'], 110 | key: 'J', 111 | }, 112 | ], 113 | editorCallback: (editor) => 114 | withMultipleSelections(editor, joinLines, { 115 | ...defaultMultipleSelectionOptions, 116 | repeatSameLineActions: false, 117 | }), 118 | }); 119 | 120 | this.addCommand({ 121 | id: 'duplicateLine', 122 | name: 'Duplicate line', 123 | hotkeys: [ 124 | { 125 | modifiers: ['Mod', 'Shift'], 126 | key: 'D', 127 | }, 128 | ], 129 | editorCallback: (editor) => 130 | withMultipleSelections(editor, copyLine, { 131 | ...defaultMultipleSelectionOptions, 132 | args: 'down', 133 | }), 134 | }); 135 | 136 | this.addCommand({ 137 | id: 'copyLineUp', 138 | name: 'Copy line up', 139 | hotkeys: [ 140 | { 141 | modifiers: ['Alt', 'Shift'], 142 | key: 'ArrowUp', 143 | }, 144 | ], 145 | editorCallback: (editor) => 146 | withMultipleSelections(editor, copyLine, { 147 | ...defaultMultipleSelectionOptions, 148 | args: 'up', 149 | }), 150 | }); 151 | 152 | this.addCommand({ 153 | id: 'copyLineDown', 154 | name: 'Copy line down', 155 | hotkeys: [ 156 | { 157 | modifiers: ['Alt', 'Shift'], 158 | key: 'ArrowDown', 159 | }, 160 | ], 161 | editorCallback: (editor) => 162 | withMultipleSelections(editor, copyLine, { 163 | ...defaultMultipleSelectionOptions, 164 | args: 'down', 165 | }), 166 | }); 167 | 168 | this.addCommand({ 169 | id: 'selectWordOrNextOccurrence', 170 | name: 'Select word or next occurrence', 171 | hotkeys: [ 172 | { 173 | modifiers: ['Mod'], 174 | key: 'D', 175 | }, 176 | ], 177 | editorCallback: (editor) => selectWordOrNextOccurrence(editor), 178 | }); 179 | 180 | this.addCommand({ 181 | id: 'selectAllOccurrences', 182 | name: 'Select all occurrences', 183 | hotkeys: [ 184 | { 185 | modifiers: ['Mod', 'Shift'], 186 | key: 'L', 187 | }, 188 | ], 189 | editorCallback: (editor) => selectAllOccurrences(editor), 190 | }); 191 | 192 | this.addCommand({ 193 | id: 'selectLine', 194 | name: 'Select line', 195 | hotkeys: [ 196 | { 197 | modifiers: ['Mod'], 198 | key: 'L', 199 | }, 200 | ], 201 | editorCallback: (editor) => withMultipleSelections(editor, selectLine), 202 | }); 203 | 204 | this.addCommand({ 205 | id: 'addCursorsToSelectionEnds', 206 | name: 'Add cursors to selection ends', 207 | hotkeys: [ 208 | { 209 | modifiers: ['Alt', 'Shift'], 210 | key: 'I', 211 | }, 212 | ], 213 | editorCallback: (editor) => addCursorsToSelectionEnds(editor), 214 | }); 215 | 216 | this.addCommand({ 217 | id: 'goToLineStart', 218 | name: 'Go to start of line', 219 | editorCallback: (editor) => 220 | withMultipleSelections(editor, goToLineBoundary, { 221 | ...defaultMultipleSelectionOptions, 222 | args: 'start', 223 | }), 224 | }); 225 | 226 | this.addCommand({ 227 | id: 'goToLineEnd', 228 | name: 'Go to end of line', 229 | editorCallback: (editor) => 230 | withMultipleSelections(editor, goToLineBoundary, { 231 | ...defaultMultipleSelectionOptions, 232 | args: 'end', 233 | }), 234 | }); 235 | 236 | this.addCommand({ 237 | id: 'goToNextLine', 238 | name: 'Go to next line', 239 | editorCallback: (editor) => 240 | withMultipleSelections(editor, navigateLine, { 241 | ...defaultMultipleSelectionOptions, 242 | args: 'next', 243 | }), 244 | }); 245 | 246 | this.addCommand({ 247 | id: 'goToPrevLine', 248 | name: 'Go to previous line', 249 | editorCallback: (editor) => 250 | withMultipleSelections(editor, navigateLine, { 251 | ...defaultMultipleSelectionOptions, 252 | args: 'prev', 253 | }), 254 | }); 255 | 256 | this.addCommand({ 257 | id: 'goToFirstLine', 258 | name: 'Go to first line', 259 | editorCallback: (editor) => 260 | withMultipleSelections(editor, navigateLine, { 261 | ...defaultMultipleSelectionOptions, 262 | args: 'first', 263 | }), 264 | }); 265 | 266 | this.addCommand({ 267 | id: 'goToLastLine', 268 | name: 'Go to last line', 269 | editorCallback: (editor) => 270 | withMultipleSelections(editor, navigateLine, { 271 | ...defaultMultipleSelectionOptions, 272 | args: 'last', 273 | }), 274 | }); 275 | 276 | this.addCommand({ 277 | id: 'goToLineNumber', 278 | name: 'Go to line number', 279 | editorCallback: (editor) => { 280 | const lineCount = editor.lineCount(); 281 | const onSubmit = (line: number) => editor.setCursor({ line, ch: 0 }); 282 | new GoToLineModal(this.app, lineCount, onSubmit).open(); 283 | }, 284 | }); 285 | 286 | this.addCommand({ 287 | id: 'goToNextChar', 288 | name: 'Move cursor forward', 289 | editorCallback: (editor) => moveCursor(editor, 'right'), 290 | }); 291 | 292 | this.addCommand({ 293 | id: 'goToPrevChar', 294 | name: 'Move cursor backward', 295 | editorCallback: (editor) => moveCursor(editor, 'left'), 296 | }); 297 | 298 | this.addCommand({ 299 | id: 'moveCursorUp', 300 | name: 'Move cursor up', 301 | editorCallback: (editor) => moveCursor(editor, 'up'), 302 | }); 303 | 304 | this.addCommand({ 305 | id: 'moveCursorDown', 306 | name: 'Move cursor down', 307 | editorCallback: (editor) => moveCursor(editor, 'down'), 308 | }); 309 | 310 | this.addCommand({ 311 | id: 'goToPreviousWord', 312 | name: 'Go to previous word', 313 | editorCallback: (editor) => moveWord(editor, 'left'), 314 | }); 315 | 316 | this.addCommand({ 317 | id: 'goToNextWord', 318 | name: 'Go to next word', 319 | editorCallback: (editor) => moveWord(editor, 'right'), 320 | }); 321 | 322 | this.addCommand({ 323 | id: 'transformToUppercase', 324 | name: 'Transform selection to uppercase', 325 | editorCallback: (editor) => 326 | withMultipleSelections(editor, transformCase, { 327 | ...defaultMultipleSelectionOptions, 328 | args: CASE.UPPER, 329 | }), 330 | }); 331 | 332 | this.addCommand({ 333 | id: 'transformToLowercase', 334 | name: 'Transform selection to lowercase', 335 | editorCallback: (editor) => 336 | withMultipleSelections(editor, transformCase, { 337 | ...defaultMultipleSelectionOptions, 338 | args: CASE.LOWER, 339 | }), 340 | }); 341 | 342 | this.addCommand({ 343 | id: 'transformToTitlecase', 344 | name: 'Transform selection to title case', 345 | editorCallback: (editor) => 346 | withMultipleSelections(editor, transformCase, { 347 | ...defaultMultipleSelectionOptions, 348 | args: CASE.TITLE, 349 | }), 350 | }); 351 | 352 | this.addCommand({ 353 | id: 'toggleCase', 354 | name: 'Toggle case of selection', 355 | editorCallback: (editor) => 356 | withMultipleSelections(editor, transformCase, { 357 | ...defaultMultipleSelectionOptions, 358 | args: CASE.NEXT, 359 | }), 360 | }); 361 | 362 | this.addCommand({ 363 | id: 'expandSelectionToBrackets', 364 | name: 'Expand selection to brackets', 365 | editorCallback: (editor) => 366 | withMultipleSelections(editor, expandSelectionToBrackets), 367 | }); 368 | 369 | this.addCommand({ 370 | id: 'expandSelectionToQuotes', 371 | name: 'Expand selection to quotes', 372 | editorCallback: (editor) => 373 | withMultipleSelections(editor, expandSelectionToQuotes), 374 | }); 375 | 376 | this.addCommand({ 377 | id: 'expandSelectionToQuotesOrBrackets', 378 | name: 'Expand selection to quotes or brackets', 379 | editorCallback: (editor) => expandSelectionToQuotesOrBrackets(editor), 380 | }); 381 | 382 | this.addCommand({ 383 | id: 'insertCursorAbove', 384 | name: 'Insert cursor above', 385 | editorCallback: (editor) => insertCursorAbove(editor), 386 | }); 387 | 388 | this.addCommand({ 389 | id: 'insertCursorBelow', 390 | name: 'Insert cursor below', 391 | editorCallback: (editor) => insertCursorBelow(editor), 392 | }); 393 | 394 | this.addCommand({ 395 | id: 'goToNextHeading', 396 | name: 'Go to next heading', 397 | editorCallback: (editor) => goToHeading(this.app, editor, 'next'), 398 | }); 399 | 400 | this.addCommand({ 401 | id: 'goToPrevHeading', 402 | name: 'Go to previous heading', 403 | editorCallback: (editor) => goToHeading(this.app, editor, 'prev'), 404 | }); 405 | 406 | this.addCommand({ 407 | id: 'toggle-line-numbers', 408 | name: 'Toggle line numbers', 409 | callback: () => toggleVaultConfig(this.app, 'showLineNumber'), 410 | }); 411 | 412 | this.addCommand({ 413 | id: 'indent-using-tabs', 414 | name: 'Indent using tabs', 415 | callback: () => setVaultConfig(this.app, 'useTab', true), 416 | }); 417 | 418 | this.addCommand({ 419 | id: 'indent-using-spaces', 420 | name: 'Indent using spaces', 421 | callback: () => setVaultConfig(this.app, 'useTab', false), 422 | }); 423 | 424 | this.addCommand({ 425 | id: 'undo', 426 | name: 'Undo', 427 | editorCallback: (editor) => editor.undo(), 428 | }); 429 | 430 | this.addCommand({ 431 | id: 'redo', 432 | name: 'Redo', 433 | editorCallback: (editor) => editor.redo(), 434 | }); 435 | 436 | this.registerSelectionChangeListeners(); 437 | 438 | this.addSettingTab(new SettingTab(this.app, this)); 439 | } 440 | 441 | private registerSelectionChangeListeners() { 442 | this.app.workspace.onLayoutReady(() => { 443 | // Change handler for selectWordOrNextOccurrence 444 | const handleSelectionChange = (evt: Event) => { 445 | if (evt instanceof KeyboardEvent && MODIFIER_KEYS.includes(evt.key)) { 446 | return; 447 | } 448 | if (!isProgrammaticSelectionChange) { 449 | setIsManualSelection(true); 450 | } 451 | setIsProgrammaticSelectionChange(false); 452 | }; 453 | iterateCodeMirrorDivs((cm: HTMLElement) => { 454 | this.registerDomEvent(cm, 'keydown', handleSelectionChange); 455 | this.registerDomEvent(cm, 'click', handleSelectionChange); 456 | this.registerDomEvent(cm, 'dblclick', handleSelectionChange); 457 | }); 458 | }); 459 | } 460 | 461 | async loadSettings() { 462 | const savedSettings = await this.loadData(); 463 | this.settings = { 464 | ...DEFAULT_SETTINGS, 465 | ...savedSettings, 466 | }; 467 | SettingsState.autoInsertListPrefix = this.settings.autoInsertListPrefix; 468 | } 469 | 470 | async saveSettings() { 471 | await this.saveData(this.settings); 472 | SettingsState.autoInsertListPrefix = this.settings.autoInsertListPrefix; 473 | } 474 | } 475 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | App, 3 | Editor, 4 | EditorChange, 5 | EditorRangeOrCaret, 6 | EditorPosition, 7 | EditorSelection, 8 | EditorSelectionOrCaret, 9 | } from 'obsidian'; 10 | import { 11 | SEARCH_DIRECTION, 12 | LOWERCASE_ARTICLES, 13 | LIST_CHARACTER_REGEX, 14 | } from './constants'; 15 | import { CustomSelectionHandler } from './custom-selection-handlers'; 16 | 17 | type EditorActionCallbackNew = ( 18 | editor: Editor, 19 | selection: EditorSelection, 20 | args: any, 21 | ) => { changes: EditorChange[]; newSelection: EditorRangeOrCaret }; 22 | 23 | type EditorActionCallback = ( 24 | editor: Editor, 25 | selection: EditorSelection, 26 | args: string, 27 | ) => EditorSelectionOrCaret; 28 | 29 | type MultipleSelectionOptions = { 30 | // Additional information to be passed to the EditorActionCallback 31 | args?: string; 32 | 33 | // Perform further processing of new selections before they are set 34 | customSelectionHandler?: CustomSelectionHandler; 35 | 36 | // Whether the action should be repeated for cursors on the same line 37 | repeatSameLineActions?: boolean; 38 | }; 39 | 40 | export type EditorActionCallbackNewArgs = Record; 41 | 42 | type MultipleSelectionOptionsNew = { 43 | // Additional information to be passed to the EditorActionCallback 44 | args?: EditorActionCallbackNewArgs; 45 | 46 | // Whether the action should be repeated for cursors on the same line 47 | repeatSameLineActions?: boolean; 48 | 49 | // Whether to combine cursors on the same line after the operation has 50 | // finished (the cursor with a smaller line number takes precedence) 51 | combineSameLineSelections?: boolean; 52 | }; 53 | 54 | export const defaultMultipleSelectionOptions = { repeatSameLineActions: true }; 55 | 56 | export const withMultipleSelectionsNew = ( 57 | editor: Editor, 58 | callback: EditorActionCallbackNew, 59 | options: MultipleSelectionOptionsNew = defaultMultipleSelectionOptions, 60 | ) => { 61 | const selections = editor.listSelections(); 62 | let selectionIndexesToProcess: number[]; 63 | const newSelections: EditorRangeOrCaret[] = []; 64 | const changes: EditorChange[] = []; 65 | 66 | if (!options.repeatSameLineActions) { 67 | const seenLines: number[] = []; 68 | selectionIndexesToProcess = selections.reduce( 69 | (indexes, currSelection, currIndex) => { 70 | const currentLine = currSelection.head.line; 71 | if (!seenLines.includes(currentLine)) { 72 | seenLines.push(currentLine); 73 | indexes.push(currIndex); 74 | } 75 | return indexes; 76 | }, 77 | [], 78 | ); 79 | } 80 | 81 | for (let i = 0; i < selections.length; i++) { 82 | // Controlled by repeatSameLineActions 83 | if (selectionIndexesToProcess && !selectionIndexesToProcess.includes(i)) { 84 | continue; 85 | } 86 | 87 | const { changes: newChanges, newSelection } = callback( 88 | editor, 89 | selections[i], 90 | { 91 | ...options.args, 92 | iteration: i, 93 | }, 94 | ); 95 | changes.push(...newChanges); 96 | 97 | if (options.combineSameLineSelections) { 98 | const existingSameLineSelection = newSelections.find( 99 | (selection) => selection.from.line === newSelection.from.line, 100 | ); 101 | // Generally only happens when deleting consecutive lines using separate cursors 102 | if (existingSameLineSelection) { 103 | // Reset to 0 as `ch` will otherwise exceed the line length 104 | existingSameLineSelection.from.ch = 0; 105 | // Skip adding a new selection with the same line number 106 | continue; 107 | } 108 | } 109 | 110 | newSelections.push(newSelection); 111 | } 112 | 113 | editor.transaction({ 114 | changes, 115 | selections: newSelections, 116 | }); 117 | }; 118 | 119 | export const withMultipleSelections = ( 120 | editor: Editor, 121 | callback: EditorActionCallback, 122 | options: MultipleSelectionOptions = defaultMultipleSelectionOptions, 123 | ) => { 124 | // @ts-expect-error: Obsidian's Editor interface does not explicitly 125 | // include the CodeMirror cm object, but it is there when using the 126 | // legacy editor 127 | const { cm } = editor; 128 | 129 | const selections = editor.listSelections(); 130 | let selectionIndexesToProcess: number[]; 131 | let newSelections: EditorSelectionOrCaret[] = []; 132 | 133 | if (!options.repeatSameLineActions) { 134 | const seenLines: number[] = []; 135 | selectionIndexesToProcess = selections.reduce( 136 | (indexes, currSelection, currIndex) => { 137 | const currentLine = currSelection.head.line; 138 | if (!seenLines.includes(currentLine)) { 139 | seenLines.push(currentLine); 140 | indexes.push(currIndex); 141 | } 142 | return indexes; 143 | }, 144 | [], 145 | ); 146 | } 147 | 148 | const applyCallbackOnSelections = () => { 149 | for (let i = 0; i < selections.length; i++) { 150 | // Controlled by repeatSameLineActions 151 | if (selectionIndexesToProcess && !selectionIndexesToProcess.includes(i)) { 152 | continue; 153 | } 154 | 155 | // Can't reuse selections variable as positions may change on each iteration 156 | const selection = editor.listSelections()[i]; 157 | 158 | // Selections may disappear (e.g. running delete line for two cursors on the same line) 159 | if (selection) { 160 | const newSelection = callback(editor, selection, options.args); 161 | newSelections.push(newSelection); 162 | } 163 | } 164 | 165 | if (options.customSelectionHandler) { 166 | newSelections = options.customSelectionHandler(newSelections); 167 | } 168 | editor.setSelections(newSelections); 169 | }; 170 | 171 | if (cm && cm.operation) { 172 | // Group all the updates into one atomic operation (so undo/redo work as expected) 173 | cm.operation(applyCallbackOnSelections); 174 | } else { 175 | // Safe fallback if cm doesn't exist (so undo/redo will step through each change) 176 | console.debug('cm object not found, operations will not be buffered'); 177 | applyCallbackOnSelections(); 178 | } 179 | }; 180 | 181 | /** 182 | * Executes the supplied callback for each top-level CodeMirror div element in the 183 | * DOM. This is an interim util made to work with both CM5 and CM6 as Obsidian's 184 | * `iterateCodeMirrors` method only works with CM5. 185 | */ 186 | export const iterateCodeMirrorDivs = (callback: (cm: HTMLElement) => any) => { 187 | let codeMirrors: NodeListOf; 188 | codeMirrors = document.querySelectorAll('.cm-content'); // CM6 189 | if (codeMirrors.length === 0) { 190 | codeMirrors = document.querySelectorAll('.CodeMirror'); // CM5 191 | } 192 | codeMirrors.forEach(callback); 193 | }; 194 | 195 | export const getLineStartPos = (line: number): EditorPosition => ({ 196 | line, 197 | ch: 0, 198 | }); 199 | 200 | export const getLineEndPos = ( 201 | line: number, 202 | editor: Editor, 203 | ): EditorPosition => ({ 204 | line, 205 | ch: editor.getLine(line).length, 206 | }); 207 | 208 | export const getSelectionBoundaries = (selection: EditorSelection) => { 209 | let { anchor: from, head: to } = selection; 210 | 211 | // In case user selects upwards 212 | if (from.line > to.line) { 213 | [from, to] = [to, from]; 214 | } 215 | 216 | // In case user selects backwards on the same line 217 | if (from.line === to.line && from.ch > to.ch) { 218 | [from, to] = [to, from]; 219 | } 220 | 221 | return { from, to, hasTrailingNewline: to.line > from.line && to.ch === 0 }; 222 | }; 223 | 224 | export const getLeadingWhitespace = (lineContent: string) => { 225 | const indentation = lineContent.match(/^\s+/); 226 | return indentation ? indentation[0] : ''; 227 | }; 228 | 229 | // Match any character from any language: https://www.regular-expressions.info/unicode.html 230 | const isLetterCharacter = (char: string) => /\p{L}\p{M}*/u.test(char); 231 | 232 | const isDigit = (char: string) => /\d/.test(char); 233 | 234 | const isLetterOrDigit = (char: string) => 235 | isLetterCharacter(char) || isDigit(char); 236 | 237 | export const wordRangeAtPos = ( 238 | pos: EditorPosition, 239 | lineContent: string, 240 | ): { anchor: EditorPosition; head: EditorPosition } => { 241 | let start = pos.ch; 242 | let end = pos.ch; 243 | while (start > 0 && isLetterOrDigit(lineContent.charAt(start - 1))) { 244 | start--; 245 | } 246 | while (end < lineContent.length && isLetterOrDigit(lineContent.charAt(end))) { 247 | end++; 248 | } 249 | return { 250 | anchor: { 251 | line: pos.line, 252 | ch: start, 253 | }, 254 | head: { 255 | line: pos.line, 256 | ch: end, 257 | }, 258 | }; 259 | }; 260 | 261 | export type CheckCharacter = (char: string) => boolean; 262 | 263 | export const findPosOfNextCharacter = ({ 264 | editor, 265 | startPos, 266 | checkCharacter, 267 | searchDirection, 268 | }: { 269 | editor: Editor; 270 | startPos: EditorPosition; 271 | checkCharacter: CheckCharacter; 272 | searchDirection: SEARCH_DIRECTION; 273 | }) => { 274 | let { line, ch } = startPos; 275 | let lineContent = editor.getLine(line); 276 | let matchFound = false; 277 | let matchedChar: string; 278 | 279 | if (searchDirection === SEARCH_DIRECTION.BACKWARD) { 280 | while (line >= 0) { 281 | // ch will initially be 0 if searching from start of line 282 | const char = lineContent.charAt(Math.max(ch - 1, 0)); 283 | matchFound = checkCharacter(char); 284 | if (matchFound) { 285 | matchedChar = char; 286 | break; 287 | } 288 | ch--; 289 | // inclusive because (ch - 1) means the first character will already 290 | // have been checked 291 | if (ch <= 0) { 292 | line--; 293 | if (line >= 0) { 294 | lineContent = editor.getLine(line); 295 | ch = lineContent.length; 296 | } 297 | } 298 | } 299 | } else { 300 | while (line < editor.lineCount()) { 301 | const char = lineContent.charAt(ch); 302 | matchFound = checkCharacter(char); 303 | if (matchFound) { 304 | matchedChar = char; 305 | break; 306 | } 307 | ch++; 308 | if (ch >= lineContent.length) { 309 | line++; 310 | lineContent = editor.getLine(line); 311 | ch = 0; 312 | } 313 | } 314 | } 315 | 316 | return matchFound 317 | ? { 318 | match: matchedChar, 319 | pos: { 320 | line, 321 | ch, 322 | }, 323 | } 324 | : null; 325 | }; 326 | 327 | export const hasSameSelectionContent = ( 328 | editor: Editor, 329 | selections: EditorSelection[], 330 | ) => 331 | new Set( 332 | selections.map((selection) => { 333 | const { from, to } = getSelectionBoundaries(selection); 334 | return editor.getRange(from, to); 335 | }), 336 | ).size === 1; 337 | 338 | export const getSearchText = ({ 339 | editor, 340 | allSelections, 341 | autoExpand, 342 | }: { 343 | editor: Editor; 344 | allSelections: EditorSelection[]; 345 | autoExpand: boolean; 346 | }) => { 347 | // Don't search if multiple selection contents are not identical 348 | const singleSearchText = hasSameSelectionContent(editor, allSelections); 349 | const firstSelection = allSelections[0]; 350 | const { from, to } = getSelectionBoundaries(firstSelection); 351 | let searchText = editor.getRange(from, to); 352 | if (searchText.length === 0 && autoExpand) { 353 | const wordRange = wordRangeAtPos(from, editor.getLine(from.line)); 354 | searchText = editor.getRange(wordRange.anchor, wordRange.head); 355 | } 356 | return { 357 | searchText, 358 | singleSearchText, 359 | }; 360 | }; 361 | 362 | /** 363 | * Escapes any special regex characters in the given string. 364 | * 365 | * Adapted from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping 366 | */ 367 | const escapeRegex = (input: string) => 368 | input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string 369 | 370 | /** 371 | * Constructs a custom regex query with word boundaries because in `\b` in JS doesn't 372 | * match word boundaries for unicode characters, even with the unicode flag on. 373 | * 374 | * Adapted from https://shiba1014.medium.com/regex-word-boundaries-with-unicode-207794f6e7ed. 375 | */ 376 | const withWordBoundaries = (input: string) => `(?<=\\W|^)${input}(?=\\W|$)`; 377 | 378 | export const findAllMatches = ({ 379 | searchText, 380 | searchWithinWords, 381 | documentContent, 382 | }: { 383 | searchText: string; 384 | searchWithinWords: boolean; 385 | documentContent: string; 386 | }) => { 387 | const escapedSearchText = escapeRegex(searchText); 388 | const searchExpression = new RegExp( 389 | searchWithinWords 390 | ? escapedSearchText 391 | : withWordBoundaries(escapedSearchText), 392 | 'g', 393 | ); 394 | return Array.from(documentContent.matchAll(searchExpression)); 395 | }; 396 | 397 | export const findNextMatchPosition = ({ 398 | editor, 399 | latestMatchPos, 400 | searchText, 401 | searchWithinWords, 402 | documentContent, 403 | }: { 404 | editor: Editor; 405 | latestMatchPos: EditorPosition; 406 | searchText: string; 407 | searchWithinWords: boolean; 408 | documentContent: string; 409 | }) => { 410 | const latestMatchOffset = editor.posToOffset(latestMatchPos); 411 | const matches = findAllMatches({ 412 | searchText, 413 | searchWithinWords, 414 | documentContent, 415 | }); 416 | let nextMatch: EditorSelection | null = null; 417 | 418 | for (const match of matches) { 419 | if (match.index > latestMatchOffset) { 420 | nextMatch = { 421 | anchor: editor.offsetToPos(match.index), 422 | head: editor.offsetToPos(match.index + searchText.length), 423 | }; 424 | break; 425 | } 426 | } 427 | // Circle back to search from the top 428 | if (!nextMatch) { 429 | const selectionIndexes = editor.listSelections().map((selection) => { 430 | const { from } = getSelectionBoundaries(selection); 431 | return editor.posToOffset(from); 432 | }); 433 | for (const match of matches) { 434 | if (!selectionIndexes.includes(match.index)) { 435 | nextMatch = { 436 | anchor: editor.offsetToPos(match.index), 437 | head: editor.offsetToPos(match.index + searchText.length), 438 | }; 439 | break; 440 | } 441 | } 442 | } 443 | 444 | return nextMatch; 445 | }; 446 | 447 | export const findAllMatchPositions = ({ 448 | editor, 449 | searchText, 450 | searchWithinWords, 451 | documentContent, 452 | }: { 453 | editor: Editor; 454 | searchText: string; 455 | searchWithinWords: boolean; 456 | documentContent: string; 457 | }) => { 458 | const matches = findAllMatches({ 459 | searchText, 460 | searchWithinWords, 461 | documentContent, 462 | }); 463 | const matchPositions = []; 464 | for (const match of matches) { 465 | matchPositions.push({ 466 | anchor: editor.offsetToPos(match.index), 467 | head: editor.offsetToPos(match.index + searchText.length), 468 | }); 469 | } 470 | return matchPositions; 471 | }; 472 | 473 | export const toTitleCase = (selectedText: string) => { 474 | // use capture group to join with the same separator used to split 475 | return selectedText 476 | .split(/(\s+)/) 477 | .map((word, index, allWords) => { 478 | if ( 479 | index > 0 && 480 | index < allWords.length - 1 && 481 | LOWERCASE_ARTICLES.includes(word.toLowerCase()) 482 | ) { 483 | return word.toLowerCase(); 484 | } 485 | return word.charAt(0).toUpperCase() + word.substring(1).toLowerCase(); 486 | }) 487 | .join(''); 488 | }; 489 | 490 | export const getNextCase = (selectedText: string): string => { 491 | const textUpper = selectedText.toUpperCase(); 492 | const textLower = selectedText.toLowerCase(); 493 | const textTitle = toTitleCase(selectedText); 494 | 495 | switch (selectedText) { 496 | case textUpper: { 497 | return textLower; 498 | } 499 | case textLower: { 500 | return textTitle; 501 | } 502 | case textTitle: { 503 | return textUpper; 504 | } 505 | default: { 506 | return textUpper; 507 | } 508 | } 509 | }; 510 | 511 | /** 512 | * Checks if an input string is numeric. 513 | * 514 | * Adapted from https://stackoverflow.com/a/60548119 515 | */ 516 | export const isNumeric = (input: string) => input.length > 0 && !isNaN(+input); 517 | 518 | /** 519 | * Determines the next markdown list character prefix for a given line. 520 | * 521 | * If it's an ordered list and direction is `after`, the prefix will be 522 | * incremented by 1. 523 | * 524 | * If it's a checklist, the newly inserted checkbox will always be unticked. 525 | * 526 | * If the current list item is empty, this will be indicated by a `null` prefix. 527 | */ 528 | export const getNextListPrefix = ( 529 | text: string, 530 | direction: 'before' | 'after', 531 | ): string | null => { 532 | const listChars = text.match(LIST_CHARACTER_REGEX); 533 | if (listChars && listChars.length > 0) { 534 | let prefix = listChars[0].trimStart(); 535 | const isEmptyListItem = prefix === listChars.input.trimStart(); 536 | if (isEmptyListItem) { 537 | return null; 538 | } 539 | if (isNumeric(prefix) && direction === 'after') { 540 | prefix = +prefix + 1 + '. '; 541 | } 542 | if (prefix.startsWith('- [') && !prefix.includes('[ ]')) { 543 | prefix = '- [ ] '; 544 | } 545 | return prefix; 546 | } 547 | return ''; 548 | }; 549 | 550 | export const formatRemainingListPrefixes = ( 551 | editor: Editor, 552 | fromLine: number, 553 | indentation: string, 554 | ) => { 555 | const changes: EditorChange[] = []; 556 | 557 | for (let i = fromLine; i < editor.lineCount(); i++) { 558 | const contentsOfCurrentLine = editor.getLine(i); 559 | // Only prefixes at the same indentation level should be updated 560 | const listPrefixRegex = new RegExp(`^${indentation}\\d+\\.`); 561 | const lineStartsWithNumberPrefix = listPrefixRegex.test( 562 | contentsOfCurrentLine, 563 | ); 564 | if (!lineStartsWithNumberPrefix) { 565 | break; 566 | } 567 | const replacementContent = contentsOfCurrentLine.replace( 568 | /\d+\./, 569 | (match) => +match + 1 + '.', 570 | ); 571 | changes.push({ 572 | from: { line: i, ch: 0 }, 573 | to: { line: i, ch: contentsOfCurrentLine.length }, 574 | text: replacementContent, 575 | }); 576 | } 577 | 578 | if (changes.length > 0) { 579 | editor.transaction({ changes }); 580 | } 581 | }; 582 | 583 | type VaultConfigSetting = 'showLineNumber' | 'useTab'; 584 | 585 | export const toggleVaultConfig = (app: App, setting: VaultConfigSetting) => { 586 | // @ts-expect-error - getConfig is not in the public API 587 | const value = app.vault.getConfig(setting); 588 | setVaultConfig(app, setting, !value); 589 | }; 590 | 591 | export const setVaultConfig = ( 592 | app: App, 593 | setting: VaultConfigSetting, 594 | value: boolean, 595 | ) => { 596 | // @ts-expect-error - setConfig is not in the public API 597 | app.vault.setConfig(setting, value); 598 | }; 599 | -------------------------------------------------------------------------------- /src/actions.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | App, 3 | Editor, 4 | EditorChange, 5 | EditorPosition, 6 | EditorSelection, 7 | } from 'obsidian'; 8 | import { 9 | CASE, 10 | SEARCH_DIRECTION, 11 | MATCHING_BRACKETS, 12 | MATCHING_QUOTES, 13 | MATCHING_QUOTES_BRACKETS, 14 | MatchingCharacterMap, 15 | CODE_EDITOR, 16 | LIST_CHARACTER_REGEX, 17 | } from './constants'; 18 | import { SettingsState } from './state'; 19 | import { 20 | CheckCharacter, 21 | EditorActionCallbackNewArgs, 22 | findAllMatchPositions, 23 | findNextMatchPosition, 24 | findPosOfNextCharacter, 25 | formatRemainingListPrefixes, 26 | getLeadingWhitespace, 27 | getLineEndPos, 28 | getLineStartPos, 29 | getNextCase, 30 | toTitleCase, 31 | getSelectionBoundaries, 32 | wordRangeAtPos, 33 | getSearchText, 34 | getNextListPrefix, 35 | isNumeric, 36 | } from './utils'; 37 | 38 | export const insertLineAbove = ( 39 | editor: Editor, 40 | selection: EditorSelection, 41 | args: EditorActionCallbackNewArgs, 42 | ) => { 43 | const { line } = selection.head; 44 | const startOfCurrentLine = getLineStartPos(line); 45 | 46 | const contentsOfCurrentLine = editor.getLine(line); 47 | const indentation = getLeadingWhitespace(contentsOfCurrentLine); 48 | 49 | let listPrefix = ''; 50 | if ( 51 | SettingsState.autoInsertListPrefix && 52 | line > 0 && 53 | // If inside a list, only insert prefix if within the same list 54 | editor.getLine(line - 1).trim().length > 0 55 | ) { 56 | listPrefix = getNextListPrefix(contentsOfCurrentLine, 'before'); 57 | if (isNumeric(listPrefix)) { 58 | formatRemainingListPrefixes(editor, line, indentation); 59 | } 60 | } 61 | 62 | const changes: EditorChange[] = [ 63 | { from: startOfCurrentLine, text: indentation + listPrefix + '\n' }, 64 | ]; 65 | const newSelection = { 66 | from: { 67 | ...startOfCurrentLine, 68 | // Offset by iteration 69 | line: startOfCurrentLine.line + args.iteration, 70 | ch: indentation.length + listPrefix.length, 71 | }, 72 | }; 73 | return { 74 | changes, 75 | newSelection, 76 | }; 77 | }; 78 | 79 | export const insertLineBelow = ( 80 | editor: Editor, 81 | selection: EditorSelection, 82 | args: EditorActionCallbackNewArgs, 83 | ) => { 84 | const { line } = selection.head; 85 | const startOfCurrentLine = getLineStartPos(line); 86 | const endOfCurrentLine = getLineEndPos(line, editor); 87 | 88 | const contentsOfCurrentLine = editor.getLine(line); 89 | const indentation = getLeadingWhitespace(contentsOfCurrentLine); 90 | 91 | let listPrefix = ''; 92 | if (SettingsState.autoInsertListPrefix) { 93 | listPrefix = getNextListPrefix(contentsOfCurrentLine, 'after'); 94 | 95 | // Performing this action on an empty list item should delete it 96 | if (listPrefix === null) { 97 | const changes: EditorChange[] = [ 98 | { from: startOfCurrentLine, to: endOfCurrentLine, text: '' }, 99 | ]; 100 | const newSelection = { 101 | from: { 102 | line, 103 | ch: 0, 104 | }, 105 | }; 106 | return { 107 | changes, 108 | newSelection, 109 | }; 110 | } 111 | 112 | if (isNumeric(listPrefix)) { 113 | formatRemainingListPrefixes(editor, line + 1, indentation); 114 | } 115 | } 116 | 117 | const changes: EditorChange[] = [ 118 | { from: endOfCurrentLine, text: '\n' + indentation + listPrefix }, 119 | ]; 120 | const newSelection = { 121 | from: { 122 | // Offset by iteration 123 | line: line + 1 + args.iteration, 124 | ch: indentation.length + listPrefix.length, 125 | }, 126 | }; 127 | return { 128 | changes, 129 | newSelection, 130 | }; 131 | }; 132 | 133 | // Note: don't use the built-in exec method for 'deleteLine' as there is a bug 134 | // where running it on a line that is long enough to be wrapped will focus on 135 | // the previous line instead of the next line after deletion 136 | let numLinesDeleted = 0; 137 | export const deleteLine = ( 138 | editor: Editor, 139 | selection: EditorSelection, 140 | args: EditorActionCallbackNewArgs, 141 | ) => { 142 | const { from, to, hasTrailingNewline } = getSelectionBoundaries(selection); 143 | 144 | if (to.line === editor.lastLine()) { 145 | // There is no 'next line' when cursor is on the last line 146 | const previousLine = Math.max(0, from.line - 1); 147 | const endOfPreviousLine = getLineEndPos(previousLine, editor); 148 | const changes: EditorChange[] = [ 149 | { 150 | from: from.line === 0 ? getLineStartPos(0) : endOfPreviousLine, 151 | to: 152 | // Exclude line starting at trailing newline at end of document from being deleted 153 | to.ch === 0 154 | ? getLineStartPos(to.line) 155 | : getLineEndPos(to.line, editor), 156 | text: '', 157 | }, 158 | ]; 159 | const newSelection = { 160 | from: { 161 | line: previousLine, 162 | ch: Math.min(from.ch, endOfPreviousLine.ch), 163 | }, 164 | }; 165 | return { 166 | changes, 167 | newSelection, 168 | }; 169 | } 170 | 171 | // Reset offset at the start of a new bulk delete operation 172 | if (args.iteration === 0) { 173 | numLinesDeleted = 0; 174 | } 175 | // Exclude line starting at trailing newline from being deleted 176 | const toLine = hasTrailingNewline ? to.line - 1 : to.line; 177 | const endOfNextLine = getLineEndPos(toLine + 1, editor); 178 | const changes: EditorChange[] = [ 179 | { 180 | from: getLineStartPos(from.line), 181 | to: getLineStartPos(toLine + 1), 182 | text: '', 183 | }, 184 | ]; 185 | const newSelection = { 186 | from: { 187 | // Offset by the number of lines deleted in all previous iterations 188 | line: from.line - numLinesDeleted, 189 | ch: Math.min(to.ch, endOfNextLine.ch), 190 | }, 191 | }; 192 | // This needs to be calculated after setting the new selection as it only 193 | // applies for subsequent iterations 194 | numLinesDeleted += toLine - from.line + 1; 195 | return { 196 | changes, 197 | newSelection, 198 | }; 199 | }; 200 | 201 | export const deleteToStartOfLine = ( 202 | editor: Editor, 203 | selection: EditorSelection, 204 | ) => { 205 | const pos = selection.head; 206 | let startPos = getLineStartPos(pos.line); 207 | 208 | if (pos.line === 0 && pos.ch === 0) { 209 | // We're at the start of the document so do nothing 210 | return selection; 211 | } 212 | 213 | if (pos.line === startPos.line && pos.ch === startPos.ch) { 214 | // We're at the start of the line so delete the preceding newline 215 | startPos = getLineEndPos(pos.line - 1, editor); 216 | } 217 | 218 | editor.replaceRange('', startPos, pos); 219 | return { 220 | anchor: startPos, 221 | }; 222 | }; 223 | 224 | export const deleteToEndOfLine = ( 225 | editor: Editor, 226 | selection: EditorSelection, 227 | ) => { 228 | const pos = selection.head; 229 | let endPos = getLineEndPos(pos.line, editor); 230 | 231 | if (pos.line === endPos.line && pos.ch === endPos.ch) { 232 | // We're at the end of the line so delete just the newline 233 | endPos = getLineStartPos(pos.line + 1); 234 | } 235 | 236 | editor.replaceRange('', pos, endPos); 237 | return { 238 | anchor: pos, 239 | }; 240 | }; 241 | 242 | export const joinLines = (editor: Editor, selection: EditorSelection) => { 243 | const { from, to } = getSelectionBoundaries(selection); 244 | const { line } = from; 245 | 246 | let endOfCurrentLine = getLineEndPos(line, editor); 247 | const joinRangeLimit = Math.max(to.line - line, 1); 248 | const selectionLength = editor.posToOffset(to) - editor.posToOffset(from); 249 | let trimmedChars = ''; 250 | 251 | for (let i = 0; i < joinRangeLimit; i++) { 252 | if (line === editor.lineCount() - 1) { 253 | break; 254 | } 255 | endOfCurrentLine = getLineEndPos(line, editor); 256 | const endOfNextLine = getLineEndPos(line + 1, editor); 257 | const contentsOfCurrentLine = editor.getLine(line); 258 | const contentsOfNextLine = editor.getLine(line + 1); 259 | 260 | const charsToTrim = contentsOfNextLine.match(LIST_CHARACTER_REGEX) ?? []; 261 | trimmedChars += charsToTrim[0] ?? ''; 262 | 263 | const newContentsOfNextLine = contentsOfNextLine.replace( 264 | LIST_CHARACTER_REGEX, 265 | '', 266 | ); 267 | if ( 268 | newContentsOfNextLine.length > 0 && 269 | contentsOfCurrentLine.charAt(endOfCurrentLine.ch - 1) !== ' ' 270 | ) { 271 | editor.replaceRange( 272 | ' ' + newContentsOfNextLine, 273 | endOfCurrentLine, 274 | endOfNextLine, 275 | ); 276 | } else { 277 | editor.replaceRange( 278 | newContentsOfNextLine, 279 | endOfCurrentLine, 280 | endOfNextLine, 281 | ); 282 | } 283 | } 284 | 285 | if (selectionLength === 0) { 286 | return { 287 | anchor: endOfCurrentLine, 288 | }; 289 | } 290 | return { 291 | anchor: from, 292 | head: { 293 | line: from.line, 294 | ch: from.ch + selectionLength - trimmedChars.length, 295 | }, 296 | }; 297 | }; 298 | 299 | export const copyLine = ( 300 | editor: Editor, 301 | selection: EditorSelection, 302 | direction: 'up' | 'down', 303 | ) => { 304 | const { from, to, hasTrailingNewline } = getSelectionBoundaries(selection); 305 | const fromLineStart = getLineStartPos(from.line); 306 | // Exclude line starting at trailing newline from being duplicated 307 | const toLine = hasTrailingNewline ? to.line - 1 : to.line; 308 | const toLineEnd = getLineEndPos(toLine, editor); 309 | const contentsOfSelectedLines = editor.getRange(fromLineStart, toLineEnd); 310 | if (direction === 'up') { 311 | editor.replaceRange('\n' + contentsOfSelectedLines, toLineEnd); 312 | return selection; 313 | } else { 314 | editor.replaceRange(contentsOfSelectedLines + '\n', fromLineStart); 315 | // This uses `to.line` instead of `toLine` to avoid a double adjustment 316 | const linesSelected = to.line - from.line + 1; 317 | return { 318 | anchor: { line: toLine + 1, ch: from.ch }, 319 | head: { line: toLine + linesSelected, ch: to.ch }, 320 | }; 321 | } 322 | }; 323 | 324 | /* 325 | Properties used to distinguish between selections that are programmatic 326 | (expanding from a cursor selection) vs. manual (using a mouse / Shift + arrow 327 | keys). This controls the match behaviour for selectWordOrNextOccurrence. 328 | */ 329 | let isManualSelection = true; 330 | export const setIsManualSelection = (value: boolean) => { 331 | isManualSelection = value; 332 | }; 333 | export let isProgrammaticSelectionChange = false; 334 | export const setIsProgrammaticSelectionChange = (value: boolean) => { 335 | isProgrammaticSelectionChange = value; 336 | }; 337 | 338 | export const selectWordOrNextOccurrence = (editor: Editor) => { 339 | setIsProgrammaticSelectionChange(true); 340 | const allSelections = editor.listSelections(); 341 | const { searchText, singleSearchText } = getSearchText({ 342 | editor, 343 | allSelections, 344 | autoExpand: false, 345 | }); 346 | 347 | if (searchText.length > 0 && singleSearchText) { 348 | const { from: latestMatchPos } = getSelectionBoundaries( 349 | allSelections[allSelections.length - 1], 350 | ); 351 | const nextMatch = findNextMatchPosition({ 352 | editor, 353 | latestMatchPos, 354 | searchText, 355 | searchWithinWords: isManualSelection, 356 | documentContent: editor.getValue(), 357 | }); 358 | const newSelections = nextMatch 359 | ? allSelections.concat(nextMatch) 360 | : allSelections; 361 | editor.setSelections(newSelections); 362 | const lastSelection = newSelections[newSelections.length - 1]; 363 | editor.scrollIntoView(getSelectionBoundaries(lastSelection)); 364 | } else { 365 | const newSelections = []; 366 | for (const selection of allSelections) { 367 | const { from, to } = getSelectionBoundaries(selection); 368 | // Don't modify existing range selections 369 | if (from.line !== to.line || from.ch !== to.ch) { 370 | newSelections.push(selection); 371 | } else { 372 | newSelections.push(wordRangeAtPos(from, editor.getLine(from.line))); 373 | setIsManualSelection(false); 374 | } 375 | } 376 | editor.setSelections(newSelections); 377 | } 378 | }; 379 | 380 | export const selectAllOccurrences = (editor: Editor) => { 381 | const allSelections = editor.listSelections(); 382 | const { searchText, singleSearchText } = getSearchText({ 383 | editor, 384 | allSelections, 385 | autoExpand: true, 386 | }); 387 | if (!singleSearchText) { 388 | return; 389 | } 390 | const matches = findAllMatchPositions({ 391 | editor, 392 | searchText, 393 | searchWithinWords: true, 394 | documentContent: editor.getValue(), 395 | }); 396 | editor.setSelections(matches); 397 | }; 398 | 399 | export const selectLine = (_editor: Editor, selection: EditorSelection) => { 400 | const { from, to } = getSelectionBoundaries(selection); 401 | const startOfCurrentLine = getLineStartPos(from.line); 402 | // if a line is already selected, expand the selection to the next line 403 | const startOfNextLine = getLineStartPos(to.line + 1); 404 | return { anchor: startOfCurrentLine, head: startOfNextLine }; 405 | }; 406 | 407 | export const addCursorsToSelectionEnds = ( 408 | editor: Editor, 409 | emulate: CODE_EDITOR = CODE_EDITOR.VSCODE, 410 | ) => { 411 | // Only apply the action if there is exactly one selection 412 | if (editor.listSelections().length !== 1) { 413 | return; 414 | } 415 | const selection = editor.listSelections()[0]; 416 | const { from, to, hasTrailingNewline } = getSelectionBoundaries(selection); 417 | const newSelections = []; 418 | // Exclude line starting at trailing newline from having cursor added 419 | const toLine = hasTrailingNewline ? to.line - 1 : to.line; 420 | for (let line = from.line; line <= toLine; line++) { 421 | const head = line === to.line ? to : getLineEndPos(line, editor); 422 | let anchor: EditorPosition; 423 | if (emulate === CODE_EDITOR.VSCODE) { 424 | anchor = head; 425 | } else { 426 | anchor = line === from.line ? from : getLineStartPos(line); 427 | } 428 | newSelections.push({ 429 | anchor, 430 | head, 431 | }); 432 | } 433 | editor.setSelections(newSelections); 434 | }; 435 | 436 | export const goToLineBoundary = ( 437 | editor: Editor, 438 | selection: EditorSelection, 439 | boundary: 'start' | 'end', 440 | ) => { 441 | const { from, to } = getSelectionBoundaries(selection); 442 | if (boundary === 'start') { 443 | return { anchor: getLineStartPos(from.line) }; 444 | } else { 445 | return { anchor: getLineEndPos(to.line, editor) }; 446 | } 447 | }; 448 | 449 | export const navigateLine = ( 450 | editor: Editor, 451 | selection: EditorSelection, 452 | position: 'next' | 'prev' | 'first' | 'last', 453 | ) => { 454 | const pos = selection.head; 455 | let line: number; 456 | let ch: number; 457 | 458 | if (position === 'prev') { 459 | line = Math.max(pos.line - 1, 0); 460 | const endOfLine = getLineEndPos(line, editor); 461 | ch = Math.min(pos.ch, endOfLine.ch); 462 | } 463 | if (position === 'next') { 464 | line = Math.min(pos.line + 1, editor.lineCount() - 1); 465 | const endOfLine = getLineEndPos(line, editor); 466 | ch = Math.min(pos.ch, endOfLine.ch); 467 | } 468 | if (position === 'first') { 469 | line = 0; 470 | ch = 0; 471 | } 472 | if (position === 'last') { 473 | line = editor.lineCount() - 1; 474 | const endOfLine = getLineEndPos(line, editor); 475 | ch = endOfLine.ch; 476 | } 477 | 478 | return { anchor: { line, ch } }; 479 | }; 480 | 481 | export const moveCursor = ( 482 | editor: Editor, 483 | direction: 'up' | 'down' | 'left' | 'right', 484 | ) => { 485 | switch (direction) { 486 | case 'up': 487 | editor.exec('goUp'); 488 | break; 489 | case 'down': 490 | editor.exec('goDown'); 491 | break; 492 | case 'left': 493 | editor.exec('goLeft'); 494 | break; 495 | case 'right': 496 | editor.exec('goRight'); 497 | break; 498 | } 499 | }; 500 | 501 | export const moveWord = (editor: Editor, direction: 'left' | 'right') => { 502 | switch (direction) { 503 | case 'left': 504 | // @ts-expect-error - command not defined in Obsidian API 505 | editor.exec('goWordLeft'); 506 | break; 507 | case 'right': 508 | // @ts-expect-error - command not defined in Obsidian API 509 | editor.exec('goWordRight'); 510 | break; 511 | } 512 | }; 513 | 514 | export const transformCase = ( 515 | editor: Editor, 516 | selection: EditorSelection, 517 | caseType: CASE, 518 | ) => { 519 | let { from, to } = getSelectionBoundaries(selection); 520 | let selectedText = editor.getRange(from, to); 521 | 522 | // apply transform on word at cursor if nothing is selected 523 | if (selectedText.length === 0) { 524 | const pos = selection.head; 525 | const { anchor, head } = wordRangeAtPos(pos, editor.getLine(pos.line)); 526 | [from, to] = [anchor, head]; 527 | selectedText = editor.getRange(anchor, head); 528 | } 529 | 530 | let replacementText = selectedText; 531 | 532 | switch (caseType) { 533 | case CASE.UPPER: { 534 | replacementText = selectedText.toUpperCase(); 535 | break; 536 | } 537 | case CASE.LOWER: { 538 | replacementText = selectedText.toLowerCase(); 539 | break; 540 | } 541 | case CASE.TITLE: { 542 | replacementText = toTitleCase(selectedText); 543 | break; 544 | } 545 | case CASE.NEXT: { 546 | replacementText = getNextCase(selectedText); 547 | break; 548 | } 549 | } 550 | 551 | editor.replaceRange(replacementText, from, to); 552 | 553 | return selection; 554 | }; 555 | 556 | const expandSelection = ({ 557 | editor, 558 | selection, 559 | openingCharacterCheck, 560 | matchingCharacterMap, 561 | }: { 562 | editor: Editor; 563 | selection: EditorSelection; 564 | openingCharacterCheck: CheckCharacter; 565 | matchingCharacterMap: MatchingCharacterMap; 566 | }) => { 567 | let { anchor, head } = selection; 568 | 569 | // in case user selects upwards 570 | if (anchor.line >= head.line && anchor.ch > anchor.ch) { 571 | [anchor, head] = [head, anchor]; 572 | } 573 | 574 | const newAnchor = findPosOfNextCharacter({ 575 | editor, 576 | startPos: anchor, 577 | checkCharacter: openingCharacterCheck, 578 | searchDirection: SEARCH_DIRECTION.BACKWARD, 579 | }); 580 | if (!newAnchor) { 581 | return selection; 582 | } 583 | 584 | const newHead = findPosOfNextCharacter({ 585 | editor, 586 | startPos: head, 587 | checkCharacter: (char: string) => 588 | char === matchingCharacterMap[newAnchor.match], 589 | searchDirection: SEARCH_DIRECTION.FORWARD, 590 | }); 591 | if (!newHead) { 592 | return selection; 593 | } 594 | 595 | return { anchor: newAnchor.pos, head: newHead.pos }; 596 | }; 597 | 598 | export const expandSelectionToBrackets = ( 599 | editor: Editor, 600 | selection: EditorSelection, 601 | ) => 602 | expandSelection({ 603 | editor, 604 | selection, 605 | openingCharacterCheck: (char: string) => /[([{]/.test(char), 606 | matchingCharacterMap: MATCHING_BRACKETS, 607 | }); 608 | 609 | export const expandSelectionToQuotes = ( 610 | editor: Editor, 611 | selection: EditorSelection, 612 | ) => 613 | expandSelection({ 614 | editor, 615 | selection, 616 | openingCharacterCheck: (char: string) => /['"`]/.test(char), 617 | matchingCharacterMap: MATCHING_QUOTES, 618 | }); 619 | 620 | export const expandSelectionToQuotesOrBrackets = (editor: Editor) => { 621 | const selections = editor.listSelections(); 622 | const newSelection = expandSelection({ 623 | editor, 624 | selection: selections[0], 625 | openingCharacterCheck: (char: string) => /['"`([{]/.test(char), 626 | matchingCharacterMap: MATCHING_QUOTES_BRACKETS, 627 | }); 628 | editor.setSelections([...selections, newSelection]); 629 | }; 630 | 631 | const insertCursor = (editor: Editor, lineOffset: number) => { 632 | const selections = editor.listSelections(); 633 | const newSelections = []; 634 | for (const selection of selections) { 635 | const { line, ch } = selection.head; 636 | if ( 637 | (line === 0 && lineOffset < 0) || 638 | (line === editor.lastLine() && lineOffset > 0) 639 | ) { 640 | break; 641 | } 642 | const targetLineLength = editor.getLine(line + lineOffset).length; 643 | newSelections.push({ 644 | anchor: { 645 | line: selection.anchor.line + lineOffset, 646 | ch: Math.min(selection.anchor.ch, targetLineLength), 647 | }, 648 | head: { 649 | line: line + lineOffset, 650 | ch: Math.min(ch, targetLineLength), 651 | }, 652 | }); 653 | } 654 | editor.setSelections([...editor.listSelections(), ...newSelections]); 655 | }; 656 | 657 | export const insertCursorAbove = (editor: Editor) => insertCursor(editor, -1); 658 | 659 | export const insertCursorBelow = (editor: Editor) => insertCursor(editor, 1); 660 | 661 | export const goToHeading = ( 662 | app: App, 663 | editor: Editor, 664 | boundary: 'prev' | 'next', 665 | ) => { 666 | const file = app.metadataCache.getFileCache(app.workspace.getActiveFile()); 667 | if (!file.headings || file.headings.length === 0) { 668 | return; 669 | } 670 | 671 | const { line } = editor.getCursor('from'); 672 | let prevHeadingLine = 0; 673 | let nextHeadingLine = editor.lastLine(); 674 | 675 | file.headings.forEach(({ position }) => { 676 | const { end: headingPos } = position; 677 | if (line > headingPos.line && headingPos.line > prevHeadingLine) { 678 | prevHeadingLine = headingPos.line; 679 | } 680 | if (line < headingPos.line && headingPos.line < nextHeadingLine) { 681 | nextHeadingLine = headingPos.line; 682 | } 683 | }); 684 | 685 | editor.setSelection( 686 | boundary === 'prev' 687 | ? getLineEndPos(prevHeadingLine, editor) 688 | : getLineEndPos(nextHeadingLine, editor), 689 | ); 690 | }; 691 | -------------------------------------------------------------------------------- /src/__tests__/actions-cursor.spec.ts: -------------------------------------------------------------------------------- 1 | import CodeMirror from 'codemirror'; 2 | import type { Editor } from 'codemirror'; 3 | import { getDocumentAndSelection } from './test-helpers'; 4 | import { 5 | deleteToStartOfLine, 6 | deleteToEndOfLine, 7 | joinLines, 8 | copyLine, 9 | selectWordOrNextOccurrence, 10 | selectAllOccurrences, 11 | selectLine, 12 | goToLineBoundary, 13 | navigateLine, 14 | moveCursor, 15 | transformCase, 16 | expandSelectionToBrackets, 17 | expandSelectionToQuotes, 18 | expandSelectionToQuotesOrBrackets, 19 | addCursorsToSelectionEnds, 20 | insertCursorAbove, 21 | insertCursorBelow, 22 | moveWord, 23 | } from '../actions'; 24 | import { CASE, CODE_EDITOR } from '../constants'; 25 | import { withMultipleSelections } from '../utils'; 26 | import { SettingsState } from '../state'; 27 | 28 | // fixes jsdom type error - https://github.com/jsdom/jsdom/issues/3002#issuecomment-655748833 29 | document.createRange = () => { 30 | const range = new Range(); 31 | 32 | range.getBoundingClientRect = jest.fn(); 33 | 34 | range.getClientRects = jest.fn(() => ({ 35 | item: () => null, 36 | length: 0, 37 | })); 38 | 39 | return range; 40 | }; 41 | 42 | export interface ObsidianEditorBridge extends Editor { 43 | /** 44 | * `exec` is the CodeMirror equivalent of `execCommand` 45 | */ 46 | exec?: (name: string) => void; 47 | } 48 | 49 | describe('Code Editor Shortcuts: actions - single cursor selection', () => { 50 | let editor: ObsidianEditorBridge; 51 | const originalDoc = 'lorem ipsum\ndolor sit\namet'; 52 | 53 | beforeAll(() => { 54 | editor = CodeMirror(document.body); 55 | // To make cm.operation() work, since editor here already refers to the 56 | // CodeMirror object 57 | (editor as any).cm = editor; 58 | 59 | // Assign the CodeMirror equivalents of posToOffset and offsetToPos 60 | (editor as any).posToOffset = editor.indexFromPos; 61 | (editor as any).offsetToPos = editor.posFromIndex; 62 | 63 | editor.exec = jest.fn(); 64 | }); 65 | 66 | beforeEach(() => { 67 | SettingsState.autoInsertListPrefix = true; 68 | editor.setValue(originalDoc); 69 | editor.setCursor({ line: 1, ch: 0 }); 70 | }); 71 | 72 | describe('deleteToStartOfLine', () => { 73 | it('should delete to the start of the line', () => { 74 | editor.setCursor({ line: 1, ch: 7 }); 75 | withMultipleSelections(editor as any, deleteToStartOfLine); 76 | 77 | const { doc, cursor } = getDocumentAndSelection(editor); 78 | expect(doc).toEqual('lorem ipsum\nit\namet'); 79 | expect(cursor).toEqual( 80 | expect.objectContaining({ 81 | line: 1, 82 | ch: 0, 83 | }), 84 | ); 85 | }); 86 | 87 | it('should delete the preceding newline when at the start of the line', () => { 88 | editor.setCursor({ line: 1, ch: 0 }); 89 | withMultipleSelections(editor as any, deleteToStartOfLine); 90 | 91 | const { doc, cursor } = getDocumentAndSelection(editor); 92 | expect(doc).toEqual('lorem ipsumdolor sit\namet'); 93 | expect(cursor).toEqual( 94 | expect.objectContaining({ 95 | line: 0, 96 | ch: 11, 97 | }), 98 | ); 99 | }); 100 | 101 | it('should delete nothing when at the start of the document', () => { 102 | editor.setCursor({ line: 0, ch: 0 }); 103 | withMultipleSelections(editor as any, deleteToStartOfLine); 104 | 105 | const { doc, cursor } = getDocumentAndSelection(editor); 106 | expect(doc).toEqual('lorem ipsum\ndolor sit\namet'); 107 | expect(cursor).toEqual( 108 | expect.objectContaining({ 109 | line: 0, 110 | ch: 0, 111 | }), 112 | ); 113 | }); 114 | }); 115 | 116 | describe('deleteToEndOfLine', () => { 117 | it('should delete to the end of the line', () => { 118 | editor.setCursor({ line: 1, ch: 1 }); 119 | withMultipleSelections(editor as any, deleteToEndOfLine); 120 | 121 | const { doc, cursor } = getDocumentAndSelection(editor); 122 | expect(doc).toEqual('lorem ipsum\nd\namet'); 123 | expect(cursor).toEqual( 124 | expect.objectContaining({ 125 | line: 1, 126 | ch: 1, 127 | }), 128 | ); 129 | }); 130 | 131 | it('should delete the newline when at the end of the line', () => { 132 | editor.setCursor({ line: 1, ch: 9 }); 133 | withMultipleSelections(editor as any, deleteToEndOfLine); 134 | 135 | const { doc, cursor } = getDocumentAndSelection(editor); 136 | expect(doc).toEqual('lorem ipsum\ndolor sitamet'); 137 | expect(cursor).toEqual( 138 | expect.objectContaining({ 139 | line: 1, 140 | ch: 9, 141 | }), 142 | ); 143 | }); 144 | 145 | it('should delete nothing when at the end of the document', () => { 146 | editor.setCursor({ line: 2, ch: 4 }); 147 | withMultipleSelections(editor as any, deleteToEndOfLine); 148 | 149 | const { doc, cursor } = getDocumentAndSelection(editor); 150 | expect(doc).toEqual('lorem ipsum\ndolor sit\namet'); 151 | expect(cursor).toEqual( 152 | expect.objectContaining({ 153 | line: 2, 154 | ch: 4, 155 | }), 156 | ); 157 | }); 158 | }); 159 | 160 | describe('joinLines', () => { 161 | it('should join next line to current line', () => { 162 | withMultipleSelections(editor as any, joinLines); 163 | 164 | const { doc, cursor } = getDocumentAndSelection(editor); 165 | expect(doc).toEqual('lorem ipsum\ndolor sit amet'); 166 | expect(cursor.line).toEqual(1); 167 | expect(cursor.ch).toEqual(9); 168 | }); 169 | 170 | it('should not join next line when at the end of the document', () => { 171 | editor.setCursor({ line: 2, ch: 2 }); 172 | 173 | withMultipleSelections(editor as any, joinLines); 174 | 175 | const { doc, cursor } = getDocumentAndSelection(editor); 176 | expect(doc).toEqual(originalDoc); 177 | expect(cursor.line).toEqual(2); 178 | expect(cursor.ch).toEqual(4); 179 | }); 180 | 181 | it('should remove markdown list characters', () => { 182 | const content = '- aaa\n* bbb\n+ ccc\n~ ddd'; 183 | editor.setValue(content); 184 | editor.setCursor({ line: 0, ch: 0 }); 185 | 186 | withMultipleSelections(editor as any, joinLines); 187 | withMultipleSelections(editor as any, joinLines); 188 | withMultipleSelections(editor as any, joinLines); 189 | 190 | const { doc, cursor } = getDocumentAndSelection(editor); 191 | expect(doc).toEqual('- aaa bbb ccc ~ ddd'); 192 | expect(cursor.line).toEqual(0); 193 | expect(cursor.ch).toEqual(13); 194 | }); 195 | 196 | it('should remove markdown quote characters', () => { 197 | const content = '> aaa\n> bbb\n> ccc'; 198 | editor.setValue(content); 199 | editor.setCursor({ line: 0, ch: 0 }); 200 | 201 | withMultipleSelections(editor as any, joinLines); 202 | 203 | const { doc, cursor } = getDocumentAndSelection(editor); 204 | expect(doc).toEqual('> aaa bbb\n> ccc'); 205 | expect(cursor.line).toEqual(0); 206 | expect(cursor.ch).toEqual(5); 207 | }); 208 | 209 | it('should not add a space after the current line if one already exists', () => { 210 | const content = 'lorem ipsum\ndolor sit \namet'; 211 | editor.setValue(content); 212 | editor.setCursor({ line: 1, ch: 0 }); 213 | 214 | withMultipleSelections(editor as any, joinLines); 215 | 216 | const { doc, cursor } = getDocumentAndSelection(editor); 217 | expect(doc).toEqual('lorem ipsum\ndolor sit amet'); 218 | expect(cursor.line).toEqual(1); 219 | expect(cursor.ch).toEqual(10); 220 | }); 221 | }); 222 | 223 | describe('copyLine', () => { 224 | it('should copy current line up', () => { 225 | editor.setCursor({ line: 1, ch: 3 }); 226 | 227 | withMultipleSelections(editor as any, copyLine, { 228 | args: 'up', 229 | }); 230 | 231 | const { doc, cursor } = getDocumentAndSelection(editor); 232 | expect(doc).toEqual('lorem ipsum\ndolor sit\ndolor sit\namet'); 233 | expect(cursor.line).toEqual(1); 234 | expect(cursor.ch).toEqual(3); 235 | }); 236 | 237 | it('should copy current line up from the end of a line', () => { 238 | editor.setCursor({ line: 1, ch: 9 }); 239 | 240 | withMultipleSelections(editor as any, copyLine, { 241 | args: 'up', 242 | }); 243 | 244 | const { doc, cursor } = getDocumentAndSelection(editor); 245 | expect(doc).toEqual('lorem ipsum\ndolor sit\ndolor sit\namet'); 246 | expect(cursor.line).toEqual(1); 247 | expect(cursor.ch).toEqual(9); 248 | }); 249 | 250 | it('should copy current line down', () => { 251 | editor.setCursor({ line: 1, ch: 3 }); 252 | 253 | withMultipleSelections(editor as any, copyLine, { 254 | args: 'down', 255 | }); 256 | 257 | const { doc, cursor } = getDocumentAndSelection(editor); 258 | expect(doc).toEqual('lorem ipsum\ndolor sit\ndolor sit\namet'); 259 | expect(cursor.line).toEqual(2); 260 | expect(cursor.ch).toEqual(3); 261 | }); 262 | }); 263 | 264 | describe('selectWordOrNextOccurrence', () => { 265 | it('should select word', () => { 266 | selectWordOrNextOccurrence(editor as any); 267 | 268 | const { doc, selectedText } = getDocumentAndSelection(editor); 269 | expect(doc).toEqual(originalDoc); 270 | expect(selectedText).toEqual('dolor'); 271 | }); 272 | 273 | it('should select word containing unicode characters', () => { 274 | editor.setValue('café'); 275 | editor.setCursor({ line: 0, ch: 2 }); 276 | 277 | selectWordOrNextOccurrence(editor as any); 278 | 279 | const { selectedText } = getDocumentAndSelection(editor); 280 | expect(selectedText).toEqual('café'); 281 | }); 282 | 283 | it('should select word comprising only digits', () => { 284 | editor.setValue('123'); 285 | editor.setCursor({ line: 0, ch: 0 }); 286 | 287 | selectWordOrNextOccurrence(editor as any); 288 | 289 | const { selectedText } = getDocumentAndSelection(editor); 290 | expect(selectedText).toEqual('123'); 291 | }); 292 | }); 293 | 294 | describe('selectAllOccurrences', () => { 295 | const originalDocRepeated = `${originalDoc}\n${originalDoc}\n${originalDoc}`; 296 | 297 | it('should select all occurrences of selection', () => { 298 | editor.setValue(originalDocRepeated); 299 | editor.setCursor({ line: 7, ch: 2 }); 300 | 301 | selectAllOccurrences(editor as any); 302 | 303 | const { doc, selections } = getDocumentAndSelection(editor); 304 | expect(doc).toEqual(originalDocRepeated); 305 | expect(selections).toEqual([ 306 | { 307 | anchor: expect.objectContaining({ line: 1, ch: 0 }), 308 | head: expect.objectContaining({ line: 1, ch: 5 }), 309 | }, 310 | { 311 | anchor: expect.objectContaining({ line: 4, ch: 0 }), 312 | head: expect.objectContaining({ line: 4, ch: 5 }), 313 | }, 314 | { 315 | anchor: expect.objectContaining({ line: 7, ch: 0 }), 316 | head: expect.objectContaining({ line: 7, ch: 5 }), 317 | }, 318 | ]); 319 | }); 320 | }); 321 | 322 | describe('selectLine', () => { 323 | it('should select line', () => { 324 | withMultipleSelections(editor as any, selectLine); 325 | 326 | const { doc, selectedText } = getDocumentAndSelection(editor); 327 | expect(doc).toEqual(originalDoc); 328 | expect(selectedText).toEqual('dolor sit\n'); 329 | }); 330 | }); 331 | 332 | describe('addCursorsToSelectionEnds', () => { 333 | it('should not add cursors to selection ends', () => { 334 | addCursorsToSelectionEnds(editor as any, CODE_EDITOR.VSCODE); 335 | 336 | const { doc, cursor } = getDocumentAndSelection(editor); 337 | expect(doc).toEqual(originalDoc); 338 | expect(cursor.line).toEqual(1); 339 | expect(cursor.ch).toEqual(0); 340 | }); 341 | }); 342 | 343 | describe('goToLineBoundary', () => { 344 | it('should go to line start', () => { 345 | withMultipleSelections(editor as any, goToLineBoundary, { 346 | args: 'start', 347 | }); 348 | 349 | const { doc, cursor } = getDocumentAndSelection(editor); 350 | expect(doc).toEqual(originalDoc); 351 | expect(cursor.line).toEqual(1); 352 | expect(cursor.ch).toEqual(0); 353 | }); 354 | 355 | it('should go to line end', () => { 356 | withMultipleSelections(editor as any, goToLineBoundary, { 357 | args: 'end', 358 | }); 359 | 360 | const { doc, cursor } = getDocumentAndSelection(editor); 361 | expect(doc).toEqual(originalDoc); 362 | expect(cursor.line).toEqual(1); 363 | expect(cursor.ch).toEqual(9); 364 | }); 365 | }); 366 | 367 | describe('navigateLine', () => { 368 | it('should navigate to the previous line', () => { 369 | editor.setCursor({ line: 2, ch: 0 }); 370 | withMultipleSelections(editor as any, navigateLine, { args: 'prev' }); 371 | 372 | const { doc, cursor } = getDocumentAndSelection(editor); 373 | expect(doc).toEqual(originalDoc); 374 | expect(cursor.line).toEqual(1); 375 | expect(cursor.ch).toEqual(0); 376 | }); 377 | 378 | it('should not navigate past the start of the document', () => { 379 | editor.setCursor({ line: 0, ch: 0 }); 380 | withMultipleSelections(editor as any, navigateLine, { args: 'prev' }); 381 | 382 | const { doc, cursor } = getDocumentAndSelection(editor); 383 | expect(doc).toEqual(originalDoc); 384 | expect(cursor.line).toEqual(0); 385 | expect(cursor.ch).toEqual(0); 386 | }); 387 | 388 | it('should navigate to the next line', () => { 389 | withMultipleSelections(editor as any, navigateLine, { args: 'next' }); 390 | 391 | const { doc, cursor } = getDocumentAndSelection(editor); 392 | expect(doc).toEqual(originalDoc); 393 | expect(cursor.line).toEqual(2); 394 | expect(cursor.ch).toEqual(0); 395 | }); 396 | 397 | it('should not navigate past the end of the document', () => { 398 | editor.setCursor({ line: 2, ch: 4 }); 399 | withMultipleSelections(editor as any, navigateLine, { args: 'next' }); 400 | 401 | const { doc, cursor } = getDocumentAndSelection(editor); 402 | expect(doc).toEqual(originalDoc); 403 | expect(cursor.line).toEqual(2); 404 | expect(cursor.ch).toEqual(4); 405 | }); 406 | 407 | it('should snap to the end of the line', () => { 408 | editor.setValue('line zero\nzz\nline two'); 409 | editor.setCursor({ line: 0, ch: 5 }); 410 | 411 | withMultipleSelections(editor as any, navigateLine, { args: 'next' }); 412 | 413 | const { cursor } = getDocumentAndSelection(editor); 414 | expect(cursor.line).toEqual(1); 415 | expect(cursor.ch).toEqual(2); 416 | }); 417 | 418 | it('should navigate to the first line', () => { 419 | withMultipleSelections(editor as any, navigateLine, { args: 'first' }); 420 | 421 | const { doc, cursor } = getDocumentAndSelection(editor); 422 | expect(doc).toEqual(originalDoc); 423 | expect(cursor.line).toEqual(0); 424 | expect(cursor.ch).toEqual(0); 425 | }); 426 | 427 | it('should navigate to the last line', () => { 428 | withMultipleSelections(editor as any, navigateLine, { args: 'last' }); 429 | 430 | const { doc, cursor } = getDocumentAndSelection(editor); 431 | expect(doc).toEqual(originalDoc); 432 | expect(cursor.line).toEqual(2); 433 | expect(cursor.ch).toEqual(4); 434 | }); 435 | }); 436 | 437 | describe('moveCursor', () => { 438 | it('should move cursor up', () => { 439 | moveCursor(editor as any, 'up'); 440 | expect(editor.exec).toHaveBeenCalledWith('goUp'); 441 | }); 442 | 443 | it('should move cursor down', () => { 444 | moveCursor(editor as any, 'down'); 445 | expect(editor.exec).toHaveBeenCalledWith('goDown'); 446 | }); 447 | 448 | it('should move cursor left', () => { 449 | moveCursor(editor as any, 'left'); 450 | expect(editor.exec).toHaveBeenCalledWith('goLeft'); 451 | }); 452 | 453 | it('should move cursor right', () => { 454 | moveCursor(editor as any, 'right'); 455 | expect(editor.exec).toHaveBeenCalledWith('goRight'); 456 | }); 457 | }); 458 | 459 | describe('moveWord', () => { 460 | it('should go to next word', () => { 461 | moveWord(editor as any, 'right'); 462 | expect(editor.exec).toHaveBeenCalledWith('goWordRight'); 463 | }); 464 | 465 | it('should go to previous word', () => { 466 | moveWord(editor as any, 'left'); 467 | expect(editor.exec).toHaveBeenCalledWith('goWordLeft'); 468 | }); 469 | }); 470 | 471 | describe('transformCase', () => { 472 | it('should transform to uppercase', () => { 473 | withMultipleSelections(editor as any, transformCase, { 474 | args: CASE.UPPER, 475 | }); 476 | 477 | const { doc, cursor } = getDocumentAndSelection(editor); 478 | expect(doc).toEqual('lorem ipsum\nDOLOR sit\namet'); 479 | expect(cursor.line).toEqual(1); 480 | expect(cursor.ch).toEqual(0); 481 | }); 482 | 483 | it('should transform to lowercase', () => { 484 | editor.setValue('lorem ipsum\nDOLOR sit\namet'); 485 | editor.setCursor({ line: 1, ch: 0 }); 486 | 487 | withMultipleSelections(editor as any, transformCase, { 488 | args: CASE.LOWER, 489 | }); 490 | 491 | const { doc, cursor } = getDocumentAndSelection(editor); 492 | expect(doc).toEqual(originalDoc); 493 | expect(cursor.line).toEqual(1); 494 | expect(cursor.ch).toEqual(0); 495 | }); 496 | 497 | it('should transform to title case', () => { 498 | withMultipleSelections(editor as any, transformCase, { 499 | args: CASE.TITLE, 500 | }); 501 | 502 | const { doc, cursor } = getDocumentAndSelection(editor); 503 | expect(doc).toEqual('lorem ipsum\nDolor sit\namet'); 504 | expect(cursor.line).toEqual(1); 505 | expect(cursor.ch).toEqual(0); 506 | }); 507 | 508 | it.each([ 509 | ['default', 'loreM', 'LOREM'], 510 | ['uppercase', 'LOREM', 'lorem'], 511 | ['lowercase', 'lorem', 'Lorem'], 512 | ['title case', 'Lorem', 'LOREM'], 513 | ])( 514 | 'should cycle to next case from %s', 515 | (_scenario, initialContent, expectedContent) => { 516 | editor.setValue(initialContent); 517 | editor.setCursor({ line: 0, ch: 0 }); 518 | 519 | withMultipleSelections(editor as any, transformCase, { 520 | args: CASE.NEXT, 521 | }); 522 | 523 | const { doc, cursor } = getDocumentAndSelection(editor); 524 | expect(doc).toEqual(expectedContent); 525 | expect(cursor.line).toEqual(0); 526 | expect(cursor.ch).toEqual(0); 527 | }, 528 | ); 529 | }); 530 | 531 | describe('expandSelectionToBrackets', () => { 532 | it.each([ 533 | ['()', '(lorem ipsum) dolor'], 534 | ['[]', 'dolor [lorem ipsum]'], 535 | ['{}', 'dolor {lorem ipsum} sit amet'], 536 | ])( 537 | 'should expand selection to %s brackets if cursor is inside', 538 | (_scenario, content) => { 539 | editor.setValue(content); 540 | editor.setCursor({ line: 0, ch: 8 }); 541 | 542 | withMultipleSelections(editor as any, expandSelectionToBrackets); 543 | 544 | const { doc, selectedText } = getDocumentAndSelection(editor); 545 | expect(doc).toEqual(content); 546 | expect(selectedText).toEqual('lorem ipsum'); 547 | }, 548 | ); 549 | 550 | it('should not expand selection to brackets if cursor is outside', () => { 551 | const content = '(lorem ipsum) dolor'; 552 | editor.setValue(content); 553 | editor.setCursor({ line: 0, ch: 15 }); 554 | 555 | withMultipleSelections(editor as any, expandSelectionToBrackets); 556 | 557 | const { doc, selectedText } = getDocumentAndSelection(editor); 558 | expect(doc).toEqual(content); 559 | expect(selectedText).toEqual(''); 560 | }); 561 | 562 | it('should not expand selection to mismatched brackets', () => { 563 | const content = '(lorem ipsum] dolor'; 564 | editor.setValue(content); 565 | editor.setCursor({ line: 0, ch: 6 }); 566 | 567 | withMultipleSelections(editor as any, expandSelectionToBrackets); 568 | 569 | const { doc, selectedText } = getDocumentAndSelection(editor); 570 | expect(doc).toEqual(content); 571 | expect(selectedText).toEqual(''); 572 | }); 573 | }); 574 | 575 | describe('expandSelectionToQuotes', () => { 576 | it.each([ 577 | ['single', "'lorem ipsum' dolor"], 578 | ['double', 'dolor "lorem ipsum"'], 579 | ])( 580 | 'should expand selection to %s quotes if cursor is inside', 581 | (_scenario, content) => { 582 | editor.setValue(content); 583 | editor.setCursor({ line: 0, ch: 8 }); 584 | 585 | withMultipleSelections(editor as any, expandSelectionToQuotes); 586 | 587 | const { doc, selectedText } = getDocumentAndSelection(editor); 588 | expect(doc).toEqual(content); 589 | expect(selectedText).toEqual('lorem ipsum'); 590 | }, 591 | ); 592 | 593 | it('should not expand selection to quotes if cursor is outside', () => { 594 | const content = '"lorem ipsum" dolor'; 595 | editor.setValue(content); 596 | editor.setCursor({ line: 0, ch: 15 }); 597 | 598 | withMultipleSelections(editor as any, expandSelectionToQuotes); 599 | 600 | const { doc, selectedText } = getDocumentAndSelection(editor); 601 | expect(doc).toEqual(content); 602 | expect(selectedText).toEqual(''); 603 | }); 604 | 605 | it('should not expand selection to mismatched quotes', () => { 606 | const content = '\'lorem ipsum" dolor'; 607 | editor.setValue(content); 608 | editor.setCursor({ line: 0, ch: 6 }); 609 | 610 | withMultipleSelections(editor as any, expandSelectionToQuotes); 611 | 612 | const { doc, selectedText } = getDocumentAndSelection(editor); 613 | expect(doc).toEqual(content); 614 | expect(selectedText).toEqual(''); 615 | }); 616 | }); 617 | 618 | describe('expandSelectionToQuotesOrBrackets', () => { 619 | it.each([ 620 | ['quotes', '("lorem ipsum" dolor)'], 621 | ['brackets', '"(lorem ipsum) dolor"'], 622 | ])('should expand selection to %s', (_scenario, content) => { 623 | editor.setValue(content); 624 | editor.setCursor({ line: 0, ch: 7 }); 625 | 626 | expandSelectionToQuotesOrBrackets(editor as any); 627 | 628 | const { doc, selectedText } = getDocumentAndSelection(editor); 629 | expect(doc).toEqual(content); 630 | expect(selectedText).toEqual('lorem ipsum'); 631 | }); 632 | }); 633 | 634 | describe('insertCursorAbove', () => { 635 | it('should insert cursor above at the same position', () => { 636 | insertCursorAbove(editor as any); 637 | 638 | const { doc, selections } = getDocumentAndSelection(editor); 639 | expect(doc).toEqual(originalDoc); 640 | expect(selections).toEqual([ 641 | { anchor: { line: 0, ch: 0 }, head: { line: 0, ch: 0 } }, 642 | { anchor: { line: 1, ch: 0 }, head: { line: 1, ch: 0 } }, 643 | ]); 644 | }); 645 | 646 | it('should insert cursor above at the end if previous line is shorter', () => { 647 | editor.setValue('aaa\nbbbbbb'); 648 | editor.setCursor({ line: 1, ch: 5 }); 649 | 650 | insertCursorAbove(editor as any); 651 | 652 | const { doc, selections } = getDocumentAndSelection(editor); 653 | expect(doc).toEqual('aaa\nbbbbbb'); 654 | expect(selections).toEqual([ 655 | { anchor: { line: 0, ch: 3 }, head: { line: 0, ch: 3 } }, 656 | { anchor: { line: 1, ch: 5 }, head: { line: 1, ch: 5 } }, 657 | ]); 658 | }); 659 | 660 | it('should not insert cursor above if on the first line of the document', () => { 661 | editor.setCursor({ line: 0, ch: 3 }); 662 | 663 | insertCursorAbove(editor as any); 664 | 665 | const { doc, selections } = getDocumentAndSelection(editor); 666 | expect(doc).toEqual(originalDoc); 667 | expect(selections).toEqual([ 668 | { anchor: { line: 0, ch: 3 }, head: { line: 0, ch: 3 } }, 669 | ]); 670 | }); 671 | }); 672 | 673 | describe('insertCursorBelow', () => { 674 | it('should insert cursor below at the same position', () => { 675 | insertCursorBelow(editor as any); 676 | 677 | const { doc, selections } = getDocumentAndSelection(editor); 678 | expect(doc).toEqual(originalDoc); 679 | expect(selections).toEqual([ 680 | { anchor: { line: 1, ch: 0 }, head: { line: 1, ch: 0 } }, 681 | { anchor: { line: 2, ch: 0 }, head: { line: 2, ch: 0 } }, 682 | ]); 683 | }); 684 | 685 | it('should insert cursor below at the end if next line is shorter', () => { 686 | editor.setValue('aaaaaa\nbbb'); 687 | editor.setCursor({ line: 0, ch: 5 }); 688 | 689 | insertCursorBelow(editor as any); 690 | 691 | const { doc, selections } = getDocumentAndSelection(editor); 692 | expect(doc).toEqual('aaaaaa\nbbb'); 693 | expect(selections).toEqual([ 694 | { anchor: { line: 0, ch: 5 }, head: { line: 0, ch: 5 } }, 695 | { anchor: { line: 1, ch: 3 }, head: { line: 1, ch: 3 } }, 696 | ]); 697 | }); 698 | 699 | it('should not insert cursor below if on the last line of the document', () => { 700 | editor.setCursor({ line: 2, ch: 3 }); 701 | 702 | insertCursorBelow(editor as any); 703 | 704 | const { doc, selections } = getDocumentAndSelection(editor); 705 | expect(doc).toEqual(originalDoc); 706 | expect(selections).toEqual([ 707 | { anchor: { line: 2, ch: 3 }, head: { line: 2, ch: 3 } }, 708 | ]); 709 | }); 710 | }); 711 | }); 712 | -------------------------------------------------------------------------------- /src/__tests__/actions-multi.spec.ts: -------------------------------------------------------------------------------- 1 | import CodeMirror from 'codemirror'; 2 | import type { Editor } from 'codemirror'; 3 | import { getDocumentAndSelection } from './test-helpers'; 4 | import { 5 | insertLineAbove, 6 | deleteToStartOfLine, 7 | deleteToEndOfLine, 8 | joinLines, 9 | copyLine, 10 | selectWordOrNextOccurrence, 11 | selectAllOccurrences, 12 | selectLine, 13 | goToLineBoundary, 14 | navigateLine, 15 | transformCase, 16 | expandSelectionToBrackets, 17 | expandSelectionToQuotes, 18 | expandSelectionToQuotesOrBrackets, 19 | addCursorsToSelectionEnds, 20 | insertCursorAbove, 21 | insertCursorBelow, 22 | } from '../actions'; 23 | import { CASE, CODE_EDITOR } from '../constants'; 24 | import { 25 | withMultipleSelections, 26 | defaultMultipleSelectionOptions, 27 | } from '../utils'; 28 | 29 | // fixes jsdom type error - https://github.com/jsdom/jsdom/issues/3002#issuecomment-655748833 30 | document.createRange = () => { 31 | const range = new Range(); 32 | 33 | range.getBoundingClientRect = jest.fn(); 34 | 35 | range.getClientRects = jest.fn(() => ({ 36 | item: () => null, 37 | length: 0, 38 | })); 39 | 40 | return range; 41 | }; 42 | 43 | describe('Code Editor Shortcuts: actions - multiple mixed selections', () => { 44 | let editor: Editor; 45 | const originalDoc = 46 | `lorem ipsum\ndolor sit\namet\n\n` + 47 | `consectetur "adipiscing" 'elit'\n(donec [mattis])\ntincidunt metus`; 48 | 49 | beforeAll(() => { 50 | editor = CodeMirror(document.body); 51 | // To make cm.operation() work, since editor here already refers to the 52 | // CodeMirror object 53 | (editor as any).cm = editor; 54 | 55 | // Assign the CodeMirror equivalents of posToOffset and offsetToPos 56 | (editor as any).posToOffset = editor.indexFromPos; 57 | (editor as any).offsetToPos = editor.posFromIndex; 58 | }); 59 | 60 | const originalSelectionRanges = [ 61 | { 62 | anchor: expect.objectContaining({ line: 1, ch: 5 }), 63 | head: expect.objectContaining({ line: 0, ch: 6 }), 64 | }, 65 | { 66 | anchor: expect.objectContaining({ line: 2, ch: 2 }), 67 | head: expect.objectContaining({ line: 2, ch: 2 }), 68 | }, 69 | { 70 | anchor: expect.objectContaining({ line: 4, ch: 14 }), 71 | head: expect.objectContaining({ line: 4, ch: 17 }), 72 | }, 73 | { 74 | anchor: expect.objectContaining({ line: 4, ch: 26 }), 75 | head: expect.objectContaining({ line: 4, ch: 26 }), 76 | }, 77 | ]; 78 | 79 | beforeEach(() => { 80 | editor.setValue(originalDoc); 81 | editor.setSelections([ 82 | { anchor: { line: 1, ch: 5 }, head: { line: 0, ch: 6 } }, // {<}ipsum\ndolor{>} 83 | { anchor: { line: 2, ch: 2 }, head: { line: 2, ch: 2 } }, // am{<>}et 84 | { anchor: { line: 4, ch: 14 }, head: { line: 4, ch: 17 } }, // a{<}dip{>}iscing 85 | { anchor: { line: 4, ch: 26 }, head: { line: 4, ch: 26 } }, // '{<>}elit 86 | ]); 87 | }); 88 | 89 | describe('deleteToStartOfLine', () => { 90 | it('should delete to the start of the lines', () => { 91 | withMultipleSelections(editor as any, deleteToStartOfLine); 92 | 93 | const { doc, selections } = getDocumentAndSelection(editor); 94 | expect(doc).toEqual( 95 | `ipsum\ndolor sit\net\n\n` + `elit'\n(donec [mattis])\ntincidunt metus`, 96 | ); 97 | expect(selections).toEqual([ 98 | { 99 | anchor: expect.objectContaining({ line: 0, ch: 0 }), 100 | head: expect.objectContaining({ line: 0, ch: 0 }), 101 | }, 102 | { 103 | anchor: expect.objectContaining({ line: 2, ch: 0 }), 104 | head: expect.objectContaining({ line: 2, ch: 0 }), 105 | }, 106 | { 107 | anchor: expect.objectContaining({ line: 4, ch: 0 }), 108 | head: expect.objectContaining({ line: 4, ch: 0 }), 109 | }, 110 | ]); 111 | }); 112 | }); 113 | 114 | describe('deleteToEndOfLine', () => { 115 | it('should delete to the end of the lines', () => { 116 | withMultipleSelections(editor as any, deleteToEndOfLine); 117 | 118 | const { doc, selections } = getDocumentAndSelection(editor); 119 | expect(doc).toEqual( 120 | `lorem \ndolor sit\nam\n\n` + 121 | `consectetur "adip\n(donec [mattis])\ntincidunt metus`, 122 | ); 123 | expect(selections).toEqual([ 124 | { 125 | anchor: expect.objectContaining({ line: 0, ch: 6 }), 126 | head: expect.objectContaining({ line: 0, ch: 6 }), 127 | }, 128 | { 129 | anchor: expect.objectContaining({ line: 2, ch: 2 }), 130 | head: expect.objectContaining({ line: 2, ch: 2 }), 131 | }, 132 | { 133 | anchor: expect.objectContaining({ line: 4, ch: 17 }), 134 | head: expect.objectContaining({ line: 4, ch: 17 }), 135 | }, 136 | ]); 137 | }); 138 | }); 139 | 140 | describe('joinLines', () => { 141 | it('should join multiple selected lines', () => { 142 | withMultipleSelections(editor as any, joinLines, { 143 | ...defaultMultipleSelectionOptions, 144 | repeatSameLineActions: false, 145 | }); 146 | 147 | const { doc, selections } = getDocumentAndSelection(editor); 148 | expect(doc).toEqual( 149 | `lorem ipsum dolor sit\namet\nconsectetur "adipiscing" 'elit' ` + 150 | `(donec [mattis])\ntincidunt metus`, 151 | ); 152 | expect(selections).toEqual([ 153 | { 154 | anchor: expect.objectContaining({ line: 0, ch: 6 }), 155 | head: expect.objectContaining({ line: 0, ch: 17 }), 156 | }, 157 | { 158 | anchor: expect.objectContaining({ line: 1, ch: 4 }), 159 | head: expect.objectContaining({ line: 1, ch: 4 }), 160 | }, 161 | { 162 | anchor: expect.objectContaining({ line: 2, ch: 14 }), 163 | head: expect.objectContaining({ line: 2, ch: 17 }), 164 | }, 165 | ]); 166 | }); 167 | 168 | it('should not join the next line after the end of the document', () => { 169 | editor.setSelections([ 170 | ...editor.listSelections(), 171 | { anchor: { line: 6, ch: 6 }, head: { line: 6, ch: 6 } }, // tincid{<>}unt 172 | ]); 173 | 174 | withMultipleSelections(editor as any, joinLines, { 175 | ...defaultMultipleSelectionOptions, 176 | repeatSameLineActions: false, 177 | }); 178 | 179 | const { doc, selections } = getDocumentAndSelection(editor); 180 | expect(doc).toEqual( 181 | `lorem ipsum dolor sit\namet\nconsectetur "adipiscing" 'elit' ` + 182 | `(donec [mattis])\ntincidunt metus`, 183 | ); 184 | expect(selections).toEqual([ 185 | { 186 | anchor: expect.objectContaining({ line: 0, ch: 6 }), 187 | head: expect.objectContaining({ line: 0, ch: 17 }), 188 | }, 189 | { 190 | anchor: expect.objectContaining({ line: 1, ch: 4 }), 191 | head: expect.objectContaining({ line: 1, ch: 4 }), 192 | }, 193 | { 194 | anchor: expect.objectContaining({ line: 2, ch: 14 }), 195 | head: expect.objectContaining({ line: 2, ch: 17 }), 196 | }, 197 | { 198 | anchor: expect.objectContaining({ line: 3, ch: 15 }), 199 | head: expect.objectContaining({ line: 3, ch: 15 }), 200 | }, 201 | ]); 202 | }); 203 | 204 | it('should remove markdown list characters', () => { 205 | const content = '- aaa\n- bbb\n- ccc\n- ddd'; 206 | editor.setValue(content); 207 | editor.setSelections([ 208 | { anchor: { line: 0, ch: 3 }, head: { line: 0, ch: 3 } }, 209 | { anchor: { line: 2, ch: 4 }, head: { line: 2, ch: 4 } }, 210 | ]); 211 | 212 | withMultipleSelections(editor as any, joinLines); 213 | 214 | const { doc, selections } = getDocumentAndSelection(editor); 215 | expect(doc).toEqual('- aaa bbb\n- ccc ddd'); 216 | expect(selections).toEqual([ 217 | { 218 | anchor: expect.objectContaining({ line: 0, ch: 5 }), 219 | head: expect.objectContaining({ line: 0, ch: 5 }), 220 | }, 221 | { 222 | anchor: expect.objectContaining({ line: 1, ch: 5 }), 223 | head: expect.objectContaining({ line: 1, ch: 5 }), 224 | }, 225 | ]); 226 | }); 227 | }); 228 | 229 | describe('copyLine', () => { 230 | it('should copy selected lines up', () => { 231 | withMultipleSelections(editor as any, copyLine, { 232 | ...defaultMultipleSelectionOptions, 233 | args: 'up', 234 | }); 235 | 236 | const { doc, selections } = getDocumentAndSelection(editor); 237 | expect(doc).toEqual( 238 | `lorem ipsum\ndolor sit\nlorem ipsum\ndolor sit\namet\namet\n\n` + 239 | `consectetur "adipiscing" 'elit'\nconsectetur "adipiscing" 'elit'\n` + 240 | `consectetur "adipiscing" 'elit'\n(donec [mattis])\ntincidunt metus`, 241 | ); 242 | expect(selections).toEqual([ 243 | { 244 | anchor: expect.objectContaining({ line: 1, ch: 5 }), 245 | head: expect.objectContaining({ line: 0, ch: 6 }), 246 | }, 247 | { 248 | anchor: expect.objectContaining({ line: 4, ch: 2 }), 249 | head: expect.objectContaining({ line: 4, ch: 2 }), 250 | }, 251 | { 252 | anchor: expect.objectContaining({ line: 7, ch: 14 }), 253 | head: expect.objectContaining({ line: 7, ch: 17 }), 254 | }, 255 | { 256 | anchor: expect.objectContaining({ line: 7, ch: 26 }), 257 | head: expect.objectContaining({ line: 7, ch: 26 }), 258 | }, 259 | ]); 260 | }); 261 | 262 | it('should copy selected lines down', () => { 263 | withMultipleSelections(editor as any, copyLine, { 264 | ...defaultMultipleSelectionOptions, 265 | args: 'down', 266 | }); 267 | 268 | const { doc, selections } = getDocumentAndSelection(editor); 269 | expect(doc).toEqual( 270 | `lorem ipsum\ndolor sit\nlorem ipsum\ndolor sit\namet\namet\n\n` + 271 | `consectetur "adipiscing" 'elit'\nconsectetur "adipiscing" 'elit'\n` + 272 | `consectetur "adipiscing" 'elit'\n(donec [mattis])\ntincidunt metus`, 273 | ); 274 | expect(selections).toEqual([ 275 | { 276 | anchor: expect.objectContaining({ line: 2, ch: 6 }), 277 | head: expect.objectContaining({ line: 3, ch: 5 }), 278 | }, 279 | { 280 | anchor: expect.objectContaining({ line: 5, ch: 2 }), 281 | head: expect.objectContaining({ line: 5, ch: 2 }), 282 | }, 283 | { 284 | anchor: expect.objectContaining({ line: 8, ch: 14 }), 285 | head: expect.objectContaining({ line: 8, ch: 17 }), 286 | }, 287 | { 288 | anchor: expect.objectContaining({ line: 9, ch: 26 }), 289 | head: expect.objectContaining({ line: 9, ch: 26 }), 290 | }, 291 | ]); 292 | }); 293 | }); 294 | 295 | describe('selectWordOrNextOccurrence', () => { 296 | it('should select words', () => { 297 | selectWordOrNextOccurrence(editor as any); 298 | 299 | const { doc, selectedTextMultiple } = getDocumentAndSelection(editor); 300 | expect(doc).toEqual(originalDoc); 301 | expect(selectedTextMultiple).toEqual([ 302 | 'ipsum\ndolor', 303 | 'amet', 304 | 'dip', 305 | 'elit', 306 | ]); 307 | }); 308 | 309 | it('should not select next occurrence if multiple selection contents are not identical', () => { 310 | selectWordOrNextOccurrence(editor as any); 311 | selectWordOrNextOccurrence(editor as any); 312 | 313 | const { doc, selectedTextMultiple } = getDocumentAndSelection(editor); 314 | expect(doc).toEqual(originalDoc); 315 | expect(selectedTextMultiple).toEqual([ 316 | 'ipsum\ndolor', 317 | 'amet', 318 | 'dip', 319 | 'elit', 320 | ]); 321 | }); 322 | 323 | it('should select words containing unicode characters', () => { 324 | editor.setValue('café e açúcar'); 325 | editor.setSelections([ 326 | { anchor: { line: 0, ch: 2 }, head: { line: 0, ch: 2 } }, 327 | { anchor: { line: 0, ch: 8 }, head: { line: 0, ch: 8 } }, 328 | ]); 329 | 330 | selectWordOrNextOccurrence(editor as any); 331 | 332 | const { selectedTextMultiple } = getDocumentAndSelection(editor); 333 | expect(selectedTextMultiple[0]).toEqual('café'); 334 | expect(selectedTextMultiple[1]).toEqual('açúcar'); 335 | }); 336 | }); 337 | 338 | describe('selectAllOccurrences', () => { 339 | it('should not select all occurrences if multiple selection contents are not identical', () => { 340 | selectAllOccurrences(editor as any); 341 | 342 | const { doc, selections } = getDocumentAndSelection(editor); 343 | expect(doc).toEqual(originalDoc); 344 | expect(selections).toEqual(originalSelectionRanges); 345 | }); 346 | }); 347 | 348 | describe('selectLine', () => { 349 | it('should select lines', () => { 350 | withMultipleSelections(editor as any, selectLine); 351 | 352 | const { doc, selectedText } = getDocumentAndSelection(editor); 353 | expect(doc).toEqual(originalDoc); 354 | expect(selectedText).toEqual( 355 | `lorem ipsum\ndolor sit\namet\n\nconsectetur "adipiscing" 'elit'\n`, 356 | ); 357 | }); 358 | }); 359 | 360 | describe('addCursorsToSelectionEnds', () => { 361 | it('should not add cursors to selection ends', () => { 362 | addCursorsToSelectionEnds(editor as any, CODE_EDITOR.VSCODE); 363 | 364 | const { doc, selections } = getDocumentAndSelection(editor); 365 | expect(doc).toEqual(originalDoc); 366 | expect(selections).toEqual(originalSelectionRanges); 367 | }); 368 | }); 369 | 370 | describe('goToLineBoundary', () => { 371 | it('should go to line starts', () => { 372 | withMultipleSelections(editor as any, goToLineBoundary, { 373 | ...defaultMultipleSelectionOptions, 374 | args: 'start', 375 | }); 376 | 377 | const { doc, selections } = getDocumentAndSelection(editor); 378 | expect(doc).toEqual(originalDoc); 379 | expect(selections).toEqual([ 380 | { 381 | anchor: expect.objectContaining({ line: 0, ch: 0 }), 382 | head: expect.objectContaining({ line: 0, ch: 0 }), 383 | }, 384 | { 385 | anchor: expect.objectContaining({ line: 2, ch: 0 }), 386 | head: expect.objectContaining({ line: 2, ch: 0 }), 387 | }, 388 | { 389 | anchor: expect.objectContaining({ line: 4, ch: 0 }), 390 | head: expect.objectContaining({ line: 4, ch: 0 }), 391 | }, 392 | ]); 393 | }); 394 | 395 | it('should go to line ends', () => { 396 | withMultipleSelections(editor as any, goToLineBoundary, { 397 | ...defaultMultipleSelectionOptions, 398 | args: 'end', 399 | }); 400 | 401 | const { doc, selections } = getDocumentAndSelection(editor); 402 | expect(doc).toEqual(originalDoc); 403 | expect(selections).toEqual([ 404 | { 405 | anchor: expect.objectContaining({ line: 1, ch: 9 }), 406 | head: expect.objectContaining({ line: 1, ch: 9 }), 407 | }, 408 | { 409 | anchor: expect.objectContaining({ line: 2, ch: 4 }), 410 | head: expect.objectContaining({ line: 2, ch: 4 }), 411 | }, 412 | { 413 | anchor: expect.objectContaining({ line: 4, ch: 31 }), 414 | head: expect.objectContaining({ line: 4, ch: 31 }), 415 | }, 416 | ]); 417 | }); 418 | }); 419 | 420 | describe('navigateLine', () => { 421 | it('should navigate to the previous lines', () => { 422 | withMultipleSelections(editor as any, navigateLine, { 423 | ...defaultMultipleSelectionOptions, 424 | args: 'prev', 425 | }); 426 | 427 | const { doc, selections } = getDocumentAndSelection(editor); 428 | expect(doc).toEqual(originalDoc); 429 | expect(selections).toEqual([ 430 | { 431 | anchor: expect.objectContaining({ line: 0, ch: 6 }), 432 | head: expect.objectContaining({ line: 0, ch: 6 }), 433 | }, 434 | { 435 | anchor: expect.objectContaining({ line: 1, ch: 2 }), 436 | head: expect.objectContaining({ line: 1, ch: 2 }), 437 | }, 438 | { 439 | anchor: expect.objectContaining({ line: 3, ch: 0 }), 440 | head: expect.objectContaining({ line: 3, ch: 0 }), 441 | }, 442 | ]); 443 | }); 444 | 445 | it('should navigate to the next lines', () => { 446 | withMultipleSelections(editor as any, navigateLine, { 447 | ...defaultMultipleSelectionOptions, 448 | args: 'next', 449 | }); 450 | 451 | const { doc, selections } = getDocumentAndSelection(editor); 452 | expect(doc).toEqual(originalDoc); 453 | expect(selections).toEqual([ 454 | { 455 | anchor: expect.objectContaining({ line: 1, ch: 6 }), 456 | head: expect.objectContaining({ line: 1, ch: 6 }), 457 | }, 458 | { 459 | anchor: expect.objectContaining({ line: 3, ch: 0 }), 460 | head: expect.objectContaining({ line: 3, ch: 0 }), 461 | }, 462 | { 463 | anchor: expect.objectContaining({ line: 5, ch: 16 }), 464 | head: expect.objectContaining({ line: 5, ch: 16 }), 465 | }, 466 | ]); 467 | }); 468 | 469 | it('should navigate to the first line', () => { 470 | withMultipleSelections(editor as any, navigateLine, { 471 | ...defaultMultipleSelectionOptions, 472 | args: 'first', 473 | }); 474 | 475 | const { doc, selections } = getDocumentAndSelection(editor); 476 | expect(doc).toEqual(originalDoc); 477 | expect(selections).toEqual([ 478 | { 479 | anchor: expect.objectContaining({ line: 0, ch: 0 }), 480 | head: expect.objectContaining({ line: 0, ch: 0 }), 481 | }, 482 | ]); 483 | }); 484 | 485 | it('should navigate to the last line', () => { 486 | withMultipleSelections(editor as any, navigateLine, { 487 | ...defaultMultipleSelectionOptions, 488 | args: 'last', 489 | }); 490 | 491 | const { doc, selections } = getDocumentAndSelection(editor); 492 | expect(doc).toEqual(originalDoc); 493 | expect(selections).toEqual([ 494 | { 495 | anchor: expect.objectContaining({ line: 6, ch: 15 }), 496 | head: expect.objectContaining({ line: 6, ch: 15 }), 497 | }, 498 | ]); 499 | }); 500 | }); 501 | 502 | describe('transformCase', () => { 503 | it('should transform to uppercase', () => { 504 | withMultipleSelections(editor as any, transformCase, { 505 | ...defaultMultipleSelectionOptions, 506 | args: CASE.UPPER, 507 | }); 508 | 509 | const { doc, selections } = getDocumentAndSelection(editor); 510 | expect(doc).toEqual( 511 | `lorem IPSUM\nDOLOR sit\nAMET\n\nconsectetur ` + 512 | `"aDIPiscing" 'ELIT'\n(donec [mattis])\ntincidunt metus`, 513 | ); 514 | expect(selections).toEqual(originalSelectionRanges); 515 | }); 516 | 517 | it('should transform to lowercase', () => { 518 | withMultipleSelections(editor as any, transformCase, { 519 | ...defaultMultipleSelectionOptions, 520 | args: CASE.LOWER, 521 | }); 522 | 523 | const { doc, selections } = getDocumentAndSelection(editor); 524 | expect(doc).toEqual( 525 | `lorem ipsum\ndolor sit\namet\n\nconsectetur ` + 526 | `"adipiscing" 'elit'\n(donec [mattis])\ntincidunt metus`, 527 | ); 528 | expect(selections).toEqual(originalSelectionRanges); 529 | }); 530 | 531 | it('should transform to title case', () => { 532 | withMultipleSelections(editor as any, transformCase, { 533 | ...defaultMultipleSelectionOptions, 534 | args: CASE.TITLE, 535 | }); 536 | 537 | const { doc, selections } = getDocumentAndSelection(editor); 538 | expect(doc).toEqual( 539 | `lorem Ipsum\nDolor sit\nAmet\n\nconsectetur ` + 540 | `"aDipiscing" 'Elit'\n(donec [mattis])\ntincidunt metus`, 541 | ); 542 | expect(selections).toEqual(originalSelectionRanges); 543 | }); 544 | 545 | it('should cycle to next case', () => { 546 | withMultipleSelections(editor as any, transformCase, { 547 | ...defaultMultipleSelectionOptions, 548 | args: CASE.NEXT, 549 | }); 550 | 551 | const { doc, selections } = getDocumentAndSelection(editor); 552 | expect(doc).toEqual( 553 | `lorem Ipsum\nDolor sit\nAmet\n\nconsectetur ` + 554 | `"aDipiscing" 'Elit'\n(donec [mattis])\ntincidunt metus`, 555 | ); 556 | expect(selections).toEqual(originalSelectionRanges); 557 | }); 558 | }); 559 | 560 | describe('expandSelectionToBrackets', () => { 561 | it('should expand selections to brackets', () => { 562 | editor.setSelections([ 563 | ...editor.listSelections(), 564 | { anchor: { line: 5, ch: 6 }, head: { line: 5, ch: 6 } }, // (donec{<>} 565 | ]); 566 | 567 | withMultipleSelections(editor as any, expandSelectionToBrackets); 568 | 569 | const { doc, selections } = getDocumentAndSelection(editor); 570 | expect(doc).toEqual(originalDoc); 571 | expect(selections).toEqual([ 572 | ...originalSelectionRanges, 573 | { 574 | anchor: expect.objectContaining({ 575 | line: 5, 576 | ch: 1, 577 | }), 578 | head: expect.objectContaining({ 579 | line: 5, 580 | ch: 15, 581 | }), 582 | }, 583 | ]); 584 | }); 585 | }); 586 | 587 | describe('expandSelectionToQuotes', () => { 588 | it('should expand selections to quotes', () => { 589 | withMultipleSelections(editor as any, expandSelectionToQuotes); 590 | 591 | const { doc, selections } = getDocumentAndSelection(editor); 592 | expect(doc).toEqual(originalDoc); 593 | expect(selections).toEqual([ 594 | { 595 | anchor: expect.objectContaining({ 596 | line: 1, 597 | ch: 5, 598 | }), 599 | head: expect.objectContaining({ 600 | line: 0, 601 | ch: 6, 602 | }), 603 | }, 604 | { 605 | anchor: expect.objectContaining({ 606 | line: 2, 607 | ch: 2, 608 | }), 609 | head: expect.objectContaining({ 610 | line: 2, 611 | ch: 2, 612 | }), 613 | }, 614 | { 615 | anchor: expect.objectContaining({ 616 | line: 4, 617 | ch: 13, 618 | }), 619 | head: expect.objectContaining({ 620 | line: 4, 621 | ch: 23, 622 | }), 623 | }, 624 | { 625 | anchor: expect.objectContaining({ 626 | line: 4, 627 | ch: 26, 628 | }), 629 | head: expect.objectContaining({ 630 | line: 4, 631 | ch: 30, 632 | }), 633 | }, 634 | ]); 635 | }); 636 | }); 637 | 638 | describe.skip('undo: sanity check', () => { 639 | it('should group changes as a single transaction', () => { 640 | let doc: string; 641 | let selections: any; 642 | const expectedDoc = 643 | `\nlorem ipsum\ndolor sit\n\namet\n\n\n\n` + 644 | `consectetur "adipiscing" 'elit'\n(donec [mattis])\ntincidunt metus`; 645 | const expectedSelectionRanges = [ 646 | { 647 | anchor: expect.objectContaining({ line: 0, ch: 0 }), 648 | head: expect.objectContaining({ line: 0, ch: 0 }), 649 | }, 650 | { 651 | anchor: expect.objectContaining({ line: 3, ch: 0 }), 652 | head: expect.objectContaining({ line: 3, ch: 0 }), 653 | }, 654 | { 655 | anchor: expect.objectContaining({ line: 6, ch: 0 }), 656 | head: expect.objectContaining({ line: 6, ch: 0 }), 657 | }, 658 | { 659 | anchor: expect.objectContaining({ line: 7, ch: 0 }), 660 | head: expect.objectContaining({ line: 7, ch: 0 }), 661 | }, 662 | ]; 663 | 664 | // @ts-expect-error - the new version of insertLineAbove is 665 | // incompatible with `withMultipleSelections` 666 | withMultipleSelections(editor as any, insertLineAbove); 667 | 668 | ({ doc, selections } = getDocumentAndSelection(editor)); 669 | expect(doc).toEqual(expectedDoc); 670 | expect(selections).toEqual(expectedSelectionRanges); 671 | 672 | editor.undo(); 673 | 674 | ({ doc, selections } = getDocumentAndSelection(editor)); 675 | expect(doc).toEqual(originalDoc); 676 | expect(selections).toEqual(originalSelectionRanges); 677 | }); 678 | }); 679 | 680 | describe('expandSelectionToQuotesOrBrackets', () => { 681 | it.each([ 682 | ['quotes', '("lorem ipsum" dolor)'], 683 | ['brackets', '"(lorem ipsum) dolor"'], 684 | ])( 685 | 'should only expand the first selection to %s and leave the others untouched', 686 | (_scenario, content) => { 687 | editor.setValue(content); 688 | editor.setSelections([ 689 | { anchor: { line: 0, ch: 5 }, head: { line: 0, ch: 11 } }, 690 | { anchor: { line: 0, ch: 18 }, head: { line: 0, ch: 18 } }, 691 | ]); 692 | 693 | expandSelectionToQuotesOrBrackets(editor as any); 694 | 695 | const { doc, selections, selectedTextMultiple } = 696 | getDocumentAndSelection(editor); 697 | expect(doc).toEqual(content); 698 | expect(selections).toEqual([ 699 | { 700 | anchor: expect.objectContaining({ 701 | line: 0, 702 | ch: 2, 703 | }), 704 | head: expect.objectContaining({ 705 | line: 0, 706 | ch: 13, 707 | }), 708 | }, 709 | { anchor: { line: 0, ch: 18 }, head: { line: 0, ch: 18 } }, 710 | ]); 711 | expect(selectedTextMultiple[0]).toEqual('lorem ipsum'); 712 | }, 713 | ); 714 | }); 715 | 716 | describe('insertCursorAbove', () => { 717 | it('should insert cursors above', () => { 718 | editor.setValue('aaaaa\nbbbbb\nccccc\nddddd'); 719 | editor.setSelections([ 720 | { anchor: { line: 1, ch: 1 }, head: { line: 1, ch: 3 } }, 721 | { anchor: { line: 3, ch: 2 }, head: { line: 3, ch: 2 } }, 722 | ]); 723 | 724 | insertCursorAbove(editor as any); 725 | 726 | const { doc, selections } = getDocumentAndSelection(editor); 727 | expect(doc).toEqual('aaaaa\nbbbbb\nccccc\nddddd'); 728 | expect(selections).toEqual([ 729 | { anchor: { line: 0, ch: 1 }, head: { line: 0, ch: 3 } }, 730 | { anchor: { line: 1, ch: 1 }, head: { line: 1, ch: 3 } }, 731 | { anchor: { line: 2, ch: 2 }, head: { line: 2, ch: 2 } }, 732 | { anchor: { line: 3, ch: 2 }, head: { line: 3, ch: 2 } }, 733 | ]); 734 | }); 735 | }); 736 | 737 | describe('insertCursorBelow', () => { 738 | it('should insert cursors below', () => { 739 | editor.setValue('aaaaa\nbbbbb\nccccc\nddddd'); 740 | editor.setSelections([ 741 | { anchor: { line: 0, ch: 1 }, head: { line: 0, ch: 3 } }, 742 | { anchor: { line: 2, ch: 2 }, head: { line: 2, ch: 2 } }, 743 | ]); 744 | 745 | insertCursorBelow(editor as any); 746 | 747 | const { doc, selections } = getDocumentAndSelection(editor); 748 | expect(doc).toEqual('aaaaa\nbbbbb\nccccc\nddddd'); 749 | expect(selections).toEqual([ 750 | { anchor: { line: 0, ch: 1 }, head: { line: 0, ch: 3 } }, 751 | { anchor: { line: 1, ch: 1 }, head: { line: 1, ch: 3 } }, 752 | { anchor: { line: 2, ch: 2 }, head: { line: 2, ch: 2 } }, 753 | { anchor: { line: 3, ch: 2 }, head: { line: 3, ch: 2 } }, 754 | ]); 755 | }); 756 | }); 757 | }); 758 | -------------------------------------------------------------------------------- /src/__tests__/actions-range.spec.ts: -------------------------------------------------------------------------------- 1 | import CodeMirror from 'codemirror'; 2 | import type { Editor } from 'codemirror'; 3 | import { getDocumentAndSelection } from './test-helpers'; 4 | import { 5 | deleteToStartOfLine, 6 | deleteToEndOfLine, 7 | joinLines, 8 | copyLine, 9 | selectWordOrNextOccurrence, 10 | selectAllOccurrences, 11 | selectLine, 12 | goToLineBoundary, 13 | navigateLine, 14 | transformCase, 15 | expandSelectionToBrackets, 16 | expandSelectionToQuotes, 17 | expandSelectionToQuotesOrBrackets, 18 | addCursorsToSelectionEnds, 19 | setIsManualSelection, 20 | insertCursorAbove, 21 | insertCursorBelow, 22 | } from '../actions'; 23 | import { CASE, CODE_EDITOR } from '../constants'; 24 | import { withMultipleSelections } from '../utils'; 25 | 26 | // fixes jsdom type error - https://github.com/jsdom/jsdom/issues/3002#issuecomment-655748833 27 | document.createRange = () => { 28 | const range = new Range(); 29 | 30 | range.getBoundingClientRect = jest.fn(); 31 | 32 | range.getClientRects = jest.fn(() => ({ 33 | item: () => null, 34 | length: 0, 35 | })); 36 | 37 | return range; 38 | }; 39 | 40 | describe('Code Editor Shortcuts: actions - single range selection', () => { 41 | let editor: Editor; 42 | const originalDoc = 'lorem ipsum\ndolor sit\namet'; 43 | 44 | beforeAll(() => { 45 | editor = CodeMirror(document.body); 46 | 47 | // To make cm.operation() work, since editor here already refers to the 48 | // CodeMirror object 49 | (editor as any).cm = editor; 50 | 51 | // Assign the CodeMirror equivalents of posToOffset and offsetToPos 52 | (editor as any).posToOffset = editor.indexFromPos; 53 | (editor as any).offsetToPos = editor.posFromIndex; 54 | }); 55 | 56 | beforeEach(() => { 57 | editor.setValue(originalDoc); 58 | editor.setSelection({ line: 0, ch: 6 }, { line: 1, ch: 5 }); 59 | }); 60 | 61 | describe('deleteToStartOfLine', () => { 62 | it('should delete to the start of the line', () => { 63 | withMultipleSelections(editor as any, deleteToStartOfLine); 64 | 65 | const { doc } = getDocumentAndSelection(editor); 66 | expect(doc).toEqual('lorem ipsum\n sit\namet'); 67 | }); 68 | }); 69 | 70 | describe('deleteToEndOfLine', () => { 71 | it('should delete to the end of the line', () => { 72 | withMultipleSelections(editor as any, deleteToEndOfLine); 73 | 74 | const { doc } = getDocumentAndSelection(editor); 75 | expect(doc).toEqual('lorem ipsum\ndolor\namet'); 76 | }); 77 | }); 78 | 79 | describe('joinLines', () => { 80 | it('should join multiple selected lines', () => { 81 | withMultipleSelections(editor as any, joinLines); 82 | 83 | const { doc, selections } = getDocumentAndSelection(editor); 84 | expect(doc).toEqual('lorem ipsum dolor sit\namet'); 85 | expect(selections[0]).toEqual({ 86 | anchor: expect.objectContaining({ line: 0, ch: 6 }), 87 | head: expect.objectContaining({ line: 0, ch: 17 }), 88 | }); 89 | }); 90 | 91 | it('should join the same text from either direction', () => { 92 | editor.setSelection({ line: 1, ch: 5 }, { line: 0, ch: 6 }); 93 | 94 | withMultipleSelections(editor as any, joinLines); 95 | 96 | const { doc, selections } = getDocumentAndSelection(editor); 97 | expect(doc).toEqual('lorem ipsum dolor sit\namet'); 98 | expect(selections[0]).toEqual({ 99 | anchor: expect.objectContaining({ line: 0, ch: 6 }), 100 | head: expect.objectContaining({ line: 0, ch: 17 }), 101 | }); 102 | }); 103 | 104 | it('should not join next line when at the end of the document', () => { 105 | editor.setSelection({ line: 2, ch: 4 }, { line: 2, ch: 0 }); 106 | 107 | withMultipleSelections(editor as any, joinLines); 108 | 109 | const { doc, selections } = getDocumentAndSelection(editor); 110 | expect(doc).toEqual(originalDoc); 111 | expect(selections[0]).toEqual({ 112 | anchor: expect.objectContaining({ line: 2, ch: 0 }), 113 | head: expect.objectContaining({ line: 2, ch: 4 }), 114 | }); 115 | }); 116 | 117 | it('should remove markdown list characters', () => { 118 | const content = '- aaa\n- bbb\n- ccc'; 119 | editor.setValue(content); 120 | editor.setSelection({ line: 2, ch: 4 }, { line: 0, ch: 3 }); 121 | 122 | withMultipleSelections(editor as any, joinLines); 123 | 124 | const { doc, selections } = getDocumentAndSelection(editor); 125 | expect(doc).toEqual('- aaa bbb ccc'); 126 | expect(selections[0]).toEqual({ 127 | anchor: expect.objectContaining({ line: 0, ch: 3 }), 128 | head: expect.objectContaining({ line: 0, ch: 12 }), 129 | }); 130 | }); 131 | }); 132 | 133 | describe('copyLine', () => { 134 | it('should copy selected lines up', () => { 135 | withMultipleSelections(editor as any, copyLine, { args: 'up' }); 136 | 137 | const { doc, selections } = getDocumentAndSelection(editor); 138 | expect(doc).toEqual( 139 | 'lorem ipsum\ndolor sit\nlorem ipsum\ndolor sit\namet', 140 | ); 141 | expect(selections[0]).toEqual({ 142 | anchor: expect.objectContaining({ line: 0, ch: 6 }), 143 | head: expect.objectContaining({ line: 1, ch: 5 }), 144 | }); 145 | }); 146 | 147 | it('should copy selected lines down', () => { 148 | withMultipleSelections(editor as any, copyLine, { args: 'down' }); 149 | 150 | const { doc, selections } = getDocumentAndSelection(editor); 151 | expect(doc).toEqual( 152 | 'lorem ipsum\ndolor sit\nlorem ipsum\ndolor sit\namet', 153 | ); 154 | expect(selections[0]).toEqual({ 155 | anchor: expect.objectContaining({ line: 2, ch: 6 }), 156 | head: expect.objectContaining({ line: 3, ch: 5 }), 157 | }); 158 | }); 159 | 160 | it('should exclude line starting at trailing newline from being duplicated', () => { 161 | editor.setSelection({ line: 0, ch: 0 }, { line: 1, ch: 0 }); 162 | 163 | withMultipleSelections(editor as any, copyLine, { args: 'down' }); 164 | 165 | const { doc, selections } = getDocumentAndSelection(editor); 166 | expect(doc).toEqual('lorem ipsum\nlorem ipsum\ndolor sit\namet'); 167 | expect(selections[0]).toEqual({ 168 | anchor: expect.objectContaining({ line: 1, ch: 0 }), 169 | head: expect.objectContaining({ line: 2, ch: 0 }), 170 | }); 171 | }); 172 | }); 173 | 174 | describe('selectWordOrNextOccurrence', () => { 175 | const originalDocRepeated = `${originalDoc}\n${originalDoc}\n${originalDoc}`; 176 | 177 | it('should not select additional words', () => { 178 | selectWordOrNextOccurrence(editor as any); 179 | 180 | const { doc, selectedText } = getDocumentAndSelection(editor); 181 | expect(doc).toEqual(originalDoc); 182 | expect(selectedText).toEqual('ipsum\ndolor'); 183 | }); 184 | 185 | it('should select next occurrence of selection', () => { 186 | editor.setValue(originalDocRepeated); 187 | editor.setSelection({ line: 1, ch: 6 }, { line: 1, ch: 9 }); 188 | 189 | selectWordOrNextOccurrence(editor as any); 190 | selectWordOrNextOccurrence(editor as any); 191 | 192 | const { doc, selections } = getDocumentAndSelection(editor); 193 | expect(doc).toEqual(originalDocRepeated); 194 | expect(selections).toEqual([ 195 | { 196 | anchor: expect.objectContaining({ line: 1, ch: 6 }), 197 | head: expect.objectContaining({ line: 1, ch: 9 }), 198 | }, 199 | { 200 | anchor: expect.objectContaining({ line: 4, ch: 6 }), 201 | head: expect.objectContaining({ line: 4, ch: 9 }), 202 | }, 203 | { 204 | anchor: expect.objectContaining({ line: 7, ch: 6 }), 205 | head: expect.objectContaining({ line: 7, ch: 9 }), 206 | }, 207 | ]); 208 | }); 209 | 210 | it('should select next occurrence of selection across newlines', () => { 211 | editor.setValue(originalDocRepeated); 212 | editor.setSelection({ line: 1, ch: 5 }, { line: 0, ch: 6 }); 213 | 214 | selectWordOrNextOccurrence(editor as any); 215 | selectWordOrNextOccurrence(editor as any); 216 | 217 | const { doc, selections } = getDocumentAndSelection(editor); 218 | expect(doc).toEqual(originalDocRepeated); 219 | expect(selections).toEqual([ 220 | { 221 | anchor: expect.objectContaining({ line: 1, ch: 5 }), 222 | head: expect.objectContaining({ line: 0, ch: 6 }), 223 | }, 224 | { 225 | anchor: expect.objectContaining({ line: 3, ch: 6 }), 226 | head: expect.objectContaining({ line: 4, ch: 5 }), 227 | }, 228 | { 229 | anchor: expect.objectContaining({ line: 6, ch: 6 }), 230 | head: expect.objectContaining({ line: 7, ch: 5 }), 231 | }, 232 | ]); 233 | }); 234 | 235 | it('should only select whole words if initial selection was made programmatically', () => { 236 | setIsManualSelection(false); 237 | editor.setValue('sit sit situation sit'); 238 | editor.setSelection({ line: 0, ch: 0 }, { line: 0, ch: 3 }); 239 | 240 | selectWordOrNextOccurrence(editor as any); 241 | selectWordOrNextOccurrence(editor as any); 242 | selectWordOrNextOccurrence(editor as any); 243 | 244 | const { doc, selections } = getDocumentAndSelection(editor); 245 | expect(doc).toEqual('sit sit situation sit'); 246 | expect(selections).toEqual([ 247 | { 248 | anchor: expect.objectContaining({ line: 0, ch: 0 }), 249 | head: expect.objectContaining({ line: 0, ch: 3 }), 250 | }, 251 | { 252 | anchor: expect.objectContaining({ line: 0, ch: 4 }), 253 | head: expect.objectContaining({ line: 0, ch: 7 }), 254 | }, 255 | { 256 | anchor: expect.objectContaining({ line: 0, ch: 18 }), 257 | head: expect.objectContaining({ line: 0, ch: 21 }), 258 | }, 259 | ]); 260 | }); 261 | 262 | it('should select within words if initial selection was made manually', () => { 263 | setIsManualSelection(true); 264 | editor.setValue('sit sit situation sit'); 265 | editor.setSelection({ line: 0, ch: 0 }, { line: 0, ch: 3 }); 266 | 267 | selectWordOrNextOccurrence(editor as any); 268 | selectWordOrNextOccurrence(editor as any); 269 | selectWordOrNextOccurrence(editor as any); 270 | 271 | const { doc, selections } = getDocumentAndSelection(editor); 272 | expect(doc).toEqual('sit sit situation sit'); 273 | expect(selections).toEqual([ 274 | { 275 | anchor: expect.objectContaining({ line: 0, ch: 0 }), 276 | head: expect.objectContaining({ line: 0, ch: 3 }), 277 | }, 278 | { 279 | anchor: expect.objectContaining({ line: 0, ch: 4 }), 280 | head: expect.objectContaining({ line: 0, ch: 7 }), 281 | }, 282 | { 283 | anchor: expect.objectContaining({ line: 0, ch: 8 }), 284 | head: expect.objectContaining({ line: 0, ch: 11 }), 285 | }, 286 | { 287 | anchor: expect.objectContaining({ line: 0, ch: 18 }), 288 | head: expect.objectContaining({ line: 0, ch: 21 }), 289 | }, 290 | ]); 291 | }); 292 | 293 | it('should escape reserved regex characters when finding a match', () => { 294 | editor.setValue('(hello)\n(hello)\n(hello)'); 295 | editor.setSelection({ line: 0, ch: 0 }, { line: 0, ch: 7 }); 296 | 297 | selectWordOrNextOccurrence(editor as any); 298 | selectWordOrNextOccurrence(editor as any); 299 | 300 | const { selections } = getDocumentAndSelection(editor); 301 | expect(selections).toEqual([ 302 | { 303 | anchor: expect.objectContaining({ line: 0, ch: 0 }), 304 | head: expect.objectContaining({ line: 0, ch: 7 }), 305 | }, 306 | { 307 | anchor: expect.objectContaining({ line: 1, ch: 0 }), 308 | head: expect.objectContaining({ line: 1, ch: 7 }), 309 | }, 310 | { 311 | anchor: expect.objectContaining({ line: 2, ch: 0 }), 312 | head: expect.objectContaining({ line: 2, ch: 7 }), 313 | }, 314 | ]); 315 | }); 316 | 317 | it('should loop around to beginning when selecting next occurrence', () => { 318 | editor.setValue(originalDocRepeated); 319 | editor.setSelection({ line: 4, ch: 0 }, { line: 4, ch: 5 }); 320 | 321 | selectWordOrNextOccurrence(editor as any); 322 | selectWordOrNextOccurrence(editor as any); 323 | 324 | const { doc, selections } = getDocumentAndSelection(editor); 325 | expect(doc).toEqual(originalDocRepeated); 326 | expect(selections).toEqual([ 327 | { 328 | anchor: expect.objectContaining({ line: 1, ch: 0 }), 329 | head: expect.objectContaining({ line: 1, ch: 5 }), 330 | }, 331 | { 332 | anchor: expect.objectContaining({ line: 4, ch: 0 }), 333 | head: expect.objectContaining({ line: 4, ch: 5 }), 334 | }, 335 | { 336 | anchor: expect.objectContaining({ line: 7, ch: 0 }), 337 | head: expect.objectContaining({ line: 7, ch: 5 }), 338 | }, 339 | ]); 340 | }); 341 | 342 | describe('with words containing unicode characters', () => { 343 | it('should only select whole words if initial selection was made programmatically', () => { 344 | setIsManualSelection(false); 345 | editor.setValue('café café cafés café'); 346 | editor.setSelection({ line: 0, ch: 5 }, { line: 0, ch: 9 }); 347 | 348 | selectWordOrNextOccurrence(editor as any); 349 | selectWordOrNextOccurrence(editor as any); 350 | selectWordOrNextOccurrence(editor as any); 351 | 352 | const { doc, selections } = getDocumentAndSelection(editor); 353 | expect(doc).toEqual('café café cafés café'); 354 | expect(selections).toEqual([ 355 | { 356 | anchor: expect.objectContaining({ line: 0, ch: 0 }), 357 | head: expect.objectContaining({ line: 0, ch: 4 }), 358 | }, 359 | { 360 | anchor: expect.objectContaining({ line: 0, ch: 5 }), 361 | head: expect.objectContaining({ line: 0, ch: 9 }), 362 | }, 363 | { 364 | anchor: expect.objectContaining({ line: 0, ch: 16 }), 365 | head: expect.objectContaining({ line: 0, ch: 20 }), 366 | }, 367 | ]); 368 | }); 369 | 370 | it('should select within words if initial selection was made manually', () => { 371 | setIsManualSelection(true); 372 | editor.setValue('café café cafés café'); 373 | editor.setSelection({ line: 0, ch: 5 }, { line: 0, ch: 9 }); 374 | 375 | selectWordOrNextOccurrence(editor as any); 376 | selectWordOrNextOccurrence(editor as any); 377 | selectWordOrNextOccurrence(editor as any); 378 | 379 | const { doc, selections } = getDocumentAndSelection(editor); 380 | expect(doc).toEqual('café café cafés café'); 381 | expect(selections).toEqual([ 382 | { 383 | anchor: expect.objectContaining({ line: 0, ch: 0 }), 384 | head: expect.objectContaining({ line: 0, ch: 4 }), 385 | }, 386 | { 387 | anchor: expect.objectContaining({ line: 0, ch: 5 }), 388 | head: expect.objectContaining({ line: 0, ch: 9 }), 389 | }, 390 | { 391 | anchor: expect.objectContaining({ line: 0, ch: 10 }), 392 | head: expect.objectContaining({ line: 0, ch: 14 }), 393 | }, 394 | { 395 | anchor: expect.objectContaining({ line: 0, ch: 16 }), 396 | head: expect.objectContaining({ line: 0, ch: 20 }), 397 | }, 398 | ]); 399 | }); 400 | }); 401 | }); 402 | 403 | describe('selectAllOccurrences', () => { 404 | const originalDocRepeated = `${originalDoc}\n${originalDoc}\n${originalDoc}`; 405 | 406 | it('should select all occurrences of selection', () => { 407 | editor.setValue(originalDocRepeated); 408 | editor.setSelection({ line: 0, ch: 6 }, { line: 0, ch: 11 }); 409 | 410 | selectAllOccurrences(editor as any); 411 | 412 | const { doc, selections } = getDocumentAndSelection(editor); 413 | expect(doc).toEqual(originalDocRepeated); 414 | expect(selections).toEqual([ 415 | { 416 | anchor: expect.objectContaining({ line: 0, ch: 6 }), 417 | head: expect.objectContaining({ line: 0, ch: 11 }), 418 | }, 419 | { 420 | anchor: expect.objectContaining({ line: 3, ch: 6 }), 421 | head: expect.objectContaining({ line: 3, ch: 11 }), 422 | }, 423 | { 424 | anchor: expect.objectContaining({ line: 6, ch: 6 }), 425 | head: expect.objectContaining({ line: 6, ch: 11 }), 426 | }, 427 | ]); 428 | }); 429 | 430 | it('should select all occurrences of selection across newlines', () => { 431 | editor.setValue(originalDocRepeated); 432 | editor.setSelection({ line: 4, ch: 5 }, { line: 3, ch: 6 }); 433 | 434 | selectAllOccurrences(editor as any); 435 | 436 | const { doc, selections } = getDocumentAndSelection(editor); 437 | expect(doc).toEqual(originalDocRepeated); 438 | expect(selections).toEqual([ 439 | { 440 | anchor: expect.objectContaining({ line: 0, ch: 6 }), 441 | head: expect.objectContaining({ line: 1, ch: 5 }), 442 | }, 443 | { 444 | anchor: expect.objectContaining({ line: 3, ch: 6 }), 445 | head: expect.objectContaining({ line: 4, ch: 5 }), 446 | }, 447 | { 448 | anchor: expect.objectContaining({ line: 6, ch: 6 }), 449 | head: expect.objectContaining({ line: 7, ch: 5 }), 450 | }, 451 | ]); 452 | }); 453 | 454 | it('should select all occurrences of selection containing unicode characters', () => { 455 | editor.setValue('ああ ああ ああ'); 456 | editor.setSelection({ line: 0, ch: 6 }, { line: 0, ch: 8 }); 457 | 458 | selectAllOccurrences(editor as any); 459 | 460 | const { doc, selections } = getDocumentAndSelection(editor); 461 | expect(doc).toEqual('ああ ああ ああ'); 462 | expect(selections).toEqual([ 463 | { 464 | anchor: expect.objectContaining({ line: 0, ch: 0 }), 465 | head: expect.objectContaining({ line: 0, ch: 2 }), 466 | }, 467 | { 468 | anchor: expect.objectContaining({ line: 0, ch: 3 }), 469 | head: expect.objectContaining({ line: 0, ch: 5 }), 470 | }, 471 | { 472 | anchor: expect.objectContaining({ line: 0, ch: 6 }), 473 | head: expect.objectContaining({ line: 0, ch: 8 }), 474 | }, 475 | ]); 476 | }); 477 | 478 | it('should escape reserved regex characters when finding a match', () => { 479 | editor.setValue('(hello)\n(hello)\n(hello)'); 480 | editor.setSelection({ line: 0, ch: 0 }, { line: 0, ch: 7 }); 481 | 482 | selectAllOccurrences(editor as any); 483 | 484 | const { selections } = getDocumentAndSelection(editor); 485 | expect(selections).toEqual([ 486 | { 487 | anchor: expect.objectContaining({ line: 0, ch: 0 }), 488 | head: expect.objectContaining({ line: 0, ch: 7 }), 489 | }, 490 | { 491 | anchor: expect.objectContaining({ line: 1, ch: 0 }), 492 | head: expect.objectContaining({ line: 1, ch: 7 }), 493 | }, 494 | { 495 | anchor: expect.objectContaining({ line: 2, ch: 0 }), 496 | head: expect.objectContaining({ line: 2, ch: 7 }), 497 | }, 498 | ]); 499 | }); 500 | }); 501 | 502 | describe('selectLine', () => { 503 | it('should select lines', () => { 504 | withMultipleSelections(editor as any, selectLine); 505 | 506 | const { doc, selectedText } = getDocumentAndSelection(editor); 507 | expect(doc).toEqual(originalDoc); 508 | expect(selectedText).toEqual('lorem ipsum\ndolor sit\n'); 509 | }); 510 | }); 511 | 512 | describe('addCursorsToSelectionEnds', () => { 513 | it('should add cursors to selection ends when emulating VS Code', () => { 514 | addCursorsToSelectionEnds(editor as any, CODE_EDITOR.VSCODE); 515 | 516 | const { doc, selections } = getDocumentAndSelection(editor); 517 | expect(doc).toEqual(originalDoc); 518 | expect(selections).toEqual([ 519 | { 520 | anchor: expect.objectContaining({ line: 0, ch: 11 }), 521 | head: expect.objectContaining({ line: 0, ch: 11 }), 522 | }, 523 | { 524 | anchor: expect.objectContaining({ line: 1, ch: 5 }), 525 | head: expect.objectContaining({ line: 1, ch: 5 }), 526 | }, 527 | ]); 528 | }); 529 | 530 | it('should add cursors to selection ends when emulating Sublime Text', () => { 531 | addCursorsToSelectionEnds(editor as any, CODE_EDITOR.SUBLIME); 532 | 533 | const { doc, selections } = getDocumentAndSelection(editor); 534 | expect(doc).toEqual(originalDoc); 535 | expect(selections).toEqual([ 536 | { 537 | anchor: expect.objectContaining({ line: 0, ch: 6 }), 538 | head: expect.objectContaining({ line: 0, ch: 11 }), 539 | }, 540 | { 541 | anchor: expect.objectContaining({ line: 1, ch: 0 }), 542 | head: expect.objectContaining({ line: 1, ch: 5 }), 543 | }, 544 | ]); 545 | }); 546 | 547 | it('should exclude line starting at trailing newline from having cursor added', () => { 548 | editor.setSelection({ line: 0, ch: 0 }, { line: 2, ch: 0 }); 549 | 550 | addCursorsToSelectionEnds(editor as any, CODE_EDITOR.VSCODE); 551 | 552 | const { doc, selections } = getDocumentAndSelection(editor); 553 | expect(doc).toEqual(originalDoc); 554 | expect(selections).toEqual([ 555 | { 556 | anchor: expect.objectContaining({ line: 0, ch: 11 }), 557 | head: expect.objectContaining({ line: 0, ch: 11 }), 558 | }, 559 | { 560 | anchor: expect.objectContaining({ line: 1, ch: 9 }), 561 | head: expect.objectContaining({ line: 1, ch: 9 }), 562 | }, 563 | ]); 564 | }); 565 | }); 566 | 567 | describe('goToLineBoundary', () => { 568 | it('should go to line start', () => { 569 | withMultipleSelections(editor as any, goToLineBoundary, { 570 | args: 'start', 571 | }); 572 | 573 | const { doc, cursor } = getDocumentAndSelection(editor); 574 | expect(doc).toEqual(originalDoc); 575 | expect(cursor.line).toEqual(0); 576 | expect(cursor.ch).toEqual(0); 577 | }); 578 | 579 | it('should go to line end', () => { 580 | withMultipleSelections(editor as any, goToLineBoundary, { 581 | args: 'end', 582 | }); 583 | 584 | const { doc, cursor } = getDocumentAndSelection(editor); 585 | expect(doc).toEqual(originalDoc); 586 | expect(cursor.line).toEqual(1); 587 | expect(cursor.ch).toEqual(9); 588 | }); 589 | }); 590 | 591 | describe('navigateLine', () => { 592 | it('should navigate to the previous line', () => { 593 | withMultipleSelections(editor as any, navigateLine, { args: 'prev' }); 594 | 595 | const { doc, cursor } = getDocumentAndSelection(editor); 596 | expect(doc).toEqual(originalDoc); 597 | expect(cursor.line).toEqual(0); 598 | expect(cursor.ch).toEqual(5); 599 | }); 600 | 601 | it('should navigate to the next line', () => { 602 | withMultipleSelections(editor as any, navigateLine, { args: 'next' }); 603 | 604 | const { doc, cursor } = getDocumentAndSelection(editor); 605 | expect(doc).toEqual(originalDoc); 606 | expect(cursor.line).toEqual(2); 607 | expect(cursor.ch).toEqual(4); 608 | }); 609 | 610 | it('should navigate to the first line', () => { 611 | withMultipleSelections(editor as any, navigateLine, { args: 'first' }); 612 | 613 | const { doc, cursor } = getDocumentAndSelection(editor); 614 | expect(doc).toEqual(originalDoc); 615 | expect(cursor.line).toEqual(0); 616 | expect(cursor.ch).toEqual(0); 617 | }); 618 | 619 | it('should navigate to the last line', () => { 620 | withMultipleSelections(editor as any, navigateLine, { args: 'last' }); 621 | 622 | const { doc, cursor } = getDocumentAndSelection(editor); 623 | expect(doc).toEqual(originalDoc); 624 | expect(cursor.line).toEqual(2); 625 | expect(cursor.ch).toEqual(4); 626 | }); 627 | }); 628 | 629 | describe('transformCase', () => { 630 | it('should transform to uppercase', () => { 631 | withMultipleSelections(editor as any, transformCase, { 632 | args: CASE.UPPER, 633 | }); 634 | 635 | const { doc, selectedText } = getDocumentAndSelection(editor); 636 | expect(doc).toEqual('lorem IPSUM\nDOLOR sit\namet'); 637 | expect(selectedText).toEqual('IPSUM\nDOLOR'); 638 | }); 639 | 640 | it('should transform to lowercase', () => { 641 | editor.setValue('lorem ipsum\nDOLOR sit\namet'); 642 | editor.setSelection({ line: 0, ch: 6 }, { line: 1, ch: 5 }); 643 | 644 | withMultipleSelections(editor as any, transformCase, { 645 | args: CASE.LOWER, 646 | }); 647 | 648 | const { doc, selectedText } = getDocumentAndSelection(editor); 649 | expect(doc).toEqual('lorem ipsum\ndolor sit\namet'); 650 | expect(selectedText).toEqual('ipsum\ndolor'); 651 | }); 652 | 653 | it('should transform to title case', () => { 654 | withMultipleSelections(editor as any, transformCase, { 655 | args: CASE.TITLE, 656 | }); 657 | 658 | const { doc, selectedText } = getDocumentAndSelection(editor); 659 | expect(doc).toEqual('lorem Ipsum\nDolor sit\namet'); 660 | expect(selectedText).toEqual('Ipsum\nDolor'); 661 | }); 662 | 663 | it.each([ 664 | ['default', 'lorem Ipsum dolor', 'LOREM IPSUM DOLOR'], 665 | ['uppercase', 'LOREM IPSUM DOLOR', 'lorem ipsum dolor'], 666 | ['lowercase', 'lorem ipsum dolor', 'Lorem Ipsum Dolor'], 667 | ['title case', 'Lorem Ipsum Dolor', 'LOREM IPSUM DOLOR'], 668 | ])( 669 | 'should cycle to next case from %s', 670 | (_scenario, initialContent, expectedContent) => { 671 | editor.setValue(initialContent); 672 | editor.setSelection({ line: 0, ch: 0 }, { line: 0, ch: 17 }); 673 | 674 | withMultipleSelections(editor as any, transformCase, { 675 | args: CASE.NEXT, 676 | }); 677 | 678 | const { doc, selections } = getDocumentAndSelection(editor); 679 | expect(doc).toEqual(expectedContent); 680 | expect(selections[0]).toEqual({ 681 | anchor: { line: 0, ch: 0 }, 682 | head: { line: 0, ch: 17 }, 683 | }); 684 | }, 685 | ); 686 | 687 | it("should not transform 'the', 'a' or 'an' to title case if not the first word", () => { 688 | editor.setValue( 689 | 'AN EXAMPLE TO TEST THE OBSIDIAN PLUGIN AND A CASE CONVERSION FEATURE', 690 | ); 691 | editor.setSelection({ line: 0, ch: 0 }, { line: 0, ch: 68 }); 692 | 693 | withMultipleSelections(editor as any, transformCase, { 694 | args: CASE.TITLE, 695 | }); 696 | 697 | const { doc } = getDocumentAndSelection(editor); 698 | expect(doc).toEqual( 699 | 'An Example To Test the Obsidian Plugin And a Case Conversion Feature', 700 | ); 701 | }); 702 | }); 703 | 704 | describe('expandSelectionToBrackets', () => { 705 | it.each([ 706 | ['()', 'lorem (ipsum\ndolor sit\nam)et'], 707 | ['[]', 'lorem [ipsum\ndolor sit\nam]et'], 708 | ['{}', 'lorem {ipsum\ndolor sit\nam}et'], 709 | ])( 710 | 'should expand selection to %s brackets if entire selection is inside', 711 | (_scenario, content) => { 712 | editor.setValue(content); 713 | editor.setSelection({ line: 0, ch: 10 }, { line: 1, ch: 5 }); 714 | 715 | withMultipleSelections(editor as any, expandSelectionToBrackets); 716 | 717 | const { doc, selectedText } = getDocumentAndSelection(editor); 718 | expect(doc).toEqual(content); 719 | expect(selectedText).toEqual('ipsum\ndolor sit\nam'); 720 | }, 721 | ); 722 | 723 | it('should not expand selection to brackets if part of selection is outside', () => { 724 | const content = '(lorem ipsum)\ndolor'; 725 | editor.setValue(content); 726 | editor.setSelection({ line: 0, ch: 10 }, { line: 1, ch: 2 }); 727 | 728 | withMultipleSelections(editor as any, expandSelectionToBrackets); 729 | 730 | const { doc, selectedText } = getDocumentAndSelection(editor); 731 | expect(doc).toEqual(content); 732 | expect(selectedText).toEqual('um)\ndo'); 733 | }); 734 | }); 735 | 736 | describe('expandSelectionToQuotes', () => { 737 | it.each([ 738 | ['single', "lorem 'ipsum\ndolor'"], 739 | ['double', 'lorem "ipsum\ndolor"'], 740 | ])( 741 | 'should expand selection to %s quotes if entire selection is inside', 742 | (_scenario, content) => { 743 | editor.setValue(content); 744 | editor.setSelection({ line: 0, ch: 10 }, { line: 1, ch: 2 }); 745 | 746 | withMultipleSelections(editor as any, expandSelectionToQuotes); 747 | 748 | const { doc, selectedText } = getDocumentAndSelection(editor); 749 | expect(doc).toEqual(content); 750 | expect(selectedText).toEqual('ipsum\ndolor'); 751 | }, 752 | ); 753 | 754 | it('should not expand selection to quotes if part of selection is outside', () => { 755 | const content = '"lorem ipsum"\ndolor'; 756 | editor.setValue(content); 757 | editor.setSelection({ line: 0, ch: 10 }, { line: 1, ch: 2 }); 758 | 759 | withMultipleSelections(editor as any, expandSelectionToQuotes); 760 | 761 | const { doc, selectedText } = getDocumentAndSelection(editor); 762 | expect(doc).toEqual(content); 763 | expect(selectedText).toEqual('um"\ndo'); 764 | }); 765 | }); 766 | 767 | describe('expandSelectionToQuotesOrBrackets', () => { 768 | it.each([ 769 | ['quotes', '("lorem ipsum" dolor)'], 770 | ['brackets', '"(lorem ipsum) dolor"'], 771 | ])('should expand selection to %s', (_scenario, content) => { 772 | editor.setValue(content); 773 | editor.setSelection({ line: 0, ch: 8 }, { line: 0, ch: 13 }); 774 | 775 | expandSelectionToQuotesOrBrackets(editor as any); 776 | 777 | const { doc, selectedText } = getDocumentAndSelection(editor); 778 | expect(doc).toEqual(content); 779 | expect(selectedText).toEqual('lorem ipsum'); 780 | }); 781 | }); 782 | 783 | describe('insertCursorAbove', () => { 784 | it('should insert cursor above with the same range', () => { 785 | editor.setSelection({ line: 1, ch: 0 }, { line: 1, ch: 3 }); 786 | 787 | insertCursorAbove(editor as any); 788 | 789 | const { doc, selections } = getDocumentAndSelection(editor); 790 | expect(doc).toEqual(originalDoc); 791 | expect(selections).toEqual([ 792 | { anchor: { line: 0, ch: 0 }, head: { line: 0, ch: 3 } }, 793 | { anchor: { line: 1, ch: 0 }, head: { line: 1, ch: 3 } }, 794 | ]); 795 | }); 796 | 797 | it('should insert cursor above with shortened range if previous line is shorter', () => { 798 | editor.setValue('aaa\nbbbbbb'); 799 | editor.setSelection({ line: 1, ch: 0 }, { line: 1, ch: 5 }); 800 | 801 | insertCursorAbove(editor as any); 802 | 803 | const { doc, selections } = getDocumentAndSelection(editor); 804 | expect(doc).toEqual('aaa\nbbbbbb'); 805 | expect(selections).toEqual([ 806 | { anchor: { line: 0, ch: 0 }, head: { line: 0, ch: 3 } }, 807 | { anchor: { line: 1, ch: 0 }, head: { line: 1, ch: 5 } }, 808 | ]); 809 | }); 810 | }); 811 | 812 | describe('insertCursorBelow', () => { 813 | it('should insert cursor below with the same range', () => { 814 | editor.setSelection({ line: 1, ch: 0 }, { line: 1, ch: 3 }); 815 | 816 | insertCursorBelow(editor as any); 817 | 818 | const { doc, selections } = getDocumentAndSelection(editor); 819 | expect(doc).toEqual(originalDoc); 820 | expect(selections).toEqual([ 821 | { anchor: { line: 1, ch: 0 }, head: { line: 1, ch: 3 } }, 822 | { anchor: { line: 2, ch: 0 }, head: { line: 2, ch: 3 } }, 823 | ]); 824 | }); 825 | 826 | it('should insert cursor below with shortened range if next line is shorter', () => { 827 | editor.setValue('aaaaaa\nbbb'); 828 | editor.setSelection({ line: 0, ch: 0 }, { line: 0, ch: 5 }); 829 | 830 | insertCursorBelow(editor as any); 831 | 832 | const { doc, selections } = getDocumentAndSelection(editor); 833 | expect(doc).toEqual('aaaaaa\nbbb'); 834 | expect(selections).toEqual([ 835 | { anchor: { line: 0, ch: 0 }, head: { line: 0, ch: 5 } }, 836 | { anchor: { line: 1, ch: 0 }, head: { line: 1, ch: 3 } }, 837 | ]); 838 | }); 839 | }); 840 | }); 841 | --------------------------------------------------------------------------------