├── .editorconfig ├── .gitattributes ├── .github └── workflows │ ├── ci.yml │ ├── esm-lint.yml │ └── github-pages.yml ├── .gitignore ├── index.html ├── index.ts ├── license ├── package-lock.json ├── package.json ├── readme.md ├── tests ├── _tools.js ├── indent.js └── unindent.js ├── tsconfig.json └── vite.config.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tabs 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.md] 11 | trim_trailing_whitespace = false 12 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | tests/* linguist-detectable=false 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | - pull_request 5 | - push 6 | 7 | jobs: 8 | Lint: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - run: npm install 13 | - run: npm run test:lint 14 | 15 | Build: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v3 19 | - run: npm install 20 | - run: npm run build 21 | 22 | Chrome: 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v3 26 | - run: npm install 27 | - run: npm run build 28 | - run: sudo apt-get install xvfb 29 | - run: xvfb-run --auto-servernum npm run test:blink 30 | 31 | Firefox: 32 | runs-on: ubuntu-latest 33 | steps: 34 | - uses: actions/checkout@v3 35 | - run: npm install 36 | - run: npm run build 37 | - run: sudo apt-get install xvfb 38 | - run: xvfb-run --auto-servernum npm run test:gecko 39 | -------------------------------------------------------------------------------- /.github/workflows/esm-lint.yml: -------------------------------------------------------------------------------- 1 | env: 2 | IMPORT_TEXT: import {enableTabToIndent} from 3 | NPM_MODULE_NAME: indent-textarea 4 | 5 | # FILE GENERATED WITH: npx ghat fregante/ghatemplates/esm-lint 6 | # SOURCE: https://github.com/fregante/ghatemplates 7 | 8 | name: ESM 9 | on: 10 | pull_request: 11 | branches: 12 | - '*' 13 | push: 14 | branches: 15 | - master 16 | - main 17 | jobs: 18 | Pack: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v3 22 | - run: npm install 23 | - run: npm run build --if-present 24 | - run: npm pack --dry-run 25 | - run: npm pack | tail -1 | xargs -n1 tar -xzf 26 | - uses: actions/upload-artifact@v3 27 | with: 28 | path: package 29 | Webpack: 30 | runs-on: ubuntu-latest 31 | needs: Pack 32 | steps: 33 | - uses: actions/download-artifact@v3 34 | - run: npm install ./artifact 35 | - run: echo "${{ env.IMPORT_TEXT }} '${{ env.NPM_MODULE_NAME }}'" > index.js 36 | - run: webpack --entry ./index.js 37 | - run: cat dist/main.js 38 | Parcel: 39 | runs-on: ubuntu-latest 40 | needs: Pack 41 | steps: 42 | - uses: actions/download-artifact@v3 43 | - run: npm install ./artifact 44 | - run: echo "${{ env.IMPORT_TEXT }} '${{ env.NPM_MODULE_NAME }}'" > index.js 45 | - run: npx parcel@2 build index.js 46 | - run: cat dist/index.js 47 | Rollup: 48 | runs-on: ubuntu-latest 49 | needs: Pack 50 | steps: 51 | - uses: actions/download-artifact@v3 52 | - run: npm install ./artifact rollup@2 @rollup/plugin-node-resolve 53 | - run: echo "${{ env.IMPORT_TEXT }} '${{ env.NPM_MODULE_NAME }}'" > index.js 54 | - run: npx rollup -p node-resolve index.js 55 | Vite: 56 | runs-on: ubuntu-latest 57 | needs: Pack 58 | steps: 59 | - uses: actions/download-artifact@v3 60 | - run: npm install ./artifact 61 | - run: >- 62 | echo '' > index.html 64 | - run: npx vite build 65 | - run: cat dist/assets/* 66 | esbuild: 67 | runs-on: ubuntu-latest 68 | needs: Pack 69 | steps: 70 | - uses: actions/download-artifact@v3 71 | - run: echo '{}' > package.json 72 | - run: echo "${{ env.IMPORT_TEXT }} '${{ env.NPM_MODULE_NAME }}'" > index.js 73 | - run: npm install ./artifact 74 | - run: npx esbuild --bundle index.js 75 | TypeScript: 76 | runs-on: ubuntu-latest 77 | needs: Pack 78 | steps: 79 | - uses: actions/download-artifact@v3 80 | - run: npm install ./artifact && npm install @types/estree 81 | - run: echo "${{ env.IMPORT_TEXT }} '${{ env.NPM_MODULE_NAME }}'" > index.ts 82 | - run: tsc index.ts 83 | - run: cat index.js 84 | Node: 85 | runs-on: ubuntu-latest 86 | needs: Pack 87 | steps: 88 | - uses: actions/download-artifact@v3 89 | - uses: actions/setup-node@v3 90 | with: 91 | node-version: 14.x 92 | - run: echo "${{ env.IMPORT_TEXT }} '${{ env.NPM_MODULE_NAME }}'" > index.mjs 93 | - run: npm install ./artifact 94 | - run: node index.mjs 95 | -------------------------------------------------------------------------------- /.github/workflows/github-pages.yml: -------------------------------------------------------------------------------- 1 | name: demo 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | workflow_dispatch: 9 | 10 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 11 | permissions: 12 | contents: read 13 | pages: write 14 | id-token: write 15 | 16 | # Allow one concurrent deployment 17 | concurrency: 18 | group: pages 19 | cancel-in-progress: true 20 | 21 | jobs: 22 | build: 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v3 26 | - uses: actions/configure-pages@v3 27 | - run: npm ci 28 | - run: npm run demo:build 29 | - uses: actions/upload-pages-artifact@v1 30 | with: 31 | path: dist 32 | 33 | # Deployment job 34 | deploy: 35 | environment: 36 | name: github-pages 37 | url: ${{ steps.deployment.outputs.page_url }} 38 | runs-on: ubuntu-latest 39 | needs: build 40 | steps: 41 | - name: Deploy to GitHub Pages 42 | id: deployment 43 | uses: actions/deploy-pages@v1 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | index.js 3 | index.d.ts 4 | *.map 5 | !tests/* 6 | dist 7 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | `indent-textarea` npm module testing ground 7 | 81 | 82 | 83 |

fregante/indent-textarea

84 |
85 |
86 |
import {enableTabToIndent} from 'indent-textarea';
 87 | enableTabToIndent('textarea');
88 |
89 |

Press tab or shift+tab to test it on real code:

90 | 109 | 115 |
116 | 117 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import {insertTextIntoField} from 'text-field-edit'; 2 | 3 | /* 4 | 5 | # Global notes 6 | 7 | Indent and unindent affect characters outside the selection, so the selection has to be expanded (`newSelection`) before applying the replacement regex. 8 | 9 | The unindent selection expansion logic is a bit convoluted and I wish a genius would rewrite it more efficiently. 10 | 11 | */ 12 | 13 | export function indentSelection(element: HTMLTextAreaElement): void { 14 | const {selectionStart, selectionEnd, value} = element; 15 | const selectedText = value.slice(selectionStart, selectionEnd); 16 | // The first line should be indented, even if it starts with `\n` 17 | // The last line should only be indented if includes any character after `\n` 18 | const lineBreakCount = /\n/g.exec(selectedText)?.length; 19 | 20 | if (lineBreakCount! > 0) { 21 | // Select full first line to replace everything at once 22 | const firstLineStart = value.lastIndexOf('\n', selectionStart - 1) + 1; 23 | 24 | const newSelection = element.value.slice(firstLineStart, selectionEnd - 1); 25 | const indentedText = newSelection.replaceAll( 26 | /^|\n/g, // Match all line starts 27 | '$&\t', 28 | ); 29 | const replacementsCount = indentedText.length - newSelection.length; 30 | 31 | // Replace newSelection with indentedText 32 | element.setSelectionRange(firstLineStart, selectionEnd - 1); 33 | insertTextIntoField(element, indentedText); 34 | 35 | // Restore selection position, including the indentation 36 | element.setSelectionRange(selectionStart + 1, selectionEnd + replacementsCount); 37 | } else { 38 | insertTextIntoField(element, '\t'); 39 | } 40 | } 41 | 42 | function findLineEnd(value: string, currentEnd: number): number { 43 | // Go to the beginning of the last line 44 | const lastLineStart = value.lastIndexOf('\n', currentEnd - 1) + 1; 45 | 46 | // There's nothing to unindent after the last cursor, so leave it as is 47 | if (value.charAt(lastLineStart) !== '\t') { 48 | return currentEnd; 49 | } 50 | 51 | return lastLineStart + 1; // Include the first character, which will be a tab 52 | } 53 | 54 | // The first line should always be unindented 55 | // The last line should only be unindented if the selection includes any characters after `\n` 56 | export function unindentSelection(element: HTMLTextAreaElement): void { 57 | const {selectionStart, selectionEnd, value} = element; 58 | 59 | // Select the whole first line because it might contain \t 60 | const firstLineStart = value.lastIndexOf('\n', selectionStart - 1) + 1; 61 | const minimumSelectionEnd = findLineEnd(value, selectionEnd); 62 | 63 | const newSelection = element.value.slice(firstLineStart, minimumSelectionEnd); 64 | const indentedText = newSelection.replaceAll( 65 | /(^|\n)(\t| {1,2})/g, 66 | '$1', 67 | ); 68 | const replacementsCount = newSelection.length - indentedText.length; 69 | 70 | // Replace newSelection with indentedText 71 | element.setSelectionRange(firstLineStart, minimumSelectionEnd); 72 | insertTextIntoField(element, indentedText); 73 | 74 | // Restore selection position, including the indentation 75 | const firstLineIndentation = /\t| {1,2}/.exec(value.slice(firstLineStart, selectionStart)); 76 | 77 | const difference = firstLineIndentation 78 | ? firstLineIndentation[0]!.length 79 | : 0; 80 | 81 | const newSelectionStart = selectionStart - difference; 82 | element.setSelectionRange( 83 | selectionStart - difference, 84 | Math.max(newSelectionStart, selectionEnd - replacementsCount), 85 | ); 86 | } 87 | 88 | export function tabToIndentListener(event: KeyboardEvent): void { 89 | if ( 90 | event.defaultPrevented 91 | || event.metaKey 92 | || event.altKey 93 | || event.ctrlKey 94 | ) { 95 | return; 96 | } 97 | 98 | const textarea = event.target as HTMLTextAreaElement; 99 | 100 | if (event.key === 'Tab') { 101 | if (event.shiftKey) { 102 | unindentSelection(textarea); 103 | } else { 104 | indentSelection(textarea); 105 | } 106 | 107 | event.preventDefault(); 108 | event.stopImmediatePropagation(); 109 | } else if ( 110 | event.key === 'Escape' 111 | && !event.shiftKey 112 | ) { 113 | textarea.blur(); 114 | event.preventDefault(); 115 | event.stopImmediatePropagation(); 116 | } 117 | } 118 | 119 | type WatchableElements = 120 | | string 121 | | HTMLTextAreaElement 122 | | Iterable; 123 | 124 | export function enableTabToIndent( 125 | elements: WatchableElements, 126 | signal?: AbortSignal, 127 | ): void { 128 | if (typeof elements === 'string') { 129 | elements = document.querySelectorAll(elements); 130 | } else if (elements instanceof HTMLTextAreaElement) { 131 | elements = [elements]; 132 | } 133 | 134 | for (const element of elements) { 135 | element.addEventListener('keydown', tabToIndentListener, {signal}); 136 | } 137 | } 138 | 139 | /** @deprecated Renamed to indentSelection */ 140 | export const indent = indentSelection; 141 | /** @deprecated Renamed to unindentSelection */ 142 | export const unindent = unindentSelection; 143 | /** @deprecated Renamed to tabToIndentListener */ 144 | export const eventHandler = tabToIndentListener; 145 | /** @deprecated Renamed to enableTabToIndent */ 146 | export const watch = enableTabToIndent; 147 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Federico Brigante (https://fregante.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "indent-textarea", 3 | "version": "4.0.0", 4 | "description": "Add editor-like tab-to-indent functionality to