Skip to content

Commit 134ffb9

Browse files
committed
Implement custom undo and redo functionality and robust text insertion in Textarea
- in `packages/ui/src/components/editor/common/Textarea/Textarea.tsx`, undo (cmd+z) works incorrectly. implement like in the prompt field. └── packages └── ui └── src └── components └── editor └── common ├── Textarea │ └── Textarea.tsx └── prompts ├── PromptField │ ├── PromptField.module.scss │ ├── PromptField.stories.tsx │ ├── PromptField.tsx │ ├── components │ │ ├── Tooltip.module.scss │ │ ├── Tooltip.tsx │ │ └── index.ts │ ├── hooks │ │ ├── use-drag-drop.ts │ │ ├── use-dropdown.ts │ │ ├── use-ghost-text.ts │ │ ├── use-handlers │ │ │ ├── handle-copy.ts │ │ │ ├── handle-cut.ts │ │ │ ├── handle-input-change.ts │ │ │ ├── handle-input-click.ts │ │ │ ├── handle-paste.ts │ │ │ ├── handle-submit.ts │ │ │ ├── index.ts │ │ │ └── utils.ts │ │ └── use-keyboard-shortcuts.ts │ └── index.ts ├── Template │ ├── Template.module.scss │ ├── Template.tsx │ └── index.ts └── shared └── symbols ├── caret.ts ├── get-highlighted-text.tsx ├── index.ts ├── position-mapping.ts ├── prompt-symbols.module.scss ├── reconstruct-raw-value.ts └── use-symbol-deletion.ts
1 parent e68e8bc commit 134ffb9

1 file changed

Lines changed: 116 additions & 4 deletions

File tree

  • packages/ui/src/components/editor/common/Textarea

‎packages/ui/src/components/editor/common/Textarea/Textarea.tsx‎

Lines changed: 116 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import React from 'react'
22
import cn from 'classnames'
33
import styles from './Textarea.module.scss'
4+
import {
5+
get_caret_position_from_div,
6+
set_caret_position_for_div
7+
} from '../prompts/shared/symbols'
48

