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