├── .github └── workflows │ ├── build.yml │ └── release.yml ├── .gitignore ├── .prettierrc.yaml ├── LICENSE ├── README.md ├── babel.config.js ├── jest.config.ts ├── package.json ├── schema └── plugin.json ├── src ├── __tests__ │ └── utils.ts ├── index.ts └── utils.ts ├── style └── index.css ├── tsconfig.json └── yarn.lock /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: 6 | - "**" # matches every branch 7 | - "!master" # excludes master 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-18.04 12 | 13 | name: n${{ matrix.node-version }}/py${{ matrix.python-version }}/j${{ matrix.jupyterlab-version}} 14 | 15 | strategy: 16 | matrix: 17 | node-version: [12, 16] 18 | python-version: [3.7, 3.8] 19 | jupyterlab-version: [">=2.0.0,<4.0.0"] 20 | 21 | steps: 22 | - name: Install node 23 | uses: actions/setup-node@v1 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | 27 | - name: Install Python 28 | uses: actions/setup-python@v1 29 | with: 30 | python-version: ${{ matrix.python-version }} 31 | 32 | - name: Install jupyterlab 33 | run: python -m pip install "jupyterlab${{ matrix.jupyterlab-version }}" 34 | 35 | - uses: actions/checkout@v1 36 | 37 | - name: Install JS dependencies, build & test 38 | run: | 39 | jlpm --frozen-lockfile 40 | jlpm prepare 41 | jlpm test 42 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-18.04 10 | steps: 11 | - name: Install node 12 | uses: actions/setup-node@v1 13 | with: 14 | node-version: 12 15 | registry-url: https://registry.npmjs.org/ 16 | 17 | - name: Install Python 18 | uses: actions/setup-python@v1 19 | with: 20 | python-version: "3.7" 21 | 22 | - name: Install jupyterlab 23 | run: python -m pip install jupyterlab 24 | 25 | - uses: actions/checkout@v1 26 | 27 | - name: Install JS dependencies and build 28 | run: jlpm 29 | 30 | - name: Automated Version Bump 31 | uses: "phips28/gh-action-bump-version@v6.0.2" 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | 35 | - run: npm publish --access public 36 | env: 37 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.bundle.* 2 | lib/ 3 | node_modules/ 4 | *.egg-info/ 5 | .ipynb_checkpoints 6 | tsconfig.tsbuildinfo 7 | -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | trailingComma: "es5" 2 | tabWidth: 4 3 | semi: true 4 | singleQuote: false 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Ryan Homer 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # text-shortcuts 2 | 3 | A jupyterlab extension to insert text via keyboard shortcuts. 4 | 5 | ![release](https://github.com/techrah/jupyterext-text-shortcuts/workflows/release/badge.svg?branch=master) 6 | 7 | ## Pre-requisites 8 | 9 | - JupyterLab 2.x, 3.x 10 | - [node 12+](https://nodejs.org) 11 | 12 | ## Installation 13 | 14 | ```bash 15 | jupyter labextension install @techrah/text-shortcuts 16 | ``` 17 | 18 | or add it through your Jupyter Lab **Extensions** tab. 19 | 20 | Then, add some user shortcuts: 21 | 22 | - In Jupyter Lab, select **Settings** / **Advanced Settings Editor** from the menu. 23 | 24 | - Select the **Keyboard Shortcuts** tab. 25 | 26 | - In the **User Preferences** section, add your shortcuts configuration and click the "save" icon. 27 | 28 | Here are two useful shortcuts for programming in R: 29 | 30 | ```json 31 | { 32 | "shortcuts": [ 33 | { 34 | "command": "text-shortcuts:insert-text", 35 | "args": { 36 | "kernel": "ir", 37 | "text": "|>", 38 | "autoPad": true 39 | }, 40 | "keys": ["Accel Shift M"], 41 | "selector": "body" 42 | }, 43 | { 44 | "command": "text-shortcuts:insert-text", 45 | "args": { 46 | "kernel": "ir", 47 | "text": "<-", 48 | "autoPad": true 49 | }, 50 | "keys": ["Alt -"], 51 | "selector": "body" 52 | } 53 | ] 54 | } 55 | ``` 56 | 57 | **NOTE: As of version 0.1.x You do NOT need to add the above shortcuts to _User Preferences_ unless you want to override the default behaviour.** These two shortcuts are now installed by default. They can be found in _Keyboard Shortcuts / System Defaults_. 58 | 59 | @techrah:text-shortcuts_default-shortcuts 60 | 61 | ### Anatomy of a Text Shortcut 62 | 63 | ``` 64 | { 65 | ... 66 | "command": "text-shortcuts:insert-text" 67 | ... 68 | } 69 | ``` 70 | 71 | Identifies the keyboard shortcut as a text shortcut that is intercepted by this extension. 72 | 73 | ``` 74 | { 75 | ... 76 | "keys": [ 77 | "Accel Shift M" 78 | ], 79 | ... 80 | } 81 | ``` 82 | 83 | `keys` is an array of keyboard shortcuts that activate the insertion of the text snippet. Each entry can be a combination of one or more of the following modifiers, ending with a text character. For example, "Accel Shift M" represents Command-Shift-M on macOS. 84 | 85 | - `Accel` : Command (macOS) / Ctrl (Windows) 86 | - `Alt` : Option (macOS) / Alt (Windows) 87 | - `Shift` : Shift 88 | - `Ctrl` : Control 89 | 90 | ``` 91 | { 92 | ... 93 | "args": { 94 | "kernel": "ir", 95 | "text": "|>", 96 | "autoPad": true 97 | } 98 | ... 99 | } 100 | ``` 101 | 102 | - `kernel` (optional): If you specify a `kernel`, the shortcut will only work in notebooks that are running the specified kernel. Examples of kernel names are `ir` and `python3`. For a list of installed kernels, use `jupyter kernelspec list`. 103 | 104 | - `text`: This is the actual text that you want inserted. 105 | 106 | - `autoPad`: (`true` | `false`). If `true`, will add spacing either before, after, or both before and after so that there is a single space on each side of the text. 107 | 108 | ``` 109 | { 110 | ... 111 | "selector": "body" 112 | ... 113 | } 114 | ``` 115 | 116 | CSS selector. Always use `"body"` for this extension. 117 | 118 | ## Development 119 | 120 | ### Pre-requisites 121 | 122 | - node 5+ 123 | - Python 3.6+ 124 | 125 | It is strongly recommended that you set up a virtual Python environment. These instructions will assume that Anaconda is already installed. 126 | 127 | - Create a new virtual environment and activate it. 128 | 129 | ```bash 130 | conda create --name text-shortcuts 131 | conda activate text-shortcuts 132 | ``` 133 | 134 | - Install jupyterlab 135 | 136 | ```bash 137 | conda install jupyterlab 138 | ``` 139 | 140 | - Clone this project and in the root of the project folder, install dependencies with the JupyterLab Package Manager 141 | 142 | ```bash 143 | jlpm 144 | ``` 145 | 146 | - Install the extension 147 | 148 | ```bash 149 | jupyter labextension install . --no-build 150 | ``` 151 | 152 | - Start up jupyter lab in watch mode. Don't forget to activate your virtual environment. If you want to use a different browser for development, specify that with the `--browser` switch. If you want to use a custom port, specify that with the `--port` switch. 153 | 154 | ```bash 155 | conda activate text-shortcuts 156 | jupyter lab --watch --browser="chrome" --port=8889 157 | ``` 158 | 159 | - In another terminal, run the TypeScript compiler in watch mode. 160 | 161 | ```bash 162 | conda activate text-shortcuts 163 | jlpm tsc -w 164 | ``` 165 | 166 | For more information on developing JupyterLab extensions, here are some helpful resources: 167 | 168 | - [Extension Developer Guide][1] 169 | - [Common Extension Points: Keyboard Shortcuts][2] 170 | - [JupyterLab Extensions by Examples][3] 171 | - [CodeMirror: Document management methods][4] 172 | - [Interface INotebookTracker][5] 173 | 174 | Pull requests are welcome! 175 | 176 | [1]: https://jupyterlab.readthedocs.io/en/stable/extension/extension_dev.html 177 | [2]: https://jupyterlab.readthedocs.io/en/stable/extension/extension_points.html#keyboard-shortcuts 178 | [3]: https://github.com/jupyterlab/extension-examples 179 | [4]: https://codemirror.net/doc/manual.html#api_doc 180 | [5]: https://jupyterlab.github.io/jupyterlab/interfaces/_notebook_src_index_.inotebooktracker.html 181 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ["@babel/preset-env", { targets: { node: "current" } }], 4 | "@babel/preset-typescript", 5 | ], 6 | }; 7 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * For a detailed explanation regarding each configuration property and type check, visit: 3 | * https://jestjs.io/docs/en/configuration.html 4 | */ 5 | 6 | export default { 7 | transformIgnorePatterns: ["/node_modules/(?!(@jupyterlab/.*)/)"], 8 | }; 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@techrah/text-shortcuts", 3 | "version": "1.0.4", 4 | "description": "Insert text via shortcut keys in Jupyter Lab.", 5 | "keywords": [ 6 | "jupyter", 7 | "jupyterlab", 8 | "jupyterlab-extension" 9 | ], 10 | "homepage": "https://github.com/techrah/jupyterext-text-shortcuts", 11 | "bugs": { 12 | "url": "https://github.com/techrah/jupyterext-text-shortcuts/issues" 13 | }, 14 | "license": "MIT", 15 | "author": "Ryan Homer", 16 | "files": [ 17 | "lib/**/*.{d.ts,js}", 18 | "style/**/*.css", 19 | "schema/**/*.json" 20 | ], 21 | "main": "lib/index.js", 22 | "types": "lib/index.d.ts", 23 | "style": "style/index.css", 24 | "repository": { 25 | "type": "git", 26 | "url": "https://github.com/techrah/jupyterext-text-shortcuts.git" 27 | }, 28 | "scripts": { 29 | "build": "tsc", 30 | "clean": "rimraf lib && rimraf tsconfig.tsbuildinfo", 31 | "prepare": "npm run clean && npm run build", 32 | "test": "jest", 33 | "watch": "tsc -w" 34 | }, 35 | "dependencies": { 36 | "@jupyterlab/application": "^3.0.0", 37 | "@jupyterlab/codemirror": "^3.0.0", 38 | "@jupyterlab/notebook": "^3.0.0", 39 | "lodash": "4.17.21" 40 | }, 41 | "devDependencies": { 42 | "@babel/core": "^7.14.0", 43 | "@babel/preset-env": "^7.14.1", 44 | "@babel/preset-typescript": "^7.13.0", 45 | "@types/codemirror": "^0.0.88", 46 | "@types/jest": "^26.0.23", 47 | "@types/lodash": "^4.0.0", 48 | "babel-jest": "^26.6.3", 49 | "jest": "^26.6.3", 50 | "prettier": "2.2.1", 51 | "rimraf": "^3.0.0", 52 | "ts-node": "^9.1.1", 53 | "typescript": "^3.8.3" 54 | }, 55 | "sideEffects": [ 56 | "style/*.css" 57 | ], 58 | "jupyterlab": { 59 | "extension": true, 60 | "schemaDir": "schema" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /schema/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Text Shortcuts", 3 | "description": "Text Shortcuts plugin settings.", 4 | "type": "object", 5 | "jupyter.lab.shortcuts": [ 6 | { 7 | "command": "text-shortcuts:insert-text", 8 | "args": { 9 | "kernel": "ir", 10 | "text": "|>", 11 | "autoPad": true 12 | }, 13 | "keys": ["Accel Shift M"], 14 | "selector": "body" 15 | }, 16 | { 17 | "command": "text-shortcuts:insert-text", 18 | "args": { 19 | "kernel": "ir", 20 | "text": "<-", 21 | "autoPad": true 22 | }, 23 | "keys": ["Alt -"], 24 | "selector": "body" 25 | } 26 | ], 27 | "properties": {}, 28 | "additionalProperties": false 29 | } 30 | -------------------------------------------------------------------------------- /src/__tests__/utils.ts: -------------------------------------------------------------------------------- 1 | import CodeMirror from "codemirror"; 2 | import _ from "lodash/fp"; 3 | 4 | import { getPaddedTextToInsert } from "../utils"; 5 | 6 | describe("getPaddedTextToInsert", () => { 7 | const docTemplate = 8 | "The quick brown fox jumps over the lazy dog." + 9 | "\nThis is line two." + 10 | "\nThis is line three."; 11 | const textToInsert = "|>"; 12 | const textToInsertLeftPadded = ` ${textToInsert}`; 13 | const textToInsertRightPadded = `${textToInsert} `; 14 | const textToInsertPadded = ` ${textToInsert} `; 15 | 16 | let doc; 17 | 18 | beforeEach(() => { 19 | doc = new CodeMirror.Doc(docTemplate); 20 | }); 21 | 22 | it("should right pad at start of text, character after cursor", () => { 23 | doc.setSelection({ line: 0, ch: 0 }, { line: 0, ch: 0 }); 24 | expect(getPaddedTextToInsert(doc, textToInsert)).toBe( 25 | textToInsertRightPadded 26 | ); 27 | }); 28 | 29 | it("shouldn't right pad at start of text, space after cursor", () => { 30 | doc.setSelection({ line: 0, ch: 0 }, { line: 0, ch: 3 }); 31 | expect(getPaddedTextToInsert(doc, textToInsert)).toBe(textToInsert); 32 | }); 33 | 34 | it("should right pad at end of text", () => { 35 | doc.setSelection({ line: doc.lastLine(), ch: docTemplate.length }); 36 | expect(doc.getSelection()).toBe(""); 37 | expect(getPaddedTextToInsert(doc, textToInsert)).toBe( 38 | textToInsertPadded 39 | ); 40 | }); 41 | 42 | it("shoudn't left pad at start of text", () => { 43 | doc.setSelection({ line: 0, ch: 0 }); 44 | expect(doc.getSelection()).toBe(""); 45 | expect(getPaddedTextToInsert(doc, textToInsert)).toBe( 46 | textToInsertRightPadded 47 | ); 48 | }); 49 | 50 | it("should left pad just after a word", () => { 51 | doc.setSelection({ line: 0, ch: 3 }); 52 | expect(doc.getSelection()).toBe(""); 53 | expect(getPaddedTextToInsert(doc, textToInsert)).toBe( 54 | textToInsertLeftPadded 55 | ); 56 | }); 57 | 58 | it("should pad in the middle of a word", () => { 59 | doc.setSelection({ line: 0, ch: 5 }); 60 | expect(doc.getSelection()).toBe(""); 61 | expect(getPaddedTextToInsert(doc, textToInsert)).toBe( 62 | textToInsertPadded 63 | ); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { get } from "lodash/fp"; 2 | import CodeMirror from "codemirror"; 3 | import { 4 | JupyterFrontEnd, 5 | JupyterFrontEndPlugin, 6 | } from "@jupyterlab/application"; 7 | import { INotebookTracker } from "@jupyterlab/notebook"; 8 | import { getPaddedTextToInsert } from "./utils"; 9 | 10 | const PLUGIN_ID = "@techrah/text-shortcuts:plugin"; 11 | 12 | const insertText = (tracker: INotebookTracker) => (args: any) => { 13 | const widget = tracker.currentWidget; 14 | if (!widget) return; 15 | 16 | // If a kernel name is specified in args, compare with current kernel name. 17 | const kernel = get("sessionContext.session.kernel", widget); 18 | if (args.kernel && kernel.name !== args.kernel) return; 19 | 20 | const doc = get("content.activeCell.editor.doc", widget) as CodeMirror.Doc; 21 | if (!doc) return; 22 | 23 | const { text, autoPad } = args; 24 | const insertionText = autoPad ? getPaddedTextToInsert(doc, text) : text; 25 | 26 | doc.replaceSelection(insertionText); 27 | }; 28 | 29 | const handleActivation = (app: JupyterFrontEnd, tracker: INotebookTracker) => { 30 | app.commands.addCommand("text-shortcuts:insert-text", { 31 | label: "Insert Text", 32 | execute: insertText(tracker), 33 | }); 34 | }; 35 | 36 | /** 37 | * text-shortcuts extension. 38 | */ 39 | const extension: JupyterFrontEndPlugin = { 40 | id: PLUGIN_ID, 41 | autoStart: true, 42 | requires: [INotebookTracker], 43 | activate: handleActivation, 44 | }; 45 | 46 | export default extension; 47 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { isEqual, pick } from "lodash/fp"; 2 | import CodeMirror from "codemirror"; 3 | 4 | const getPaddingText = ( 5 | pos1: CodeMirror.Position, 6 | pos2: CodeMirror.Position, 7 | lastPos: CodeMirror.Position, 8 | ch: string 9 | ): string => { 10 | const pickPos = pick(["line", "ch"]); 11 | 12 | // End of line 13 | if (isEqual(pickPos(pos2), pickPos(lastPos))) return " "; 14 | 15 | // Nothing selected 16 | if (isEqual(pickPos(pos1), pickPos(pos2))) return ""; 17 | 18 | // Already padded 19 | if (ch === " ") return ""; 20 | 21 | // Not yet padded 22 | return " "; 23 | }; 24 | 25 | export const getPaddedTextToInsert = ( 26 | doc: CodeMirror.Doc, 27 | textToInsert: string 28 | ): string => { 29 | const _doc = doc.copy(false); 30 | 31 | const from = _doc.getCursor("from"); 32 | const to = _doc.getCursor("to"); 33 | 34 | _doc.extendSelection( 35 | { ...from, ch: from.ch - 1 }, 36 | { ...to, ch: to.ch + 1 } 37 | ); 38 | 39 | const extSelectedText = _doc.getSelection(); 40 | const extFrom = _doc.getCursor("from"); 41 | const extTo = _doc.getCursor("to"); 42 | 43 | const lastLine = _doc.lastLine(); 44 | const lastPos: CodeMirror.Position = { 45 | line: lastLine, 46 | ch: _doc.getLine(lastLine).length, 47 | }; 48 | 49 | const leftCh = getPaddingText( 50 | from, 51 | extFrom, 52 | lastPos, 53 | extSelectedText.slice(0, 1) 54 | ); 55 | const rightCh = getPaddingText( 56 | to, 57 | extTo, 58 | lastPos, 59 | extSelectedText.slice(-1) 60 | ); 61 | const paddedTextToInsert = `${leftCh}${textToInsert}${rightCh}`; 62 | 63 | return paddedTextToInsert; 64 | }; 65 | -------------------------------------------------------------------------------- /style/index.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/techrah/jupyterext-text-shortcuts/852463e2fb6fd6ec82e40630b27a42ce3f269bb4/style/index.css -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "composite": false, 5 | "declaration": true, 6 | "esModuleInterop": true, 7 | "incremental": true, 8 | "jsx": "react", 9 | "module": "esnext", 10 | "moduleResolution": "node", 11 | "noEmitOnError": true, 12 | "noImplicitAny": true, 13 | "noUnusedLocals": true, 14 | "preserveWatchOutput": true, 15 | "resolveJsonModule": true, 16 | "outDir": "lib", 17 | "strict": true, 18 | "strictNullChecks": false, 19 | "target": "es2017", 20 | "types": [] 21 | }, 22 | "include": ["src/*"], 23 | "exclude": ["src/tests/*"] 24 | } 25 | --------------------------------------------------------------------------------