59
type Props = {
610
id?: string
@@ -22,6 +26,29 @@ type Props = {
2226
export const Textarea = React.forwardRef<HTMLDivElement, Props>(
2327
(props, ref) => {
2428
const internal_ref = React.useRef<HTMLDivElement | null>(null)
29+
const [undo_stack, set_undo_stack] = React.useState<
30+
{ value: string; caret_pos: number }[]
31+
>([])
32+
const [redo_stack, set_redo_stack] = React.useState<
33+
{ value: string; caret_pos: number }[]
34+
>([])
35+
const caret_pos_ref = React.useRef(0)
36+
37+
React.useEffect(() => {
38+
const on_selection_change = () => {
39+
if (
40+
document.activeElement === internal_ref.current &&
41+
internal_ref.current
42+
) {
43+
caret_pos_ref.current = get_caret_position_from_div(
44+
internal_ref.current
45+
)
46+
}
47+
}
48+
document.addEventListener('selectionchange', on_selection_change)
49+
return () =>
50+
document.removeEventListener('selectionchange', on_selection_change)
51+
}, [])
2552

2653
const set_ref = React.useCallback(
2754
(node: HTMLDivElement | null) => {
@@ -56,11 +83,72 @@ export const Textarea = React.forwardRef<HTMLDivElement, Props>(
5683
}
5784
}, [props.autofocus])
5885

86+
const update_value = (new_value: string) => {
87+
if (new_value === props.value) return
88+
set_undo_stack((prev) => [
89+
...prev,
90+
{ value: props.value || '', caret_pos: caret_pos_ref.current }
91+
])
92+
set_redo_stack([])
93+
props.on_change(new_value)
94+
}
95+
5996
const handle_input = (e: React.FormEvent<HTMLDivElement>) => {
60-
props.on_change(e.currentTarget.textContent || '')
97+
update_value(e.currentTarget.textContent || '')
6198
}
6299

63100
const handle_key_down = (e: React.KeyboardEvent<HTMLDivElement>) => {
101+
if (
102+
(e.ctrlKey || e.metaKey) &&
103+
!e.shiftKey &&
104+
e.key.toLowerCase() == 'z'
105+
) {
106+
e.preventDefault()
107+
if (undo_stack.length > 0) {
108+
const prev_entry = undo_stack[undo_stack.length - 1]
109+
set_undo_stack((prev) => prev.slice(0, -1))
110+
set_redo_stack((prev) => [
111+
...prev,
112+
{ value: props.value || '', caret_pos: caret_pos_ref.current }
113+
])
114+
props.on_change(prev_entry.value)
115+
setTimeout(() => {
116+
if (internal_ref.current) {
117+
set_caret_position_for_div(
118+
internal_ref.current,
119+
prev_entry.caret_pos
120+
)
121+
}
122+
}, 0)
123+
}
124+
return
125+
}
126+
127+
if (
128+
((e.ctrlKey || e.metaKey) && e.key.toLowerCase() == 'y') ||
129+
((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() == 'z')
130+
) {
131+
e.preventDefault()
132+
if (redo_stack.length > 0) {
133+
const next_entry = redo_stack[redo_stack.length - 1]
134+
set_redo_stack((prev) => prev.slice(0, -1))
135+
set_undo_stack((prev) => [
136+
...prev,
137+
{ value: props.value || '', caret_pos: caret_pos_ref.current }
138+
])
139+
props.on_change(next_entry.value)
140+
setTimeout(() => {
141+
if (internal_ref.current) {
142+
set_caret_position_for_div(
143+
internal_ref.current,
144+
next_entry.caret_pos
145+
)
146+
}
147+
}, 0)
148+
}
149+
return
150+
}
151+
64152
if (
65153
(e.key == 'Escape' ||
66154
(e.key == 'Enter' && (props.blur_on_enter ?? true))) &&
@@ -70,15 +158,35 @@ export const Textarea = React.forwardRef<HTMLDivElement, Props>(
70158
e.currentTarget.blur()
71159
} else if (e.key === 'Enter') {
72160
e.preventDefault()
73-
document.execCommand('insertText', false, '\n')
161+
const selection = window.getSelection()
162+
if (selection && selection.rangeCount > 0) {
163+
const range = selection.getRangeAt(0)
164+
range.deleteContents()
165+
const text_node = document.createTextNode('\n')
166+
range.insertNode(text_node)
167+
range.collapse(false)
168+
selection.removeAllRanges()
169+
selection.addRange(range)
170+
update_value(e.currentTarget.textContent || '')
171+
}
74172
}
75173
props.on_key_down?.(e)
76174
}
77175

78176
const handle_paste = (e: React.ClipboardEvent<HTMLDivElement>) => {
79177
e.preventDefault()
80178
const text = e.clipboardData.getData('text/plain')
81-
document.execCommand('insertText', false, text)
179+
const selection = window.getSelection()
180+
if (selection && selection.rangeCount > 0) {
181+
const range = selection.getRangeAt(0)
182+
range.deleteContents()
183+
const text_node = document.createTextNode(text)
184+
range.insertNode(text_node)
185+
range.collapse(false)
186+
selection.removeAllRanges()
187+
selection.addRange(range)
188+
update_value(e.currentTarget.textContent || '')
189+
}
82190
}
83191

84192
const handle_focus = (e: React.FocusEvent<HTMLDivElement>) => {
@@ -102,7 +210,11 @@ export const Textarea = React.forwardRef<HTMLDivElement, Props>(
102210
>
103211
{props.action_icon && props.on_action_click && (
104212
<div
105-
className={cn(styles.action, 'codicon', `codicon-${props.action_icon}`)}
213+
className={cn(
214+
styles.action,
215+
'codicon',
216+
`codicon-${props.action_icon}`
217+
)}
106218
title={props.action_title}
107219
onMouseDown={(e) => e.preventDefault()}
108220
onClick={(e) => {

0 commit comments

Comments
 (0)