├── .gitattributes ├── .gitignore ├── tsconfig.json ├── .editorconfig ├── vite.config.js ├── tests ├── _tools.js ├── indent.js └── unindent.js ├── .github └── workflows │ ├── ci.yml │ ├── github-pages.yml │ └── esm-lint.yml ├── license ├── package.json ├── index.html ├── readme.md └── index.ts /.gitattributes: -------------------------------------------------------------------------------- 1 | tests/* linguist-detectable=false 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | index.js 3 | index.d.ts 4 | *.map 5 | !tests/* 6 | dist 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@sindresorhus/tsconfig", 3 | "files": [ 4 | "index.ts" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import {defineConfig} from 'vite'; 2 | import {viteSingleFile} from 'vite-plugin-singlefile'; 3 | 4 | export default defineConfig({ 5 | base: '', 6 | build: { 7 | target: 'esnext', 8 | }, 9 | plugins: [viteSingleFile()], 10 | }); 11 | -------------------------------------------------------------------------------- /tests/_tools.js: -------------------------------------------------------------------------------- 1 | export function getField(state = '') { 2 | const field = document.createElement('textarea'); 3 | const cursor = state.indexOf('|'); 4 | const selectionStart = state.indexOf('{'); 5 | const selectionEnd = state.indexOf('}') - 1; 6 | field.value = state.replaceAll(/[{|}]/g, ''); 7 | field.selectionStart = cursor >= 0 ? cursor : selectionStart; 8 | field.selectionEnd = cursor >= 0 ? cursor : selectionEnd; 9 | document.body.append(field); 10 | return field; 11 | } 12 | 13 | export function getState({value, selectionStart, selectionEnd}) { 14 | if (selectionStart === selectionEnd) { 15 | return value.slice(0, selectionStart) + '|' + value.slice(selectionStart); 16 | } 17 | 18 | return ( 19 | value.slice(0, selectionStart) 20 | + '{' 21 | + value.slice(selectionStart, selectionEnd) 22 | + '}' 23 | + value.slice(selectionEnd) 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /.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/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 | -------------------------------------------------------------------------------- /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 109 | 115 | 116 | 117 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /tests/unindent.js: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import {unindentSelection} from '../index.js'; 3 | import {getField, getState} from './_tools.js'; 4 | 5 | test('unindent empty field (noop)', t => { 6 | const textarea = getField(); 7 | t.equal(getState(textarea), '|'); 8 | unindentSelection(textarea); 9 | t.equal(getState(textarea), '|'); 10 | t.end(); 11 | }); 12 | 13 | test('unindent filled field (start)', t => { 14 | const textarea = getField('\t|hello'); 15 | t.equal(getState(textarea), '\t|hello'); 16 | unindentSelection(textarea); 17 | t.equal(getState(textarea), '|hello'); 18 | t.end(); 19 | }); 20 | 21 | test('unindent filled field (middle)', t => { 22 | const textarea = getField('\thel|lo'); 23 | t.equal(getState(textarea), '\thel|lo'); 24 | unindentSelection(textarea); 25 | t.equal(getState(textarea), 'hel|lo'); 26 | t.end(); 27 | }); 28 | 29 | test('unindent filled field (between tabs)', t => { 30 | const textarea = getField('\t|\thello'); 31 | t.equal(getState(textarea), '\t|\thello'); 32 | unindentSelection(textarea); 33 | t.equal(getState(textarea), '|\thello'); 34 | t.end(); 35 | }); 36 | 37 | test('unindent filled field (end)', t => { 38 | const textarea = getField('\thello|'); 39 | t.equal(getState(textarea), '\thello|'); 40 | unindentSelection(textarea); 41 | t.equal(getState(textarea), 'hello|'); 42 | t.end(); 43 | }); 44 | 45 | test('unindent line with selection without replacing it', t => { 46 | const textarea = getField('\the{ll}o'); 47 | t.equal(getState(textarea), '\the{ll}o'); 48 | unindentSelection(textarea); 49 | t.equal(getState(textarea), 'he{ll}o'); 50 | t.end(); 51 | }); 52 | 53 | test('unindent every selected line', t => { 54 | const textarea = getField('{\t\ta\nb\n\t\tc}'); 55 | t.equal(getState(textarea), '{\t\ta\nb\n\t\tc}'); 56 | unindentSelection(textarea); 57 | t.equal(getState(textarea), '{\ta\nb\n\tc}'); 58 | unindentSelection(textarea); 59 | t.equal(getState(textarea), '{a\nb\nc}'); 60 | t.end(); 61 | }); 62 | 63 | test('unindent every line counting from the linebreak itself', t => { 64 | const textarea = getField('\ta{\n\tb\n\tc}'); 65 | t.equal(getState(textarea), '\ta{\n\tb\n\tc}'); 66 | unindentSelection(textarea); 67 | t.equal(getState(textarea), 'a{\nb\nc}'); 68 | t.end(); 69 | }); 70 | 71 | test('unindent every line stopping before the last linebreak', t => { 72 | const textarea = getField('\ta{\n\tb\n}c'); 73 | t.equal(getState(textarea), '\ta{\n\tb\n}c'); 74 | unindentSelection(textarea); 75 | t.equal(getState(textarea), 'a{\nb\n}c'); 76 | t.end(); 77 | }); 78 | 79 | test('unindent every line (following both the previous rules)', t => { 80 | const textarea = getField('\ta{\n}b\nc'); 81 | t.equal(getState(textarea), '\ta{\n}b\nc'); 82 | unindentSelection(textarea); 83 | t.equal(getState(textarea), 'a{\n}b\nc'); 84 | t.end(); 85 | }); 86 | 87 | test('preserve cursor position when deindenting after it', t => { 88 | const textarea = getField('\t\n|\t'); 89 | t.equal(getState(textarea), '\t\n|\t'); 90 | unindentSelection(textarea); 91 | t.equal(getState(textarea), '\t\n|'); 92 | unindentSelection(textarea); 93 | t.end(); 94 | }); 95 | 96 | test('ignore whitespace on other lines', t => { 97 | let textarea = getField('\t\n\t|\t\n\t'); 98 | t.equal(getState(textarea), '\t\n\t|\t\n\t'); 99 | unindentSelection(textarea); 100 | t.equal(getState(textarea), '\t\n|\t\n\t'); 101 | unindentSelection(textarea); 102 | 103 | textarea = getField('\t\t\t\t\n\t|\n\t'); 104 | t.equal(getState(textarea), '\t\t\t\t\n\t|\n\t'); 105 | unindentSelection(textarea); 106 | t.equal(getState(textarea), '\t\t\t\t\n|\n\t'); 107 | 108 | // TODO: Fix this test, it used to work 109 | // unindent(textarea); 110 | // t.equal(getState(textarea), '\t\t\t\t\n|\n\t'); 111 | 112 | textarea = getField(' \t\n |\n\t'); 113 | t.equal(getState(textarea), ' \t\n |\n\t'); 114 | unindentSelection(textarea); 115 | t.equal(getState(textarea), ' \t\n|\n\t'); 116 | 117 | t.end(); 118 | }); 119 | 120 | test.skip('ignore trailing whitespace', t => { 121 | const textarea = getField('a\t\t|'); 122 | t.equal(getState(textarea), 'a\t\t|'); 123 | unindentSelection(textarea); 124 | t.equal(getState(textarea), 'a\t\t|'); 125 | t.end(); 126 | }); 127 | 128 | test('unindent 2 spaces', t => { 129 | const textarea = getField(' hel|lo'); 130 | t.equal(getState(textarea), ' hel|lo'); 131 | unindentSelection(textarea); 132 | t.equal(getState(textarea), ' hel|lo'); 133 | unindentSelection(textarea); 134 | t.equal(getState(textarea), 'hel|lo'); 135 | t.end(); 136 | }); 137 | 138 | test('unindent mixed spaces and tabs', t => { 139 | const textarea = getField(' \t hel|lo'); 140 | t.equal(getState(textarea), ' \t hel|lo'); 141 | unindentSelection(textarea); 142 | t.equal(getState(textarea), '\t hel|lo'); 143 | unindentSelection(textarea); 144 | t.equal(getState(textarea), ' hel|lo'); 145 | t.end(); 146 | }); 147 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # indent-textarea [![][badge-gzip]][link-npm] 2 | 3 | [badge-gzip]: https://img.shields.io/bundlephobia/minzip/indent-textarea.svg?label=gzipped 4 | [link-npm]: https://www.npmjs.com/package/indent-textarea 5 | 6 | [](https://fregante.github.io/indent-textarea/) 7 | 8 | > Add editor-like tab-to-indent functionality to `