Skip to content

Commit 210d28c

Browse files
committed
feat: Support indent line or selected text by pressing tab key, with customizable indentation.
1 parent f725669 commit 210d28c

File tree

7 files changed

+261
-3
lines changed

7 files changed

+261
-3
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ function App() {
2828
<CodeEditor
2929
value={code}
3030
language="js"
31+
placeholder="Please enter JS code."
3132
onChange={(evn) => setCode(evn.target.value)}
3233
padding={15}
3334
style={{

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
"@kkt/raw-modules": "6.10.4",
5656
"@kkt/scope-plugin-options": "6.10.4",
5757
"@testing-library/react": "12.0.0",
58+
"@types/testing-library__jest-dom": "5.14.0",
5859
"@types/react": "17.0.11",
5960
"@types/react-dom": "17.0.8",
6061
"@types/react-test-renderer": "17.0.1",

src/SelectionText.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
export class SelectionText {
2+
elm: HTMLTextAreaElement;
3+
start: number;
4+
end: number;
5+
value: string;
6+
constructor(elm: HTMLTextAreaElement) {
7+
const { selectionStart, selectionEnd } = elm;
8+
this.elm = elm;
9+
this.start = selectionStart;
10+
this.end = selectionEnd;
11+
this.value = this.elm.value;
12+
}
13+
position(start?: number, end?: number) {
14+
const { selectionStart, selectionEnd } = this.elm;
15+
this.start = typeof start === 'number' && !isNaN(start) ? start : selectionStart;
16+
this.end = typeof end === 'number' && !isNaN(end) ? end : selectionEnd;
17+
this.elm.selectionStart = this.start;
18+
this.elm.selectionEnd = this.end;
19+
return this;
20+
}
21+
insertText(text: string) {
22+
// Most of the used APIs only work with the field selected
23+
this.elm.focus();
24+
this.elm.setRangeText(text);
25+
this.value = this.elm.value;
26+
this.position();
27+
return this;
28+
}
29+
getSelectedValue(start?: number, end?: number) {
30+
const { selectionStart, selectionEnd } = this.elm;
31+
return this.value.slice(
32+
typeof start === 'number' && !isNaN(start) ? start : selectionStart,
33+
typeof end === 'number' && !isNaN(end) ? start : selectionEnd,
34+
);
35+
}
36+
getLineStartNumber() {
37+
let start = this.start;
38+
while (start > 0) {
39+
start--;
40+
if (this.value.charAt(start) === '\n') {
41+
start++;
42+
break;
43+
}
44+
}
45+
return start;
46+
}
47+
lineStarInstert(text: string = '') {
48+
if (text) {
49+
const oldStart = this.start;
50+
const start = this.getLineStartNumber();
51+
const str = this.getSelectedValue(start);
52+
this.position(start, this.end)
53+
.insertText(
54+
str
55+
.split('\n')
56+
.map((txt) => text + txt)
57+
.join('\n'),
58+
)
59+
.position(oldStart + text.length, this.end);
60+
}
61+
return this;
62+
}
63+
lineStarRemove(text: string = '') {
64+
if (text) {
65+
const oldStart = this.start;
66+
const start = this.getLineStartNumber();
67+
const str = this.getSelectedValue(start);
68+
const reg = new RegExp(`^${text}`, 'g');
69+
let newStart = oldStart - text.length;
70+
if (!reg.test(str)) {
71+
newStart = oldStart;
72+
}
73+
this.position(start, this.end)
74+
.insertText(
75+
str
76+
.split('\n')
77+
.map((txt) => txt.replace(reg, ''))
78+
.join('\n'),
79+
)
80+
.position(newStart, this.end);
81+
}
82+
}
83+
/** Notify any possible listeners of the change */
84+
notifyChange() {
85+
const event = new Event('input', { bubbles: true, cancelable: false });
86+
this.elm.dispatchEvent(event);
87+
}
88+
}

src/__test__/index.test.tsx

Lines changed: 137 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22
import React, { useRef } from 'react';
33
import TestRenderer from 'react-test-renderer';
44
import { fireEvent, render } from '@testing-library/react';
5+
import '@testing-library/jest-dom';
6+
import userEvent from '@testing-library/user-event';
57
import TextareaCodeEditor from '../';
68

79
it('Should output a TextareaCodeEditor', async () => {
8-
const component = TestRenderer.create(<TextareaCodeEditor />);
10+
const component = TestRenderer.create(<TextareaCodeEditor placeholder="Please enter JS code." />);
911
let tree = component.toJSON();
1012
if (tree && !Array.isArray(tree)) {
1113
expect(tree.type).toEqual('div');
@@ -23,6 +25,7 @@ it('Should output a TextareaCodeEditor', async () => {
2325
if (typeof child === 'object') {
2426
expect(/^(div|textarea)$/.test(child.type || '')).toBeTruthy();
2527
if (child.type === 'textarea') {
28+
expect(child.props.placeholder).toEqual('Please enter JS code.');
2629
expect(child.props.autoComplete).toEqual('off');
2730
expect(child.props.autoCorrect).toEqual('off');
2831
expect(child.props.spellCheck).toEqual('false');
@@ -120,3 +123,136 @@ it('TextareaCodeEditor onChange', async () => {
120123
fireEvent.input(firstChild.firstChild, { target: { value: 'a' } });
121124
}
122125
});
126+
127+
it('TextareaCodeEditor Tab Input', async () => {
128+
const {
129+
container: { firstChild },
130+
} = render(
131+
<TextareaCodeEditor language="js" data-testid="textarea" autoFocus value="console.log('This is a bad example')" />,
132+
);
133+
134+
if (firstChild && firstChild.firstChild) {
135+
(firstChild.firstChild as any).setSelectionRange(23, 26);
136+
userEvent.type(firstChild.firstChild as any, '{backspace}good');
137+
138+
expect(firstChild.firstChild).toHaveFocus();
139+
expect(firstChild.firstChild).toHaveValue(`console.log('This is a good example')`);
140+
fireEvent.keyDown(firstChild.firstChild, {
141+
key: 'Tab',
142+
code: 'Tab',
143+
charCode: 9,
144+
});
145+
}
146+
});
147+
148+
it('TextareaCodeEditor onKeyDown Tab Input', async () => {
149+
const {
150+
container: { firstChild },
151+
} = render(
152+
<TextareaCodeEditor
153+
language="js"
154+
data-testid="textarea"
155+
autoFocus
156+
value="console.log('This is a bad example')"
157+
onKeyDown={(evn) => {
158+
expect(evn.code.toLowerCase()).toEqual('tab');
159+
expect((evn.target as any).value).toEqual(`console.log('This is a example')`);
160+
}}
161+
/>,
162+
);
163+
164+
if (firstChild && firstChild.firstChild) {
165+
(firstChild.firstChild as any).setSelectionRange(23, 26);
166+
fireEvent.keyDown(firstChild.firstChild, {
167+
key: 'Tab',
168+
code: 'Tab',
169+
charCode: 9,
170+
});
171+
}
172+
});
173+
174+
it('TextareaCodeEditor onKeyDown Tab One-Line Input', async () => {
175+
const {
176+
container: { firstChild },
177+
} = render(
178+
<TextareaCodeEditor
179+
language="js"
180+
data-testid="textarea"
181+
autoFocus
182+
value={`console.log('This is a bad example')\nconsole.log('This is a good example')`}
183+
onKeyDown={(evn) => {
184+
expect(evn.code.toLowerCase()).toEqual('tab');
185+
expect((evn.target as any).value).toEqual(
186+
` console.log('This is a bad example')\n console.log('This is a good example')`,
187+
);
188+
}}
189+
/>,
190+
);
191+
192+
if (firstChild && firstChild.firstChild) {
193+
(firstChild.firstChild as any).setSelectionRange(4, 60);
194+
fireEvent.keyDown(firstChild.firstChild, {
195+
key: 'Tab',
196+
code: 'Tab',
197+
charCode: 9,
198+
});
199+
}
200+
});
201+
202+
it('TextareaCodeEditor onKeyDown Tab Multi-Line Input', async () => {
203+
const example = `\nfunction stopPropagation(e) {\n e.stopPropagation();\n e.preventDefault();\n}`;
204+
const expected = `\nfunction stopPropagation(e) {\ne.stopPropagation();\ne.preventDefault();\n}`;
205+
const {
206+
container: { firstChild },
207+
} = render(
208+
<TextareaCodeEditor
209+
language="js"
210+
data-testid="textarea"
211+
autoFocus
212+
value={example}
213+
onKeyDown={(evn) => {
214+
expect(evn.code.toLowerCase()).toEqual('tab');
215+
expect((evn.target as any).value).toEqual(expected);
216+
}}
217+
/>,
218+
);
219+
220+
if (firstChild && firstChild.firstChild) {
221+
(firstChild.firstChild as any).setSelectionRange(38, 67);
222+
fireEvent.keyDown(firstChild.firstChild, {
223+
key: 'Tab',
224+
code: 'Tab',
225+
charCode: 9,
226+
shiftKey: true,
227+
});
228+
}
229+
});
230+
231+
it('TextareaCodeEditor onKeyDown Tab Multi-Line 2 Input', async () => {
232+
const example = `\nfunction stopPropagation(e) {\n e.stopPropagation();\n e.preventDefault();\n}`;
233+
const expected = `\nfunction stopPropagation(e) {\ne.stopPropagation();\ne.preventDefault();\n}`;
234+
const {
235+
container: { firstChild },
236+
} = render(
237+
<TextareaCodeEditor
238+
language="js"
239+
data-testid="textarea"
240+
autoFocus
241+
value={example}
242+
onKeyDown={(evn) => {
243+
expect(evn.code.toLowerCase()).toEqual('tab');
244+
expect((evn.target as any).value).toEqual(expected);
245+
}}
246+
/>,
247+
);
248+
249+
if (firstChild && firstChild.firstChild) {
250+
(firstChild.firstChild as any).setSelectionRange(6, 67);
251+
fireEvent.keyDown(firstChild.firstChild, {
252+
key: 'Tab',
253+
code: 'Tab',
254+
charCode: 9,
255+
shiftKey: true,
256+
});
257+
}
258+
});

src/index.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1-
import React, { useEffect, useMemo, useState } from 'react';
1+
import React, { useEffect, useMemo, useRef, useState } from 'react';
22
import { processHtml, htmlEncode } from './utils';
3+
import shortcuts from './shortcuts';
34
import * as styles from './styles';
45
import './style/index.less';
56

7+
export * from './SelectionText';
8+
69
export interface TextareaCodeEditorProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
710
prefixCls?: string;
811
/**
@@ -35,6 +38,7 @@ export default React.forwardRef<HTMLTextAreaElement, TextareaCodeEditorProps>((p
3538

3639
const [value, setValue] = useState(props.value || '');
3740
useEffect(() => setValue(props.value || ''), [props.value]);
41+
const textRef = useRef<HTMLTextAreaElement>(null);
3842

3943
const contentStyle = {
4044
paddingTop: padding,
@@ -75,14 +79,18 @@ export default React.forwardRef<HTMLTextAreaElement, TextareaCodeEditorProps>((p
7579
autoCapitalize="off"
7680
{...other}
7781
placeholder={placeholder}
82+
onKeyDown={(evn) => {
83+
shortcuts(evn);
84+
other.onKeyDown && other.onKeyDown(evn);
85+
}}
7886
style={{
7987
...styles.editor,
8088
...styles.textarea,
8189
...contentStyle,
8290
minHeight,
8391
...(placeholder && !value ? { WebkitTextFillColor: 'inherit' } : {}),
8492
}}
85-
ref={ref}
93+
ref={textRef}
8694
onChange={(evn) => {
8795
setValue(evn.target.value);
8896
onChange && onChange(evn);

src/shortcuts.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { stopPropagation } from './utils';
2+
import { SelectionText } from './SelectionText';
3+
4+
export default function shortcuts(e: React.KeyboardEvent<HTMLTextAreaElement>) {
5+
const api = new SelectionText(e.target as HTMLTextAreaElement);
6+
if (e.code && e.code.toLowerCase() === 'tab') {
7+
stopPropagation(e);
8+
if (api.start === api.end) {
9+
api.insertText(' ').position(api.start + 2, api.end + 2);
10+
} else if (api.getSelectedValue().indexOf('\n') > -1 && e.shiftKey) {
11+
api.lineStarRemove(' ');
12+
} else if (api.getSelectedValue().indexOf('\n') > -1) {
13+
api.lineStarInstert(' ');
14+
} else {
15+
api.insertText(' ').position(api.start + 2, api.end);
16+
}
17+
api.notifyChange();
18+
}
19+
}

src/utils.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,8 @@ export function htmlEncode(sHtml: string) {
2424
(c: string) => (({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' } as Record<string, string>)[c]),
2525
);
2626
}
27+
28+
export function stopPropagation(e: React.KeyboardEvent<HTMLTextAreaElement>) {
29+
e.stopPropagation();
30+
e.preventDefault();
31+
}

0 commit comments

Comments
 (0)